Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ Live: **https://cinemate.valegboth.win** · runs entirely on **Cloudflare free t
- **Invite** — 6-char code + one-tap shareable link (`/join?code=…`).
- **Solo mode** + persistent watchlist.
- **Overseerr** (optional) — excludes already requested/available titles from the deck
and adds an **"Add to Overseerr"** button on match (auto-request → Radarr/Sonarr).
and adds an **"Add to Overseerr"** button on match **and on each solo-watchlist title**
(auto-request → Radarr/Sonarr). Requests are gated by a shared **PIN** (the app is public).
- Watch providers (where to watch, RO) on the match screen. TMDb attribution in the footer.

## Stack
Expand Down Expand Up @@ -70,6 +71,7 @@ npx wrangler dev # http://localhost:8787 (UI + API, same orig
| `OMDB_API_KEY` | optional | IMDb/RT/Metacritic ratings (best-effort) |
| `OVERSEERR_URL` / `OVERSEERR_API_KEY` | optional | Overseerr integration |
| `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` | optional | if Overseerr is behind Cloudflare Access |
| `REQUEST_PIN` | optional | shared PIN required to request in Overseerr; unset = requests disabled |

The keys never reach the frontend — the browser calls the Worker, the Worker calls the APIs.

Expand All @@ -82,7 +84,7 @@ The keys never reach the frontend — the browser calls the Worker, the Worker c
| `npm run db:migrate:local` / `:remote` | apply `schema.sql` to local / remote D1 |

## API routes (all under `/api`)
`POST /users` · `GET /users/:id/watchlist` · `POST /profile/quiz` · `GET /profile/:id`
`POST /users` · `GET /users/:id/watchlist` · `POST /users/:id/request` · `POST /profile/quiz` · `GET /profile/:id`
· `GET /search` · `POST /rooms` · `POST /rooms/join` · `GET /rooms/:id`
· `PATCH /rooms/:id` (movie/TV toggle) · `POST /rooms/:id/new-session`
· `GET /rooms/:id/deck` · `POST /rooms/:id/swipe` · `DELETE /rooms/:id/swipe` (undo)
Expand Down
35 changes: 23 additions & 12 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -626,21 +626,16 @@ function showMatch(card, reason) {
showScreen("screen-match");
}

async function addToOverseerr() {
if (!currentMatchCard) return;
// Shared Overseerr request flow: ask for the PIN once (stored locally), POST, toast.
async function overseerrRequest(path, extraBody) {
let pin = localStorage.getItem("cinemate_request_pin");
if (!pin) {
pin = (window.prompt("Request PIN") || "").trim();
if (!pin) return;
localStorage.setItem("cinemate_request_pin", pin);
}
const btn = $("add-overseerr-btn");
btn.disabled = true;
try {
await api(`/api/rooms/${state.room.id}/request`, {
method: "POST",
body: JSON.stringify({ user_id: state.userId, tmdb_id: currentMatchCard.tmdb_id, pin }),
});
await api(path, { method: "POST", body: JSON.stringify({ user_id: state.userId, pin, ...extraBody }) });
toast("✅ Requested in Overseerr");
} catch (e) {
if (e.message === "invalid_pin") {
Expand All @@ -653,11 +648,17 @@ async function addToOverseerr() {
} else {
toast("Overseerr request failed");
}
} finally {
btn.disabled = false;
}
}

async function addToOverseerr() {
if (!currentMatchCard) return;
const btn = $("add-overseerr-btn");
btn.disabled = true;
await overseerrRequest(`/api/rooms/${state.room.id}/request`, { tmdb_id: currentMatchCard.tmdb_id });
btn.disabled = false;
}

async function renderRatings(tmdbId, elId) {
const el = $(elId);
el.textContent = "";
Expand Down Expand Up @@ -709,7 +710,7 @@ async function renderProviders(tmdbId) {
}

// ── Card lists ──────────────────────────────────────────────────────────────
function renderCardList(container, items, emptyText) {
function renderCardList(container, items, emptyText, onRequest) {
container.innerHTML = "";
if (!items.length) {
container.innerHTML = `<p class='muted'>${emptyText}</p>`;
Expand All @@ -734,6 +735,14 @@ function renderCardList(container, items, emptyText) {
info.appendChild(title);
info.appendChild(document.createElement("br"));
info.appendChild(link);
if (onRequest) {
const reqBtn = document.createElement("button");
reqBtn.className = "chip-btn";
reqBtn.textContent = "➕ Overseerr";
reqBtn.onclick = () => onRequest(m);
info.appendChild(document.createElement("br"));
info.appendChild(reqBtn);
}
item.appendChild(poster);
item.appendChild(info);
container.appendChild(item);
Expand All @@ -758,7 +767,9 @@ async function showWatchlist() {
list.innerHTML = "<p class='muted'>Loading…</p>";
try {
const res = await api(`/api/users/${state.userId}/watchlist`);
renderCardList(list, res.watchlist || [], "Your watchlist is empty. Like titles in solo mode.");
renderCardList(list, res.watchlist || [], "Your watchlist is empty. Like titles in solo mode.", (m) =>
overseerrRequest(`/api/users/${state.userId}/request`, { tmdb_id: m.tmdb_id, media_type: m.media_type }),
);
} catch (e) {
list.innerHTML = "<p class='error'>Error: " + e.message + "</p>";
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export { Room } from "./durable-objects/room";

const app = new Hono<{ Bindings: Env }>();

app.get("/api/health", (c) => c.json({ ok: true, service: "cinemate", version: "v3.0" }));
app.get("/api/health", (c) => c.json({ ok: true, service: "cinemate", version: "v3.1" }));

app.route("/api/users", users);
app.route("/api/profile", profile);
Expand Down
18 changes: 18 additions & 0 deletions src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Hono } from "hono";
import type { Env, MediaType } from "../types";
import { genId } from "../lib/ids";
import { getTitleCard } from "../services/tmdb";
import { createRequest } from "../services/overseerr";

export const users = new Hono<{ Bindings: Env }>();

Expand Down Expand Up @@ -55,3 +56,20 @@ users.get("/:userId/watchlist", async (c) => {

return c.json({ user_id: userId, watchlist }, 200);
});

// POST /api/users/:userId/request — request a watchlist title in Overseerr (PIN-gated).
// Body: { tmdb_id, media_type, pin }. No room needed; the shared PIN is the gate.
users.post("/:userId/request", async (c) => {
const body = await c.req.json().catch(() => null);
const tmdbId = Number(body?.tmdb_id);
const mediaType = body?.media_type === "tv" ? "tv" : "movie";
if (!Number.isInteger(tmdbId) || tmdbId <= 0) return c.json({ error: "tmdb_id_invalid" }, 400);

if (!c.env.REQUEST_PIN) return c.json({ error: "requests_disabled" }, 403);
const pin = typeof body?.pin === "string" ? body.pin : "";
if (pin !== c.env.REQUEST_PIN) return c.json({ error: "invalid_pin" }, 403);

const result = await createRequest(c.env, mediaType, tmdbId);
if (!result.ok) return c.json({ error: result.error ?? "request_failed" }, 502);
return c.json({ ok: true, tmdb_id: tmdbId }, 200);
});
Loading