From 6e2f650fed3261d3ebe52f0fcd76291d8ba99b8d Mon Sep 17 00:00:00 2001 From: valeboth <109695782+valeboth@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:55:28 +0300 Subject: [PATCH] feat: seed titles (autocomplete + recommendations) + weighted-random deck order - seeds: 'titles you loved' with live autocomplete (GET /api/search -> TMDb search, KV-cached, 300ms debounce); picked titles stored in profile.prefs.seeds - pool with seeds (strongest signal): recommendations (+ /similar fallback) of both users' seeds drive the pool + a common-ground genre slice; avoid_genres enforced on recs (the API doesn't filter them); union across users; no seeds -> genre-union pool - randomizer: per-user weighted shuffle at serve time (taste-biased but varies each call and differs between users) instead of a fixed deterministic order - seeds only apply to the room's media type; capped at 3/user (free-tier friendly) - health -> v2.8 Tested local: autocomplete works, seed pool = recommendations, avoid removes those genres from recs, two users' seeds union (sci-fi + romance), order differs across calls. --- PLAN.md | 9 ++-- public/app.js | 92 ++++++++++++++++++++++++++++++++++++ public/index.html | 5 ++ public/style.css | 41 ++++++++++++++++ src/index.ts | 4 +- src/lib/deck.ts | 107 ++++++++++++++++++++++++++++++++---------- src/routes/profile.ts | 16 ++++++- src/routes/search.ts | 19 ++++++++ src/services/tmdb.ts | 89 +++++++++++++++++++++++++++++++++++ 9 files changed, 350 insertions(+), 32 deletions(-) create mode 100644 src/routes/search.ts 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 @@
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