diff --git a/public/app.js b/public/app.js index 3a1abaa..80f9f72 100644 --- a/public/app.js +++ b/public/app.js @@ -46,6 +46,8 @@ const state = { soloMode: false, deck: [], deckIndex: 0, + loadingMore: false, // a deck top-up request is in flight + deckExhausted: false, // server has no more titles to add (pool at its cap) ws: null, pendingCode: null, lastSwiped: null, // { card } for undo @@ -409,6 +411,8 @@ async function loadDeck() { state.deck = res.cards || []; state.deckIndex = 0; state.lastSwiped = null; + state.loadingMore = false; + state.deckExhausted = false; updateUndo(); $("deck-loading").classList.add("hidden"); renderCard(); @@ -417,6 +421,29 @@ async function loadDeck() { } } +// Top up the shared pool when running low, so the deck feels endless (until the +// server-side cap). Idempotent-ish: guarded so only one request is in flight. +const TOPUP_THRESHOLD = 5; // fetch more once ≤ this many cards remain +async function maybeTopUp() { + if (state.loadingMore || state.deckExhausted) return; + if (state.deck.length - state.deckIndex > TOPUP_THRESHOLD) return; + state.loadingMore = true; + try { + const res = await api( + `/api/rooms/${state.room.id}/deck/more?user_id=${encodeURIComponent(state.userId)}`, + ); + const more = res.cards || []; + if (more.length) state.deck = state.deck.concat(more); + else state.deckExhausted = true; // pool at its cap — nothing more to add + } catch (e) { + console.error("deck top-up error", e); + } finally { + state.loadingMore = false; + // Refresh only if we're on the loading/empty view; don't disturb a shown card. + if ($("card").classList.contains("hidden")) renderCard(); + } +} + function syncMediaToggle() { document.querySelectorAll("#swipe-media-toggle .toggle-opt").forEach((b) => { b.classList.toggle("active", b.dataset.media === state.mediaType); @@ -467,10 +494,20 @@ function renderCard() { resetCardTransform(); if (!card) { el.classList.add("hidden"); - $("deck-empty").classList.remove("hidden"); + if (state.deckExhausted) { + $("deck-loading").classList.add("hidden"); + $("deck-empty").classList.remove("hidden"); + } else { + // More may be on the way — show a loader and make sure a top-up is running. + $("deck-empty").classList.add("hidden"); + $("deck-loading").textContent = "Finding more titles…"; + $("deck-loading").classList.remove("hidden"); + maybeTopUp(); + } return; } $("deck-empty").classList.add("hidden"); + $("deck-loading").classList.add("hidden"); el.classList.remove("hidden"); $("card-poster").style.backgroundImage = card.poster_path ? `url(${TMDB_IMG}${card.poster_path})` : "none"; $("card-title").textContent = card.title; @@ -503,6 +540,7 @@ async function swipe(direction) { state.deckIndex++; updateUndo(); renderCard(); + maybeTopUp(); // proactively fetch more as we near the end (seamless refill) try { const res = await api(`/api/rooms/${state.room.id}/swipe`, { method: "POST", diff --git a/src/lib/deck.ts b/src/lib/deck.ts index cfa967f..cba2df2 100644 --- a/src/lib/deck.ts +++ b/src/lib/deck.ts @@ -13,9 +13,26 @@ const MAX_SEEDS_PER_USER = 3; const TOP_GENRES = 3; const MIN_DECK = 5; +// Hard cap on the shared pool. The deck tops up on demand (extendDeck) but never +// past this — keeps TMDb calls, KV writes and D1 row size bounded on the free tier. +const MAX_DECK = 120; +// Pages pulled per top-up call (20 titles/page). Small → cheap, cache-friendly. +const TOPUP_PAGES = 2; +// Rotated across generation slices + top-up rounds so the pool isn't all "popular". +const SORTS = ["popularity.desc", "vote_average.desc", "primary_release_date.desc"]; const DECK_CARDS_KV_PREFIX = "deck-cards:"; const DECK_CARDS_TTL = 60 * 60 * 24 * 7; // 7 days +/** + * Where the next top-up should read from. Derived from the current pool size + * (~20 titles/TMDb page) so we don't re-fetch pages we already have, and the + * sort rotates each round for variety. Pure → unit-tested. + */ +export function topupCursor(poolSize: number): { startPage: number; sortBy: string } { + const startPage = Math.floor(poolSize / 20) + 1; + return { startPage, sortBy: SORTS[startPage % SORTS.length] }; +} + async function loadProfile(env: Env, userId: string | null): Promise { if (!userId) return null; const row = await env.DB.prepare("SELECT * FROM profiles WHERE user_id = ?") @@ -113,7 +130,7 @@ async function generatePool( genreIds: combineTopGenres(a, b), avoidGenres, platform: room.platform_filter, - pages: 1, + pages: 2, }), ); // Recommendations aren't genre-filtered by the API → enforce avoid_genres here. @@ -125,17 +142,19 @@ async function generatePool( } // No applicable seeds → union of genre slices (per-user tastes + common ground). + // Each slice uses a different sort so the pool spans popular / acclaimed / recent. const slices: number[][] = [topGenresOf(a)]; if (b) slices.push(topGenresOf(b)); slices.push(combineTopGenres(a, b)); - for (const genreIds of slices) { + for (let i = 0; i < slices.length; i++) { addAll( await discoverTitles(env, { mediaType: room.media_type, - genreIds, + genreIds: slices[i], avoidGenres, platform: room.platform_filter, pages: 2, + sortBy: SORTS[i % SORTS.length], }), ); } @@ -244,6 +263,80 @@ export async function getDeckForUser( return { ...deck, cards }; } +/** + * Top up the shared pool on demand: fetch the next TMDb page(s) of the common-ground + * slice, dedupe against the existing pool, persist the extended pool (D1 + KV), and + * return ONLY the new cards for THIS user (their swipes + Overseerr ids excluded). + * Capped at MAX_DECK so the pool — and TMDb/KV/D1 usage — stays bounded on the free tier. + * Both users share the same extended pool, so matches remain possible. + */ +export async function extendDeck( + env: Env, + room: Room, + userId: string | null, +): Promise { + const base = await getOrCreateDeck(env, room); + const existingIds = new Set(base.cards.map((c) => c.tmdb_id)); + + const empty: DeckResult = { + room_id: room.id, + media_type: room.media_type, + generated: false, + cards: [], + }; + if (existingIds.size >= MAX_DECK) return empty; // pool already at the cap + + const [profileA, profileB] = await Promise.all([ + loadProfile(env, room.user_a_id), + loadProfile(env, room.user_b_id), + ]); + const avoidGenres = unionAvoidGenres(profileA, profileB); + const { startPage, sortBy } = topupCursor(existingIds.size); + + const fresh = await discoverTitles(env, { + mediaType: room.media_type, + genreIds: combineTopGenres(profileA, profileB), + avoidGenres, + platform: room.platform_filter, + pages: TOPUP_PAGES, + startPage, + sortBy, + }); + + const added: DeckCard[] = []; + for (const c of fresh) { + if (existingIds.size >= MAX_DECK) break; + if (existingIds.has(c.tmdb_id)) continue; + existingIds.add(c.tmdb_id); + added.push(c); + } + if (added.length === 0) return empty; // deep pages exhausted or all duplicates + + const allCards = [...base.cards, ...added]; + const ids = allCards.map((c) => c.tmdb_id); + await env.DB.prepare("UPDATE rooms SET deck = ? WHERE id = ?") + .bind(JSON.stringify(ids), room.id) + .run(); + await env.TMDB_CACHE.put(DECK_CARDS_KV_PREFIX + room.id, JSON.stringify(allCards), { + expirationTtl: DECK_CARDS_TTL, + }); + + // Serve the new cards to this user, excluding anything they've already swiped/requested. + let cards = added; + if (userId) { + 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), + ]); + const seenByUser = new Set((results ?? []).map((r) => r.tmdb_id)); + cards = added.filter((c) => !seenByUser.has(c.tmdb_id) && !requested.has(c.tmdb_id)); + } + + return { room_id: room.id, media_type: room.media_type, generated: false, cards }; +} + /** Find a card in the room's deck cache (for the match screen). */ export async function getCardFromDeck( env: Env, diff --git a/src/routes/rooms.ts b/src/routes/rooms.ts index d33fc56..9c2a4be 100644 --- a/src/routes/rooms.ts +++ b/src/routes/rooms.ts @@ -3,7 +3,7 @@ import type { Env } from "../types"; import { genId } from "../lib/ids"; import { uniqueJoinCode, userExists } from "../lib/db"; import { mapRoom } from "../lib/mappers"; -import { getDeckForUser, getCardFromDeck, resetDeck, buildMatchReason } from "../lib/deck"; +import { getDeckForUser, extendDeck, getCardFromDeck, resetDeck, buildMatchReason } from "../lib/deck"; import { getWatchProviders, getImdbId, getTrailerKey, getTitleDetails } from "../services/tmdb"; import { getOmdbRatings } from "../services/omdb"; import { createRequest } from "../services/overseerr"; @@ -138,6 +138,31 @@ rooms.get("/:id/deck", async (c) => { } }); +// GET /api/rooms/:id/deck/more?user_id=... — top up the shared pool on demand. +// Extends the SAME pool (both users converge), capped at MAX_DECK for the free tier. +// Returns { cards: [...only the new titles for this user...] } — [] when at the cap. +rooms.get("/:id/deck/more", async (c) => { + const id = c.req.param("id"); + const userId = c.req.query("user_id"); + 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 && userId !== room.user_a_id && userId !== room.user_b_id) { + return c.json({ error: "forbidden" }, 403); + } + + try { + const deck = await extendDeck(c.env, room, userId ?? null); + return c.json(deck, 200); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + return c.json({ error: "deck_topup_failed", detail }, 502); + } +}); + // GET /api/rooms/:id/rating/:tmdbId — OMDb ratings (IMDb/RT/Metacritic). Best-effort. rooms.get("/:id/rating/:tmdbId", async (c) => { const id = c.req.param("id"); diff --git a/src/services/tmdb.ts b/src/services/tmdb.ts index ae093d8..e58b191 100644 --- a/src/services/tmdb.ts +++ b/src/services/tmdb.ts @@ -126,19 +126,25 @@ export interface DiscoverOptions { avoidGenres?: number[]; platform?: string | null; pages?: number; + /** First TMDb page to fetch (default 1) — lets the deck top-up read deeper pages. */ + startPage?: number; + /** TMDb sort_by (default popularity.desc) — varied across slices for a diverse pool. */ + sortBy?: string; } /** discover /discover/{movie|tv} for RO; returns deduplicated cards. */ export async function discoverTitles(env: Env, opts: DiscoverOptions): Promise { const pages = Math.max(1, Math.min(opts.pages ?? 2, 5)); + const startPage = Math.max(1, opts.startPage ?? 1); + const sortBy = opts.sortBy ?? "popularity.desc"; const cards: DeckCard[] = []; const seen = new Set(); - for (let page = 1; page <= pages; page++) { + for (let page = startPage; page < startPage + pages; page++) { const params: Record = { language: TMDB_LANG, region: TMDB_REGION, - sort_by: "popularity.desc", + sort_by: sortBy, include_adult: "false", "vote_count.gte": "50", page: String(page), diff --git a/test/unit.test.ts b/test/unit.test.ts index e1fb328..458ac32 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { genJoinCode, genId } from "../src/lib/ids"; import { mapProfile, mapRoom } from "../src/lib/mappers"; +import { topupCursor } from "../src/lib/deck"; describe("ids", () => { it("genJoinCode is 6 chars from the safe alphabet", () => { @@ -38,3 +39,17 @@ describe("mappers", () => { expect(bad.prefs).toEqual({}); }); }); + +describe("deck top-up cursor", () => { + it("advances the start page with the pool size (~20/TMDb page)", () => { + expect(topupCursor(0).startPage).toBe(1); + expect(topupCursor(19).startPage).toBe(1); + expect(topupCursor(20).startPage).toBe(2); + expect(topupCursor(45).startPage).toBe(3); + }); + it("rotates sort_by across rounds for a varied pool", () => { + const sorts = [0, 20, 40, 60].map((n) => topupCursor(n).sortBy); + expect(new Set(sorts).size).toBeGreaterThan(1); // not always popularity.desc + expect(sorts.every((s) => typeof s === "string" && s.includes("."))).toBe(true); + }); +});