diff --git a/README.md b/README.md index eb53a72..ec0d04f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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) diff --git a/public/app.js b/public/app.js index 6a3f23a..19c4f4c 100644 --- a/public/app.js +++ b/public/app.js @@ -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") { @@ -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 = ""; @@ -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 = `
${emptyText}
`; @@ -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); @@ -758,7 +767,9 @@ async function showWatchlist() { list.innerHTML = "Loading…
"; 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 = "Error: " + e.message + "
"; } diff --git a/src/index.ts b/src/index.ts index e9c8ef2..5f137ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); diff --git a/src/routes/users.ts b/src/routes/users.ts index fcd6d42..d759322 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -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 }>(); @@ -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); +});