From 6414cbdfc33db21239adba3efb1812822a8d884e Mon Sep 17 00:00:00 2001 From: valeboth <109695782+valeboth@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:04:40 +0300 Subject: [PATCH 1/3] feat: Overseerr integration (exclude requested/available + request on match) - services/overseerr.ts: best-effort, KV-cached (1h) set of tmdb ids already in the Overseerr pipeline (pending/processing/available via /api/v1/media); createRequest via POST /api/v1/request (tv -> seasons: all) - deck: exclude Overseerr requested/available titles from what's served (getDeckForUser) - POST /api/rooms/:id/request ('Add to Overseerr' on match), auth-gated - Env: OVERSEERR_URL + OVERSEERR_API_KEY (secrets, optional); feature inert if unset - frontend: 'Add to Overseerr' button on the match screen - health -> v3.0 Tested local (unconfigured): deck unaffected, request -> not_configured (502), auth 403. Live Overseerr test pending reachability + URL/key. --- .dev.vars.example | 8 ++++ public/app.js | 20 +++++++++ public/index.html | 1 + src/index.ts | 2 +- src/lib/deck.ts | 14 +++--- src/routes/rooms.ts | 25 +++++++++++ src/services/overseerr.ts | 94 +++++++++++++++++++++++++++++++++++++++ src/types.ts | 4 ++ 8 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 src/services/overseerr.ts diff --git a/.dev.vars.example b/.dev.vars.example index 5f4a15e..69046ec 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -4,3 +4,11 @@ # Get a free TMDb key at https://www.themoviedb.org/settings/api # Either an API Key (v3 auth) or a Read Access Token (v4) works. See src/services/tmdb.ts. TMDB_API_KEY="your_key_here" + +# Optional — OMDb (IMDb/RT/Metacritic ratings). Get one at http://www.omdbapi.com/apikey.aspx +OMDB_API_KEY="" + +# Optional — Overseerr integration (exclude requested/available + "Add to Overseerr" on match). +# Must be reachable from the internet (the Worker runs in the cloud). Leave empty to disable. +OVERSEERR_URL="" +OVERSEERR_API_KEY="" diff --git a/public/app.js b/public/app.js index afb8c86..3cd1549 100644 --- a/public/app.js +++ b/public/app.js @@ -609,7 +609,9 @@ function closeWs() { } // ── Match screen ──────────────────────────────────────────────────────────── +let currentMatchCard = null; function showMatch(card, reason) { + currentMatchCard = card; $("match-poster").style.backgroundImage = card.poster_path ? `url(${TMDB_IMG}${card.poster_path})` : "none"; $("match-name").textContent = card.title || "Match!"; const bits = []; @@ -624,6 +626,23 @@ function showMatch(card, reason) { showScreen("screen-match"); } +async function addToOverseerr() { + if (!currentMatchCard) return; + 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 }), + }); + toast("✅ Requested in Overseerr"); + } catch (e) { + toast(e.message === "not_configured" ? "Overseerr not set up" : "Overseerr request failed"); + } finally { + btn.disabled = false; + } +} + async function renderRatings(tmdbId, elId) { const el = $(elId); el.textContent = ""; @@ -798,6 +817,7 @@ function init() { $("new-session-btn").onclick = newSession; $("matches-back-btn").onclick = () => showScreen("screen-swipe"); $("match-continue-btn").onclick = () => showScreen("screen-swipe"); + $("add-overseerr-btn").onclick = addToOverseerr; $("copy-link-btn").onclick = () => copyText(inviteLink(), "Invite link"); $("user-badge").onclick = showProfile; $("edit-prefs-btn").onclick = openEditPrefs; diff --git a/public/index.html b/public/index.html index a9b9dd2..ba4b7a3 100644 --- a/public/index.html +++ b/public/index.html @@ -123,6 +123,7 @@

