diff --git a/logocinemate/README.md b/logocinemate/README.md new file mode 100644 index 0000000..7c039a6 --- /dev/null +++ b/logocinemate/README.md @@ -0,0 +1,50 @@ +# Cinemate — logo & app icons + +Set complet de icoane pentru web/PWA, generat din sursa SVG. Pune `icons/` si +`site.webmanifest` in `public/` (radacina servita de Cloudflare Pages). + +## Structura +- `svg/icon.svg` — sursa master, patrat full-bleed (gradient + inima cu play decupat) +- `svg/favicon.svg` — varianta cu colturi rotunjite (favicon SVG modern) +- `svg/icon-mono.svg` — o singura culoare, `fill="currentColor"` (mosteneste color din CSS) +- `svg/lockup.svg` — icon + wordmark pe fundal dark (README / social / header) +- `icons/` — PNG-uri rasterizate (vezi mai jos) +- `og-image.png` — 1200x630, pentru share pe social (Open Graph / Twitter) +- `logo-lockup.png` — lockup orizontal pe fundal transparent (headere) + +## Icoane raster (`icons/`) +| fisier | dimensiune | rol | +|---|---|---| +| favicon.ico | 16/32/48 | favicon clasic | +| favicon-16.png / -32.png / -48.png | 16–48 | favicon PNG | +| icon-64 / -128 / -256 | 64–256 | uz general | +| icon-192.png | 192 | PWA | +| icon-512.png | 512 | PWA / splash | +| icon-1024.png | 1024 | master raster / store | +| apple-touch-icon.png | 180 | iOS home screen | +| maskable-192 / -512 | 192/512 | PWA maskable (safe-zone) | +| icon-reverse-512 | 512 | semn colorat pe fundal deschis | +| icon-coral-512 | 512 | mono coral, fundal transparent | +| icon-mono-white-512 | 512 | mono alb, fundal transparent | +| icon-mono-dark-512 | 512 | mono inchis, fundal transparent | + +## HTML (``) +```html + + + + + + + + + + + +``` + +## Culori de brand +- Coral `#FF8A5B` -> Rosu `#E23755` (gradient diagonal) +- Coral solid `#FF6B5C` +- Fundal dark `#131318` +- Text deschis `#F5F5F7` diff --git a/logocinemate/icon-512.png b/logocinemate/icon-512.png new file mode 100644 index 0000000..ceddd33 Binary files /dev/null and b/logocinemate/icon-512.png differ diff --git a/logocinemate/icon.svg b/logocinemate/icon.svg new file mode 100644 index 0000000..bc1b54b --- /dev/null +++ b/logocinemate/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/logocinemate/logo-lockup.png b/logocinemate/logo-lockup.png new file mode 100644 index 0000000..5dc771e Binary files /dev/null and b/logocinemate/logo-lockup.png differ diff --git a/logocinemate/og-image.png b/logocinemate/og-image.png new file mode 100644 index 0000000..05fd223 Binary files /dev/null and b/logocinemate/og-image.png differ diff --git a/logocinemate/site.webmanifest b/logocinemate/site.webmanifest new file mode 100644 index 0000000..beee2d3 --- /dev/null +++ b/logocinemate/site.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "Cinemate", + "short_name": "Cinemate", + "description": "Swipe. Match. Watch. Tinder pentru filme si seriale.", + "start_url": "/", + "display": "standalone", + "background_color": "#131318", + "theme_color": "#E23755", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/src/lib/deck.ts b/src/lib/deck.ts index d0339ff..0c7858f 100644 --- a/src/lib/deck.ts +++ b/src/lib/deck.ts @@ -22,6 +22,10 @@ const MAX_DECK = 300; 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"]; +// Adaptive top-up: the N most-recent liked titles become live seeds, each expanded +// into up to RECS_PER_SEED recommendations. Kept small → a few cached TMDb calls. +const LIKED_SEEDS_PER_TOPUP = 3; +const RECS_PER_SEED = 8; const DECK_CARDS_KV_PREFIX = "deck-cards:"; const DECK_CARDS_TTL = 60 * 60 * 24 * 7; // 7 days @@ -35,6 +39,29 @@ export function topupCursor(poolSize: number): { startPage: number; sortBy: stri return { startPage, sortBy: SORTS[startPage % SORTS.length] }; } +/** + * Drop cards in any avoided genre. TMDb recommendations/similar are NOT genre-filtered + * by the API, so callers that use them must re-apply avoid_genres. Pure → unit-tested. + */ +export function rejectAvoidGenres(cards: DeckCard[], avoidGenres: number[]): DeckCard[] { + if (avoidGenres.length === 0) return cards; + const av = new Set(avoidGenres); + return cards.filter((c) => !c.genres.some((g) => av.has(g))); +} + +/** Most-recent liked tmdb_ids in the room (both users → shared pool; solo = the one user). */ +async function recentLikedIds(env: Env, roomId: string, limit: number): Promise { + const { results } = await env.DB.prepare( + `SELECT tmdb_id FROM swipes + WHERE room_id = ? AND direction = 'like' + ORDER BY created_at DESC + LIMIT ?`, + ) + .bind(roomId, limit) + .all<{ tmdb_id: number }>(); + return (results ?? []).map((r) => r.tmdb_id); +} + async function loadProfile(env: Env, userId: string | null): Promise { if (!userId) return null; const row = await env.DB.prepare("SELECT * FROM profiles WHERE user_id = ?") @@ -136,11 +163,7 @@ async function generatePool( }), ); // 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; + return rejectAvoidGenres(cards, avoidGenres); } // No applicable seeds → union of genre slices (per-user tastes + common ground). @@ -266,11 +289,12 @@ export async function getDeckForUser( } /** - * 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. + * Top up the shared pool on demand. Adaptive: the room's most-recent likes (both users) + * are expanded into TMDb recommendations and mixed with a genre-discovery slice, so the + * deck leans toward what's being liked. Deduped against the existing pool, persisted + * (D1 + KV), and only the new cards for THIS user are returned (their swipes + Overseerr + * ids excluded). Capped at MAX_DECK so TMDb/KV/D1 usage stays bounded on the free tier. + * Recommendations go into the SHARED pool, so matches remain possible (and solo works too). */ export async function extendDeck( env: Env, @@ -293,8 +317,20 @@ export async function extendDeck( loadProfile(env, room.user_b_id), ]); const avoidGenres = unionAvoidGenres(profileA, profileB); - const { startPage, sortBy } = topupCursor(existingIds.size); + // Adaptive: expand the room's most-recent likes into TMDb recommendations, so the deck + // leans toward what's actually being liked. Likes come from BOTH users → they land in + // the shared pool, so matches survive; in a solo room it's simply this one user's likes. + const likedIds = await recentLikedIds(env, room.id, LIKED_SEEDS_PER_TOPUP); + const recLists = await Promise.all( + likedIds.map((id) => getRecommendations(env, room.media_type, id)), + ); + const recCards = recLists.flatMap((recs) => + rejectAvoidGenres(recs, avoidGenres).slice(0, RECS_PER_SEED), + ); + + // Genre-based discovery as filler + variety (deeper page each round, rotated sort). + const { startPage, sortBy } = topupCursor(existingIds.size); const fresh = await discoverTitles(env, { mediaType: room.media_type, genreIds: combineTopGenres(profileA, profileB), @@ -305,8 +341,9 @@ export async function extendDeck( sortBy, }); + // Personalized recommendations first, then discovery — deduped into the shared pool. const added: DeckCard[] = []; - for (const c of fresh) { + for (const c of [...recCards, ...fresh]) { if (existingIds.size >= MAX_DECK) break; if (existingIds.has(c.tmdb_id)) continue; existingIds.add(c.tmdb_id); diff --git a/test/unit.test.ts b/test/unit.test.ts index 458ac32..67a5e42 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -1,7 +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"; +import { topupCursor, rejectAvoidGenres } from "../src/lib/deck"; describe("ids", () => { it("genJoinCode is 6 chars from the safe alphabet", () => { @@ -53,3 +53,27 @@ describe("deck top-up cursor", () => { expect(sorts.every((s) => typeof s === "string" && s.includes("."))).toBe(true); }); }); + +describe("rejectAvoidGenres (adaptive top-up filter)", () => { + const card = (tmdb_id: number, genres: number[]) => ({ + tmdb_id, + media_type: "movie" as const, + title: `t${tmdb_id}`, + overview: "", + poster_path: null, + genres, + release_year: null, + vote_average: null, + }); + + it("drops cards that contain any avoided genre", () => { + const cards = [card(1, [28, 12]), card(2, [27]), card(3, [35, 27]), card(4, [18])]; + const kept = rejectAvoidGenres(cards, [27]); // 27 = Horror + expect(kept.map((c) => c.tmdb_id)).toEqual([1, 4]); + }); + + it("returns everything when no genres are avoided", () => { + const cards = [card(1, [28]), card(2, [27])]; + expect(rejectAvoidGenres(cards, [])).toHaveLength(2); + }); +});