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
10 changes: 10 additions & 0 deletions .agents/skills/naming/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ For every concept there is **one canonical word**. The others are **forbidden**

Don't use `user` for profile data or `profile` for the auth session. `user` = who is logged in; `profile` = the editable public record in our DB.

### Streaming services (providers vs platforms)

| Concept | ✅ Canonical |
|---|---|
| The TMDB catalog entity (e.g. Netflix, the row in `providers`) — id, name, logo | **`provider`** (`providerId`, `name`, `logoPath`; `providers` table; `tmdb.WatchProviders`) |
| The user's selected services — what *they* subscribe to | **`platforms`** (`user_platforms` table; `GET/PUT /platforms/me`; `services/platforms`; `useMyPlatforms`) |
| Availability of a title on a given provider in a region | **`mediaProviders`** (the cache table) — never `streaming-rights`, never `offers` as a top-level noun |

Why split: a `provider` is the global thing (one Netflix exists), a `platform` is *the user's chosen subset* of providers (which services *I* pay for). Mixing the two leads to ambiguous APIs (`/providers/me` could mean either the catalog or my subscriptions). Keep `provider` for catalog/availability, `platforms` for the user's selection — both in URLs, files, types, and locale keys (`platforms.menuAction`, `mediaDetail.whereToWatch`).

---

## 2. File & folder architecture
Expand Down
8 changes: 8 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
"./tmdb": {
"types": "./src/modules/tmdb/model.ts",
"default": "./src/modules/tmdb/model.ts"
},
"./platforms": {
"types": "./src/modules/platforms/model.ts",
"default": "./src/modules/platforms/model.ts"
},
"./recommendations": {
"types": "./src/modules/recommendations/model.ts",
"default": "./src/modules/recommendations/model.ts"
}
},
"scripts": {
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/lib/sort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const NO_PRIORITY = 9999;

// Order providers by TMDB display priority, pushing entries without one last.
export function byDisplayPriority(
a: { displayPriority?: number | null },
b: { displayPriority?: number | null },
): number {
return (a.displayPriority ?? NO_PRIORITY) - (b.displayPriority ?? NO_PRIORITY);
}
2 changes: 2 additions & 0 deletions apps/api/src/modules/platforms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { platformsController } from "./router";
export type { ProviderRefDto, UserPlatformsDto, SetUserPlatformsInputDto } from "./model";
32 changes: 32 additions & 0 deletions apps/api/src/modules/platforms/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Elysia, t } from "elysia";
import type { Static } from "@sinclair/typebox";

const providerRef = t.Object({
providerId: t.Number(),
name: t.String(),
logoPath: t.Nullable(t.String()),
});

const userPlatforms = t.Object({
region: t.String(),
providers: t.Array(providerRef),
});

const setUserPlatformsInput = t.Object({
region: t.String({ minLength: 2, maxLength: 4 }),
providerIds: t.Array(t.Number(), { maxItems: 100 }),
});

export const PlatformsModel = new Elysia({ name: "Platforms.Model" }).model({
"platforms.ProviderRef": providerRef,
"platforms.ProviderList": t.Array(providerRef),
"platforms.UserPlatforms": userPlatforms,
"platforms.RegionQuery": t.Object({
region: t.Optional(t.String({ minLength: 2, maxLength: 4 })),
}),
"platforms.SetUserPlatformsInput": setUserPlatformsInput,
});

export type ProviderRefDto = Static<typeof providerRef>;
export type UserPlatformsDto = Static<typeof userPlatforms>;
export type SetUserPlatformsInputDto = Static<typeof setUserPlatformsInput>;
1 change: 1 addition & 0 deletions apps/api/src/modules/platforms/mutations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { setUserPlatforms } from "./set-user-platforms";
42 changes: 42 additions & 0 deletions apps/api/src/modules/platforms/mutations/set-user-platforms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { db } from "@seen/db";
import { providers as providersTable, userPlatforms } from "@seen/db/schema";
import { and, eq, inArray } from "@seen/db/orm";

import { getUserPlatforms } from "../queries/get-user-platforms";
import type { SetUserPlatformsInputDto, UserPlatformsDto } from "../model";

