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
9 changes: 5 additions & 4 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
92 changes: 92 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = "";
Expand All @@ -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 })),
}),
});

Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -635,6 +726,7 @@ async function copyText(text, label) {
function init() {
renderGenreChips();
renderAvoidChips();
setupSeedSearch();
setupMediaToggle();
setupGestures();

Expand Down
5 changes: 5 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ <h2>Welcome 👋</h2>
<p class="muted mt">Genres to avoid <span class="hint">(never shown)</span></p>
<div id="avoid-chips" class="chips"></div>

<p class="muted mt">Titles you loved <span class="hint">(strongest signal)</span></p>
<input id="seed-input" class="input" type="text" placeholder="search a movie or show…" autocomplete="off" />
<div id="seed-suggestions" class="suggestions hidden"></div>
<div id="seed-chips" class="chips"></div>

<button id="onboarding-continue" class="btn btn-primary mt">Continue</button>
<p id="onboarding-error" class="error"></p>
</div>
Expand Down
41 changes: 41 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))));
Expand Down
107 changes: 82 additions & 25 deletions src/lib/deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:";
Expand Down Expand Up @@ -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<string>();
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,
Expand All @@ -72,33 +94,68 @@ async function generatePool(
b: Profile | null,
avoidGenres: number[],
): Promise<DeckCard[]> {
const slices: number[][] = [topGenresOf(a)];
if (b) slices.push(topGenresOf(b));
slices.push(combineTopGenres(a, b)); // common ground

const seen = new Set<number>();
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);
}

Expand Down Expand Up @@ -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 = ?",
Expand Down
Loading
Loading