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;
+}