View on TMDb ↗ + diff --git a/src/index.ts b/src/index.ts index dc11a99..e9c8ef2 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: "v2.9" })); +app.get("/api/health", (c) => c.json({ ok: true, service: "cinemate", version: "v3.0" })); app.route("/api/users", users); app.route("/api/profile", profile); diff --git a/src/lib/deck.ts b/src/lib/deck.ts index a4f5a48..cfa967f 100644 --- a/src/lib/deck.ts +++ b/src/lib/deck.ts @@ -6,6 +6,7 @@ import type { DeckCard, Env, MediaType, Profile, Room, SeedTitle } from "../types"; import { discoverTitles, getGenreMap, getRecommendations, getTitleCard } from "../services/tmdb"; +import { getRequestedTmdbIds } from "../services/overseerr"; import { mapProfile } from "./mappers"; const MAX_SEEDS_PER_USER = 3; @@ -231,13 +232,14 @@ export async function getDeckForUser( const profile = await loadProfile(env, userId); let cards = weightedShuffle(deck.cards, profile); - const { results } = await env.DB.prepare( - "SELECT tmdb_id FROM swipes WHERE room_id = ? AND user_id = ?", - ) - .bind(room.id, userId) - .all<{ tmdb_id: number }>(); + const [{ results }, requested] = await Promise.all([ + env.DB.prepare("SELECT tmdb_id FROM swipes WHERE room_id = ? AND user_id = ?") + .bind(room.id, userId) + .all<{ tmdb_id: number }>(), + getRequestedTmdbIds(env), // already requested/available in Overseerr (best-effort) + ]); const seen = new Set((results ?? []).map((r) => r.tmdb_id)); - cards = cards.filter((c) => !seen.has(c.tmdb_id)); + cards = cards.filter((c) => !seen.has(c.tmdb_id) && !requested.has(c.tmdb_id)); return { ...deck, cards }; } diff --git a/src/routes/rooms.ts b/src/routes/rooms.ts index 1bb9da9..6294697 100644 --- a/src/routes/rooms.ts +++ b/src/routes/rooms.ts @@ -6,6 +6,7 @@ import { mapRoom } from "../lib/mappers"; import { getDeckForUser, getCardFromDeck, resetDeck, buildMatchReason } from "../lib/deck"; import { getWatchProviders, getImdbId, getTrailerKey } from "../services/tmdb"; import { getOmdbRatings } from "../services/omdb"; +import { createRequest } from "../services/overseerr"; export const rooms = new Hono<{ Bindings: Env }>(); @@ -394,6 +395,30 @@ rooms.get("/:id/trailer/:tmdbId", async (c) => { return c.json({ tmdb_id: tmdbId, youtube_key: key }, 200); }); +// POST /api/rooms/:id/request — request a title in Overseerr ("Add to Overseerr"). +// Body: { user_id, tmdb_id } +rooms.post("/:id/request", async (c) => { + const id = c.req.param("id"); + const body = await c.req.json().catch(() => null); + const userId = typeof body?.user_id === "string" ? body.user_id : ""; + const tmdbId = Number(body?.tmdb_id); + if (!userId) return c.json({ error: "user_id_required" }, 400); + if (!Number.isInteger(tmdbId) || tmdbId <= 0) return c.json({ error: "tmdb_id_invalid" }, 400); + + const row = await c.env.DB.prepare("SELECT * FROM rooms WHERE id = ?") + .bind(id) + .first>(); + if (!row) return c.json({ error: "room_not_found" }, 404); + const room = mapRoom(row); + if (userId !== room.user_a_id && userId !== room.user_b_id) { + return c.json({ error: "forbidden" }, 403); + } + + const result = await createRequest(c.env, room.media_type, tmdbId); + if (!result.ok) return c.json({ error: result.error ?? "request_failed" }, 502); + return c.json({ ok: true, tmdb_id: tmdbId }, 200); +}); + // POST /api/rooms/:id/new-session — fresh deck: reset the pool + clear the room's swipes. // Keeps the room (pairing intact); both clients reload via a deck_reset broadcast. // Body: { user_id } diff --git a/src/services/overseerr.ts b/src/services/overseerr.ts new file mode 100644 index 0000000..9c86615 --- /dev/null +++ b/src/services/overseerr.ts @@ -0,0 +1,94 @@ +// Overseerr integration — enrichment only, best-effort. +// - exclude titles already requested/available in Overseerr from the deck +// - create a request on match ("Add to Overseerr") +// URL + API key come ONLY from env (secrets); if unset, the feature is inert. + +import type { Env, MediaType } from "../types"; + +const REQUESTED_CACHE_KEY = "overseerr:requested"; +const REQUESTED_TTL = 60 * 60; // 1h — a newly requested title drops out within the hour +const MEDIA_PAGES = 5; // up to 5 × 100 = 500 titles scanned + +export function overseerrConfigured(env: Env): boolean { + return !!(env.OVERSEERR_URL && env.OVERSEERR_API_KEY); +} + +function baseUrl(env: Env): string { + return env.OVERSEERR_URL.replace(/\/+$/, ""); // trim trailing slash +} + +interface OverseerrMediaItem { + tmdbId?: number; + status?: number; // 2 pending, 3 processing, 4 partially available, 5 available +} +interface OverseerrMediaResponse { + results?: OverseerrMediaItem[]; +} + +/** + * Set of tmdbIds already in the Overseerr pipeline (pending/processing/available). + * Cached in KV. Best-effort: returns an empty set on any issue. + */ +export async function getRequestedTmdbIds(env: Env): Promise> { + if (!overseerrConfigured(env)) return new Set(); + + const cached = await env.TMDB_CACHE.get(REQUESTED_CACHE_KEY); + if (cached) return new Set(JSON.parse(cached) as number[]); + + const ids = new Set(); + try { + for (let page = 1; page <= MEDIA_PAGES; page++) { + const url = `${baseUrl(env)}/api/v1/media?take=100&skip=${(page - 1) * 100}&filter=allavailable`; + const res = await fetch(url, { headers: { "X-Api-Key": env.OVERSEERR_API_KEY, accept: "application/json" } }); + if (!res.ok) break; + const data = (await res.json()) as OverseerrMediaResponse; + const results = data.results ?? []; + for (const m of results) { + if (typeof m.tmdbId === "number" && (m.status ?? 0) >= 2) ids.add(m.tmdbId); + } + if (results.length < 100) break; + } + await env.TMDB_CACHE.put(REQUESTED_CACHE_KEY, JSON.stringify([...ids]), { + expirationTtl: REQUESTED_TTL, + }); + } catch { + // best-effort + } + return ids; +} + +export interface RequestResult { + ok: boolean; + error?: string; +} + +/** Create an Overseerr request for a title. Best-effort. */ +export async function createRequest( + env: Env, + mediaType: MediaType, + tmdbId: number, +): Promise { + if (!overseerrConfigured(env)) return { ok: false, error: "not_configured" }; + try { + const body: Record = { mediaType, mediaId: tmdbId }; + if (mediaType === "tv") body.seasons = "all"; + const res = await fetch(`${baseUrl(env)}/api/v1/request`, { + method: "POST", + headers: { + "X-Api-Key": env.OVERSEERR_API_KEY, + "content-type": "application/json", + accept: "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + return { ok: false, error: `overseerr_${res.status}: ${text.slice(0, 120)}` }; + } + // Invalidate the requested-set cache so it disappears from decks quickly. + await env.TMDB_CACHE.delete(REQUESTED_CACHE_KEY); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : "request_failed" }; + } +} diff --git a/src/types.ts b/src/types.ts index 75be54d..95e2b0d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,10 @@ export interface Env { TMDB_API_KEY: string; /** Secret — the OMDb key (enrichment: IMDb/RT/Metacritic ratings). Best-effort. */ OMDB_API_KEY: string; + /** Secret — Overseerr base URL (e.g. https://overseerr.example.com). Optional. */ + OVERSEERR_URL: string; + /** Secret — Overseerr API key (X-Api-Key). Optional; feature inert if unset. */ + OVERSEERR_API_KEY: string; } export type MediaType = "movie" | "tv"; From 6f74933f5b4f77c77c8356017681cfac0f28590c Mon Sep 17 00:00:00 2001 From: valeboth <109695782+valeboth@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:20:27 +0300 Subject: [PATCH 2/3] feat(overseerr): optional Cloudflare Access service-token headers - send CF-Access-Client-Id/Secret when set (for Overseerr behind Cloudflare Access/Zero Trust) - keeps working without Access (plain tunnel) when unset - .dev.vars.example: OVERSEERR_URL/KEY + optional CF_ACCESS_CLIENT_ID/SECRET --- .dev.vars.example | 3 +++ src/services/overseerr.ts | 22 ++++++++++++++++------ src/types.ts | 3 +++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.dev.vars.example b/.dev.vars.example index 69046ec..e031814 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -12,3 +12,6 @@ OMDB_API_KEY="" # Must be reachable from the internet (the Worker runs in the cloud). Leave empty to disable. OVERSEERR_URL="" OVERSEERR_API_KEY="" +# Only if the Overseerr hostname is behind Cloudflare Access (Zero Trust) — service token. +CF_ACCESS_CLIENT_ID="" +CF_ACCESS_CLIENT_SECRET="" diff --git a/src/services/overseerr.ts b/src/services/overseerr.ts index 9c86615..a2b34b5 100644 --- a/src/services/overseerr.ts +++ b/src/services/overseerr.ts @@ -17,6 +17,20 @@ function baseUrl(env: Env): string { return env.OVERSEERR_URL.replace(/\/+$/, ""); // trim trailing slash } +// Overseerr API key + (optional) Cloudflare Access service token headers. +function authHeaders(env: Env, extra: Record = {}): Record { + const h: Record = { + "X-Api-Key": env.OVERSEERR_API_KEY, + accept: "application/json", + ...extra, + }; + if (env.CF_ACCESS_CLIENT_ID && env.CF_ACCESS_CLIENT_SECRET) { + h["CF-Access-Client-Id"] = env.CF_ACCESS_CLIENT_ID; + h["CF-Access-Client-Secret"] = env.CF_ACCESS_CLIENT_SECRET; + } + return h; +} + interface OverseerrMediaItem { tmdbId?: number; status?: number; // 2 pending, 3 processing, 4 partially available, 5 available @@ -39,7 +53,7 @@ export async function getRequestedTmdbIds(env: Env): Promise> { try { for (let page = 1; page <= MEDIA_PAGES; page++) { const url = `${baseUrl(env)}/api/v1/media?take=100&skip=${(page - 1) * 100}&filter=allavailable`; - const res = await fetch(url, { headers: { "X-Api-Key": env.OVERSEERR_API_KEY, accept: "application/json" } }); + const res = await fetch(url, { headers: authHeaders(env) }); if (!res.ok) break; const data = (await res.json()) as OverseerrMediaResponse; const results = data.results ?? []; @@ -74,11 +88,7 @@ export async function createRequest( if (mediaType === "tv") body.seasons = "all"; const res = await fetch(`${baseUrl(env)}/api/v1/request`, { method: "POST", - headers: { - "X-Api-Key": env.OVERSEERR_API_KEY, - "content-type": "application/json", - accept: "application/json", - }, + headers: authHeaders(env, { "content-type": "application/json" }), body: JSON.stringify(body), }); if (!res.ok) { diff --git a/src/types.ts b/src/types.ts index 95e2b0d..0378ea4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,9 @@ export interface Env { OVERSEERR_URL: string; /** Secret — Overseerr API key (X-Api-Key). Optional; feature inert if unset. */ OVERSEERR_API_KEY: string; + /** Secret — Cloudflare Access service token (optional; if the Overseerr hostname is behind Access). */ + CF_ACCESS_CLIENT_ID: string; + CF_ACCESS_CLIENT_SECRET: string; } export type MediaType = "movie" | "tv"; From bbc32cfd395a0bd837285dfc2435e60fb94cc5e3 Mon Sep 17 00:00:00 2001 From: valeboth <109695782+valeboth@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:28:00 +0300 Subject: [PATCH 3/3] feat(overseerr): add /api/overseerr/ping connectivity check --- src/index.ts | 4 ++++ src/services/overseerr.ts | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/index.ts b/src/index.ts index e9c8ef2..4751159 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { users } from "./routes/users"; import { profile } from "./routes/profile"; import { rooms } from "./routes/rooms"; import { search } from "./routes/search"; +import { pingOverseerr } from "./services/overseerr"; // Durable Object — live per-room state (WebSocket + match broadcast). export { Room } from "./durable-objects/room"; @@ -17,6 +18,9 @@ app.route("/api/profile", profile); app.route("/api/rooms", rooms); app.route("/api/search", search); +// Overseerr connectivity check (debug the tunnel/Access/API-key setup). +app.get("/api/overseerr/ping", async (c) => c.json(await pingOverseerr(c.env))); + // Invite link path: serve the SPA shell so ?code= is picked up by the frontend. app.get("/join", (c) => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url)))); diff --git a/src/services/overseerr.ts b/src/services/overseerr.ts index a2b34b5..0144123 100644 --- a/src/services/overseerr.ts +++ b/src/services/overseerr.ts @@ -71,6 +71,27 @@ export async function getRequestedTmdbIds(env: Env): Promise> { return ids; } +export interface PingResult { + configured: boolean; + ok: boolean; + version?: string; + http?: number; + error?: string; +} + +/** Connectivity + auth check against Overseerr (for debugging the setup). */ +export async function pingOverseerr(env: Env): Promise { + if (!overseerrConfigured(env)) return { configured: false, ok: false }; + try { + const res = await fetch(`${baseUrl(env)}/api/v1/status`, { headers: authHeaders(env) }); + if (!res.ok) return { configured: true, ok: false, http: res.status }; + const data = (await res.json()) as { version?: string }; + return { configured: true, ok: true, version: data.version }; + } catch (e) { + return { configured: true, ok: false, error: e instanceof Error ? e.message : "error" }; + } +} + export interface RequestResult { ok: boolean; error?: string;