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
50 changes: 50 additions & 0 deletions logocinemate/README.md
Original file line number Diff line number Diff line change
@@ -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 (`<head>`)
```html
<link rel="icon" href="/icons/favicon.ico" sizes="any">
<link rel="icon" href="/svg/favicon.svg" type="image/svg+xml">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="theme-color" content="#E23755">
<meta property="og:image" content="/og-image.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
```

## Culori de brand
- Coral `#FF8A5B` -> Rosu `#E23755` (gradient diagonal)
- Coral solid `#FF6B5C`
- Fundal dark `#131318`
- Text deschis `#F5F5F7`
Binary file added logocinemate/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions logocinemate/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added logocinemate/logo-lockup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added logocinemate/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions logocinemate/site.webmanifest
Original file line number Diff line number Diff line change
@@ -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" }
]
}
61 changes: 49 additions & 12 deletions src/lib/deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<number[]> {
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<Profile | null> {
if (!userId) return null;
const row = await env.DB.prepare("SELECT * FROM profiles WHERE user_id = ?")
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand All @@ -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);
Expand Down
26 changes: 25 additions & 1 deletion test/unit.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
Loading