From a6391fdc2e4ef78f22584a062550363e8e96e11d Mon Sep 17 00:00:00 2001 From: valeboth <109695782+valeboth@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:53:06 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20endless=20deck=20=E2=80=94=20bigger=20p?= =?UTF-8?q?ool,=20varied=20sorting=20&=20on-demand=20top-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deck was a finite, generate-once pool of popular titles: users could swipe through it in one sitting and hit "that's the whole deck" with no way to get more. Three changes, all bounded to stay on the Cloudflare free tier: - Bigger initial pool: common-ground slice 1→2 pages; each no-seed genre slice uses a different sort (popularity / rating / recency) for variety. - discoverTitles gains startPage + sortBy so we can read deeper pages and diversify without new endpoints. - On-demand top-up: GET /api/rooms/:id/deck/more extends the SAME shared pool (both users converge → matches still work), capped at MAX_DECK=120. Frontend prefetches more once ≤5 cards remain, so the deck feels endless. Free-tier safety: MAX_DECK caps TMDb calls, KV writes and the D1 deck row; top-up reads 2 cached pages; the page cursor is derived from pool size so we never re-fetch. topupCursor is pure + unit-tested. --- public/app.js | 40 +++++++++++++++++- src/lib/deck.ts | 99 ++++++++++++++++++++++++++++++++++++++++++-- src/routes/rooms.ts | 27 +++++++++++- src/services/tmdb.ts | 10 ++++- test/unit.test.ts | 15 +++++++ 5 files changed, 184 insertions(+), 7 deletions(-) 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); + }); +});