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
40 changes: 39 additions & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
99 changes: 96 additions & 3 deletions src/lib/deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Profile | null> {
if (!userId) return null;
const row = await env.DB.prepare("SELECT * FROM profiles WHERE user_id = ?")
Expand Down Expand Up @@ -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.
Expand All @@ -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],
}),
);
}
Expand Down Expand Up @@ -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<DeckResult> {
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,
Expand Down
27 changes: 26 additions & 1 deletion src/routes/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Record<string, unknown>>();
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");
Expand Down
10 changes: 8 additions & 2 deletions src/services/tmdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeckCard[]> {
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<number>();

for (let page = 1; page <= pages; page++) {
for (let page = startPage; page < startPage + pages; page++) {
const params: Record<string, string> = {
language: TMDB_LANG,
region: TMDB_REGION,
sort_by: "popularity.desc",
sort_by: sortBy,
include_adult: "false",
"vote_count.gte": "50",
page: String(page),
Expand Down
15 changes: 15 additions & 0 deletions test/unit.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
Loading