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;