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
11 changes: 11 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
20 changes: 20 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -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 = "";
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ <h3 id="match-name"></h3>
<p id="match-reason" class="match-reason"></p>
<div id="match-providers" class="providers"></div>
<a id="match-tmdb-link" class="btn-link" target="_blank" rel="noopener noreferrer">View on TMDb ↗</a>
<button id="add-overseerr-btn" class="btn mt">➕ Add to Overseerr</button>
<button id="match-continue-btn" class="btn btn-primary mt">Keep swiping</button>
</div>

Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))));

Expand Down
14 changes: 8 additions & 6 deletions src/lib/deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}
Expand Down
25 changes: 25 additions & 0 deletions src/routes/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();

Expand Down Expand Up @@ -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<Record<string, unknown>>();
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 }
Expand Down
125 changes: 125 additions & 0 deletions src/services/overseerr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// 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
}

// Overseerr API key + (optional) Cloudflare Access service token headers.
function authHeaders(env: Env, extra: Record<string, string> = {}): Record<string, string> {
const h: Record<string, string> = {
"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
}
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<Set<number>> {
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<number>();
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: authHeaders(env) });
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 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<PingResult> {
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;
}

/** Create an Overseerr request for a title. Best-effort. */
export async function createRequest(
env: Env,
mediaType: MediaType,
tmdbId: number,
): Promise<RequestResult> {
if (!overseerrConfigured(env)) return { ok: false, error: "not_configured" };
try {
const body: Record<string, unknown> = { mediaType, mediaId: tmdbId };
if (mediaType === "tv") body.seasons = "all";
const res = await fetch(`${baseUrl(env)}/api/v1/request`, {
method: "POST",
headers: authHeaders(env, { "content-type": "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" };
}
}
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ 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;
/** 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";
Expand Down
Loading