diff --git a/PLAN.md b/PLAN.md index 5771f24..5e9fe33 100644 --- a/PLAN.md +++ b/PLAN.md @@ -44,11 +44,12 @@ live over WebSocket). Works solo too (any like = personal watchlist). - [x] **V2.2** — quiz (like/love genres + **genres to avoid**), persistent solo watchlist, IMDb/RT/Metacritic ratings (OMDb), shareable invite link, swipe gestures + trailer + undo, "New session" fresh deck. -- [x] **V2.3** — **per-user decks**: one shared union pool (both users' tastes + common ground), - but ordered per user (each sees their own taste first) → matches stay possible. - (Period filter removed to keep it simple.) +- [x] **V2.3** — **per-user decks**: one shared union pool, ordered per user (each sees their + own taste first) → matches stay possible. (Period filter removed.) +- [x] **V2.4** — **seed titles** ("titles you loved" with autocomplete → TMDb recommendations + drive the pool, avoid_genres enforced) + **weighted-random deck order** (varies each time, + per user). ## Roadmap -**Seeds** — "titles I liked" with autocomplete → TMDb recommendations blended into the pool (next). **V3** — Overseerr integration (exclude already-requested titles + auto-request on match), Letterboxd CSV import, group mode, real auth. (Plex paused by decision.) diff --git a/public/app.js b/public/app.js index 19d8771..7981143 100644 --- a/public/app.js +++ b/public/app.js @@ -39,6 +39,7 @@ const state = { username: localStorage.getItem("cinemate_username") || null, genreLevels: {}, avoidGenres: new Set(), + seeds: [], // [{ tmdb_id, media_type, title }] mediaType: "movie", room: null, soloMode: false, @@ -128,6 +129,91 @@ function renderAvoidChips() { }); } +// Seed autocomplete: search TMDb as you type (debounced), pick titles you loved. +let seedSearchTimer = null; +function setupSeedSearch() { + const input = $("seed-input"); + input.addEventListener("input", () => { + clearTimeout(seedSearchTimer); + const q = input.value.trim(); + if (q.length < 2) { + hideSuggestions(); + return; + } + seedSearchTimer = setTimeout(() => searchSeeds(q), 300); + }); + document.addEventListener("click", (e) => { + if (!e.target.closest("#seed-suggestions") && e.target !== input) hideSuggestions(); + }); +} + +async function searchSeeds(q) { + try { + const res = await api(`/api/search?q=${encodeURIComponent(q)}`); + renderSuggestions(res.results || []); + } catch { + hideSuggestions(); + } +} + +function hideSuggestions() { + $("seed-suggestions").classList.add("hidden"); + $("seed-suggestions").innerHTML = ""; +} + +function renderSuggestions(list) { + const box = $("seed-suggestions"); + box.innerHTML = ""; + if (!list.length) { + hideSuggestions(); + return; + } + list.forEach((hit) => { + const row = document.createElement("button"); + row.className = "suggestion"; + const thumb = document.createElement("span"); + thumb.className = "sug-thumb"; + if (hit.poster_path) thumb.style.backgroundImage = `url(https://image.tmdb.org/t/p/w92${hit.poster_path})`; + const label = document.createElement("span"); + const kind = hit.media_type === "tv" ? "TV" : "Movie"; + label.textContent = `${hit.title}${hit.year ? " (" + hit.year + ")" : ""} · ${kind}`; + row.appendChild(thumb); + row.appendChild(label); + row.onclick = () => addSeed(hit); + box.appendChild(row); + }); + box.classList.remove("hidden"); +} + +function addSeed(hit) { + if (!state.seeds.some((s) => s.tmdb_id === hit.tmdb_id && s.media_type === hit.media_type)) { + if (state.seeds.length >= 6) { + toast("Up to 6 titles"); + } else { + state.seeds.push({ tmdb_id: hit.tmdb_id, media_type: hit.media_type, title: hit.title }); + renderSeedChips(); + } + } + $("seed-input").value = ""; + hideSuggestions(); +} + +function renderSeedChips() { + const box = $("seed-chips"); + box.innerHTML = ""; + state.seeds.forEach((s) => { + const chip = document.createElement("button"); + chip.className = "chip love"; + chip.textContent = s.title + " ✕"; + chip.title = "remove"; + chip.onclick = () => { + state.seeds = state.seeds.filter((x) => !(x.tmdb_id === s.tmdb_id && x.media_type === s.media_type)); + renderSeedChips(); + }; + box.appendChild(chip); + }); +} + async function handleOnboarding() { const err = $("onboarding-error"); err.textContent = ""; @@ -153,6 +239,7 @@ async function handleOnboarding() { user_id: user.id, genre_scores: genreScores, avoid_genres: [...state.avoidGenres], + seeds: state.seeds.map((s) => ({ tmdb_id: s.tmdb_id, media_type: s.media_type })), }), }); @@ -605,6 +692,10 @@ function resetData() { state.username = null; state.genreLevels = {}; state.avoidGenres = new Set(); + state.seeds = []; + $("seed-input").value = ""; + renderSeedChips(); + hideSuggestions(); state.room = null; state.soloMode = false; state.deck = []; @@ -635,6 +726,7 @@ async function copyText(text, label) { function init() { renderGenreChips(); renderAvoidChips(); + setupSeedSearch(); setupMediaToggle(); setupGestures(); diff --git a/public/index.html b/public/index.html index 373ff52..e95b173 100644 --- a/public/index.html +++ b/public/index.html @@ -26,6 +26,11 @@

Welcome 👋

Genres to avoid (never shown)

+

Titles you loved (strongest signal)

+ + +
+

diff --git a/public/style.css b/public/style.css index 03bc1ba..2c1e16d 100644 --- a/public/style.css +++ b/public/style.css @@ -166,6 +166,47 @@ select.input { gap: 1.5rem; } +/* Seed autocomplete */ +.suggestions { + background: var(--surface); + border: 1px solid var(--surface-2); + border-radius: var(--radius); + margin-top: 0.4rem; + max-height: 260px; + overflow-y: auto; + display: flex; + flex-direction: column; +} +.suggestion { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.4rem 0.6rem; + background: none; + border: none; + border-bottom: 1px solid var(--surface-2); + color: var(--fg); + text-align: left; + cursor: pointer; + font-size: 0.9rem; +} +.suggestion:last-child { + border-bottom: none; +} +.suggestion:active { + background: var(--surface-2); +} +.sug-thumb { + width: 34px; + height: 51px; + flex-shrink: 0; + border-radius: 5px; + background-size: cover; + background-position: center; + background-color: var(--surface-2); +} + .card-box { background: var(--surface); border-radius: var(--radius); diff --git a/src/index.ts b/src/index.ts index 9295281..042c1f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,17 +3,19 @@ import type { Env } from "./types"; import { users } from "./routes/users"; import { profile } from "./routes/profile"; import { rooms } from "./routes/rooms"; +import { search } from "./routes/search"; // 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.7" })); +app.get("/api/health", (c) => c.json({ ok: true, service: "cinemate", version: "v2.8" })); app.route("/api/users", users); app.route("/api/profile", profile); app.route("/api/rooms", rooms); +app.route("/api/search", search); // 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 cc26784..a4f5a48 100644 --- a/src/lib/deck.ts +++ b/src/lib/deck.ts @@ -4,10 +4,12 @@ // Both users draw from the SAME pool → this guarantees the overlap needed for a match. // The order may be shuffled per user; what matters is the shared pool, not the order. -import type { DeckCard, Env, MediaType, Profile, Room } from "../types"; -import { discoverTitles, getGenreMap, getTitleCard } from "../services/tmdb"; +import type { DeckCard, Env, MediaType, Profile, Room, SeedTitle } from "../types"; +import { discoverTitles, getGenreMap, getRecommendations, getTitleCard } from "../services/tmdb"; import { mapProfile } from "./mappers"; +const MAX_SEEDS_PER_USER = 3; + const TOP_GENRES = 3; const MIN_DECK = 5; const DECK_CARDS_KV_PREFIX = "deck-cards:"; @@ -60,10 +62,30 @@ function topGenresOf(p: Profile | null, n = TOP_GENRES): number[] { .map((x) => x.id); } +/** Collect both users' seed titles (capped per user, deduped). */ +function collectSeeds(a: Profile | null, b: Profile | null): SeedTitle[] { + const out: SeedTitle[] = []; + const seen = new Set(); + for (const p of [a, b]) { + let n = 0; + for (const s of p?.prefs.seeds ?? []) { + if (n >= MAX_SEEDS_PER_USER) break; + const key = `${s.media_type}:${s.tmdb_id}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(s); + n++; + } + } + return out; +} + /** - * Union pool: each user's own slice (their top genres) + a "common ground" slice - * (both users' averaged top genres). It's ONE shared pool → matches stay possible; - * per-user ordering (rankByProfile) makes each user see their own taste first. + * Build the shared pool. + * - With seeds (strongest signal): recommendations of both users' seeds drive the pool + * + a common-ground genre slice; avoid_genres always applied. + * - Without seeds: union of each user's top-genre slice + a common-ground slice. + * One shared pool → matches stay possible; per-user weighted shuffle at serve time. */ async function generatePool( env: Env, @@ -72,33 +94,68 @@ async function generatePool( b: Profile | null, avoidGenres: number[], ): Promise { - const slices: number[][] = [topGenresOf(a)]; - if (b) slices.push(topGenresOf(b)); - slices.push(combineTopGenres(a, b)); // common ground - const seen = new Set(); const cards: DeckCard[] = []; + const addAll = (arr: DeckCard[]) => { + for (const c of arr) if (!seen.has(c.tmdb_id)) { seen.add(c.tmdb_id); cards.push(c); } + }; + + // Seeds only apply to titles of the room's media type (recs of a movie are movies). + const seeds = collectSeeds(a, b).filter((s) => s.media_type === room.media_type); + + if (seeds.length > 0) { + for (const s of seeds) addAll(await getRecommendations(env, room.media_type, s.tmdb_id)); + // Common ground so overlap exists even if the two users' seeds diverge. + addAll( + await discoverTitles(env, { + mediaType: room.media_type, + genreIds: combineTopGenres(a, b), + avoidGenres, + platform: room.platform_filter, + pages: 1, + }), + ); + // Recommendations aren't genre-filtered by the API → enforce avoid_genres here. + if (avoidGenres.length > 0) { + const av = new Set(avoidGenres); + return cards.filter((c) => !c.genres.some((g) => av.has(g))); + } + return cards; + } + + // No applicable seeds → union of genre slices (per-user tastes + common ground). + const slices: number[][] = [topGenresOf(a)]; + if (b) slices.push(topGenresOf(b)); + slices.push(combineTopGenres(a, b)); for (const genreIds of slices) { - const part = await discoverTitles(env, { - mediaType: room.media_type, - genreIds, - avoidGenres, - platform: room.platform_filter, - pages: 2, - }); - for (const c of part) if (!seen.has(c.tmdb_id)) { seen.add(c.tmdb_id); cards.push(c); } + addAll( + await discoverTitles(env, { + mediaType: room.media_type, + genreIds, + avoidGenres, + platform: room.platform_filter, + pages: 2, + }), + ); } return cards; } -/** Order cards by how well they match a user's genre_scores (their taste first). */ -function rankByProfile(cards: DeckCard[], profile: Profile | null): DeckCard[] { - const scores = profile?.genre_scores; - if (!scores || Object.keys(scores).length === 0) return cards; - const scoreOf = (c: DeckCard) => c.genres.reduce((sum, g) => sum + (Number(scores[g]) || 0), 0); +/** + * Weighted random order: taste-biased but shuffled, so it differs each time and + * between the two users (they still converge — same pool). Base weight 1 keeps + * every title reachable; taste lifts a user's favourites toward the top. + */ +function weightedShuffle(cards: DeckCard[], profile: Profile | null): DeckCard[] { + const scores = profile?.genre_scores ?? {}; return cards - .map((c, i) => ({ c, i, s: scoreOf(c) })) - .sort((x, y) => y.s - x.s || x.i - y.i) // stable: taste desc, then original (popularity) + .map((c) => { + const taste = c.genres.reduce((sum, g) => sum + (Number(scores[g]) || 0), 0); + const weight = 1 + taste; + // Efraimidis–Spirakis weighted sampling key: random^(1/weight). + return { c, key: Math.pow(Math.random(), 1 / weight) }; + }) + .sort((x, y) => y.key - x.key) .map((x) => x.c); } @@ -172,7 +229,7 @@ export async function getDeckForUser( if (!userId) return deck; const profile = await loadProfile(env, userId); - let cards = rankByProfile(deck.cards, profile); + let cards = weightedShuffle(deck.cards, profile); const { results } = await env.DB.prepare( "SELECT tmdb_id FROM swipes WHERE room_id = ? AND user_id = ?", diff --git a/src/routes/profile.ts b/src/routes/profile.ts index 421c047..ec5af0b 100644 --- a/src/routes/profile.ts +++ b/src/routes/profile.ts @@ -10,6 +10,18 @@ function toNumberArray(value: unknown): number[] { return value.map(Number).filter((n) => Number.isFinite(n)); } +function toSeeds(value: unknown): { tmdb_id: number; media_type: "movie" | "tv" }[] { + if (!Array.isArray(value)) return []; + return value + .filter((s): s is Record => !!s && typeof s === "object") + .map((s) => ({ + tmdb_id: Number(s.tmdb_id), + media_type: s.media_type === "tv" ? ("tv" as const) : ("movie" as const), + })) + .filter((s) => Number.isInteger(s.tmdb_id) && s.tmdb_id > 0) + .slice(0, 6); +} + // POST /api/profile/quiz — upsert the taste profile. // Body: { user_id, genre_scores?, avoid_genres?: number[] } // Every field is optional (empty = no filter). Seeds are added in a later step. @@ -26,8 +38,8 @@ profile.post("/quiz", async (c) => { const prefs: Record = {}; const avoidGenres = toNumberArray(body?.avoid_genres); if (avoidGenres.length) prefs.avoid_genres = avoidGenres; - // seeds are preserved when present (set by the seeds step). - if (Array.isArray(body?.seeds)) prefs.seeds = body.seeds; + const seeds = toSeeds(body?.seeds); + if (seeds.length) prefs.seeds = seeds; const prefsJson = JSON.stringify(prefs); if (!(await userExists(c.env.DB, userId))) { diff --git a/src/routes/search.ts b/src/routes/search.ts new file mode 100644 index 0000000..75b1ff3 --- /dev/null +++ b/src/routes/search.ts @@ -0,0 +1,19 @@ +import { Hono } from "hono"; +import type { Env } from "../types"; +import { searchTitles } from "../services/tmdb"; + +export const search = new Hono<{ Bindings: Env }>(); + +// GET /api/search?q=...&media_type=movie|tv — autocomplete hits (KV-cached via tmdbFetch). +search.get("/", async (c) => { + const q = c.req.query("q") ?? ""; + const mt = c.req.query("media_type"); + const mediaType = mt === "movie" ? "movie" : mt === "tv" ? "tv" : undefined; + try { + const results = await searchTitles(c.env, q, mediaType); + return c.json({ results }, 200); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + return c.json({ error: "search_failed", detail }, 502); + } +}); diff --git a/src/services/tmdb.ts b/src/services/tmdb.ts index d9cb833..23006c1 100644 --- a/src/services/tmdb.ts +++ b/src/services/tmdb.ts @@ -287,3 +287,92 @@ export async function getGenreMap(env: Env, mediaType: MediaType): Promise { + const q = query.trim(); + if (!q) return []; + const path = + mediaType === "movie" ? "/search/movie" : mediaType === "tv" ? "/search/tv" : "/search/multi"; + const data = await tmdbFetch(env, path, { + language: TMDB_LANG, + query: q, + include_adult: "false", + page: "1", + }); + const hits: SearchHit[] = []; + for (const r of data.results ?? []) { + const mt: MediaType | null = + mediaType ?? (r.media_type === "movie" ? "movie" : r.media_type === "tv" ? "tv" : null); + if (!mt) continue; // skip people/other from /search/multi + const title = (mt === "movie" ? r.title : r.name) ?? ""; + if (!title) continue; + hits.push({ + tmdb_id: r.id, + media_type: mt, + title, + year: dateToYear(mt === "movie" ? r.release_date : r.first_air_date), + poster_path: r.poster_path ?? null, + }); + if (hits.length >= 8) break; + } + return hits; +} + +/** Recommendations for a seed title (falls back to /similar if few). Best-effort. */ +export async function getRecommendations( + env: Env, + mediaType: MediaType, + id: number, +): Promise { + const cards: DeckCard[] = []; + const seen = new Set(); + const add = (data: TmdbDiscoverResponse) => { + for (const r of data.results ?? []) { + if (!seen.has(r.id)) { + seen.add(r.id); + cards.push(discoverResultToCard(r, mediaType)); + } + } + }; + try { + add(await tmdbFetch(env, `/${mediaType}/${id}/recommendations`, { + language: TMDB_LANG, + page: "1", + })); + if (cards.length < 10) { + add(await tmdbFetch(env, `/${mediaType}/${id}/similar`, { + language: TMDB_LANG, + page: "1", + })); + } + } catch { + // best-effort + } + return cards; +}