diff --git a/.dev.vars.example b/.dev.vars.example index 5f4a15e..e031814 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -4,3 +4,14 @@ # 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="" +# Only if the Overseerr hostname is behind Cloudflare Access (Zero Trust) — service token. +CF_ACCESS_CLIENT_ID="" +CF_ACCESS_CLIENT_SECRET="" 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..4751159 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,19 +4,23 @@ 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"; 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); 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/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