diff --git a/.agents/skills/naming/SKILL.md b/.agents/skills/naming/SKILL.md index c588b09..c7346a5 100644 --- a/.agents/skills/naming/SKILL.md +++ b/.agents/skills/naming/SKILL.md @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json index 03736c7..7bdf10b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": { diff --git a/apps/api/src/lib/sort.ts b/apps/api/src/lib/sort.ts new file mode 100644 index 0000000..e9ac6e5 --- /dev/null +++ b/apps/api/src/lib/sort.ts @@ -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); +} diff --git a/apps/api/src/modules/platforms/index.ts b/apps/api/src/modules/platforms/index.ts new file mode 100644 index 0000000..c629794 --- /dev/null +++ b/apps/api/src/modules/platforms/index.ts @@ -0,0 +1,2 @@ +export { platformsController } from "./router"; +export type { ProviderRefDto, UserPlatformsDto, SetUserPlatformsInputDto } from "./model"; diff --git a/apps/api/src/modules/platforms/model.ts b/apps/api/src/modules/platforms/model.ts new file mode 100644 index 0000000..a0c4959 --- /dev/null +++ b/apps/api/src/modules/platforms/model.ts @@ -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; +export type UserPlatformsDto = Static; +export type SetUserPlatformsInputDto = Static; diff --git a/apps/api/src/modules/platforms/mutations/index.ts b/apps/api/src/modules/platforms/mutations/index.ts new file mode 100644 index 0000000..b467e7a --- /dev/null +++ b/apps/api/src/modules/platforms/mutations/index.ts @@ -0,0 +1 @@ +export { setUserPlatforms } from "./set-user-platforms"; diff --git a/apps/api/src/modules/platforms/mutations/set-user-platforms.ts b/apps/api/src/modules/platforms/mutations/set-user-platforms.ts new file mode 100644 index 0000000..2ae5d43 --- /dev/null +++ b/apps/api/src/modules/platforms/mutations/set-user-platforms.ts @@ -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 { + 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); +} diff --git a/apps/api/src/modules/platforms/queries/get-user-platforms.ts b/apps/api/src/modules/platforms/queries/get-user-platforms.ts new file mode 100644 index 0000000..dbdb0e6 --- /dev/null +++ b/apps/api/src/modules/platforms/queries/get-user-platforms.ts @@ -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 { + 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 }; +} diff --git a/apps/api/src/modules/platforms/queries/index.ts b/apps/api/src/modules/platforms/queries/index.ts new file mode 100644 index 0000000..006de43 --- /dev/null +++ b/apps/api/src/modules/platforms/queries/index.ts @@ -0,0 +1,2 @@ +export { listProviders } from "./list-providers"; +export { getUserPlatforms } from "./get-user-platforms"; diff --git a/apps/api/src/modules/platforms/queries/list-providers.ts b/apps/api/src/modules/platforms/queries/list-providers.ts new file mode 100644 index 0000000..18f156b --- /dev/null +++ b/apps/api/src/modules/platforms/queries/list-providers.ts @@ -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 { + const res = await tmdbFetch( + `/watch/providers/${mediaType}`, + { watch_region: region }, + 60 * 60, + ); + return Array.isArray(res.results) ? res.results : []; +} + +async function warmProviders(rows: ProviderRefDto[], priorities: Map) { + 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 { + const [movies, series] = await Promise.all([ + fetchCatalog("movie", region), + fetchCatalog("tv", region), + ]); + + const dedup = new Map(); + const priorities = new Map(); + + 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; +} diff --git a/apps/api/src/modules/platforms/router.ts b/apps/api/src/modules/platforms/router.ts new file mode 100644 index 0000000..b77f0f0 --- /dev/null +++ b/apps/api/src/modules/platforms/router.ts @@ -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", + }, + }); diff --git a/apps/api/src/modules/recommendations/index.ts b/apps/api/src/modules/recommendations/index.ts new file mode 100644 index 0000000..5027b9b --- /dev/null +++ b/apps/api/src/modules/recommendations/index.ts @@ -0,0 +1,2 @@ +export { recommendationsController } from "./router"; +export type { AvailableEntryDto } from "./model"; diff --git a/apps/api/src/modules/recommendations/model.ts b/apps/api/src/modules/recommendations/model.ts new file mode 100644 index 0000000..5094c02 --- /dev/null +++ b/apps/api/src/modules/recommendations/model.ts @@ -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; diff --git a/apps/api/src/modules/recommendations/queries/available-feed.ts b/apps/api/src/modules/recommendations/queries/available-feed.ts new file mode 100644 index 0000000..63f7792 --- /dev/null +++ b/apps/api/src/modules/recommendations/queries/available-feed.ts @@ -0,0 +1,234 @@ +import { db } from "@seen/db"; +import { + mediaProviders, + movies as moviesTable, + providers as providersTable, + userPlatforms, + watchlist, +} from "@seen/db/schema"; +import { and, eq, inArray } from "@seen/db/orm"; + +import { byDisplayPriority } from "../../../lib/sort"; +import { normalizeSummary, trending } from "../../tmdb/client"; +import { getMediaDetail } from "../../tmdb/queries/media-detail"; +import type { MediaFilter, TmdbMovieSummary } from "../../tmdb"; +import type { AvailableEntryDto } from "../model"; + +// Cap on how many cache-cold titles we warm per request, so a fresh feed +// populates itself over a few loads without fanning out to TMDB unbounded. +const MAX_WARM_PER_REQUEST = 16; + +const SHORT_MOVIE_RUNTIME_MAX = 100; +const TV_EPISODE_RUNTIME_MAX = 45; + +type Candidate = { + summary: TmdbMovieSummary; + runtime: number | null; +}; + +async function getUserPlatformIds(userId: string, region: string): Promise { + const rows = await db + .select({ providerId: userPlatforms.providerId }) + .from(userPlatforms) + .where(and(eq(userPlatforms.userId, userId), eq(userPlatforms.region, region))); + return rows.map((row) => row.providerId); +} + +async function getWatchlistCandidates(userId: string, filter: MediaFilter): Promise { + const where = and( + eq(watchlist.userId, userId), + filter === "all" ? undefined : eq(watchlist.mediaType, filter), + ); + + const rows = await db + .select({ media: moviesTable }) + .from(watchlist) + .innerJoin( + moviesTable, + and(eq(watchlist.tmdbId, moviesTable.tmdbId), eq(watchlist.mediaType, moviesTable.mediaType)), + ) + .where(where); + + return rows.map(({ media }) => ({ + summary: { + id: media.tmdbId, + media_type: media.mediaType as "movie" | "tv", + title: media.title ?? undefined, + original_title: media.originalTitle ?? undefined, + overview: media.overview ?? undefined, + release_date: media.releaseDate ?? undefined, + runtime: media.runtime ?? null, + poster_path: media.posterPath ?? null, + backdrop_path: media.backdropPath ?? null, + vote_average: media.voteAverage ?? undefined, + vote_count: media.voteCount ?? undefined, + popularity: media.popularity ?? undefined, + genre_ids: Array.isArray(media.genres) ? (media.genres as number[]) : undefined, + }, + runtime: media.runtime ?? null, + })); +} + +async function getTrendingCandidates(filter: MediaFilter): Promise { + const list = await trending(filter, "week"); + return list.map((summary) => ({ summary, runtime: summary.runtime ?? null })); +} + +// Trending summaries carry no runtime, so back-fill it from the movies cache +// (populated whenever a detail is viewed); without this the short shelf can only +// ever surface watchlist titles. +async function fillRuntimes(candidates: Candidate[]): Promise { + const missing = candidates.filter((candidate) => candidate.runtime === null); + if (missing.length === 0) return; + const ids = missing.map((candidate) => candidate.summary.id); + const rows = await db + .select({ + tmdbId: moviesTable.tmdbId, + mediaType: moviesTable.mediaType, + runtime: moviesTable.runtime, + }) + .from(moviesTable) + .where(inArray(moviesTable.tmdbId, ids)); + + const byKey = new Map(); + for (const row of rows) { + if (row.runtime !== null) byKey.set(`${row.mediaType}:${row.tmdbId}`, row.runtime); + } + for (const candidate of missing) { + const runtime = byKey.get(`${candidate.summary.media_type}:${candidate.summary.id}`); + if (runtime !== undefined) candidate.runtime = runtime; + } +} + +// Fire-and-forget warm for titles with no cached availability, so a cold feed +// fills in over the next few loads instead of staying empty. +function warmMissingProviders( + candidates: Candidate[], + providersByKey: ReadonlyMap, +): void { + const missing = candidates + .filter( + (candidate) => !providersByKey.has(`${candidate.summary.media_type}:${candidate.summary.id}`), + ) + .slice(0, MAX_WARM_PER_REQUEST); + for (const candidate of missing) { + void getMediaDetail(candidate.summary.media_type, candidate.summary.id).catch(() => {}); + } +} + +function dedup(candidates: Candidate[]): Candidate[] { + const seen = new Set(); + const out: Candidate[] = []; + for (const candidate of candidates) { + const key = `${candidate.summary.media_type}:${candidate.summary.id}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(candidate); + } + return out; +} + +async function getProvidersForCandidates( + candidates: Candidate[], + region: string, +): Promise> { + if (candidates.length === 0) return new Map(); + const ids = candidates.map((candidate) => candidate.summary.id); + const rows = await db + .select({ + tmdbId: mediaProviders.tmdbId, + mediaType: mediaProviders.mediaType, + providerId: mediaProviders.providerId, + offerType: mediaProviders.offerType, + displayPriority: providersTable.displayPriority, + name: providersTable.name, + logoPath: providersTable.logoPath, + }) + .from(mediaProviders) + .innerJoin(providersTable, eq(providersTable.providerId, mediaProviders.providerId)) + .where( + and( + inArray(mediaProviders.tmdbId, ids), + eq(mediaProviders.region, region), + eq(mediaProviders.offerType, "flatrate"), + ), + ); + + const grouped = new Map< + string, + { providerId: number; name: string; logoPath: string | null; displayPriority: number | null }[] + >(); + + for (const row of rows) { + const key = `${row.mediaType}:${row.tmdbId}`; + const entry = { + providerId: row.providerId, + name: row.name, + logoPath: row.logoPath ?? null, + displayPriority: row.displayPriority, + }; + const list = grouped.get(key); + if (list) list.push(entry); + else grouped.set(key, [entry]); + } + + const byKey = new Map(); + for (const [key, list] of grouped) { + byKey.set( + key, + list + .sort(byDisplayPriority) + .map(({ providerId, name, logoPath }) => ({ providerId, name, logoPath })), + ); + } + return byKey; +} + +function passesShortFilter(candidate: Candidate): boolean { + if (candidate.summary.media_type === "movie") { + return candidate.runtime !== null && candidate.runtime <= SHORT_MOVIE_RUNTIME_MAX; + } + // For TV, treat short as a typical sitcom-length episode (best effort: + // movies.runtime stores the first episode runtime when known, otherwise null). + return candidate.runtime !== null && candidate.runtime <= TV_EPISODE_RUNTIME_MAX; +} + +export async function getAvailableFeed( + userId: string, + region: string, + filter: MediaFilter = "all", +): Promise { + const [userPlatformIds, watchlistCandidates, trendingCandidates] = await Promise.all([ + getUserPlatformIds(userId, region), + getWatchlistCandidates(userId, filter), + getTrendingCandidates(filter), + ]); + + if (userPlatformIds.length === 0) return []; + + const candidates = dedup([...watchlistCandidates, ...trendingCandidates]); + if (candidates.length === 0) return []; + + // Runtime back-fill and the provider lookup are independent reads. + const [, providersByKey] = await Promise.all([ + fillRuntimes(candidates), + getProvidersForCandidates(candidates, region), + ]); + warmMissingProviders(candidates, providersByKey); + + const platformIds = new Set(userPlatformIds); + const entries: AvailableEntryDto[] = []; + for (const candidate of candidates) { + const key = `${candidate.summary.media_type}:${candidate.summary.id}`; + const providers = providersByKey.get(key) ?? []; + const matching = providers.filter((provider) => platformIds.has(provider.providerId)); + if (matching.length === 0) continue; + entries.push({ + ...normalizeSummary(candidate.summary, candidate.summary.media_type), + providers: matching, + isShort: passesShortFilter(candidate), + }); + } + + return entries.sort((a, b) => (b.popularity ?? 0) - (a.popularity ?? 0)); +} diff --git a/apps/api/src/modules/recommendations/queries/index.ts b/apps/api/src/modules/recommendations/queries/index.ts new file mode 100644 index 0000000..ad87365 --- /dev/null +++ b/apps/api/src/modules/recommendations/queries/index.ts @@ -0,0 +1 @@ +export { getAvailableFeed } from "./available-feed"; diff --git a/apps/api/src/modules/recommendations/router.ts b/apps/api/src/modules/recommendations/router.ts new file mode 100644 index 0000000..9a92451 --- /dev/null +++ b/apps/api/src/modules/recommendations/router.ts @@ -0,0 +1,25 @@ +import { Elysia } from "elysia"; + +import { authGuard } from "../../auth-plugin"; +import { DEFAULT_REGION } from "../tmdb/client"; +import { RecommendationsModel } from "./model"; +import { getAvailableFeed } from "./queries"; + +export const recommendationsController = new Elysia({ + name: "Recommendations.Controller", + prefix: "/recommendations", +}) + .use(authGuard) + .use(RecommendationsModel) + .get( + "/available", + ({ user, query }) => + getAvailableFeed(user.id, query.region ?? DEFAULT_REGION, query.filter ?? "all"), + { + auth: true, + query: "recommendations.AvailableQuery", + response: { + 200: "recommendations.AvailableList", + }, + }, + ); diff --git a/apps/api/src/modules/router.ts b/apps/api/src/modules/router.ts index 57e84e2..dfdf5bf 100644 --- a/apps/api/src/modules/router.ts +++ b/apps/api/src/modules/router.ts @@ -6,7 +6,9 @@ import { eventsController } from "./events"; import { importController } from "./import"; import { likesController } from "./likes"; import { notInterestedController } from "./not-interested"; +import { platformsController } from "./platforms"; import { profileController } from "./profiles"; +import { recommendationsController } from "./recommendations"; import { reviewController } from "./reviews"; import { tmdbController } from "./tmdb"; import { watchlistController } from "./watchlist"; @@ -24,4 +26,6 @@ export const apiRouter = new Elysia({ name: "api.router" }) .use(eventsController) .use(importController) .use(accountController) + .use(platformsController) + .use(recommendationsController) .use(whatsNewController); diff --git a/apps/api/src/modules/tmdb/client.ts b/apps/api/src/modules/tmdb/client.ts index 041b60a..304caac 100644 --- a/apps/api/src/modules/tmdb/client.ts +++ b/apps/api/src/modules/tmdb/client.ts @@ -1,8 +1,9 @@ import { db } from "@seen/db"; -import { movies as moviesTable } from "@seen/db/schema"; +import { mediaProviders, movies as moviesTable } from "@seen/db/schema"; +import { and, eq } from "@seen/db/orm"; import { env } from "../../env"; -import { asDateString, asNumber, asString } from "../../lib/coerce"; +import { asDateString, asNumber, asRecord, asString } from "../../lib/coerce"; import { HttpError } from "../../lib/http-error"; import { redisGetJson, redisSetJson, withRedisLock } from "../../lib/redis"; import type { MovieDetailDto } from "./model"; @@ -13,14 +14,17 @@ import type { MovieDetailDto } from "./model"; const TMDB_BASE = "https://api.themoviedb.org/3"; export const DEFAULT_LANGUAGE = "fr-FR"; +export const DEFAULT_REGION = "FR"; const HOT_TTL_SECONDS = 5 * 60; export const DETAIL_TTL_MS = 30 * 24 * 60 * 60 * 1000; export const DETAIL_APPEND: Record = { - movie: "credits,videos,images,release_dates", - tv: "credits,videos,images,content_ratings", + movie: "credits,videos,images,release_dates,watch/providers", + tv: "credits,videos,images,content_ratings,watch/providers", }; +const OFFER_TYPES = ["flatrate", "rent", "buy", "ads", "free"] as const; + export const MEDIA_GENRE_SHELVES = [ { key: "Action", name: "Action", movieGenreId: 28, tvGenreId: 10759 }, { key: "Comedy", name: "Comedy", movieGenreId: 35, tvGenreId: 35 }, @@ -244,7 +248,11 @@ export async function upsertMovieList( ); } -export async function upsertMovieDetail(detail: MovieDetailDto, language: string): Promise { +export async function upsertMovieDetail( + detail: MovieDetailDto, + raw: Record, + language: string, +): Promise { const values = { ...movieSummaryValues(detail, language), runtime: detail.runtime ?? null, @@ -262,6 +270,61 @@ export async function upsertMovieDetail(detail: MovieDetailDto, language: string target: [moviesTable.tmdbId, moviesTable.mediaType], set: values, }); + + void upsertMediaProvidersFromDetail(detail.id, detail.media_type, raw).catch((error) => + console.error("media providers cache warm failed", error), + ); +} + +type WatchProvidersByRegion = Record>; + +function readRegionResults(detail: Record): WatchProvidersByRegion { + const providers = asRecord(detail["watch/providers"]); + const results = asRecord(providers.results); + return results as WatchProvidersByRegion; +} + +export async function upsertMediaProvidersFromDetail( + tmdbId: number, + mediaType: MediaType, + detail: Record, +): Promise { + const results = readRegionResults(detail); + const regions = Object.keys(results); + if (regions.length === 0) return; + + const rows: (typeof mediaProviders.$inferInsert)[] = []; + for (const region of regions) { + const regionEntry = asRecord(results[region]); + for (const offerType of OFFER_TYPES) { + const offers = regionEntry[offerType]; + if (!Array.isArray(offers)) continue; + for (const offer of offers) { + const providerId = asNumber(asRecord(offer).provider_id); + if (providerId === undefined) continue; + rows.push({ + tmdbId, + mediaType, + region, + providerId, + offerType, + updatedAt: new Date(), + }); + } + } + } + + // Authoritatively replace this title's cached availability: the detail + // response carries every region/offer, so delete-then-insert prunes providers + // that dropped the title. A plain upsert would leave stale rows forever, since + // cache freshness uses the newest row and removed offers are never refreshed. + await db.transaction(async (tx) => { + await tx + .delete(mediaProviders) + .where(and(eq(mediaProviders.tmdbId, tmdbId), eq(mediaProviders.mediaType, mediaType))); + if (rows.length === 0) return; + await tx.insert(mediaProviders).values(rows).onConflictDoNothing(); + }); } export async function discover( diff --git a/apps/api/src/modules/tmdb/model.ts b/apps/api/src/modules/tmdb/model.ts index d696ee1..0627f91 100644 --- a/apps/api/src/modules/tmdb/model.ts +++ b/apps/api/src/modules/tmdb/model.ts @@ -106,6 +106,20 @@ const episodeDetail = t.Composite([ }), ]); +const providerRef = t.Object({ + providerId: t.Number(), + name: t.String(), + logoPath: t.Nullable(t.String()), +}); + +const watchProviders = t.Object({ + region: t.String(), + link: t.Nullable(t.String()), + flatrate: t.Array(providerRef), + rent: t.Array(providerRef), + buy: t.Array(providerRef), +}); + const genreRow = t.Object({ key: t.String(), name: t.String(), @@ -141,6 +155,12 @@ export const TmdbModel = new Elysia({ name: "Tmdb.Model" }).model({ "tmdb.LanguageQuery": t.Object({ language: t.Optional(t.String({ minLength: 2, maxLength: 12 })), }), + "tmdb.RegionQuery": t.Object({ + region: t.Optional(t.String({ minLength: 2, maxLength: 4 })), + language: t.Optional(t.String({ minLength: 2, maxLength: 12 })), + }), + "tmdb.WatchProviders": watchProviders, + "tmdb.ProviderRef": providerRef, "tmdb.SeasonParams": t.Object({ seriesId: t.Numeric(), seasonNumber: t.Numeric(), @@ -169,3 +189,5 @@ export type SeasonDetailDto = Static; export type EpisodeDetailDto = Static; export type DiscoverFeedDto = Static; export type GenreRowDto = Static; +export type ProviderRefDto = Static; +export type WatchProvidersDto = Static; diff --git a/apps/api/src/modules/tmdb/queries/index.ts b/apps/api/src/modules/tmdb/queries/index.ts index a4583c8..c198586 100644 --- a/apps/api/src/modules/tmdb/queries/index.ts +++ b/apps/api/src/modules/tmdb/queries/index.ts @@ -3,3 +3,4 @@ export * from "./search"; export * from "./media-detail"; export * from "./tv-season-detail"; export * from "./tv-episode-detail"; +export * from "./watch-providers"; diff --git a/apps/api/src/modules/tmdb/queries/media-detail.ts b/apps/api/src/modules/tmdb/queries/media-detail.ts index 38b2766..c91766a 100644 --- a/apps/api/src/modules/tmdb/queries/media-detail.ts +++ b/apps/api/src/modules/tmdb/queries/media-detail.ts @@ -46,6 +46,6 @@ export async function getMediaDetail( 60 * 60, ); const dto = toMovieDetail(detail, mediaType, "miss"); - await upsertMovieDetail(dto, language); + await upsertMovieDetail(dto, detail, language); return dto; } diff --git a/apps/api/src/modules/tmdb/queries/watch-providers.ts b/apps/api/src/modules/tmdb/queries/watch-providers.ts new file mode 100644 index 0000000..074efee --- /dev/null +++ b/apps/api/src/modules/tmdb/queries/watch-providers.ts @@ -0,0 +1,81 @@ +import { db } from "@seen/db"; +import { mediaProviders, providers as providersTable } from "@seen/db/schema"; +import { and, eq } from "@seen/db/orm"; + +import { byDisplayPriority } from "../../../lib/sort"; + +import { + DEFAULT_LANGUAGE, + DETAIL_APPEND, + DETAIL_TTL_MS, + type MediaType, + tmdbFetch, + upsertMovieDetail, +} from "../client"; +import { toMovieDetail, toWatchProviders, type WatchProvidersResource } from "../resources"; + +async function readFromCache( + mediaType: MediaType, + tmdbId: number, + region: string, +): Promise { + const rows = await db + .select({ + providerId: mediaProviders.providerId, + offerType: mediaProviders.offerType, + updatedAt: mediaProviders.updatedAt, + name: providersTable.name, + logoPath: providersTable.logoPath, + displayPriority: providersTable.displayPriority, + }) + .from(mediaProviders) + .innerJoin(providersTable, eq(providersTable.providerId, mediaProviders.providerId)) + .where( + and( + eq(mediaProviders.tmdbId, tmdbId), + eq(mediaProviders.mediaType, mediaType), + eq(mediaProviders.region, region), + ), + ); + + if (rows.length === 0) return null; + + const freshest = rows.reduce( + (max, row) => (row.updatedAt.getTime() > max ? row.updatedAt.getTime() : max), + 0, + ); + if (Date.now() - freshest > DETAIL_TTL_MS) return null; + + const dto: WatchProvidersResource = { region, link: null, flatrate: [], rent: [], buy: [] }; + const sorted = [...rows].sort(byDisplayPriority); + for (const row of sorted) { + if (row.offerType !== "flatrate" && row.offerType !== "rent" && row.offerType !== "buy") { + continue; + } + const ref = { providerId: row.providerId, name: row.name, logoPath: row.logoPath ?? null }; + dto[row.offerType].push(ref); + } + return dto; +} + +export async function getWatchProviders( + mediaType: MediaType, + tmdbId: number, + region: string, + language = DEFAULT_LANGUAGE, +): Promise { + const cached = await readFromCache(mediaType, tmdbId, region); + if (cached) return cached; + + const raw = await tmdbFetch>( + `/${mediaType}/${tmdbId}`, + { + language, + append_to_response: DETAIL_APPEND[mediaType], + }, + 60 * 60, + ); + const dto = toMovieDetail(raw, mediaType, "miss"); + await upsertMovieDetail(dto, raw, language); + return toWatchProviders(raw, region); +} diff --git a/apps/api/src/modules/tmdb/resources/index.ts b/apps/api/src/modules/tmdb/resources/index.ts index 39d71ac..2512236 100644 --- a/apps/api/src/modules/tmdb/resources/index.ts +++ b/apps/api/src/modules/tmdb/resources/index.ts @@ -3,3 +3,5 @@ export { toCredit, toCredits } from "./to-credit"; export { toEpisodeDetail, toEpisodeSummary } from "./to-episode-detail"; export { toSeasonDetail, toSeasonSummary } from "./to-season-detail"; export { toMovieDetail } from "./to-movie-detail"; +export { toWatchProviders } from "./to-watch-providers"; +export type { ProviderRef, WatchProvidersResource } from "./to-watch-providers"; diff --git a/apps/api/src/modules/tmdb/resources/to-watch-providers.ts b/apps/api/src/modules/tmdb/resources/to-watch-providers.ts new file mode 100644 index 0000000..aa957cd --- /dev/null +++ b/apps/api/src/modules/tmdb/resources/to-watch-providers.ts @@ -0,0 +1,47 @@ +import { asNumber, asRecord, asString } from "../../../lib/coerce"; + +export type ProviderRef = { + providerId: number; + name: string; + logoPath: string | null; +}; + +export type WatchProvidersResource = { + region: string; + link: string | null; + flatrate: ProviderRef[]; + rent: ProviderRef[]; + buy: ProviderRef[]; +}; + +function toProviderList(value: unknown): ProviderRef[] { + if (!Array.isArray(value)) return []; + const out: ProviderRef[] = []; + for (const entry of value) { + const record = asRecord(entry); + const providerId = asNumber(record.provider_id); + const name = asString(record.provider_name); + if (providerId === undefined || !name) continue; + out.push({ + providerId, + name, + logoPath: asString(record.logo_path) ?? null, + }); + } + return out; +} + +export function toWatchProviders( + raw: Record | undefined, + region: string, +): WatchProvidersResource { + const results = asRecord(asRecord(raw?.["watch/providers"]).results); + const regionEntry = asRecord(results[region]); + return { + region, + link: asString(regionEntry.link) ?? null, + flatrate: toProviderList(regionEntry.flatrate), + rent: toProviderList(regionEntry.rent), + buy: toProviderList(regionEntry.buy), + }; +} diff --git a/apps/api/src/modules/tmdb/router.ts b/apps/api/src/modules/tmdb/router.ts index 359d1ce..efdd966 100644 --- a/apps/api/src/modules/tmdb/router.ts +++ b/apps/api/src/modules/tmdb/router.ts @@ -1,16 +1,16 @@ import { Elysia } from "elysia"; +import { DEFAULT_LANGUAGE, DEFAULT_REGION } from "./client"; import { TmdbModel } from "./model"; import { discoverFeed, getMediaDetail, getTvEpisodeDetail, getTvSeasonDetail, + getWatchProviders, search, } from "./queries"; -const DEFAULT_LANGUAGE = "fr-FR"; - export const tmdbController = new Elysia({ name: "Tmdb.Controller", prefix: "/tmdb", @@ -49,6 +49,23 @@ export const tmdbController = new Elysia({ }, }, ) + .get( + "/:mediaType/:tmdbId/watch-providers", + ({ params, query }) => + getWatchProviders( + params.mediaType, + params.tmdbId, + query.region ?? DEFAULT_REGION, + query.language ?? DEFAULT_LANGUAGE, + ), + { + params: "tmdb.MediaParams", + query: "tmdb.RegionQuery", + response: { + 200: "tmdb.WatchProviders", + }, + }, + ) .get( "/tv/:seriesId/season/:seasonNumber", ({ params, query }) => diff --git a/apps/mobile/src/app/(setup)/platforms.tsx b/apps/mobile/src/app/(setup)/platforms.tsx new file mode 100644 index 0000000..b39aa67 --- /dev/null +++ b/apps/mobile/src/app/(setup)/platforms.tsx @@ -0,0 +1,5 @@ +import { PlatformsPicker } from "@/components/screens/platforms-picker"; + +export default function SetupPlatformsRoute() { + return ; +} diff --git a/apps/mobile/src/app/(tabs)/profile/platforms.tsx b/apps/mobile/src/app/(tabs)/profile/platforms.tsx new file mode 100644 index 0000000..ec6044e --- /dev/null +++ b/apps/mobile/src/app/(tabs)/profile/platforms.tsx @@ -0,0 +1,5 @@ +import { PlatformsPicker } from "@/components/screens/platforms-picker"; + +export default function ProfilePlatformsRoute() { + return ; +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index ff01996..bc89a66 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -33,6 +33,7 @@ function RootNavigator() { + diff --git a/apps/mobile/src/components/discover/container.tsx b/apps/mobile/src/components/discover/container.tsx index ce71e6d..1beefcc 100644 --- a/apps/mobile/src/components/discover/container.tsx +++ b/apps/mobile/src/components/discover/container.tsx @@ -1,11 +1,16 @@ +import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { StyleSheet, View } from "react-native"; +import { Button } from "@/components/ui/button/button"; import { EmptyState } from "@/components/ui/empty-state"; import { Text } from "@/components/ui/text"; import { SPACING } from "@/constants/design-tokens"; +import { useMyPlatforms } from "@/hooks/platforms/use-my-platforms"; import { useNotInterestedList } from "@/hooks/not-interested/use-not-interested-list"; +import { useAvailableFeed } from "@/hooks/recommendations/use-available-feed"; import { useDiscoverMedia } from "@/hooks/tmdb/use-discover-media"; +import { hapticTap } from "@/lib/haptics"; import type { MediaFilter, TmdbMovieSummary } from "@/lib/tmdb"; import { DiscoverSkeleton } from "./discover-skeleton"; @@ -19,10 +24,16 @@ const keyOf = (media: TmdbMovieSummary, index: number) => export const DiscoverContainer = ({ filter }: { filter: MediaFilter }) => { const { t } = useTranslation(); + const router = useRouter(); const { trending, topToday, newReleases, genres, isLoading, error, isOffline } = useDiscoverMedia(filter); + const myPlatforms = useMyPlatforms(); + const hasPlatforms = (myPlatforms.data?.providers.length ?? 0) > 0; + const available = useAvailableFeed({ filter, enabled: hasPlatforms }); const { isDismissed } = useNotInterestedList(); const filterDismissed = (media: TmdbMovieSummary) => !isDismissed(media.id, media.media_type); + const availableMedia = available.data.filter(filterDismissed); + const availableShort = availableMedia.filter((entry) => entry.isShort); if (isOffline) { return ( @@ -86,6 +97,48 @@ export const DiscoverContainer = ({ filter }: { filter: MediaFilter }) => { renderItem={(media, _index, cardWidth) => } /> + {hasPlatforms ? ( + <> + ( + + )} + /> + ( + + )} + /> + + ) : !myPlatforms.isLoading ? ( + + { + hapticTap(); + router.push("/profile/platforms"); + }} + /> + } + /> + + ) : null} + state.completeOnboardingAction); const markImportSkipped = useOnboardingStore((state) => state.markLetterboxdImportSkippedAction); const markImportCompleted = useOnboardingStore( (state) => state.markLetterboxdImportCompletedAction, @@ -99,11 +98,11 @@ export function LetterboxdImport({ mode }: LetterboxdImportProps) { hapticTap(); if (mode === "onboarding") { if (!summary) markImportSkipped(); - completeOnboarding(); + router.replace("/platforms"); } else { router.back(); } - }, [completeOnboarding, markImportSkipped, mode, router, summary]); + }, [markImportSkipped, mode, router, summary]); const summaryText = summary ? unmatched.length > 0 diff --git a/apps/mobile/src/components/screens/media-detail/index.tsx b/apps/mobile/src/components/screens/media-detail/index.tsx index f06e8af..b745c38 100644 --- a/apps/mobile/src/components/screens/media-detail/index.tsx +++ b/apps/mobile/src/components/screens/media-detail/index.tsx @@ -17,6 +17,7 @@ import { MediaSummary } from "./media-summary"; import { OverviewSection } from "./overview-section"; import { RatingsSection } from "./ratings-section"; import { useMediaDetailViewModel } from "./use-media-detail-view-model"; +import { WatchProvidersSection } from "./watch-providers-section"; export function MediaDetail() { const theme = useTheme(); @@ -96,6 +97,8 @@ export function MediaDetail() { + + {vm.mediaType === "tv" ? ( + + {providers.map((provider) => ( + + ))} + + + ); +} + +function ProviderLogo({ + provider, + mediaType, + tmdbId, +}: { + provider: TmdbProviderRef; + mediaType: MediaType; + tmdbId: number; +}) { + const theme = useTheme(); + const uri = tmdbImageUrl(provider.logoPath, "w92"); + + function handlePress() { + hapticTap(); + track("clicked_streaming", { + tmdbId, + mediaType, + metadata: { provider_id: provider.providerId, provider_name: provider.name }, + }); + } + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + flexWrap: "wrap", + gap: SPACING.SM, + }, + item: { + width: LOGO_SIZE, + height: LOGO_SIZE, + }, + logo: { + width: LOGO_SIZE, + height: LOGO_SIZE, + borderRadius: 12, + }, +}); diff --git a/apps/mobile/src/components/screens/platforms-picker/index.tsx b/apps/mobile/src/components/screens/platforms-picker/index.tsx new file mode 100644 index 0000000..c6c98d1 --- /dev/null +++ b/apps/mobile/src/components/screens/platforms-picker/index.tsx @@ -0,0 +1,140 @@ +import { Button, Form, Host, Label, Section, Toggle } from "@expo/ui/swift-ui"; +import { tint } from "@expo/ui/swift-ui/modifiers"; +import { useRouter } from "expo-router"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useAccentColor } from "@/hooks/use-accent-color"; +import { useMyPlatforms } from "@/hooks/platforms/use-my-platforms"; +import { useProviders } from "@/hooks/platforms/use-providers"; +import { useSetMyPlatforms } from "@/hooks/platforms/use-set-my-platforms"; +import { useTheme } from "@/hooks/use-theme"; +import { hapticSelection, hapticTap } from "@/lib/haptics"; +import { getRegion } from "@/lib/region"; +import { useOnboardingStore } from "@/store/use-onboarding-store"; + +type Props = { + mode: "onboarding" | "settings"; +}; + +export function PlatformsPicker({ mode }: Props) { + const { t } = useTranslation(); + const theme = useTheme(); + const router = useRouter(); + const { accentHex } = useAccentColor(); + + const region = getRegion(); + const providers = useProviders(region); + const myPlatforms = useMyPlatforms(region); + const setMutation = useSetMyPlatforms(); + + const completeOnboarding = useOnboardingStore((state) => state.completeOnboardingAction); + const markPlatformsSkipped = useOnboardingStore((state) => state.markPlatformsSkippedAction); + const markPlatformsCompleted = useOnboardingStore((state) => state.markPlatformsCompletedAction); + + const initialIds = useMemo( + () => new Set(myPlatforms.data?.providers.map((provider) => provider.providerId) ?? []), + [myPlatforms.data], + ); + + const [selected, setSelected] = useState>(initialIds); + const [seenInitial, setSeenInitial] = useState(initialIds); + if (seenInitial !== initialIds) { + setSeenInitial(initialIds); + setSelected(initialIds); + } + + const errorMessage = setMutation.error ? t("platforms.saveError") : (providers.error ?? null); + + function toggle(providerId: number) { + hapticSelection(); + setSelected((prev) => { + const next = new Set(prev); + if (next.has(providerId)) next.delete(providerId); + else next.add(providerId); + return next; + }); + } + + async function save({ skipped }: { skipped: boolean }) { + hapticTap(); + try { + await setMutation.mutateAsync({ + region, + providerIds: skipped ? [] : Array.from(selected), + }); + if (mode === "onboarding") { + if (skipped) markPlatformsSkipped(); + else markPlatformsCompleted(); + completeOnboarding(); + } else { + router.back(); + } + } catch { + // hapticError fires inside the mutation; surface the message via errorMessage + } + } + + const subtitle = + mode === "onboarding" ? t("platforms.onboardingSubtitle") : t("platforms.subtitle"); + const headerTitle = mode === "onboarding" ? t("platforms.onboardingTitle") : t("platforms.title"); + const primaryLabel = setMutation.isPending + ? t("platforms.saving") + : mode === "onboarding" + ? t("platforms.onboardingContinue") + : t("platforms.save"); + + return ( + +
+
+
+ +
+ {providers.data.length === 0 ? ( +
+ + {errorMessage ? ( +
+
+ ) : null} + +
+
+
+
+ ); +} diff --git a/apps/mobile/src/components/screens/profile/account-settings/index.tsx b/apps/mobile/src/components/screens/profile/account-settings/index.tsx index caf0673..bb6528d 100644 --- a/apps/mobile/src/components/screens/profile/account-settings/index.tsx +++ b/apps/mobile/src/components/screens/profile/account-settings/index.tsx @@ -38,6 +38,11 @@ export function AccountSettingsSheet() { router.push("/import-letterboxd"); }, [router]); + const openPlatforms = useCallback(() => { + hapticTap(); + router.push("/profile/platforms"); + }, [router]); + const openWhatsNew = useCallback(() => { hapticTap(); router.push("/whats-new"); @@ -78,6 +83,7 @@ export function AccountSettingsSheet() { label={t("import.menuAction")} onPress={openImport} /> + diff --git a/apps/mobile/src/hooks/platforms/use-my-platforms.ts b/apps/mobile/src/hooks/platforms/use-my-platforms.ts new file mode 100644 index 0000000..98310d0 --- /dev/null +++ b/apps/mobile/src/hooks/platforms/use-my-platforms.ts @@ -0,0 +1,20 @@ +import { platformKeys } from "@seen/shared"; +import { useQuery } from "@tanstack/react-query"; + +import { errorMessage } from "@/lib/format"; +import { getRegion } from "@/lib/region"; +import { getMyPlatforms, type UserPlatforms } from "@/services/platforms"; + +export function useMyPlatforms(region = getRegion()) { + const query = useQuery({ + queryKey: platformKeys.me(region), + queryFn: () => getMyPlatforms(region), + }); + + return { + data: (query.data ?? null) as UserPlatforms | null, + isLoading: query.isLoading, + error: query.error ? errorMessage(query.error, "Couldn't load your services.") : null, + refetch: query.refetch, + }; +} diff --git a/apps/mobile/src/hooks/platforms/use-providers.ts b/apps/mobile/src/hooks/platforms/use-providers.ts new file mode 100644 index 0000000..f0638f9 --- /dev/null +++ b/apps/mobile/src/hooks/platforms/use-providers.ts @@ -0,0 +1,21 @@ +import { platformKeys } from "@seen/shared"; +import { useQuery } from "@tanstack/react-query"; + +import { errorMessage } from "@/lib/format"; +import { getRegion } from "@/lib/region"; +import { listProviders, type PlatformProvider } from "@/services/platforms"; + +export function useProviders(region = getRegion()) { + const query = useQuery({ + queryKey: platformKeys.providers(region), + queryFn: () => listProviders(region), + staleTime: 12 * 60 * 60 * 1000, + }); + + return { + data: (query.data ?? []) as PlatformProvider[], + isLoading: query.isLoading, + error: query.error ? errorMessage(query.error, "Couldn't load streaming services.") : null, + refetch: query.refetch, + }; +} diff --git a/apps/mobile/src/hooks/platforms/use-set-my-platforms.ts b/apps/mobile/src/hooks/platforms/use-set-my-platforms.ts new file mode 100644 index 0000000..3413266 --- /dev/null +++ b/apps/mobile/src/hooks/platforms/use-set-my-platforms.ts @@ -0,0 +1,26 @@ +import { platformKeys, recommendationKeys } from "@seen/shared"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { hapticError, hapticSuccess } from "@/lib/haptics"; +import { + setMyPlatforms, + type SetUserPlatformsInput, + type UserPlatforms, +} from "@/services/platforms"; + +export function useSetMyPlatforms() { + const client = useQueryClient(); + + return useMutation({ + mutationFn: (input: SetUserPlatformsInput) => setMyPlatforms(input), + onSuccess: (data: UserPlatforms) => { + client.setQueryData(platformKeys.me(data.region), data); + void client.invalidateQueries({ queryKey: platformKeys.me(data.region) }); + void client.invalidateQueries({ queryKey: recommendationKeys.all() }); + hapticSuccess(); + }, + onError: () => { + hapticError(); + }, + }); +} diff --git a/apps/mobile/src/hooks/recommendations/use-available-feed.ts b/apps/mobile/src/hooks/recommendations/use-available-feed.ts new file mode 100644 index 0000000..341d0f3 --- /dev/null +++ b/apps/mobile/src/hooks/recommendations/use-available-feed.ts @@ -0,0 +1,31 @@ +import { recommendationKeys } from "@seen/shared"; +import { useQuery } from "@tanstack/react-query"; + +import { errorMessage } from "@/lib/format"; +import { getRegion } from "@/lib/region"; +import { getAvailableFeed, type AvailableEntry } from "@/services/recommendations"; +import type { MediaFilter } from "@/lib/tmdb"; + +type Options = { + region?: string; + filter?: MediaFilter; + enabled?: boolean; +}; + +export function useAvailableFeed(options: Options = {}) { + const region = options.region ?? getRegion(); + const filter = options.filter ?? "all"; + + const query = useQuery({ + queryKey: recommendationKeys.available(region, filter), + queryFn: () => getAvailableFeed({ region, filter }), + enabled: options.enabled ?? true, + }); + + return { + data: (query.data ?? []) as AvailableEntry[], + isLoading: query.isLoading, + error: query.error ? errorMessage(query.error, "Couldn't load the available feed.") : null, + refetch: query.refetch, + }; +} diff --git a/apps/mobile/src/hooks/watch-providers/use-watch-providers.ts b/apps/mobile/src/hooks/watch-providers/use-watch-providers.ts new file mode 100644 index 0000000..540621f --- /dev/null +++ b/apps/mobile/src/hooks/watch-providers/use-watch-providers.ts @@ -0,0 +1,27 @@ +import { watchProviderKeys } from "@seen/shared"; +import { useQuery } from "@tanstack/react-query"; + +import { errorMessage } from "@/lib/format"; +import { getRegion } from "@/lib/region"; +import { getWatchProviders, type MediaType, type TmdbWatchProviders } from "@/lib/tmdb"; + +export function useWatchProviders(mediaType: MediaType, tmdbId: number, region = getRegion()) { + const query = useQuery({ + queryKey: watchProviderKeys.forTitle(mediaType, tmdbId, region), + queryFn: () => getWatchProviders(tmdbId, mediaType, region), + enabled: Number.isFinite(tmdbId) && tmdbId > 0, + staleTime: 60 * 60 * 1000, + }); + + return { + data: query.data ?? null, + isLoading: query.isLoading, + error: query.error ? errorMessage(query.error, "Couldn't load watch providers.") : null, + refetch: query.refetch, + } satisfies { + data: TmdbWatchProviders | null; + isLoading: boolean; + error: string | null; + refetch: () => unknown; + }; +} diff --git a/apps/mobile/src/lib/i18n/locales/en.ts b/apps/mobile/src/lib/i18n/locales/en.ts index bec32ef..c5a6e93 100644 --- a/apps/mobile/src/lib/i18n/locales/en.ts +++ b/apps/mobile/src/lib/i18n/locales/en.ts @@ -95,6 +95,13 @@ export const en = { genreAction: "Action", genreComedy: "Comedy", genreSciFiFantasy: "Sci-Fi & Fantasy", + availableOnYourServices: "Available on your services", + availableEyebrow: "On your subscriptions", + shortAndAvailable: "Short & available tonight", + shortAndAvailableEyebrow: "Under 100 min", + pickPlatformsTitle: "Tell us where you stream", + pickPlatformsSubtitle: "Pick your services to see what's available tonight.", + pickPlatformsAction: "Choose services", }, review: { screenTitle: "Review Sheet", @@ -139,6 +146,7 @@ export const en = { noReviewsYet: "No reviews yet.", noReviewsHint: "Be the first to share what you thought.", retry: "Retry", + whereToWatch: "Where to watch", }, likes: { like: "Like", @@ -230,6 +238,20 @@ export const en = { continue: "Continue", empty: "Nothing new right now.", }, + platforms: { + title: "My streaming services", + subtitle: "Pick the platforms you subscribe to. We'll surface titles you can watch tonight.", + menuAction: "My services", + empty: "No streaming services available for your region.", + save: "Save", + skip: "Skip", + saving: "Saving…", + selectedCount: "{{count}} selected", + saveError: "Couldn't save your services. Try again.", + onboardingTitle: "Where do you stream?", + onboardingSubtitle: "Pick your services so we can surface things you can watch right now.", + onboardingContinue: "Continue", + }, notInterested: { dismiss: "Not interested", undismiss: "Interested again", diff --git a/apps/mobile/src/lib/i18n/locales/fr.ts b/apps/mobile/src/lib/i18n/locales/fr.ts index 1dedf6f..387c7a7 100644 --- a/apps/mobile/src/lib/i18n/locales/fr.ts +++ b/apps/mobile/src/lib/i18n/locales/fr.ts @@ -99,6 +99,13 @@ export const fr: typeof en = { genreAction: "Action", genreComedy: "Comédie", genreSciFiFantasy: "Science-fiction & Fantastique", + availableOnYourServices: "Disponible sur tes services", + availableEyebrow: "Sur tes abonnements", + shortAndAvailable: "Court & dispo ce soir", + shortAndAvailableEyebrow: "Moins de 100 min", + pickPlatformsTitle: "Dis-nous où tu regardes", + pickPlatformsSubtitle: "Choisis tes services pour voir ce qui est disponible ce soir.", + pickPlatformsAction: "Choisir mes services", }, review: { screenTitle: "Critique", @@ -143,6 +150,7 @@ export const fr: typeof en = { noReviewsYet: "Aucune critique pour le moment.", noReviewsHint: "Sois le premier à partager ton avis.", retry: "Réessayer", + whereToWatch: "Où regarder", }, likes: { like: "J'aime", @@ -234,6 +242,22 @@ export const fr: typeof en = { continue: "Continuer", empty: "Rien de neuf pour le moment.", }, + platforms: { + title: "Mes services de streaming", + subtitle: + "Choisis les plateformes auxquelles tu es abonné. On te montrera ce que tu peux regarder ce soir.", + menuAction: "Mes services", + empty: "Aucun service de streaming disponible pour ta région.", + save: "Enregistrer", + skip: "Passer", + saving: "Enregistrement…", + selectedCount: "{{count}} sélectionné{{plural}}", + saveError: "Impossible d'enregistrer tes services. Réessaie.", + onboardingTitle: "Où regardes-tu ?", + onboardingSubtitle: + "Choisis tes services pour qu'on te propose ce que tu peux regarder maintenant.", + onboardingContinue: "Continuer", + }, notInterested: { dismiss: "Pas intéressé", undismiss: "Intéressé à nouveau", diff --git a/apps/mobile/src/lib/region.ts b/apps/mobile/src/lib/region.ts new file mode 100644 index 0000000..7d4f4a8 --- /dev/null +++ b/apps/mobile/src/lib/region.ts @@ -0,0 +1,8 @@ +import { getLocales } from "expo-localization"; + +const DEFAULT_REGION = "FR"; + +export function getRegion(): string { + const code = getLocales()[0]?.regionCode; + return code && code.length > 0 ? code.toUpperCase() : DEFAULT_REGION; +} diff --git a/apps/mobile/src/lib/tmdb/index.ts b/apps/mobile/src/lib/tmdb/index.ts index 22cd02c..4d1706c 100644 --- a/apps/mobile/src/lib/tmdb/index.ts +++ b/apps/mobile/src/lib/tmdb/index.ts @@ -6,10 +6,12 @@ export type { TmdbMovieSummary, TmdbMovieDetail, TmdbCredit, + TmdbProviderRef, TmdbTvEpisodeDetail, TmdbTvEpisodeSummary, TmdbTvSeasonDetail, TmdbTvSeasonSummary, + TmdbWatchProviders, } from "./types"; export { tmdbImageUrl } from "./images"; @@ -20,4 +22,5 @@ export { trendingMedia } from "./trending"; export { findByExternalId } from "./find"; export { getMovieDetail } from "./movie"; export { getTvEpisodeDetail, getTvSeasonDetail } from "./tv"; +export { getWatchProviders } from "./watch-providers"; export { tmdbLanguage } from "./client"; diff --git a/apps/mobile/src/lib/tmdb/types.ts b/apps/mobile/src/lib/tmdb/types.ts index 421f736..d1464bf 100644 --- a/apps/mobile/src/lib/tmdb/types.ts +++ b/apps/mobile/src/lib/tmdb/types.ts @@ -12,6 +12,8 @@ export type { SummaryDto as TmdbMovieSummary, DiscoverFeedDto as DiscoverFeed, GenreRowDto as GenreRow, + ProviderRefDto as TmdbProviderRef, + WatchProvidersDto as TmdbWatchProviders, } from "@seen/api/tmdb"; export type MediaType = "movie" | "tv"; diff --git a/apps/mobile/src/lib/tmdb/watch-providers.ts b/apps/mobile/src/lib/tmdb/watch-providers.ts new file mode 100644 index 0000000..45c05d2 --- /dev/null +++ b/apps/mobile/src/lib/tmdb/watch-providers.ts @@ -0,0 +1,17 @@ +import { eden, unwrapEden } from "@/lib/eden"; + +import { tmdbLanguage } from "./client"; +import type { MediaType, TmdbWatchProviders } from "./types"; + +export async function getWatchProviders( + tmdbId: number, + mediaType: MediaType, + region: string, + language = tmdbLanguage(), +): Promise { + return unwrapEden( + eden.tmdb({ mediaType })({ tmdbId })["watch-providers"].get({ + query: { region, language }, + }), + ); +} diff --git a/apps/mobile/src/services/platforms/handlers/get-my-platforms.ts b/apps/mobile/src/services/platforms/handlers/get-my-platforms.ts new file mode 100644 index 0000000..01ae6cb --- /dev/null +++ b/apps/mobile/src/services/platforms/handlers/get-my-platforms.ts @@ -0,0 +1,7 @@ +import { eden, unwrapEden } from "@/lib/eden"; + +import type { UserPlatforms } from "../types"; + +export function getMyPlatforms(region: string): Promise { + return unwrapEden(eden.platforms.me.get({ query: { region } })); +} diff --git a/apps/mobile/src/services/platforms/handlers/list-providers.ts b/apps/mobile/src/services/platforms/handlers/list-providers.ts new file mode 100644 index 0000000..275e51e --- /dev/null +++ b/apps/mobile/src/services/platforms/handlers/list-providers.ts @@ -0,0 +1,7 @@ +import { eden, unwrapEden } from "@/lib/eden"; + +import type { PlatformProvider } from "../types"; + +export function listProviders(region: string): Promise { + return unwrapEden(eden.platforms.providers.get({ query: { region } })); +} diff --git a/apps/mobile/src/services/platforms/handlers/set-my-platforms.ts b/apps/mobile/src/services/platforms/handlers/set-my-platforms.ts new file mode 100644 index 0000000..8b004d6 --- /dev/null +++ b/apps/mobile/src/services/platforms/handlers/set-my-platforms.ts @@ -0,0 +1,7 @@ +import { eden, unwrapEden } from "@/lib/eden"; + +import type { SetUserPlatformsInput, UserPlatforms } from "../types"; + +export function setMyPlatforms(input: SetUserPlatformsInput): Promise { + return unwrapEden(eden.platforms.me.put(input)); +} diff --git a/apps/mobile/src/services/platforms/index.ts b/apps/mobile/src/services/platforms/index.ts new file mode 100644 index 0000000..b5c1164 --- /dev/null +++ b/apps/mobile/src/services/platforms/index.ts @@ -0,0 +1,4 @@ +export type { PlatformProvider, SetUserPlatformsInput, UserPlatforms } from "./types"; +export { listProviders } from "./handlers/list-providers"; +export { getMyPlatforms } from "./handlers/get-my-platforms"; +export { setMyPlatforms } from "./handlers/set-my-platforms"; diff --git a/apps/mobile/src/services/platforms/types.ts b/apps/mobile/src/services/platforms/types.ts new file mode 100644 index 0000000..4cf51f3 --- /dev/null +++ b/apps/mobile/src/services/platforms/types.ts @@ -0,0 +1,5 @@ +export type { + ProviderRefDto as PlatformProvider, + UserPlatformsDto as UserPlatforms, + SetUserPlatformsInputDto as SetUserPlatformsInput, +} from "@seen/api/platforms"; diff --git a/apps/mobile/src/services/recommendations/handlers/get-available-feed.ts b/apps/mobile/src/services/recommendations/handlers/get-available-feed.ts new file mode 100644 index 0000000..219c5f7 --- /dev/null +++ b/apps/mobile/src/services/recommendations/handlers/get-available-feed.ts @@ -0,0 +1,14 @@ +import { eden, unwrapEden } from "@/lib/eden"; + +import type { AvailableEntry, AvailableFeedQuery } from "../types"; + +export function getAvailableFeed({ + region, + filter = "all", +}: AvailableFeedQuery): Promise { + return unwrapEden( + eden.recommendations.available.get({ + query: { region, filter }, + }), + ); +} diff --git a/apps/mobile/src/services/recommendations/index.ts b/apps/mobile/src/services/recommendations/index.ts new file mode 100644 index 0000000..d115855 --- /dev/null +++ b/apps/mobile/src/services/recommendations/index.ts @@ -0,0 +1,2 @@ +export type { AvailableEntry, AvailableFeedQuery } from "./types"; +export { getAvailableFeed } from "./handlers/get-available-feed"; diff --git a/apps/mobile/src/services/recommendations/types.ts b/apps/mobile/src/services/recommendations/types.ts new file mode 100644 index 0000000..fc0090c --- /dev/null +++ b/apps/mobile/src/services/recommendations/types.ts @@ -0,0 +1,8 @@ +import type { MediaFilter } from "@/lib/tmdb"; + +export type { AvailableEntryDto as AvailableEntry } from "@seen/api/recommendations"; + +export type AvailableFeedQuery = { + region: string; + filter?: MediaFilter; +}; diff --git a/apps/mobile/src/store/use-onboarding-store.ts b/apps/mobile/src/store/use-onboarding-store.ts index e3ccbb1..3457359 100644 --- a/apps/mobile/src/store/use-onboarding-store.ts +++ b/apps/mobile/src/store/use-onboarding-store.ts @@ -3,6 +3,8 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { Storage } from "@/store/storage"; +type OnboardingStepStatus = "pending" | "skipped" | "completed"; + interface OnboardingStore { // Whether the user has been through (or skipped) the post-sign-up setup step. // Client UI state only — never auth/session data. @@ -10,10 +12,13 @@ interface OnboardingStore { // Flips true once the persisted value has loaded, so the root navigator doesn't // flash the setup step before we know whether it was already completed. hasHydrated: boolean; - letterboxdImportStatus: "pending" | "skipped" | "completed"; + letterboxdImportStatus: OnboardingStepStatus; + platformsStatus: OnboardingStepStatus; completeOnboardingAction: () => void; markLetterboxdImportSkippedAction: () => void; markLetterboxdImportCompletedAction: () => void; + markPlatformsSkippedAction: () => void; + markPlatformsCompletedAction: () => void; setHasHydratedAction: (value: boolean) => void; } @@ -23,9 +28,12 @@ export const useOnboardingStore = create( completed: false, hasHydrated: false, letterboxdImportStatus: "pending", + platformsStatus: "pending", completeOnboardingAction: () => set({ completed: true }), markLetterboxdImportSkippedAction: () => set({ letterboxdImportStatus: "skipped" }), markLetterboxdImportCompletedAction: () => set({ letterboxdImportStatus: "completed" }), + markPlatformsSkippedAction: () => set({ platformsStatus: "skipped" }), + markPlatformsCompletedAction: () => set({ platformsStatus: "completed" }), setHasHydratedAction: (value) => set({ hasHydrated: value }), }), { diff --git a/packages/db/drizzle/0007_slippery_chameleon.sql b/packages/db/drizzle/0007_slippery_chameleon.sql new file mode 100644 index 0000000..c80c1fe --- /dev/null +++ b/packages/db/drizzle/0007_slippery_chameleon.sql @@ -0,0 +1,33 @@ +CREATE TABLE "media_providers" ( + "tmdb_id" bigint NOT NULL, + "media_type" text NOT NULL, + "region" text NOT NULL, + "provider_id" bigint NOT NULL, + "offer_type" text NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "media_providers_tmdb_id_media_type_region_provider_id_offer_type_pk" PRIMARY KEY("tmdb_id","media_type","region","provider_id","offer_type"), + CONSTRAINT "media_providers_media_type_check" CHECK ("media_providers"."media_type" in ('movie', 'tv')), + CONSTRAINT "media_providers_offer_type_check" CHECK ("media_providers"."offer_type" in ('flatrate', 'rent', 'buy', 'ads', 'free')) +); +--> statement-breakpoint +CREATE TABLE "providers" ( + "provider_id" bigint PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "logo_path" text, + "display_priority" integer, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_platforms" ( + "user_id" text NOT NULL, + "provider_id" bigint NOT NULL, + "region" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_platforms_user_provider_region_unique" UNIQUE("user_id","provider_id","region") +); +--> statement-breakpoint +ALTER TABLE "user_platforms" ADD CONSTRAINT "user_platforms_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_platforms" ADD CONSTRAINT "user_platforms_provider_id_providers_provider_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."providers"("provider_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "media_providers_region_provider_idx" ON "media_providers" USING btree ("region","provider_id");--> statement-breakpoint +CREATE INDEX "user_platforms_user_idx" ON "user_platforms" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "user_platforms_user_region_idx" ON "user_platforms" USING btree ("user_id","region"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0007_snapshot.json b/packages/db/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..9fd7a19 --- /dev/null +++ b/packages/db/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2133 @@ +{ + "id": "00dbe020-4213-47be-8958-0e90cd91be0b", + "prevId": "824208cb-7adc-4cee-8f04-98114d463dd7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_metadata": { + "name": "user_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "app_metadata": { + "name": "app_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episode_rating_stats": { + "name": "episode_rating_stats", + "schema": "", + "columns": { + "series_tmdb_id": { + "name": "series_tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sum_rating": { + "name": "sum_rating", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rating_count": { + "name": "rating_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "histogram": { + "name": "histogram", + "type": "integer[]", + "primaryKey": false, + "notNull": true, + "default": "'{0,0,0,0,0,0,0,0,0,0}'::integer[]" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "episode_rating_stats_series_tmdb_id_season_number_episode_number_pk": { + "name": "episode_rating_stats_series_tmdb_id_season_number_episode_number_pk", + "columns": [ + "series_tmdb_id", + "season_number", + "episode_number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episode_reviews": { + "name": "episode_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "series_tmdb_id": { + "name": "series_tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "episode_tmdb_id": { + "name": "episode_tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season_number": { + "name": "season_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episode_reviews_series_idx": { + "name": "episode_reviews_series_idx", + "columns": [ + { + "expression": "series_tmdb_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episode_reviews_episode_idx": { + "name": "episode_reviews_episode_idx", + "columns": [ + { + "expression": "series_tmdb_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episode_reviews_user_idx": { + "name": "episode_reviews_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episode_reviews_user_id_user_id_fk": { + "name": "episode_reviews_user_id_user_id_fk", + "tableFrom": "episode_reviews", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "episode_reviews_user_episode_unique": { + "name": "episode_reviews_user_episode_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "series_tmdb_id", + "season_number", + "episode_number" + ] + } + }, + "policies": {}, + "checkConstraints": { + "episode_reviews_season_number_check": { + "name": "episode_reviews_season_number_check", + "value": "\"episode_reviews\".\"season_number\" >= 0" + }, + "episode_reviews_episode_number_check": { + "name": "episode_reviews_episode_number_check", + "value": "\"episode_reviews\".\"episode_number\" > 0" + }, + "episode_reviews_rating_range": { + "name": "episode_reviews_rating_range", + "value": "\"episode_reviews\".\"rating\" between 1 and 10" + }, + "episode_reviews_has_content": { + "name": "episode_reviews_has_content", + "value": "\"episode_reviews\".\"rating\" is not null or (\"episode_reviews\".\"title\" is not null and length(btrim(\"episode_reviews\".\"title\")) > 0) or (\"episode_reviews\".\"comment\" is not null and length(btrim(\"episode_reviews\".\"comment\")) > 0)" + } + }, + "isRLSEnabled": false + }, + "public.interaction_events": { + "name": "interaction_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_events_user_idx": { + "name": "interaction_events_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_events_media_idx": { + "name": "interaction_events_media_idx", + "columns": [ + { + "expression": "tmdb_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_events_user_id_user_id_fk": { + "name": "interaction_events_user_id_user_id_fk", + "tableFrom": "interaction_events", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "interaction_events_type_check": { + "name": "interaction_events_type_check", + "value": "\"interaction_events\".\"type\" in ('opened_detail', 'viewed_trailer', 'searched', 'search_query', 'shared', 'clicked_streaming', 'added_watchlist', 'removed_watchlist', 'marked_watched', 'rated', 'reviewed', 'liked', 'dismissed', 'not_interested')" + }, + "interaction_events_media_type_check": { + "name": "interaction_events_media_type_check", + "value": "\"interaction_events\".\"media_type\" is null or \"interaction_events\".\"media_type\" in ('movie', 'tv')" + } + }, + "isRLSEnabled": false + }, + "public.likes": { + "name": "likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "likes_user_kind_created_idx": { + "name": "likes_user_kind_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "likes_user_id_user_id_fk": { + "name": "likes_user_id_user_id_fk", + "tableFrom": "likes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "likes_user_media_kind_unique": { + "name": "likes_user_media_kind_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "tmdb_id", + "media_type", + "kind" + ] + } + }, + "policies": {}, + "checkConstraints": { + "likes_media_type_check": { + "name": "likes_media_type_check", + "value": "\"likes\".\"media_type\" in ('movie', 'tv')" + }, + "likes_kind_check": { + "name": "likes_kind_check", + "value": "\"likes\".\"kind\" in ('like', 'favorite')" + } + }, + "isRLSEnabled": false + }, + "public.media_providers": { + "name": "media_providers", + "schema": "", + "columns": { + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "offer_type": { + "name": "offer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_providers_region_provider_idx": { + "name": "media_providers_region_provider_idx", + "columns": [ + { + "expression": "region", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "media_providers_tmdb_id_media_type_region_provider_id_offer_type_pk": { + "name": "media_providers_tmdb_id_media_type_region_provider_id_offer_type_pk", + "columns": [ + "tmdb_id", + "media_type", + "region", + "provider_id", + "offer_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "media_providers_media_type_check": { + "name": "media_providers_media_type_check", + "value": "\"media_providers\".\"media_type\" in ('movie', 'tv')" + }, + "media_providers_offer_type_check": { + "name": "media_providers_offer_type_check", + "value": "\"media_providers\".\"offer_type\" in ('flatrate', 'rent', 'buy', 'ads', 'free')" + } + }, + "isRLSEnabled": false + }, + "public.media_rating_stats": { + "name": "media_rating_stats", + "schema": "", + "columns": { + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sum_rating": { + "name": "sum_rating", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rating_count": { + "name": "rating_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_count": { + "name": "review_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "histogram": { + "name": "histogram", + "type": "integer[]", + "primaryKey": false, + "notNull": true, + "default": "'{0,0,0,0,0,0,0,0,0,0}'::integer[]" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "media_rating_stats_tmdb_id_media_type_pk": { + "name": "media_rating_stats_tmdb_id_media_type_pk", + "columns": [ + "tmdb_id", + "media_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.movies": { + "name": "movies", + "schema": "", + "columns": { + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_title": { + "name": "original_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overview": { + "name": "overview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "poster_path": { + "name": "poster_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backdrop_path": { + "name": "backdrop_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vote_average": { + "name": "vote_average", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "vote_count": { + "name": "vote_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "popularity": { + "name": "popularity", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fr-FR'" + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "detail_fetched_at": { + "name": "detail_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "movies_tmdb_id_media_type_pk": { + "name": "movies_tmdb_id_media_type_pk", + "columns": [ + "tmdb_id", + "media_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "movies_media_type_check": { + "name": "movies_media_type_check", + "value": "\"movies\".\"media_type\" in ('movie', 'tv')" + } + }, + "isRLSEnabled": false + }, + "public.not_interested": { + "name": "not_interested", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "not_interested_user_created_idx": { + "name": "not_interested_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "not_interested_user_id_user_id_fk": { + "name": "not_interested_user_id_user_id_fk", + "tableFrom": "not_interested", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "not_interested_user_media_unique": { + "name": "not_interested_user_media_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "tmdb_id", + "media_type" + ] + } + }, + "policies": {}, + "checkConstraints": { + "not_interested_media_type_check": { + "name": "not_interested_media_type_check", + "value": "\"not_interested\".\"media_type\" in ('movie', 'tv')" + } + }, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_path": { + "name": "avatar_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_id_user_id_fk": { + "name": "profiles_id_user_id_fk", + "tableFrom": "profiles", + "tableTo": "user", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_username_unique": { + "name": "profiles_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": { + "profiles_full_name_not_blank": { + "name": "profiles_full_name_not_blank", + "value": "length(btrim(\"profiles\".\"full_name\")) > 0" + }, + "profiles_username_format": { + "name": "profiles_username_format", + "value": "\"profiles\".\"username\" = lower(\"profiles\".\"username\") and \"profiles\".\"username\" ~ '^[a-z0-9_.]{3,20}$'" + } + }, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "provider_id": { + "name": "provider_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo_path": { + "name": "logo_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_priority": { + "name": "display_priority", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommendation_events": { + "name": "recommendation_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "clicked": { + "name": "clicked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "added_to_watchlist": { + "name": "added_to_watchlist", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marked_watched": { + "name": "marked_watched", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rated": { + "name": "rated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dismissed": { + "name": "dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "time_spent_ms": { + "name": "time_spent_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "recommendation_events_user_idx": { + "name": "recommendation_events_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shown_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recommendation_events_media_idx": { + "name": "recommendation_events_media_idx", + "columns": [ + { + "expression": "tmdb_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recommendation_events_user_id_user_id_fk": { + "name": "recommendation_events_user_id_user_id_fk", + "tableFrom": "recommendation_events", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "recommendation_events_source_check": { + "name": "recommendation_events_source_check", + "value": "\"recommendation_events\".\"source\" in ('content', 'collaborative', 'trending', 'availability', 'social')" + }, + "recommendation_events_media_type_check": { + "name": "recommendation_events_media_type_check", + "value": "\"recommendation_events\".\"media_type\" in ('movie', 'tv')" + } + }, + "isRLSEnabled": false + }, + "public.reviews": { + "name": "reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reviews_movie_idx": { + "name": "reviews_movie_idx", + "columns": [ + { + "expression": "tmdb_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reviews_user_idx": { + "name": "reviews_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_user_id_user_id_fk": { + "name": "reviews_user_id_user_id_fk", + "tableFrom": "reviews", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reviews_user_movie_unique": { + "name": "reviews_user_movie_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "tmdb_id", + "media_type" + ] + } + }, + "policies": {}, + "checkConstraints": { + "reviews_rating_range": { + "name": "reviews_rating_range", + "value": "\"reviews\".\"rating\" is null or \"reviews\".\"rating\" between 1 and 10" + }, + "reviews_has_content": { + "name": "reviews_has_content", + "value": "\"reviews\".\"rating\" is not null or (\"reviews\".\"title\" is not null and length(btrim(\"reviews\".\"title\")) > 0) or (\"reviews\".\"comment\" is not null and length(btrim(\"reviews\".\"comment\")) > 0)" + } + }, + "isRLSEnabled": false + }, + "public.series_rating_stats": { + "name": "series_rating_stats", + "schema": "", + "columns": { + "series_tmdb_id": { + "name": "series_tmdb_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "sum_of_episode_avgs": { + "name": "sum_of_episode_avgs", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "episodes_with_ratings": { + "name": "episodes_with_ratings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_rating_count": { + "name": "total_rating_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "histogram": { + "name": "histogram", + "type": "integer[]", + "primaryKey": false, + "notNull": true, + "default": "'{0,0,0,0,0,0,0,0,0,0}'::integer[]" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_platforms": { + "name": "user_platforms", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_platforms_user_idx": { + "name": "user_platforms_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_platforms_user_region_idx": { + "name": "user_platforms_user_region_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "region", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_platforms_user_id_user_id_fk": { + "name": "user_platforms_user_id_user_id_fk", + "tableFrom": "user_platforms", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_platforms_provider_id_providers_provider_id_fk": { + "name": "user_platforms_provider_id_providers_provider_id_fk", + "tableFrom": "user_platforms", + "tableTo": "providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "provider_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_platforms_user_provider_region_unique": { + "name": "user_platforms_user_provider_region_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider_id", + "region" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist": { + "name": "watchlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + } + }, + "indexes": { + "watchlist_user_added_idx": { + "name": "watchlist_user_added_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_user_media_type_added_idx": { + "name": "watchlist_user_media_type_added_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "media_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "added_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlist_user_id_user_id_fk": { + "name": "watchlist_user_id_user_id_fk", + "tableFrom": "watchlist", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "watchlist_user_media_unique": { + "name": "watchlist_user_media_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "tmdb_id", + "media_type" + ] + } + }, + "policies": {}, + "checkConstraints": { + "watchlist_media_type_check": { + "name": "watchlist_media_type_check", + "value": "\"watchlist\".\"media_type\" in ('movie', 'tv')" + }, + "watchlist_visibility_check": { + "name": "watchlist_visibility_check", + "value": "\"watchlist\".\"visibility\" in ('private')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.movie_review_stats": { + "columns": { + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating_count": { + "name": "rating_count", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "avg_rating": { + "name": "avg_rating", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "histogram": { + "name": "histogram", + "type": "integer[]", + "primaryKey": false, + "notNull": false + } + }, + "name": "movie_review_stats", + "schema": "public", + "isExisting": true, + "materialized": false + }, + "public.series_episode_review_stats": { + "columns": { + "tmdb_id": { + "name": "tmdb_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating_count": { + "name": "rating_count", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "avg_rating": { + "name": "avg_rating", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "histogram": { + "name": "histogram", + "type": "integer[]", + "primaryKey": false, + "notNull": false + } + }, + "name": "series_episode_review_stats", + "schema": "public", + "isExisting": true, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 19f4dd4..f01f735 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1780914506261, "tag": "0006_light_rumiko_fujikawa", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1780927130561, + "tag": "0007_slippery_chameleon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/domain.ts b/packages/db/src/schema/domain.ts index 1bbfd90..79429d1 100644 --- a/packages/db/src/schema/domain.ts +++ b/packages/db/src/schema/domain.ts @@ -309,6 +309,60 @@ export const recommendationEvents = pgTable( ], ); +export const providers = pgTable("providers", { + providerId: bigint("provider_id", { mode: "number" }).primaryKey(), + name: text("name").notNull(), + logoPath: text("logo_path"), + displayPriority: integer("display_priority"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const mediaProviders = pgTable( + "media_providers", + { + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + region: text("region").notNull(), + providerId: bigint("provider_id", { mode: "number" }).notNull(), + offerType: text("offer_type").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ + columns: [table.tmdbId, table.mediaType, table.region, table.providerId, table.offerType], + }), + index("media_providers_region_provider_idx").on(table.region, table.providerId), + check("media_providers_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), + check( + "media_providers_offer_type_check", + sql`${table.offerType} in ('flatrate', 'rent', 'buy', 'ads', 'free')`, + ), + ], +); + +export const userPlatforms = pgTable( + "user_platforms", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + providerId: bigint("provider_id", { mode: "number" }) + .notNull() + .references(() => providers.providerId, { onDelete: "cascade" }), + region: text("region").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique("user_platforms_user_provider_region_unique").on( + table.userId, + table.providerId, + table.region, + ), + index("user_platforms_user_idx").on(table.userId), + index("user_platforms_user_region_idx").on(table.userId, table.region), + ], +); + export const movieReviewStats = pgView("movie_review_stats", { tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), mediaType: text("media_type").notNull(), diff --git a/packages/shared/src/query-keys.ts b/packages/shared/src/query-keys.ts index 616de31..ad3540a 100644 --- a/packages/shared/src/query-keys.ts +++ b/packages/shared/src/query-keys.ts @@ -24,6 +24,22 @@ export const tmdbKeys = { ["tmdb", "detail", mediaType, tmdbId, locale] as const, }; +export const watchProviderKeys = { + forTitle: (mediaType: MediaType, tmdbId: number, region: string) => + ["watch-providers", mediaType, tmdbId, region] as const, +}; + +export const platformKeys = { + providers: (region: string) => ["platforms", "providers", region] as const, + me: (region: string) => ["platforms", "me", region] as const, +}; + +export const recommendationKeys = { + all: () => ["recommendations"] as const, + available: (region: string, filter: string) => + ["recommendations", "available", region, filter] as const, +}; + export const reviewKeys = { my: (mediaType: MediaType, tmdbId: number) => ["reviews", "my", mediaType, tmdbId] as const, list: (mediaType: MediaType, tmdbId: number) => ["reviews", "list", mediaType, tmdbId] as const,