export async function setUserPlatforms(
userId: string,
input: SetUserPlatformsInputDto,
): Promise<UserPlatformsDto> {
const uniqueIds = [...new Set(input.providerIds)];

await db.transaction(async (tx) => {
await tx
.delete(userPlatforms)
.where(and(eq(userPlatforms.userId, userId), eq(userPlatforms.region, input.region)));

if (uniqueIds.length === 0) return;

const existing = await tx
.select({ providerId: providersTable.providerId })
.from(providersTable)
.where(inArray(providersTable.providerId, uniqueIds));
const known = new Set(existing.map((row) => row.providerId));
const valid = uniqueIds.filter((id) => known.has(id));
if (valid.length === 0) return;

await tx
.insert(userPlatforms)
.values(
valid.map((providerId) => ({
userId,
providerId,
region: input.region,
})),
)
.onConflictDoNothing();
});

return getUserPlatforms(userId, input.region);
}
27 changes: 27 additions & 0 deletions apps/api/src/modules/platforms/queries/get-user-platforms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { db } from "@seen/db";
import { providers as providersTable, userPlatforms } from "@seen/db/schema";
import { and, eq } from "@seen/db/orm";

import { byDisplayPriority } from "../../../lib/sort";
import type { UserPlatformsDto } from "../model";

export async function getUserPlatforms(userId: string, region: string): Promise<UserPlatformsDto> {
const rows = await db
.select({
providerId: userPlatforms.providerId,
name: providersTable.name,
logoPath: providersTable.logoPath,
displayPriority: providersTable.displayPriority,
})
.from(userPlatforms)
.innerJoin(providersTable, eq(providersTable.providerId, userPlatforms.providerId))
.where(and(eq(userPlatforms.userId, userId), eq(userPlatforms.region, region)));

const providers = rows.sort(byDisplayPriority).map((row) => ({
providerId: row.providerId,
name: row.name,
logoPath: row.logoPath ?? null,
}));

return { region, providers };
}
2 changes: 2 additions & 0 deletions apps/api/src/modules/platforms/queries/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { listProviders } from "./list-providers";
export { getUserPlatforms } from "./get-user-platforms";
98 changes: 98 additions & 0 deletions apps/api/src/modules/platforms/queries/list-providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { db } from "@seen/db";
import { providers as providersTable } from "@seen/db/schema";
import { sql } from "@seen/db/orm";

import { asNumber, asRecord, asString } from "../../../lib/coerce";
import { tmdbFetch } from "../../tmdb/client";
import type { ProviderRefDto } from "../model";

type TmdbProvider = {
provider_id?: unknown;
provider_name?: unknown;
logo_path?: unknown;
display_priority?: unknown;
};

type TmdbProvidersResponse = {
results?: TmdbProvider[];
};

async function fetchCatalog(mediaType: "movie" | "tv", region: string): Promise<TmdbProvider[]> {
const res = await tmdbFetch<TmdbProvidersResponse>(
`/watch/providers/${mediaType}`,
{ watch_region: region },
60 * 60,
);
return Array.isArray(res.results) ? res.results : [];
}

async function warmProviders(rows: ProviderRefDto[], priorities: Map<number, number>) {
if (rows.length === 0) return;
await db
.insert(providersTable)
.values(
rows.map((row) => ({
providerId: row.providerId,
name: row.name,
logoPath: row.logoPath ?? null,
displayPriority: priorities.get(row.providerId) ?? null,
})),
)
.onConflictDoUpdate({
target: providersTable.providerId,
set: {
name: sql`excluded.name`,
logoPath: sql`excluded.logo_path`,
displayPriority: sql`excluded.display_priority`,
updatedAt: new Date(),
},
});
}

export async function listProviders(region: string): Promise<ProviderRefDto[]> {
const [movies, series] = await Promise.all([
fetchCatalog("movie", region),
fetchCatalog("tv", region),
]);

const dedup = new Map<number, ProviderRefDto>();
const priorities = new Map<number, number>();

for (const entry of [...movies, ...series]) {
const record = asRecord(entry);
const providerId = asNumber(record.provider_id);
const name = asString(record.provider_name);
if (providerId === undefined || !name) continue;
if (!dedup.has(providerId)) {
dedup.set(providerId, {
providerId,
name,
logoPath: asString(record.logo_path) ?? null,
});
}
const priority = asNumber(record.display_priority);
if (priority !== undefined) {
const current = priorities.get(providerId);
if (current === undefined || priority < current) priorities.set(providerId, priority);
}
}

const list = [...dedup.values()].sort((a, b) => {
const pa = priorities.get(a.providerId) ?? 9999;
const pb = priorities.get(b.providerId) ?? 9999;
if (pa !== pb) return pa - pb;
return a.name.localeCompare(b.name);
});

// Await the warm so the `providers` table is authoritative before the client
// can POST a selection back: setUserPlatforms validates provider ids against
// this table, so a fire-and-forget warm would let a fast save silently drop
// every selection on a cold catalog.
try {
await warmProviders(list, priorities);
} catch (error) {
console.error("provider catalog warm failed", error);
}

return list;
}
34 changes: 34 additions & 0 deletions apps/api/src/modules/platforms/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Elysia } from "elysia";

import { authGuard } from "../../auth-plugin";
import { DEFAULT_REGION } from "../tmdb/client";
import { PlatformsModel } from "./model";
import { setUserPlatforms } from "./mutations";
import { getUserPlatforms, listProviders } from "./queries";

export const platformsController = new Elysia({
name: "Platforms.Controller",
prefix: "/platforms",
})
.use(authGuard)
.use(PlatformsModel)
.get("/providers", ({ query }) => listProviders(query.region ?? DEFAULT_REGION), {
query: "platforms.RegionQuery",
response: {
200: "platforms.ProviderList",
},
})
.get("/me", ({ user, query }) => getUserPlatforms(user.id, query.region ?? DEFAULT_REGION), {
auth: true,
query: "platforms.RegionQuery",
response: {
200: "platforms.UserPlatforms",
},
})
.put("/me", ({ user, body }) => setUserPlatforms(user.id, body), {
auth: true,
body: "platforms.SetUserPlatformsInput",
response: {
200: "platforms.UserPlatforms",
},
});
2 changes: 2 additions & 0 deletions apps/api/src/modules/recommendations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { recommendationsController } from "./router";
export type { AvailableEntryDto } from "./model";
46 changes: 46 additions & 0 deletions apps/api/src/modules/recommendations/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Elysia, t } from "elysia";
import type { Static } from "@sinclair/typebox";

const mediaType = t.Union([t.Literal("movie"), t.Literal("tv")]);
const mediaFilter = t.Union([t.Literal("all"), mediaType]);

const summary = t.Object({
id: t.Number(),
media_type: mediaType,
title: t.Optional(t.String()),
original_title: t.Optional(t.String()),
overview: t.Optional(t.String()),
release_date: t.Optional(t.String()),
runtime: t.Optional(t.Nullable(t.Number())),
poster_path: t.Optional(t.Nullable(t.String())),
backdrop_path: t.Optional(t.Nullable(t.String())),
vote_average: t.Optional(t.Number()),
vote_count: t.Optional(t.Number()),
popularity: t.Optional(t.Number()),
genre_ids: t.Optional(t.Array(t.Number())),
});

const providerRef = t.Object({
providerId: t.Number(),
name: t.String(),
logoPath: t.Nullable(t.String()),
});

const availableEntry = t.Composite([
summary,
t.Object({
providers: t.Array(providerRef),
isShort: t.Boolean(),
}),
]);

export const RecommendationsModel = new Elysia({ name: "Recommendations.Model" }).model({
"recommendations.AvailableQuery": t.Object({
region: t.Optional(t.String({ minLength: 2, maxLength: 4 })),
filter: t.Optional(mediaFilter),
}),
"recommendations.AvailableList": t.Array(availableEntry),
"recommendations.AvailableEntry": availableEntry,
});

export type AvailableEntryDto = Static<typeof availableEntry>;
Loading
Loading