diff --git a/AGENTS.md b/AGENTS.md index 7d395ce..d5cef35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ bun run lint # Biome lint check bun run format # Biome format (auto-fix) bun run check-types # TypeScript type check bun run test # Run tests +bun run generate:openapi # Regenerate OpenAPI spec + docs API pages (run after contract/schema changes) # Database commands (run from packages/db/) cd packages/db && bun run db:push # Push schema changes to SQLite database @@ -38,8 +39,10 @@ bun run test couch-potato/ ├── apps/ │ ├── native/ # @sofa/native — Expo 55 React Native app (Expo Router, UniWind) +│ ├── public-api/ # @sofa/public-api — Hono microservice on Vercel (version check, telemetry) │ ├── server/ # @sofa/server — Hono API server (oRPC, auth, cron, webhooks) │ └── web/ # @sofa/web — Vite SPA (TanStack Router, TanStack Query) +├── docs/ # sofa-docs — Fumadocs (Next.js) documentation site + OpenAPI reference ├── packages/ │ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared, JIT) │ ├── auth/ # @sofa/auth — Better Auth server config (JIT) @@ -61,6 +64,8 @@ All shared packages are JIT (raw TypeScript exports, no build step). - **`@sofa/server`** — Hono API server. Hosts oRPC procedures, Better Auth, webhook/image/backup routes, and cron jobs. Runs DB migrations on startup. Dev: port 3001. Prod: serves SPA static files too, port 3000. - **`@sofa/web`** — Vite SPA with TanStack Router (file-based routing). No SSR, no DB. All data via oRPC. Vite dev server proxies `/api/*` and `/rpc/*` to the API server. - **`@sofa/native`** — Expo Router app with 4-tab layout (Home, Explore, Search, Settings). UniWind for styling, `@better-auth/expo` with SecureStore for auth, oRPC client for API calls. Dark-only cinema theme matching web. +- **`@sofa/public-api`** — Minimal Hono microservice deployed on Vercel. Two endpoints: `GET /v1/version` (latest release from GitHub) and `POST /v1/telemetry` (forwards instance stats to PostHog). Dev: port 3002. +- **`sofa-docs`** — Fumadocs site (Next.js) with landing page, Markdown docs, and auto-generated OpenAPI API reference. Content lives in `docs/content/docs/`. API docs are generated from `docs/openapi.json` via `fumadocs-openapi`. ### Stack @@ -70,6 +75,7 @@ All shared packages are JIT (raw TypeScript exports, no build step). - **Database**: SQLite via `bun:sqlite` + Drizzle ORM (WAL mode, sync queries, auto-migrations) - **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO - **Monorepo**: Turborepo with Bun workspaces +- **Docs**: Fumadocs (Next.js), fumadocs-openapi for API reference - **Linting**: Biome (2-space indent, organized imports) - **External API**: TMDB (The Movie Database) diff --git a/apps/native/package.json b/apps/native/package.json index 06f3c44..513da5a 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -27,7 +27,6 @@ "@react-navigation/elements": "2.9.10", "@shopify/flash-list": "2.3.0", "@sofa/api": "workspace:*", - "@sofa/tmdb": "workspace:*", "@tabler/icons-react-native": "3.40.0", "@tanstack/query-async-storage-persister": "5.90.24", "@tanstack/react-form": "1.28.5", diff --git a/apps/native/src/app/person/[id].tsx b/apps/native/src/app/person/[id].tsx index 1be7d33..d7717f6 100644 --- a/apps/native/src/app/person/[id].tsx +++ b/apps/native/src/app/person/[id].tsx @@ -91,6 +91,7 @@ export default function PersonDetailScreen() { title={credit.title} type={credit.type} posterPath={credit.posterPath} + posterThumbHash={credit.posterThumbHash} releaseDate={credit.releaseDate ?? credit.firstAirDate} voteAverage={credit.voteAverage} userStatus={data?.userStatuses?.[credit.titleId] ?? null} @@ -189,6 +190,7 @@ export default function PersonDetailScreen() { {person.profilePath && ( diff --git a/apps/native/src/app/title/[id].tsx b/apps/native/src/app/title/[id].tsx index 0fa5171..558334b 100644 --- a/apps/native/src/app/title/[id].tsx +++ b/apps/native/src/app/title/[id].tsx @@ -165,6 +165,7 @@ export default function TitleDetailScreen() { title: item.title, type: item.type, posterPath: item.posterPath, + posterThumbHash: item.posterThumbHash, releaseDate: item.releaseDate, firstAirDate: item.firstAirDate, voteAverage: item.voteAverage, @@ -277,6 +278,7 @@ export default function TitleDetailScreen() { {title.backdropPath && ( )} diff --git a/apps/native/src/components/dashboard/continue-watching-card.tsx b/apps/native/src/components/dashboard/continue-watching-card.tsx index 8ba5c9f..1e2f6c2 100644 --- a/apps/native/src/components/dashboard/continue-watching-card.tsx +++ b/apps/native/src/components/dashboard/continue-watching-card.tsx @@ -15,6 +15,7 @@ export interface ContinueWatchingItem { id: string; title: string; backdropPath: string | null; + backdropThumbHash?: string | null; }; watchedEpisodes: number; totalEpisodes: number; @@ -23,6 +24,7 @@ export interface ContinueWatchingItem { episodeNumber: number; name: string | null; stillPath: string | null; + stillThumbHash?: string | null; } | null; } @@ -61,6 +63,10 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) { uri: (item.nextEpisode?.stillPath ?? item.title.backdropPath) as string, }} + thumbHash={ + item.nextEpisode?.stillThumbHash ?? + item.title.backdropThumbHash + } className="h-full w-full" contentFit="cover" /> diff --git a/apps/native/src/components/dashboard/horizontal-poster-row.tsx b/apps/native/src/components/dashboard/horizontal-poster-row.tsx index 20581d7..c20a61f 100644 --- a/apps/native/src/components/dashboard/horizontal-poster-row.tsx +++ b/apps/native/src/components/dashboard/horizontal-poster-row.tsx @@ -9,6 +9,7 @@ export interface PosterRowItem { title: string; type: string; posterPath: string | null; + posterThumbHash?: string | null; releaseDate?: string | null; firstAirDate?: string | null; voteAverage?: number | null; @@ -55,6 +56,7 @@ export function HorizontalPosterRow({ title={item.title} type={item.type as "movie" | "tv"} posterPath={item.posterPath} + posterThumbHash={item.posterThumbHash} releaseDate={item.releaseDate ?? item.firstAirDate} voteAverage={item.voteAverage} userStatus={item.userStatus} diff --git a/apps/native/src/components/titles/cast-card.tsx b/apps/native/src/components/titles/cast-card.tsx index 81d43f4..b6d36e5 100644 --- a/apps/native/src/components/titles/cast-card.tsx +++ b/apps/native/src/components/titles/cast-card.tsx @@ -12,6 +12,7 @@ export function CastCard({ name: string; character: string | null; profilePath: string | null; + profileThumbHash?: string | null; }; }) { return ( @@ -23,6 +24,7 @@ export function CastCard({ {person.profilePath && ( diff --git a/apps/native/src/components/titles/continue-watching-banner.tsx b/apps/native/src/components/titles/continue-watching-banner.tsx index bc7dc07..e1a9f71 100644 --- a/apps/native/src/components/titles/continue-watching-banner.tsx +++ b/apps/native/src/components/titles/continue-watching-banner.tsx @@ -14,11 +14,13 @@ export function ContinueWatchingBanner({ watchedEpisodeIds, userStatus, backdropPath, + backdropThumbHash, }: { seasons: Season[]; watchedEpisodeIds: Set; userStatus: string | null; backdropPath: string | null; + backdropThumbHash?: string | null; }) { const titleAccentColor = useCSSVariable("--color-title-accent") as string; @@ -54,6 +56,7 @@ export function ContinueWatchingBanner({ {stillUrl && ( diff --git a/apps/native/src/components/ui/image.tsx b/apps/native/src/components/ui/image.tsx index 4ebed94..d67a9dc 100644 --- a/apps/native/src/components/ui/image.tsx +++ b/apps/native/src/components/ui/image.tsx @@ -5,8 +5,9 @@ import { resolveUrl } from "@/lib/server-url"; export function Image({ source, className, + thumbHash, ...props -}: ImageProps & { className?: string }) { +}: ImageProps & { className?: string; thumbHash?: string | null }) { const resolved = source && typeof source === "object" && "uri" in source && source.uri ? { ...source, uri: resolveUrl(source.uri) ?? undefined } @@ -15,6 +16,11 @@ export function Image({ const style = useResolveClassNames(className ?? ""); return ( - + ); } diff --git a/apps/native/src/components/ui/poster-card.tsx b/apps/native/src/components/ui/poster-card.tsx index 1288b3e..ab543cd 100644 --- a/apps/native/src/components/ui/poster-card.tsx +++ b/apps/native/src/components/ui/poster-card.tsx @@ -35,6 +35,7 @@ interface PosterCardProps { title: string; type: "movie" | "tv"; posterPath: string | null; + posterThumbHash?: string | null; releaseDate?: string | null; voteAverage?: number | null; userStatus?: TitleStatus | null; @@ -55,6 +56,7 @@ export function PosterCard({ title, type, posterPath, + posterThumbHash, releaseDate, voteAverage, userStatus, @@ -117,6 +119,7 @@ export function PosterCard({ {posterPath ? ( r.titleId); } +function getThumbhashBackfillTitleIds(): string[] { + const titleIds = new Set(getLibraryTitleIds()); + + const addIds = (ids: string[]) => { + for (const id of ids) titleIds.add(id); + }; + + addIds( + db + .select({ id: titles.id }) + .from(titles) + .where( + or( + and( + isNotNull(titles.posterPath), + sql`${titles.posterThumbHash} IS NULL`, + ), + and( + isNotNull(titles.backdropPath), + sql`${titles.backdropThumbHash} IS NULL`, + ), + ), + ) + .all() + .map((row) => row.id), + ); + + addIds( + db + .select({ titleId: seasons.titleId }) + .from(seasons) + .where( + and( + isNotNull(seasons.posterPath), + sql`${seasons.posterThumbHash} IS NULL`, + ), + ) + .groupBy(seasons.titleId) + .all() + .map((row) => row.titleId), + ); + + addIds( + db + .select({ titleId: seasons.titleId }) + .from(episodes) + .innerJoin(seasons, eq(episodes.seasonId, seasons.id)) + .where( + and( + isNotNull(episodes.stillPath), + sql`${episodes.stillThumbHash} IS NULL`, + ), + ) + .groupBy(seasons.titleId) + .all() + .map((row) => row.titleId), + ); + + addIds( + db + .select({ titleId: titleCast.titleId }) + .from(titleCast) + .innerJoin(persons, eq(titleCast.personId, persons.id)) + .where( + and( + isNotNull(persons.profilePath), + sql`${persons.profileThumbHash} IS NULL`, + ), + ) + .groupBy(titleCast.titleId) + .all() + .map((row) => row.titleId), + ); + + return [...titleIds]; +} + // Refresh titles where lastFetchedAt is stale async function nightlyRefreshLibrary() { const libraryIds = getLibraryTitleIds(); @@ -248,25 +332,58 @@ async function refreshTvChildrenJob() { if (titlesWithStaleSeasons.has(show.id)) { const details = await getTvDetails(show.tmdbId); await refreshTvChildren(show.id, show.tmdbId, details.number_of_seasons); + await syncTvChildArt(show.id, { warmCache: true }); await Bun.sleep(RATE_LIMIT_MS); } } } async function cacheImagesJob() { - if (!imageCacheEnabled()) return; + const titleIds = getThumbhashBackfillTitleIds(); + log.debug( + `Caching images for ${titleIds.length} titles needing art backfill`, + ); - const libraryIds = getLibraryTitleIds(); - log.debug(`Caching images for ${libraryIds.length} library titles`); - - for (const titleId of libraryIds) { + for (const titleId of titleIds) { try { - await Promise.all([ - cacheImagesForTitle(titleId), - cacheEpisodeStills(titleId), - cacheProviderLogos(titleId), - cacheProfilePhotos(titleId), - ]); + const title = db + .select() + .from(titles) + .where(eq(titles.id, titleId)) + .get(); + if (!title) continue; + + // Phase 1: warm the image cache so thumbhash generation can read from disk + if (imageCacheEnabled()) { + await Promise.all([ + cacheImagesForTitle(titleId), + cacheEpisodeStills(titleId), + cacheProviderLogos(titleId), + cacheProfilePhotos(titleId), + ]); + } + + // Phase 2: generate thumbhashes (reads from warm cache, no duplicate downloads) + const hashTasks: Promise[] = []; + + if (!title.posterThumbHash && title.posterPath) { + hashTasks.push(generateTitlePosterThumbHash(titleId, title.posterPath)); + } + if (!title.backdropThumbHash && title.backdropPath) { + hashTasks.push( + generateTitleBackdropThumbHash(titleId, title.backdropPath), + ); + } + + if (title.type === "tv") { + hashTasks.push(syncTvChildArt(titleId, { warmCache: false })); + } + + hashTasks.push( + syncCastProfileThumbHashes(titleId, undefined, { warmCache: false }), + ); + + await Promise.all(hashTasks); } catch (err) { log.warn(`Failed to cache images for title ${titleId}:`, err); } diff --git a/apps/server/src/orpc/procedures/browse-thumbhashes.ts b/apps/server/src/orpc/procedures/browse-thumbhashes.ts new file mode 100644 index 0000000..ca4f1ec --- /dev/null +++ b/apps/server/src/orpc/procedures/browse-thumbhashes.ts @@ -0,0 +1,43 @@ +import { db } from "@sofa/db/client"; +import { and, inArray } from "@sofa/db/helpers"; +import { titles } from "@sofa/db/schema"; + +type BrowseLookup = { + tmdbId: number; + type: "movie" | "tv"; +}; + +export function browseLookupKey({ tmdbId, type }: BrowseLookup): string { + return `${tmdbId}-${type}`; +} + +export function getBrowsePosterThumbHashes(lookups: BrowseLookup[]) { + if (lookups.length === 0) { + return new Map(); + } + + const tmdbIds = [...new Set(lookups.map((lookup) => lookup.tmdbId))]; + const mediaTypes = [...new Set(lookups.map((lookup) => lookup.type))]; + + const rows = db + .select({ + tmdbId: titles.tmdbId, + type: titles.type, + posterThumbHash: titles.posterThumbHash, + }) + .from(titles) + .where( + and(inArray(titles.tmdbId, tmdbIds), inArray(titles.type, mediaTypes)), + ) + .all(); + + return new Map( + rows.map((row) => [ + browseLookupKey({ + tmdbId: row.tmdbId, + type: row.type as "movie" | "tv", + }), + row.posterThumbHash, + ]), + ); +} diff --git a/apps/server/src/orpc/procedures/dashboard.ts b/apps/server/src/orpc/procedures/dashboard.ts index 19b1f6b..64870ff 100644 --- a/apps/server/src/orpc/procedures/dashboard.ts +++ b/apps/server/src/orpc/procedures/dashboard.ts @@ -25,6 +25,7 @@ export const continueWatching = os.dashboard.continueWatching id: item.title.id, title: item.title.title, backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"), + backdropThumbHash: item.title.backdropThumbHash, }, nextEpisode: item.nextEpisode ? { @@ -32,6 +33,7 @@ export const continueWatching = os.dashboard.continueWatching episodeNumber: item.nextEpisode.episodeNumber, name: item.nextEpisode.name, stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"), + stillThumbHash: item.nextEpisode.stillThumbHash, } : null, totalEpisodes: item.totalEpisodes, @@ -50,6 +52,7 @@ export const library = os.dashboard.library type: t.type, title: t.title, posterPath: tmdbImageUrl(t.posterPath, "posters"), + posterThumbHash: t.posterThumbHash ?? null, releaseDate: t.releaseDate ?? null, firstAirDate: t.firstAirDate ?? null, voteAverage: t.voteAverage, @@ -71,6 +74,7 @@ export const recommendations = os.dashboard.recommendations type: t.type, title: t.title, posterPath: tmdbImageUrl(t.posterPath, "posters"), + posterThumbHash: t.posterThumbHash ?? null, releaseDate: t.releaseDate ?? null, firstAirDate: t.firstAirDate ?? null, voteAverage: t.voteAverage, diff --git a/apps/server/src/orpc/procedures/discover.ts b/apps/server/src/orpc/procedures/discover.ts index 5e32b50..917c86b 100644 --- a/apps/server/src/orpc/procedures/discover.ts +++ b/apps/server/src/orpc/procedures/discover.ts @@ -8,6 +8,10 @@ import { isTmdbConfigured } from "@sofa/tmdb/config"; import { tmdbImageUrl } from "@sofa/tmdb/image"; import { os } from "../context"; import { authed } from "../middleware"; +import { + browseLookupKey, + getBrowsePosterThumbHashes, +} from "./browse-thumbhashes"; export const discover = os.discover .use(authed) @@ -31,7 +35,7 @@ export const discover = os.discover first_air_date?: string; }; - const items = ((results.results ?? []) as DiscoverResult[]) + const baseItems = ((results.results ?? []) as DiscoverResult[]) .filter((r) => r.poster_path) .map((r) => ({ tmdbId: r.id, @@ -42,6 +46,11 @@ export const discover = os.discover firstAirDate: (r.first_air_date as string | undefined) ?? null, voteAverage: r.vote_average ?? null, })); + const posterThumbHashes = getBrowsePosterThumbHashes(baseItems); + const items = baseItems.map((item) => ({ + ...item, + posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null, + })); const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type })); const [userStatuses, episodeProgress] = diff --git a/apps/server/src/orpc/procedures/explore.ts b/apps/server/src/orpc/procedures/explore.ts index 68807ec..8997ee0 100644 --- a/apps/server/src/orpc/procedures/explore.ts +++ b/apps/server/src/orpc/procedures/explore.ts @@ -8,6 +8,10 @@ import { isTmdbConfigured } from "@sofa/tmdb/config"; import { tmdbImageUrl } from "@sofa/tmdb/image"; import { os } from "../context"; import { authed } from "../middleware"; +import { + browseLookupKey, + getBrowsePosterThumbHashes, +} from "./browse-thumbhashes"; function requireTmdb() { if (!isTmdbConfigured()) { @@ -25,7 +29,7 @@ export const trending = os.explore.trending const data = await getTrending(input.type, "day"); const results = (data.results ?? []) as Record[]; - const items = results + const baseItems = results .filter((r) => r.poster_path) .map((r) => { const mediaType = @@ -45,6 +49,11 @@ export const trending = os.explore.trending voteAverage: (r.vote_average as number | undefined) ?? null, }; }); + const posterThumbHashes = getBrowsePosterThumbHashes(baseItems); + const items = baseItems.map((item) => ({ + ...item, + posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null, + })); const heroResult = results.find( (r) => @@ -83,7 +92,7 @@ export const popular = os.explore.popular requireTmdb(); const data = await getPopular(input.type); - const items = ((data.results ?? []) as Record[]) + const baseItems = ((data.results ?? []) as Record[]) .filter((r) => r.poster_path) .map((r) => ({ tmdbId: r.id as number, @@ -94,6 +103,11 @@ export const popular = os.explore.popular firstAirDate: (r.first_air_date as string | undefined) ?? null, voteAverage: (r.vote_average as number | undefined) ?? null, })); + const posterThumbHashes = getBrowsePosterThumbHashes(baseItems); + const items = baseItems.map((item) => ({ + ...item, + posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null, + })); const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type })); const [userStatuses, episodeProgress] = diff --git a/apps/server/src/routes/health.ts b/apps/server/src/routes/health.ts index 08fc313..9c4a17d 100644 --- a/apps/server/src/routes/health.ts +++ b/apps/server/src/routes/health.ts @@ -1,6 +1,4 @@ import { getInstanceId } from "@sofa/core/settings"; -import { db } from "@sofa/db/client"; -import { sql } from "@sofa/db/helpers"; import { createLogger } from "@sofa/logger"; import { Hono } from "hono"; @@ -10,7 +8,6 @@ const app = new Hono(); app.get("/", (c) => { try { - db.run(sql`SELECT 1`); return c.json({ status: "healthy", instanceId: getInstanceId() }, 200); } catch (err) { log.error("Health check failed:", err); diff --git a/apps/web/package.json b/apps/web/package.json index e5672b9..ea2cd07 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "shadcn": "4.0.6", "sonner": "2.0.7", "tailwind-merge": "catalog:", + "thumbhash": "catalog:", "tw-animate-css": "1.4.0", "vaul": "1.1.2", "youtube-video-element": "1.9.0", diff --git a/apps/web/src/components/dashboard/continue-watching-card.tsx b/apps/web/src/components/dashboard/continue-watching-card.tsx index 2141007..c47dcc2 100644 --- a/apps/web/src/components/dashboard/continue-watching-card.tsx +++ b/apps/web/src/components/dashboard/continue-watching-card.tsx @@ -1,18 +1,21 @@ import { IconPlayerPlay } from "@tabler/icons-react"; import { Link } from "@tanstack/react-router"; +import { thumbHashToUrl } from "@/lib/thumbhash"; export interface ContinueWatchingItemProps { title: { id: string; title: string; backdropPath: string | null; + backdropThumbHash?: string | null; }; nextEpisode: { seasonNumber: number; episodeNumber: number; name: string | null; stillPath: string | null; + stillThumbHash?: string | null; } | null; totalEpisodes: number; watchedEpisodes: number; @@ -36,7 +39,17 @@ export function ContinueWatchingCard({ params={{ id: item.title.id }} className="group relative inline-block w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] transition-shadow hover:shadow-black/25 hover:shadow-lg sm:w-72" > -
+
{ + const hash = + item.nextEpisode?.stillThumbHash ?? item.title.backdropThumbHash; + const url = thumbHashToUrl(hash); + return url + ? { backgroundImage: `url(${url})`, backgroundSize: "cover" } + : undefined; + })()} + > {stillUrl ? ( -
+
{person.profilePath ? ( -
+
{posterPath ? ( -
+
{member.profilePath ? ( {/* Backdrop hero */} {title.backdropPath && ( -
+
c.charCodeAt(0)); + return thumbHashToDataURL(binary); +} diff --git a/bun.lock b/bun.lock index 367ace8..7bbea7c 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,6 @@ "@react-navigation/elements": "2.9.10", "@shopify/flash-list": "2.3.0", "@sofa/api": "workspace:*", - "@sofa/tmdb": "workspace:*", "@tabler/icons-react-native": "3.40.0", "@tanstack/query-async-storage-persister": "5.90.24", "@tanstack/react-form": "1.28.5", @@ -154,6 +153,7 @@ "shadcn": "4.0.6", "sonner": "2.0.7", "tailwind-merge": "catalog:", + "thumbhash": "catalog:", "tw-animate-css": "1.4.0", "vaul": "1.1.2", "youtube-video-element": "1.9.0", @@ -224,6 +224,8 @@ "@sofa/tmdb": "workspace:*", "date-fns": "catalog:", "node-vibrant": "4.0.4", + "sharp": "0.34.5", + "thumbhash": "catalog:", "zod": "catalog:", }, "devDependencies": { @@ -286,6 +288,7 @@ "react-dom": "19.2.0", "tailwind-merge": "3.5.0", "tailwindcss": "4.2.1", + "thumbhash": "0.1.1", "typescript": "5.9.3", "zod": "4.3.6", }, @@ -566,6 +569,8 @@ "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], + "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -704,6 +709,56 @@ "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], @@ -2376,7 +2431,7 @@ "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], - "semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], @@ -2400,6 +2455,8 @@ "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -2510,6 +2567,8 @@ "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], + "thumbhash": ["thumbhash@0.1.1", "", {}, "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg=="], + "timm": ["timm@1.7.1", "", {}, "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -2718,22 +2777,16 @@ "@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], - "@expo/cli/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@expo/cli/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "@expo/cli/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@expo/config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "@expo/config/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@expo/config-plugins/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@expo/config-plugins/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "@expo/config-plugins/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "@expo/devtools/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -2744,12 +2797,8 @@ "@expo/fingerprint/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "@expo/fingerprint/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@expo/image-utils/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@expo/image-utils/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@expo/local-build-cache-provider/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@expo/metro/metro-runtime": ["metro-runtime@0.83.3", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw=="], @@ -2766,8 +2815,6 @@ "@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], - "@expo/prebuild-config/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@expo/xcpretty/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -2816,8 +2863,6 @@ "@react-native/codegen/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], - "@react-native/community-cli-plugin/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], @@ -2898,6 +2943,8 @@ "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -2944,8 +2991,6 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "jsonwebtoken/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], @@ -2978,8 +3023,6 @@ "node-vibrant/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "npm-package-arg/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "npm-package-arg/validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -2996,12 +3039,10 @@ "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "react-native/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], + "react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], - "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - - "react-native-worklets/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/docs/openapi.json b/docs/openapi.json index 1c9a084..104d9b0 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -163,6 +163,16 @@ } ] }, + "posterThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "backdropPath": { "anyOf": [ { @@ -173,6 +183,16 @@ } ] }, + "backdropThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "popularity": { "anyOf": [ { @@ -330,7 +350,9 @@ "releaseDate", "firstAirDate", "posterPath", + "posterThumbHash", "backdropPath", + "backdropThumbHash", "popularity", "voteAverage", "voteCount", @@ -403,6 +425,16 @@ } ] }, + "stillThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "airDate": { "anyOf": [ { @@ -430,6 +462,7 @@ "name", "overview", "stillPath", + "stillThumbHash", "airDate", "runtimeMinutes" ] @@ -551,6 +584,16 @@ } ] }, + "profileThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "tmdbId": { "type": "number" } @@ -565,6 +608,7 @@ "displayOrder", "episodeCount", "profilePath", + "profileThumbHash", "tmdbId" ] } @@ -986,6 +1030,16 @@ } ] }, + "posterThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "releaseDate": { "anyOf": [ { @@ -1023,6 +1077,7 @@ "type", "title", "posterPath", + "posterThumbHash", "releaseDate", "firstAirDate", "voteAverage" @@ -1161,6 +1216,16 @@ } ] }, + "stillThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "airDate": { "anyOf": [ { @@ -1188,6 +1253,7 @@ "name", "overview", "stillPath", + "stillThumbHash", "airDate", "runtimeMinutes" ] @@ -1608,6 +1674,16 @@ } ] }, + "profileThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "knownForDepartment": { "anyOf": [ { @@ -1638,6 +1714,7 @@ "deathday", "placeOfBirth", "profilePath", + "profileThumbHash", "knownForDepartment", "imdbId" ] @@ -1672,6 +1749,16 @@ } ] }, + "posterThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "releaseDate": { "anyOf": [ { @@ -1732,6 +1819,7 @@ "type", "title", "posterPath", + "posterThumbHash", "releaseDate", "firstAirDate", "voteAverage", @@ -1892,12 +1980,23 @@ "type": "null" } ] + }, + "backdropThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ "id", "title", - "backdropPath" + "backdropPath", + "backdropThumbHash" ] }, "nextEpisode": { @@ -1930,13 +2029,24 @@ "type": "null" } ] + }, + "stillThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ "seasonNumber", "episodeNumber", "name", - "stillPath" + "stillPath", + "stillThumbHash" ] }, { @@ -2014,6 +2124,16 @@ } ] }, + "posterThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "releaseDate": { "anyOf": [ { @@ -2065,6 +2185,7 @@ "type", "title", "posterPath", + "posterThumbHash", "releaseDate", "firstAirDate", "voteAverage", @@ -2127,6 +2248,16 @@ } ] }, + "posterThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "releaseDate": { "anyOf": [ { @@ -2164,6 +2295,7 @@ "type", "title", "posterPath", + "posterThumbHash", "releaseDate", "firstAirDate", "voteAverage" diff --git a/package.json b/package.json index 0db9c18..5a17c87 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "react-dom": "19.2.0", "tailwind-merge": "3.5.0", "tailwindcss": "4.2.1", + "thumbhash": "0.1.1", "typescript": "5.9.3", "zod": "4.3.6" } @@ -33,7 +34,7 @@ "format": "turbo run format", "check-types": "turbo run check-types", "test": "turbo run test", - "generate:openapi": "bun scripts/generate-openapi-spec.ts" + "generate:openapi": "bun scripts/generate-openapi-spec.ts && cd docs && bun run generate:api-docs" }, "devDependencies": { "@biomejs/biome": "2.4.7", diff --git a/packages/api/src/schemas.ts b/packages/api/src/schemas.ts index abe12b9..84d8155 100644 --- a/packages/api/src/schemas.ts +++ b/packages/api/src/schemas.ts @@ -156,6 +156,7 @@ const EpisodeSchema = z.object({ name: z.string().nullable(), overview: z.string().nullable(), stillPath: z.string().nullable(), + stillThumbHash: z.string().nullable(), airDate: z.string().nullable(), runtimeMinutes: z.number().nullable(), }); @@ -185,6 +186,7 @@ const CastMemberSchema = z.object({ displayOrder: z.number(), episodeCount: z.number().nullable(), profilePath: z.string().nullable(), + profileThumbHash: z.string().nullable(), tmdbId: z.number(), }); @@ -198,7 +200,9 @@ const ResolvedTitleSchema = z.object({ releaseDate: z.string().nullable(), firstAirDate: z.string().nullable(), posterPath: z.string().nullable(), + posterThumbHash: z.string().nullable(), backdropPath: z.string().nullable(), + backdropThumbHash: z.string().nullable(), popularity: z.number().nullable(), voteAverage: z.number().nullable(), voteCount: z.number().nullable(), @@ -218,6 +222,7 @@ const PersonSchema = z.object({ deathday: z.string().nullable(), placeOfBirth: z.string().nullable(), profilePath: z.string().nullable(), + profileThumbHash: z.string().nullable(), knownForDepartment: z.string().nullable(), imdbId: z.string().nullable(), }); @@ -228,6 +233,7 @@ const PersonCreditSchema = z.object({ type: mediaType, title: z.string(), posterPath: z.string().nullable(), + posterThumbHash: z.string().nullable(), releaseDate: z.string().nullable(), firstAirDate: z.string().nullable(), voteAverage: z.number().nullable(), @@ -242,6 +248,7 @@ const TmdbBrowseItem = z.object({ type: mediaType, title: z.string(), posterPath: z.string().nullable(), + posterThumbHash: z.string().nullable(), releaseDate: z.string().nullable(), firstAirDate: z.string().nullable(), voteAverage: z.number().nullable(), @@ -254,6 +261,7 @@ const RecommendationItemSchema = z.object({ type: mediaType, title: z.string(), posterPath: z.string().nullable(), + posterThumbHash: z.string().nullable(), releaseDate: z.string().nullable(), firstAirDate: z.string().nullable(), voteAverage: z.number().nullable(), @@ -324,6 +332,7 @@ export const ContinueWatchingOutput = z.object({ id: z.string(), title: z.string(), backdropPath: z.string().nullable(), + backdropThumbHash: z.string().nullable(), }), nextEpisode: z .object({ @@ -331,6 +340,7 @@ export const ContinueWatchingOutput = z.object({ episodeNumber: z.number(), name: z.string().nullable(), stillPath: z.string().nullable(), + stillThumbHash: z.string().nullable(), }) .nullable(), totalEpisodes: z.number(), @@ -347,6 +357,7 @@ export const LibraryOutput = z.object({ type: mediaType, title: z.string(), posterPath: z.string().nullable(), + posterThumbHash: z.string().nullable(), releaseDate: z.string().nullable(), firstAirDate: z.string().nullable(), voteAverage: z.number().nullable(), diff --git a/packages/api/src/utils.ts b/packages/api/src/utils.ts index df8f1b7..9819cf4 100644 --- a/packages/api/src/utils.ts +++ b/packages/api/src/utils.ts @@ -6,6 +6,7 @@ interface NextEpisodeInfo { episodeNumber: number; name: string | null; stillPath: string | null; + stillThumbHash: string | null; } export interface NextEpisodeResult { @@ -41,6 +42,7 @@ export function getNextEpisode( episodeNumber: ep.episodeNumber, name: ep.name, stillPath: ep.stillPath, + stillThumbHash: ep.stillThumbHash, }; } } diff --git a/packages/core/package.json b/packages/core/package.json index afd9dc6..0f15a7c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,7 @@ "./settings": "./src/settings.ts", "./system-health": "./src/system-health.ts", "./telemetry": "./src/telemetry.ts", + "./thumbhash": "./src/thumbhash.ts", "./tracking": "./src/tracking.ts", "./update-check": "./src/update-check.ts", "./webhooks": "./src/webhooks.ts" @@ -35,6 +36,8 @@ "@sofa/tmdb": "workspace:*", "date-fns": "catalog:", "node-vibrant": "4.0.4", + "sharp": "0.34.5", + "thumbhash": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/core/src/colors.ts b/packages/core/src/colors.ts index 16e815a..a9335e1 100644 --- a/packages/core/src/colors.ts +++ b/packages/core/src/colors.ts @@ -1,15 +1,9 @@ -import path from "node:path"; import { db } from "@sofa/db/client"; import { eq } from "@sofa/db/helpers"; import { titles } from "@sofa/db/schema"; import { createLogger } from "@sofa/logger"; import { Vibrant } from "node-vibrant/node"; -import { - downloadAndCacheImage, - getLocalImagePath, - imageCacheEnabled, - isImageCached, -} from "./image-cache"; +import { loadImageBuffer } from "./image-cache"; const log = createLogger("colors"); @@ -25,27 +19,23 @@ export interface ColorPalette { export async function extractAndStoreColors( titleId: string, posterPath: string | null, + sourceBuffer?: Buffer | null, ): Promise { - if (!posterPath) return null; - - // Use local cached image, downloading first if needed - let source: string; - const filename = path.basename(posterPath); - if (imageCacheEnabled()) { - if (!(await isImageCached("posters", filename))) { - await downloadAndCacheImage(posterPath, "posters"); - } - source = getLocalImagePath("posters", filename); - } else { - const baseUrl = - process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p"; - source = `${baseUrl}/w300${posterPath}`; + if (!posterPath) { + db.update(titles) + .set({ colorPalette: null }) + .where(eq(titles.id, titleId)) + .run(); + return null; } + const source = + sourceBuffer === undefined + ? await loadImageBuffer(posterPath, "posters") + : sourceBuffer; + if (!source) return null; + try { - log.debug( - `Extracting colors for title ${titleId} from ${imageCacheEnabled() ? "cache" : "remote"}`, - ); const palette = await Vibrant.from(source).getPalette(); const colors: ColorPalette = { diff --git a/packages/core/src/credits.ts b/packages/core/src/credits.ts index 6ff32d1..4166380 100644 --- a/packages/core/src/credits.ts +++ b/packages/core/src/credits.ts @@ -6,6 +6,7 @@ import { createLogger } from "@sofa/logger"; import { getMovieCredits, getTvAggregateCredits } from "@sofa/tmdb/client"; import { tmdbImageUrl } from "@sofa/tmdb/image"; import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache"; +import { generatePersonThumbHash } from "./thumbhash"; const log = createLogger("credits"); @@ -37,49 +38,107 @@ function batchUpsertPersons(people: PersonData[]): Map { // Batch prefetch existing persons (1 query) const existing = db - .select({ id: persons.id, tmdbId: persons.tmdbId }) + .select({ + id: persons.id, + tmdbId: persons.tmdbId, + name: persons.name, + profilePath: persons.profilePath, + profileThumbHash: persons.profileThumbHash, + popularity: persons.popularity, + }) .from(persons) .where(inArray(persons.tmdbId, tmdbIds)) .all(); + const existingMap = new Map(existing.map((p) => [p.tmdbId, p])); const idMap = new Map(existing.map((p) => [p.tmdbId, p.id])); - // Insert only new persons in a transaction - const newPeople = uniquePeople.filter((p) => !idMap.has(p.tmdbId)); - if (newPeople.length > 0) { - db.transaction((tx) => { - for (const p of newPeople) { - const row = tx - .insert(persons) - .values({ - tmdbId: p.tmdbId, + db.transaction((tx) => { + for (const p of uniquePeople) { + const existingPerson = existingMap.get(p.tmdbId); + if (existingPerson) { + const nextPopularity = p.popularity ?? null; + const pathChanged = existingPerson.profilePath !== p.profilePath; + const needsUpdate = + existingPerson.name !== p.name || + pathChanged || + existingPerson.popularity !== nextPopularity; + if (!needsUpdate) continue; + + tx.update(persons) + .set({ name: p.name, profilePath: p.profilePath, - popularity: p.popularity ?? null, + popularity: nextPopularity, + profileThumbHash: pathChanged + ? null + : existingPerson.profileThumbHash, }) - .onConflictDoNothing() - .returning() - .get(); - if (row) idMap.set(p.tmdbId, row.id); + .where(eq(persons.id, existingPerson.id)) + .run(); + continue; } - }); - // One fallback query for any that conflicted - const stillMissing = newPeople - .filter((p) => !idMap.has(p.tmdbId)) - .map((p) => p.tmdbId); - if (stillMissing.length > 0) { - const fallbacks = db - .select({ id: persons.id, tmdbId: persons.tmdbId }) - .from(persons) - .where(inArray(persons.tmdbId, stillMissing)) - .all(); - for (const f of fallbacks) idMap.set(f.tmdbId, f.id); + const row = tx + .insert(persons) + .values({ + tmdbId: p.tmdbId, + name: p.name, + profilePath: p.profilePath, + popularity: p.popularity ?? null, + }) + .onConflictDoNothing() + .returning() + .get(); + if (row) idMap.set(p.tmdbId, row.id); } + }); + + // One fallback query for any concurrent inserts that conflicted + const stillMissing = uniquePeople + .filter((p) => !idMap.has(p.tmdbId)) + .map((p) => p.tmdbId); + if (stillMissing.length > 0) { + const fallbacks = db + .select({ id: persons.id, tmdbId: persons.tmdbId }) + .from(persons) + .where(inArray(persons.tmdbId, stillMissing)) + .all(); + for (const f of fallbacks) idMap.set(f.tmdbId, f.id); } return idMap; } +export async function syncCastProfileThumbHashes( + titleId: string, + personIds?: string[], + options?: { warmCache?: boolean }, +) { + if (options?.warmCache !== false && imageCacheEnabled()) { + await cacheProfilePhotos(titleId); + } + + const personRows = db + .select({ + id: persons.id, + profilePath: persons.profilePath, + profileThumbHash: persons.profileThumbHash, + }) + .from(titleCast) + .innerJoin(persons, eq(titleCast.personId, persons.id)) + .where(eq(titleCast.titleId, titleId)) + .all(); + + const allowedIds = personIds ? new Set(personIds) : null; + const uniqueRows = new Map(personRows.map((p) => [p.id, p])); + await Promise.all( + [...uniqueRows.values()] + .filter((p) => (!allowedIds || allowedIds.has(p.id)) && p.profilePath) + .filter((p) => !p.profileThumbHash) + .map((p) => generatePersonThumbHash(p.id, p.profilePath)), + ); +} + export async function refreshCredits(titleId: string) { const title = db.select().from(titles).where(eq(titles.id, titleId)).get(); if (!title) return; @@ -276,11 +335,9 @@ export async function refreshCredits(titleId: string) { log.debug(`Credits refreshed for "${title.title}"`); - if (imageCacheEnabled()) { - cacheProfilePhotos(titleId).catch((err) => - log.debug("Profile photo caching failed:", err), - ); - } + (async () => { + await syncCastProfileThumbHashes(titleId, [...personIds.values()]); + })().catch((err) => log.debug("Profile cache/thumbhash failed:", err)); } catch (err) { log.error(`Failed to refresh credits for title ${titleId}:`, err); } @@ -298,6 +355,7 @@ export function getCastForTitle(titleId: string): CastMember[] { displayOrder: titleCast.displayOrder, episodeCount: titleCast.episodeCount, profilePath: persons.profilePath, + profileThumbHash: persons.profileThumbHash, tmdbId: persons.tmdbId, }) .from(titleCast) diff --git a/packages/core/src/discovery.ts b/packages/core/src/discovery.ts index bddd3c8..68ce6ab 100644 --- a/packages/core/src/discovery.ts +++ b/packages/core/src/discovery.ts @@ -177,6 +177,7 @@ export interface ContinueWatchingItem { title: string; posterPath: string | null; backdropPath: string | null; + backdropThumbHash: string | null; type: string; }; nextEpisode: { @@ -185,6 +186,7 @@ export interface ContinueWatchingItem { episodeNumber: number; name: string | null; stillPath: string | null; + stillThumbHash: string | null; overview: string | null; } | null; lastWatchedAt: Date | null; @@ -322,6 +324,7 @@ export function getContinueWatchingFeed( episodeNumber: ep.episodeNumber, name: ep.name, stillPath: ep.stillPath, + stillThumbHash: ep.stillThumbHash, overview: ep.overview, }; } @@ -335,6 +338,7 @@ export function getContinueWatchingFeed( title: title.title, posterPath: title.posterPath, backdropPath: title.backdropPath, + backdropThumbHash: title.backdropThumbHash, type: title.type, }, nextEpisode, @@ -366,6 +370,7 @@ export function getNewAvailableFeed(userId: string, days = 14) { type: titles.type, tmdbId: titles.tmdbId, posterPath: titles.posterPath, + posterThumbHash: titles.posterThumbHash, releaseDate: titles.releaseDate, firstAirDate: titles.firstAirDate, voteAverage: titles.voteAverage, @@ -524,6 +529,7 @@ export function getRecommendationsForTitle(titleId: string) { type: r.type as "movie" | "tv", title: r.title, posterPath: tmdbImageUrl(r.posterPath, "posters"), + posterThumbHash: r.posterThumbHash, releaseDate: r.releaseDate, firstAirDate: r.firstAirDate, voteAverage: r.voteAverage, diff --git a/packages/core/src/image-cache.ts b/packages/core/src/image-cache.ts index d09344b..508fea3 100644 --- a/packages/core/src/image-cache.ts +++ b/packages/core/src/image-cache.ts @@ -12,26 +12,22 @@ import { titles, } from "@sofa/db/schema"; import { createLogger } from "@sofa/logger"; -import type { ImageCategory } from "@sofa/tmdb/image"; +import { + IMAGE_CATEGORY_SIZES, + type ImageCategory, + tmdbCdnImageUrl, +} from "@sofa/tmdb/image"; const log = createLogger("image-cache"); export type { ImageCategory }; -const CATEGORY_SIZES: Record = { - posters: "w500", - backdrops: "w1280", - stills: "w1280", - logos: "w92", - profiles: "w185", -}; - export function imageCacheEnabled(): boolean { return process.env.IMAGE_CACHE_ENABLED !== "false"; } export async function ensureImageDirs() { - for (const category of Object.keys(CATEGORY_SIZES)) { + for (const category of Object.keys(IMAGE_CATEGORY_SIZES) as ImageCategory[]) { await mkdir(path.join(CACHE_DIR, category), { recursive: true }); } } @@ -60,20 +56,33 @@ export async function readCachedImage( return Buffer.from(await file.arrayBuffer()); } +async function fetchRemoteImage( + tmdbPath: string, + category: ImageCategory, +): Promise<{ buffer: Buffer; contentType: string } | null> { + const url = + tmdbCdnImageUrl(tmdbPath, category) ?? `${TMDB_IMAGE_BASE_URL}${tmdbPath}`; + + const res = await fetch(url); + if (!res.ok) { + log.warn(`Fetch failed: ${url} -> ${res.status}`); + return null; + } + + return { + buffer: Buffer.from(await res.arrayBuffer()), + contentType: res.headers.get("content-type") || "image/jpeg", + }; +} + export async function downloadAndCacheImage( tmdbPath: string, category: ImageCategory, ): Promise { - const size = CATEGORY_SIZES[category]; - const url = `${TMDB_IMAGE_BASE_URL}/${size}${tmdbPath}`; + const remote = await fetchRemoteImage(tmdbPath, category); + if (!remote) return null; - const res = await fetch(url); - if (!res.ok) { - log.warn(`Download failed: ${url} -> ${res.status}`); - return null; - } - - const buffer = Buffer.from(await res.arrayBuffer()); + const { buffer } = remote; const filename = path.basename(tmdbPath); const finalPath = getLocalImagePath(category, filename); const tmpPath = `${finalPath}.tmp.${Date.now()}`; @@ -109,16 +118,10 @@ export async function fetchAndMaybeCache( } // Fetch from TMDB - const size = CATEGORY_SIZES[category]; - const url = `${TMDB_IMAGE_BASE_URL}/${size}${tmdbPath}`; - const res = await fetch(url); - if (!res.ok) { - log.warn(`Fetch failed: ${url} -> ${res.status}`); - return null; - } + const remote = await fetchRemoteImage(tmdbPath, category); + if (!remote) return null; - const buffer = Buffer.from(await res.arrayBuffer()); - const contentType = res.headers.get("content-type") || "image/jpeg"; + const { buffer, contentType } = remote; // Fire-and-forget save to disk const finalPath = getLocalImagePath(category, filename); @@ -130,6 +133,22 @@ export async function fetchAndMaybeCache( return { buffer, contentType }; } +export async function loadImageBuffer( + tmdbPath: string, + category: ImageCategory, +): Promise { + const filename = path.basename(tmdbPath); + + if (imageCacheEnabled()) { + const cached = await readCachedImage(category, filename); + if (cached) return cached; + return downloadAndCacheImage(tmdbPath, category); + } + + const remote = await fetchRemoteImage(tmdbPath, category); + return remote?.buffer ?? null; +} + export async function cacheImagesForTitle(titleId: string) { const title = db.select().from(titles).where(eq(titles.id, titleId)).get(); if (!title) return; diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 12b2515..81f705b 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -6,7 +6,7 @@ import type { Season, } from "@sofa/api/schemas"; import { db } from "@sofa/db/client"; -import { eq, inArray, sql } from "@sofa/db/helpers"; +import { and, eq, inArray, isNotNull, sql } from "@sofa/db/helpers"; import { availabilityOffers, episodes, @@ -39,8 +39,15 @@ import { cacheEpisodeStills, cacheImagesForTitle, imageCacheEnabled, + loadImageBuffer, } from "./image-cache"; import { generateProviderUrl } from "./providers"; +import { + generateEpisodeThumbHash, + generateSeasonThumbHash, + generateTitleBackdropThumbHash, + generateTitlePosterThumbHash, +} from "./thumbhash"; const log = createLogger("metadata"); @@ -86,6 +93,47 @@ function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) { }); } +export function updateTitleWithArtInvalidation( + title: Pick< + typeof titles.$inferSelect, + | "id" + | "posterPath" + | "backdropPath" + | "posterThumbHash" + | "backdropThumbHash" + | "colorPalette" + >, + values: Partial, +) { + const nextPosterPath = Object.hasOwn(values, "posterPath") + ? (values.posterPath ?? null) + : title.posterPath; + const nextBackdropPath = Object.hasOwn(values, "backdropPath") + ? (values.backdropPath ?? null) + : title.backdropPath; + + const posterPathChanged = nextPosterPath !== title.posterPath; + const backdropPathChanged = nextBackdropPath !== title.backdropPath; + + db.update(titles) + .set({ + ...values, + ...(posterPathChanged + ? { + posterThumbHash: null, + colorPalette: null, + } + : {}), + ...(backdropPathChanged + ? { + backdropThumbHash: null, + } + : {}), + }) + .where(eq(titles.id, title.id)) + .run(); +} + /** @internal */ export function extractMovieContentRating( movie: TmdbMovieDetails, @@ -148,18 +196,15 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") { if (!hasSeason) { const show = await getTvDetails(tmdbId); if (!existing.lastFetchedAt) { - db.update(titles) - .set({ - overview: show.overview, - posterPath: show.poster_path, - backdropPath: show.backdrop_path, - status: show.status, - contentRating: extractTvContentRating(show), - tvdbId: show.external_ids?.tvdb_id ?? null, - lastFetchedAt: new Date(), - }) - .where(eq(titles.id, existing.id)) - .run(); + updateTitleWithArtInvalidation(existing, { + overview: show.overview, + posterPath: show.poster_path, + backdropPath: show.backdrop_path, + status: show.status, + contentRating: extractTvContentRating(show), + tvdbId: show.external_ids?.tvdb_id ?? null, + lastFetchedAt: new Date(), + }); } upsertGenres(existing.id, show.genres ?? []); await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons); @@ -169,23 +214,18 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") { refreshRecommendations(existing.id).catch((err) => log.debug("Recommendations enrichment failed:", err), ); - extractAndStoreColors(existing.id, show.poster_path ?? null).catch( - (err) => log.debug("Color extraction failed:", err), - ); + syncTitleArt( + existing.id, + show.poster_path, + show.backdrop_path, + "tv", + ).catch((err) => log.debug("Cache/thumbhash failed:", err)); refreshCredits(existing.id).catch((err) => log.debug("Credits enrichment failed:", err), ); refreshTrailer(existing.id).catch((err) => log.debug("Trailer enrichment failed:", err), ); - if (imageCacheEnabled()) { - cacheImagesForTitle(existing.id).catch((err) => - log.debug("Image caching failed:", err), - ); - cacheEpisodeStills(existing.id).catch((err) => - log.debug("Episode stills caching failed:", err), - ); - } return db.select().from(titles).where(eq(titles.id, existing.id)).get(); } } @@ -223,8 +263,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") { refreshRecommendations(row.id).catch((err) => log.debug("Recommendations enrichment failed:", err), ); - extractAndStoreColors(row.id, movie.poster_path ?? null).catch((err) => - log.debug("Color extraction failed:", err), + syncTitleArt(row.id, movie.poster_path, movie.backdrop_path, "movie").catch( + (err) => log.debug("Cache/thumbhash failed:", err), ); refreshCredits(row.id).catch((err) => log.debug("Credits enrichment failed:", err), @@ -232,11 +272,6 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") { refreshTrailer(row.id).catch((err) => log.debug("Trailer enrichment failed:", err), ); - if (imageCacheEnabled()) { - cacheImagesForTitle(row.id).catch((err) => - log.debug("Image caching failed:", err), - ); - } log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`); return row; } @@ -272,8 +307,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") { refreshRecommendations(row.id).catch((err) => log.debug("Recommendations enrichment failed:", err), ); - extractAndStoreColors(row.id, show.poster_path ?? null).catch((err) => - log.debug("Color extraction failed:", err), + syncTitleArt(row.id, show.poster_path, show.backdrop_path, "tv").catch( + (err) => log.debug("Cache/thumbhash failed:", err), ); refreshCredits(row.id).catch((err) => log.debug("Credits enrichment failed:", err), @@ -281,14 +316,6 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") { refreshTrailer(row.id).catch((err) => log.debug("Trailer enrichment failed:", err), ); - if (imageCacheEnabled()) { - cacheImagesForTitle(row.id).catch((err) => - log.debug("Image caching failed:", err), - ); - cacheEpisodeStills(row.id).catch((err) => - log.debug("Episode stills caching failed:", err), - ); - } log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`); return row; } @@ -301,69 +328,56 @@ export async function refreshTitle(titleId: string) { if (title.type === "movie") { const movie = await getMovieDetails(title.tmdbId); - db.update(titles) - .set({ - title: movie.title, - originalTitle: movie.original_title, - overview: movie.overview, - releaseDate: movie.release_date || null, - posterPath: movie.poster_path, - backdropPath: movie.backdrop_path, - popularity: movie.popularity, - voteAverage: movie.vote_average, - voteCount: movie.vote_count, - status: movie.status, - contentRating: extractMovieContentRating(movie), - lastFetchedAt: now, - }) - .where(eq(titles.id, titleId)) - .run(); + updateTitleWithArtInvalidation(title, { + title: movie.title, + originalTitle: movie.original_title, + overview: movie.overview, + releaseDate: movie.release_date || null, + posterPath: movie.poster_path, + backdropPath: movie.backdrop_path, + popularity: movie.popularity, + voteAverage: movie.vote_average, + voteCount: movie.vote_count, + status: movie.status, + contentRating: extractMovieContentRating(movie), + lastFetchedAt: now, + }); upsertGenres(titleId, movie.genres ?? []); } else { const show = await getTvDetails(title.tmdbId); - db.update(titles) - .set({ - title: show.name, - originalTitle: show.original_name, - overview: show.overview, - firstAirDate: show.first_air_date || null, - posterPath: show.poster_path, - backdropPath: show.backdrop_path, - popularity: show.popularity, - voteAverage: show.vote_average, - voteCount: show.vote_count, - status: show.status, - contentRating: extractTvContentRating(show), - tvdbId: show.external_ids?.tvdb_id ?? null, - lastFetchedAt: now, - }) - .where(eq(titles.id, titleId)) - .run(); + updateTitleWithArtInvalidation(title, { + title: show.name, + originalTitle: show.original_name, + overview: show.overview, + firstAirDate: show.first_air_date || null, + posterPath: show.poster_path, + backdropPath: show.backdrop_path, + popularity: show.popularity, + voteAverage: show.vote_average, + voteCount: show.vote_count, + status: show.status, + contentRating: extractTvContentRating(show), + tvdbId: show.external_ids?.tvdb_id ?? null, + lastFetchedAt: now, + }); upsertGenres(titleId, show.genres ?? []); await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons); } const updated = db.select().from(titles).where(eq(titles.id, titleId)).get(); if (updated) { - extractAndStoreColors(updated.id, updated.posterPath).catch((err) => - log.debug("Color extraction failed:", err), - ); + syncTitleArt( + updated.id, + updated.posterPath, + updated.backdropPath, + updated.type as "movie" | "tv", + ).catch((err) => log.debug("Cache/thumbhash failed:", err)); refreshTrailer(updated.id).catch((err) => log.debug("Trailer enrichment failed:", err), ); refreshCredits(updated.id).catch((err) => log.debug("Credits enrichment failed:", err), ); - if (imageCacheEnabled()) { - cacheImagesForTitle(updated.id).catch((err) => - log.debug("Image caching failed:", err), - ); - if (updated.type === "tv") { - cacheEpisodeStills(updated.id).catch((err) => - log.debug("Episode stills caching failed:", err), - ); - } - } } return updated; } @@ -381,6 +395,14 @@ export async function refreshTvChildren( try { const seasonData = await getTvSeasonDetails(tmdbId, sn); + const existingSeason = db + .select({ + id: seasons.id, + posterPath: seasons.posterPath, + }) + .from(seasons) + .where(and(eq(seasons.titleId, titleId), eq(seasons.seasonNumber, sn))) + .get(); const seasonRow = db .insert(seasons) @@ -406,6 +428,19 @@ export async function refreshTvChildren( .returning() .get(); + // Snapshot existing episode still paths before upsert so we can detect changes + const oldEpStills = new Map( + db + .select({ + episodeNumber: episodes.episodeNumber, + stillPath: episodes.stillPath, + }) + .from(episodes) + .where(eq(episodes.seasonId, existingSeason?.id ?? seasonRow.id)) + .all() + .map((e) => [e.episodeNumber, e.stillPath] as const), + ); + // Batch all episode upserts in a single transaction per season const eps = seasonData.episodes ?? []; if (eps.length > 0) { @@ -435,6 +470,38 @@ export async function refreshTvChildren( } }); } + + // Clear stale hashes when image paths change during the upsert. + // Full hash (re)generation is handled by syncTitleArt() after + // cache warming, so we only need to null out stale values here. + if ( + (existingSeason?.posterPath ?? null) !== + (seasonData.poster_path ?? null) + ) { + db.update(seasons) + .set({ posterThumbHash: null }) + .where(eq(seasons.id, seasonRow.id)) + .run(); + } + const seasonEps = db + .select({ + id: episodes.id, + episodeNumber: episodes.episodeNumber, + stillPath: episodes.stillPath, + stillThumbHash: episodes.stillThumbHash, + }) + .from(episodes) + .where(eq(episodes.seasonId, seasonRow.id)) + .all(); + for (const ep of seasonEps) { + const oldStill = oldEpStills.get(ep.episodeNumber); + if (oldStill !== ep.stillPath && ep.stillThumbHash) { + db.update(episodes) + .set({ stillThumbHash: null }) + .where(eq(episodes.id, ep.id)) + .run(); + } + } } catch (err) { // Skip this season and continue with the rest — partial data is // better than aborting entirely. The next refresh cycle will retry. @@ -492,47 +559,123 @@ export async function refreshRecommendations(titleId: string) { if (allItems.length === 0) return; + const uniqueTitles = new Map< + number, + { + tmdbId: number; + type: "movie" | "tv"; + title: string; + originalTitle: string | null; + overview: string | null; + releaseDate: string | null; + firstAirDate: string | null; + posterPath: string | null; + backdropPath: string | null; + popularity: number | null; + voteAverage: number | null; + voteCount: number | null; + } + >(); + for (const item of allItems) { + if (uniqueTitles.has(item.result.id)) continue; + uniqueTitles.set(item.result.id, { + tmdbId: item.result.id, + type: item.type, + title: item.result.title ?? item.result.name ?? "Unknown", + originalTitle: + item.result.original_title ?? item.result.original_name ?? null, + overview: item.result.overview ?? null, + releaseDate: item.result.release_date ?? null, + firstAirDate: item.result.first_air_date ?? null, + posterPath: item.result.poster_path ?? null, + backdropPath: item.result.backdrop_path ?? null, + popularity: item.result.popularity ?? null, + voteAverage: item.result.vote_average ?? null, + voteCount: item.result.vote_count ?? null, + }); + } + // Batch prefetch existing titles (1 query) - const tmdbIds = [...new Set(allItems.map((item) => item.result.id))]; + const tmdbIds = [...uniqueTitles.keys()]; const existingTitles = db - .select({ id: titles.id, tmdbId: titles.tmdbId }) + .select({ + id: titles.id, + tmdbId: titles.tmdbId, + posterPath: titles.posterPath, + backdropPath: titles.backdropPath, + posterThumbHash: titles.posterThumbHash, + backdropThumbHash: titles.backdropThumbHash, + }) .from(titles) .where(inArray(titles.tmdbId, tmdbIds)) .all(); + const existingTitleMap = new Map(existingTitles.map((t) => [t.tmdbId, t])); const titleIdMap = new Map( existingTitles.map((t) => [t.tmdbId, t.id]), ); // Insert missing titles + upsert recommendations in a single transaction db.transaction((tx) => { - // Insert only new titles - const newItems = allItems.filter((item) => !titleIdMap.has(item.result.id)); const insertedTmdbIds = new Set(); - for (const item of newItems) { - if (insertedTmdbIds.has(item.result.id)) continue; - insertedTmdbIds.add(item.result.id); - const r = item.result; + for (const item of uniqueTitles.values()) { + const existingTitle = existingTitleMap.get(item.tmdbId); + if (existingTitle) { + const posterPathChanged = existingTitle.posterPath !== item.posterPath; + const backdropPathChanged = + existingTitle.backdropPath !== item.backdropPath; + + tx.update(titles) + .set({ + type: item.type, + title: item.title, + originalTitle: item.originalTitle, + overview: item.overview, + releaseDate: item.releaseDate, + firstAirDate: item.firstAirDate, + posterPath: item.posterPath, + backdropPath: item.backdropPath, + popularity: item.popularity, + voteAverage: item.voteAverage, + voteCount: item.voteCount, + ...(posterPathChanged + ? { + posterThumbHash: null, + colorPalette: null, + } + : {}), + ...(backdropPathChanged + ? { + backdropThumbHash: null, + } + : {}), + }) + .where(eq(titles.id, existingTitle.id)) + .run(); + continue; + } + + insertedTmdbIds.add(item.tmdbId); const row = tx .insert(titles) .values({ - tmdbId: r.id, + tmdbId: item.tmdbId, type: item.type, - title: r.title ?? r.name ?? "Unknown", - originalTitle: r.original_title ?? r.original_name, - overview: r.overview, - releaseDate: r.release_date, - firstAirDate: r.first_air_date, - posterPath: r.poster_path, - backdropPath: r.backdrop_path, - popularity: r.popularity, - voteAverage: r.vote_average, - voteCount: r.vote_count, + title: item.title, + originalTitle: item.originalTitle, + overview: item.overview, + releaseDate: item.releaseDate, + firstAirDate: item.firstAirDate, + posterPath: item.posterPath, + backdropPath: item.backdropPath, + popularity: item.popularity, + voteAverage: item.voteAverage, + voteCount: item.voteCount, lastFetchedAt: null, }) .onConflictDoNothing() .returning() .get(); - if (row) titleIdMap.set(r.id, row.id); + if (row) titleIdMap.set(item.tmdbId, row.id); } // One fallback query for any that conflicted @@ -580,6 +723,31 @@ export async function refreshRecommendations(titleId: string) { .run(); } }); + + // Fire-and-forget thumbhash generation for recommendation titles missing one + const recTitleIds = [ + ...new Set( + allItems.map((i) => titleIdMap.get(i.result.id)).filter(Boolean), + ), + ] as string[]; + if (recTitleIds.length > 0) { + const needingHash = db + .select({ id: titles.id, posterPath: titles.posterPath }) + .from(titles) + .where( + and( + inArray(titles.id, recTitleIds), + isNotNull(titles.posterPath), + sql`${titles.posterThumbHash} IS NULL`, + ), + ) + .all(); + for (const t of needingHash) { + generateTitlePosterThumbHash(t.id, t.posterPath).catch((err) => + log.debug("Recommendation poster thumbhash failed:", err), + ); + } + } } /** Fetch seasons from the DB, building the Season[] structure. */ @@ -610,6 +778,7 @@ function fetchSeasonsFromDb(titleId: string): Season[] { name: ep.name, overview: ep.overview, stillPath: tmdbImageUrl(ep.stillPath, "stills"), + stillThumbHash: ep.stillThumbHash, airDate: ep.airDate, runtimeMinutes: ep.runtimeMinutes, }); @@ -639,17 +808,14 @@ export async function ensureTvHydrated( if (!title.lastFetchedAt) { try { const show = await getTvDetails(tmdbId); - db.update(titles) - .set({ - overview: show.overview, - posterPath: show.poster_path, - backdropPath: show.backdrop_path, - status: show.status, - contentRating: extractTvContentRating(show), - lastFetchedAt: new Date(), - }) - .where(eq(titles.id, titleId)) - .run(); + updateTitleWithArtInvalidation(title, { + overview: show.overview, + posterPath: show.poster_path, + backdropPath: show.backdrop_path, + status: show.status, + contentRating: extractTvContentRating(show), + lastFetchedAt: new Date(), + }); upsertGenres(titleId, show.genres ?? []); await refreshTvChildren(titleId, tmdbId, show.number_of_seasons); } catch (err) { @@ -686,7 +852,7 @@ async function ensureEnriched( title: typeof titles.$inferSelect, existing: { hasCast: boolean; hasAvailability: boolean }, ): Promise { - const tasks: Promise[] = []; + const tasks: Promise[] = []; if (!existing.hasCast) { tasks.push( @@ -720,12 +886,20 @@ async function ensureEnriched( ); } - if (!title.colorPalette && title.posterPath) { + const needsTitleArtSync = + (!title.posterPath && (!!title.posterThumbHash || !!title.colorPalette)) || + (!!title.posterPath && (!title.posterThumbHash || !title.colorPalette)) || + (!title.backdropPath && !!title.backdropThumbHash) || + (!!title.backdropPath && !title.backdropThumbHash); + + if (needsTitleArtSync) { tasks.push( - extractAndStoreColors(titleId, title.posterPath).then( - () => {}, - (err) => log.debug("Color enrichment failed:", err), - ), + syncTitleArt( + titleId, + title.posterPath, + title.backdropPath, + title.type as "movie" | "tv", + ).catch((err) => log.debug("Title art enrichment failed:", err)), ); } @@ -786,23 +960,20 @@ export async function getOrFetchTitle(id: string): Promise<{ if (title.type === "movie" && !title.lastFetchedAt) { try { const movie = await getMovieDetails(title.tmdbId); - db.update(titles) - .set({ - title: movie.title, - originalTitle: movie.original_title, - overview: movie.overview, - releaseDate: movie.release_date || null, - posterPath: movie.poster_path, - backdropPath: movie.backdrop_path, - popularity: movie.popularity, - voteAverage: movie.vote_average, - voteCount: movie.vote_count, - status: movie.status, - contentRating: extractMovieContentRating(movie), - lastFetchedAt: new Date(), - }) - .where(eq(titles.id, id)) - .run(); + updateTitleWithArtInvalidation(title, { + title: movie.title, + originalTitle: movie.original_title, + overview: movie.overview, + releaseDate: movie.release_date || null, + posterPath: movie.poster_path, + backdropPath: movie.backdrop_path, + popularity: movie.popularity, + voteAverage: movie.vote_average, + voteCount: movie.vote_count, + status: movie.status, + contentRating: extractMovieContentRating(movie), + lastFetchedAt: new Date(), + }); upsertGenres(id, movie.genres ?? []); title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title; } catch (err) { @@ -849,7 +1020,9 @@ export async function getOrFetchTitle(id: string): Promise<{ releaseDate: title.releaseDate, firstAirDate: title.firstAirDate, posterPath: tmdbImageUrl(title.posterPath, "posters"), + posterThumbHash: title.posterThumbHash, backdropPath: tmdbImageUrl(title.backdropPath, "backdrops"), + backdropThumbHash: title.backdropThumbHash, popularity: title.popularity, voteAverage: title.voteAverage, voteCount: title.voteCount, @@ -916,6 +1089,92 @@ export async function refreshTrailer(titleId: string) { } } +async function generateMissingTvChildThumbHashes(titleId: string) { + const titleSeasons = db + .select() + .from(seasons) + .where(eq(seasons.titleId, titleId)) + .all(); + + const hashTasks: Promise[] = []; + + for (const s of titleSeasons) { + if (s.posterPath && !s.posterThumbHash) { + hashTasks.push(generateSeasonThumbHash(s.id, s.posterPath)); + } + } + + const seasonIds = titleSeasons.map((s) => s.id); + if (seasonIds.length > 0) { + const epsNeedingHash = db + .select({ + id: episodes.id, + stillPath: episodes.stillPath, + }) + .from(episodes) + .where( + and( + inArray(episodes.seasonId, seasonIds), + isNotNull(episodes.stillPath), + sql`${episodes.stillThumbHash} IS NULL`, + ), + ) + .all(); + for (const ep of epsNeedingHash) { + hashTasks.push(generateEpisodeThumbHash(ep.id, ep.stillPath)); + } + } + + await Promise.all(hashTasks); +} + +export async function syncTvChildArt( + titleId: string, + options?: { warmCache?: boolean }, +) { + if (options?.warmCache !== false && imageCacheEnabled()) { + await Promise.all([ + cacheImagesForTitle(titleId), + cacheEpisodeStills(titleId), + ]); + } + + await generateMissingTvChildThumbHashes(titleId); +} + +/** + * Warm the image cache for a title, then derive poster colors and thumbhashes. + * Sequencing avoids duplicate TMDB downloads on cold caches. + */ +async function syncTitleArt( + titleId: string, + posterPath: string | null | undefined, + backdropPath: string | null | undefined, + type: "movie" | "tv", +) { + if (imageCacheEnabled()) { + await cacheImagesForTitle(titleId); + if (type === "tv") { + await cacheEpisodeStills(titleId); + } + } + + const posterBuffer = posterPath + ? await loadImageBuffer(posterPath, "posters") + : undefined; + + // Title poster colors + thumbhash, then backdrop thumbhash + await Promise.all([ + extractAndStoreColors(titleId, posterPath ?? null, posterBuffer), + generateTitlePosterThumbHash(titleId, posterPath ?? null, posterBuffer), + generateTitleBackdropThumbHash(titleId, backdropPath ?? null), + ]); + + if (type === "tv") { + await syncTvChildArt(titleId, { warmCache: false }); + } +} + function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/packages/core/src/person.ts b/packages/core/src/person.ts index 3687f8f..069fc63 100644 --- a/packages/core/src/person.ts +++ b/packages/core/src/person.ts @@ -5,9 +5,30 @@ import { persons, titleCast, titles } from "@sofa/db/schema"; import { createLogger } from "@sofa/logger"; import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client"; import { tmdbImageUrl } from "@sofa/tmdb/image"; +import { generatePersonThumbHash } from "./thumbhash"; const log = createLogger("person"); +async function ensureProfileThumbHash( + person: Pick< + typeof persons.$inferSelect, + "id" | "profilePath" | "profileThumbHash" + >, +) { + if (!person.profilePath) { + if (person.profileThumbHash) { + await generatePersonThumbHash(person.id, null); + } + return null; + } + + if (person.profileThumbHash) { + return person.profileThumbHash; + } + + return (await generatePersonThumbHash(person.id, person.profilePath)) ?? null; +} + export async function getOrFetchPerson( personId: string, ): Promise { @@ -38,6 +59,20 @@ export async function getOrFetchPerson( .where(eq(persons.id, personId)) .run(); + const newProfilePath = details.profile_path ?? null; + const pathChanged = newProfilePath !== person.profilePath; + const profileThumbHash = pathChanged + ? await ensureProfileThumbHash({ + id: personId, + profilePath: newProfilePath, + profileThumbHash: null, + }) + : await ensureProfileThumbHash({ + id: personId, + profilePath: newProfilePath, + profileThumbHash: person.profileThumbHash, + }); + return { id: person.id, tmdbId: person.tmdbId, @@ -47,6 +82,7 @@ export async function getOrFetchPerson( deathday: details.deathday ?? null, placeOfBirth: details.place_of_birth ?? null, profilePath: tmdbImageUrl(details.profile_path ?? null, "profiles"), + profileThumbHash, knownForDepartment: details.known_for_department ?? null, imdbId: details.imdb_id ?? null, }; @@ -55,6 +91,8 @@ export async function getOrFetchPerson( } } + const profileThumbHash = await ensureProfileThumbHash(person); + return { id: person.id, tmdbId: person.tmdbId, @@ -64,6 +102,7 @@ export async function getOrFetchPerson( deathday: person.deathday, placeOfBirth: person.placeOfBirth, profilePath: tmdbImageUrl(person.profilePath, "profiles"), + profileThumbHash, knownForDepartment: person.knownForDepartment, imdbId: person.imdbId, }; @@ -108,6 +147,8 @@ export async function getOrFetchPersonByTmdbId( row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get(); if (!person) return null; + const profileThumbHash = await ensureProfileThumbHash(person); + return { id: person.id, tmdbId: person.tmdbId, @@ -117,6 +158,7 @@ export async function getOrFetchPersonByTmdbId( deathday: person.deathday, placeOfBirth: person.placeOfBirth, profilePath: tmdbImageUrl(person.profilePath, "profiles"), + profileThumbHash, knownForDepartment: person.knownForDepartment, imdbId: person.imdbId, }; @@ -134,6 +176,7 @@ export function getLocalFilmography(personId: string): PersonCredit[] { type: titles.type, title: titles.title, posterPath: titles.posterPath, + posterThumbHash: titles.posterThumbHash, releaseDate: titles.releaseDate, firstAirDate: titles.firstAirDate, voteAverage: titles.voteAverage, @@ -152,6 +195,7 @@ export function getLocalFilmography(personId: string): PersonCredit[] { type: r.type as "movie" | "tv", title: r.title, posterPath: tmdbImageUrl(r.posterPath, "posters"), + posterThumbHash: r.posterThumbHash, releaseDate: r.releaseDate, firstAirDate: r.firstAirDate, voteAverage: r.voteAverage, @@ -251,6 +295,7 @@ export async function fetchFullFilmography( type: c.media_type as "movie" | "tv", title: c.title ?? c.name ?? "Unknown", posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"), + posterThumbHash: null, releaseDate: c.release_date ?? null, firstAirDate: c.first_air_date ?? null, voteAverage: c.vote_average, diff --git a/packages/core/src/thumbhash.ts b/packages/core/src/thumbhash.ts new file mode 100644 index 0000000..e78e443 --- /dev/null +++ b/packages/core/src/thumbhash.ts @@ -0,0 +1,111 @@ +import path from "node:path"; +import { db } from "@sofa/db/client"; +import { eq } from "@sofa/db/helpers"; +import { episodes, persons, seasons, titles } from "@sofa/db/schema"; +import { createLogger } from "@sofa/logger"; +import type { ImageCategory } from "@sofa/tmdb/image"; +import sharp from "sharp"; +import { rgbaToThumbHash } from "thumbhash"; +import { loadImageBuffer } from "./image-cache"; + +const log = createLogger("thumbhash"); + +/** + * Generate a ThumbHash string from an image path. + * Returns a base64-encoded ThumbHash, or null on failure. + */ +export async function generateThumbHash( + imagePath: string, + category: ImageCategory, + sourceBuffer?: Buffer | null, +): Promise { + const filename = path.basename(imagePath); + const source = + sourceBuffer === undefined + ? await loadImageBuffer(imagePath, category) + : sourceBuffer; + if (!source) return null; + + try { + const { data, info } = await sharp(source) + .resize(100, 100, { fit: "inside" }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const hash = rgbaToThumbHash(info.width, info.height, data); + return Buffer.from(hash).toString("base64"); + } catch (err) { + log.error(`Failed to generate thumbhash for ${category}/${filename}:`, err); + return null; + } +} + +export async function generateTitlePosterThumbHash( + titleId: string, + posterPath: string | null, + sourceBuffer?: Buffer | null, +): Promise { + const hash = posterPath + ? await generateThumbHash(posterPath, "posters", sourceBuffer) + : null; + db.update(titles) + .set({ posterThumbHash: hash }) + .where(eq(titles.id, titleId)) + .run(); + return hash; +} + +export async function generateTitleBackdropThumbHash( + titleId: string, + backdropPath: string | null, +): Promise { + const hash = backdropPath + ? await generateThumbHash(backdropPath, "backdrops") + : null; + db.update(titles) + .set({ backdropThumbHash: hash }) + .where(eq(titles.id, titleId)) + .run(); + return hash; +} + +export async function generateSeasonThumbHash( + seasonId: string, + posterPath: string | null, +): Promise { + const hash = posterPath + ? await generateThumbHash(posterPath, "posters") + : null; + db.update(seasons) + .set({ posterThumbHash: hash }) + .where(eq(seasons.id, seasonId)) + .run(); + return hash; +} + +export async function generateEpisodeThumbHash( + episodeId: string, + stillPath: string | null, +): Promise { + const hash = stillPath ? await generateThumbHash(stillPath, "stills") : null; + db.update(episodes) + .set({ stillThumbHash: hash }) + .where(eq(episodes.id, episodeId)) + .run(); + return hash; +} + +export async function generatePersonThumbHash( + personId: string, + profilePath: string | null, +): Promise { + const hash = profilePath + ? await generateThumbHash(profilePath, "profiles") + : null; + db.update(persons) + .set({ profileThumbHash: hash }) + .where(eq(persons.id, personId)) + .run(); + return hash; +} diff --git a/packages/core/test/image-cache.test.ts b/packages/core/test/image-cache.test.ts new file mode 100644 index 0000000..0f7e97d --- /dev/null +++ b/packages/core/test/image-cache.test.ts @@ -0,0 +1,34 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { loadImageBuffer } from "../src/image-cache"; + +const originalFetch = globalThis.fetch; + +beforeEach(() => { + process.env.IMAGE_CACHE_ENABLED = "false"; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + delete process.env.IMAGE_CACHE_ENABLED; +}); + +describe("loadImageBuffer", () => { + test("uses category-specific TMDB sizes when cache is disabled", async () => { + const urls: string[] = []; + globalThis.fetch = mock(async (input: string | URL | Request) => { + urls.push(String(input)); + return new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + }) as unknown as typeof fetch; + + await loadImageBuffer("/poster.jpg", "posters"); + await loadImageBuffer("/profile.jpg", "profiles"); + + expect(urls).toEqual([ + "https://image.tmdb.org/t/p/w500/poster.jpg", + "https://image.tmdb.org/t/p/w185/profile.jpg", + ]); + }); +}); diff --git a/packages/core/test/metadata-refresh.test.ts b/packages/core/test/metadata-refresh.test.ts new file mode 100644 index 0000000..d0079c0 --- /dev/null +++ b/packages/core/test/metadata-refresh.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { eq } from "@sofa/db/helpers"; +import { episodes, seasons, titles } from "@sofa/db/schema"; +import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils"; + +interface MockSeasonEpisode { + episode_number: number; + name: string | null; + overview: string | null; + still_path: string | null; + air_date: string | null; + runtime: number | null; +} + +interface MockSeasonDetails { + season_number: number; + name: string | null; + overview: string | null; + poster_path: string | null; + air_date: string | null; + episodes: MockSeasonEpisode[]; +} + +let seasonDetails: MockSeasonDetails = { + season_number: 1, + name: "Season 1", + overview: null, + poster_path: "/new-season.png", + air_date: null, + episodes: [ + { + episode_number: 1, + name: "Episode 1", + overview: null, + still_path: "/new-still.png", + air_date: null, + runtime: null, + }, + ], +}; + +mock.module("@sofa/tmdb/client", () => ({ + getMovieDetails: async () => { + throw new Error("not used"); + }, + getRecommendations: async () => ({ results: [] }), + getSimilar: async () => ({ results: [] }), + getTvDetails: async () => { + throw new Error("not used"); + }, + getTvSeasonDetails: async () => seasonDetails, + getVideos: async () => ({ results: [] }), +})); + +import { + refreshTvChildren, + updateTitleWithArtInvalidation, +} from "../src/metadata"; + +beforeEach(() => { + clearAllTables(); + seasonDetails = { + season_number: 1, + name: "Season 1", + overview: null, + poster_path: "/new-season.png", + air_date: null, + episodes: [ + { + episode_number: 1, + name: "Episode 1", + overview: null, + still_path: "/new-still.png", + air_date: null, + runtime: null, + }, + ], + }; +}); + +describe("refreshTvChildren", () => { + test("clears stale season and episode thumbhashes when artwork paths change", async () => { + insertTitle({ id: "tv-1", tmdbId: 10, type: "tv", title: "Show" }); + testDb + .insert(seasons) + .values({ + id: "season-1", + titleId: "tv-1", + seasonNumber: 1, + posterPath: "/old-season.png", + posterThumbHash: "stale-season-hash", + }) + .run(); + testDb + .insert(episodes) + .values({ + id: "episode-1", + seasonId: "season-1", + episodeNumber: 1, + stillPath: "/old-still.png", + stillThumbHash: "stale-episode-hash", + }) + .run(); + + await refreshTvChildren("tv-1", 10, 1); + + const season = testDb + .select() + .from(seasons) + .where(eq(seasons.id, "season-1")) + .get(); + const episode = testDb + .select() + .from(episodes) + .where(eq(episodes.id, "episode-1")) + .get(); + + expect(season?.posterPath).toBe("/new-season.png"); + expect(season?.posterThumbHash).toBeNull(); + expect(episode?.stillPath).toBe("/new-still.png"); + expect(episode?.stillThumbHash).toBeNull(); + }); + + test("clears an episode thumbhash when the still disappears", async () => { + insertTitle({ id: "tv-1", tmdbId: 10, type: "tv", title: "Show" }); + testDb + .insert(seasons) + .values({ + id: "season-1", + titleId: "tv-1", + seasonNumber: 1, + posterPath: "/season.png", + }) + .run(); + testDb + .insert(episodes) + .values({ + id: "episode-1", + seasonId: "season-1", + episodeNumber: 1, + stillPath: "/old-still.png", + stillThumbHash: "stale-episode-hash", + }) + .run(); + + seasonDetails = { + ...seasonDetails, + poster_path: "/season.png", + episodes: [ + { + episode_number: 1, + name: "Episode 1", + overview: null, + still_path: null, + air_date: null, + runtime: null, + }, + ], + }; + + await refreshTvChildren("tv-1", 10, 1); + + const episode = testDb + .select() + .from(episodes) + .where(eq(episodes.id, "episode-1")) + .get(); + + expect(episode?.stillPath).toBeNull(); + expect(episode?.stillThumbHash).toBeNull(); + }); +}); + +describe("updateTitleWithArtInvalidation", () => { + test("clears stale title hashes and palette when artwork changes", () => { + testDb + .insert(titles) + .values({ + id: "movie-1", + tmdbId: 20, + type: "movie", + title: "Movie", + posterPath: "/old-poster.png", + posterThumbHash: "stale-poster-hash", + backdropPath: "/old-backdrop.png", + backdropThumbHash: "stale-backdrop-hash", + colorPalette: JSON.stringify({ vibrant: "#fff" }), + lastFetchedAt: new Date(), + }) + .run(); + + const existing = testDb + .select() + .from(titles) + .where(eq(titles.id, "movie-1")) + .get(); + + expect(existing).not.toBeUndefined(); + if (!existing) { + throw new Error("Expected seeded title row"); + } + + updateTitleWithArtInvalidation(existing, { + posterPath: "/new-poster.png", + backdropPath: "/new-backdrop.png", + }); + + const title = testDb + .select() + .from(titles) + .where(eq(titles.id, "movie-1")) + .get(); + + expect(title?.posterPath).toBe("/new-poster.png"); + expect(title?.backdropPath).toBe("/new-backdrop.png"); + expect(title?.posterThumbHash).toBeNull(); + expect(title?.backdropThumbHash).toBeNull(); + expect(title?.colorPalette).toBeNull(); + }); +}); diff --git a/packages/core/test/person-detail.test.ts b/packages/core/test/person-detail.test.ts new file mode 100644 index 0000000..6233196 --- /dev/null +++ b/packages/core/test/person-detail.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { eq } from "@sofa/db/helpers"; +import { persons } from "@sofa/db/schema"; +import { clearAllTables, testDb } from "@sofa/db/test-utils"; + +const TINY_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==", + "base64", +); + +let nextBuffer: Buffer | null = TINY_PNG; +let nextPersonDetails = { + id: 100, + name: "Updated Person", + biography: "Bio", + birthday: null, + deathday: null, + place_of_birth: null, + profile_path: "/new-profile.png", + known_for_department: "Acting", + popularity: 10, + imdb_id: null, +}; + +mock.module("../src/image-cache", () => ({ + loadImageBuffer: async () => nextBuffer, +})); + +mock.module("@sofa/tmdb/client", () => ({ + getPersonDetails: async () => nextPersonDetails, + getPersonCombinedCredits: async () => ({ cast: [] }), +})); + +import { getOrFetchPerson } from "../src/person"; + +beforeEach(() => { + clearAllTables(); + nextBuffer = TINY_PNG; + nextPersonDetails = { + id: 100, + name: "Updated Person", + biography: "Bio", + birthday: null, + deathday: null, + place_of_birth: null, + profile_path: "/new-profile.png", + known_for_department: "Acting", + popularity: 10, + imdb_id: null, + }; +}); + +describe("getOrFetchPerson", () => { + test("backfills a missing profile thumbhash for an already-fetched person", async () => { + testDb + .insert(persons) + .values({ + id: "p1", + tmdbId: 100, + name: "Existing Person", + profilePath: "/profile.png", + profileThumbHash: null, + lastFetchedAt: new Date(), + }) + .run(); + + const person = await getOrFetchPerson("p1"); + const stored = testDb + .select() + .from(persons) + .where(eq(persons.id, "p1")) + .get(); + + expect(person?.profileThumbHash).toBeString(); + expect(stored?.profileThumbHash).toBe(person?.profileThumbHash); + }); + + test("replaces a stale profile thumbhash when shell hydration changes the image path", async () => { + testDb + .insert(persons) + .values({ + id: "p1", + tmdbId: 100, + name: "Shell Person", + profilePath: "/old-profile.png", + profileThumbHash: "stale-hash", + lastFetchedAt: null, + }) + .run(); + + const person = await getOrFetchPerson("p1"); + const stored = testDb + .select() + .from(persons) + .where(eq(persons.id, "p1")) + .get(); + + expect(stored?.profilePath).toBe("/new-profile.png"); + expect(person?.profileThumbHash).toBeString(); + expect(person?.profileThumbHash).not.toBe("stale-hash"); + expect(stored?.profileThumbHash).toBe(person?.profileThumbHash); + }); +}); diff --git a/packages/core/test/thumbhash.test.ts b/packages/core/test/thumbhash.test.ts new file mode 100644 index 0000000..1d3e6e1 --- /dev/null +++ b/packages/core/test/thumbhash.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { eq } from "@sofa/db/helpers"; +import { episodes, seasons, titles } from "@sofa/db/schema"; +import { clearAllTables, testDb } from "@sofa/db/test-utils"; + +const TINY_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==", + "base64", +); + +let nextBuffer: Buffer | null = TINY_PNG; + +mock.module("../src/image-cache", () => ({ + loadImageBuffer: async () => nextBuffer, +})); + +import { generateEpisodeThumbHash, generateThumbHash } from "../src/thumbhash"; + +beforeEach(() => { + clearAllTables(); + nextBuffer = TINY_PNG; +}); + +describe("thumbhash generation", () => { + test("generates a thumbhash from a loaded image buffer", async () => { + const hash = await generateThumbHash("/poster.png", "posters"); + expect(hash).toBeString(); + expect(hash).not.toBe(""); + }); + + test("clears a stale episode thumbhash when regeneration fails", async () => { + testDb + .insert(titles) + .values({ id: "tv-1", tmdbId: 100, type: "tv", title: "Show" }) + .run(); + testDb + .insert(seasons) + .values({ id: "season-1", titleId: "tv-1", seasonNumber: 1 }) + .run(); + testDb + .insert(episodes) + .values({ + id: "ep-1", + seasonId: "season-1", + episodeNumber: 1, + stillPath: "/old-still.png", + stillThumbHash: "stale-hash", + }) + .run(); + + nextBuffer = null; + const hash = await generateEpisodeThumbHash("ep-1", "/new-still.png"); + + const episode = testDb + .select() + .from(episodes) + .where(eq(episodes.id, "ep-1")) + .get(); + + expect(hash).toBeNull(); + expect(episode?.stillThumbHash).toBeNull(); + }); +}); diff --git a/packages/db/drizzle/20260314183844_lethal_odin/migration.sql b/packages/db/drizzle/20260314183844_lethal_odin/migration.sql new file mode 100644 index 0000000..2c55f8d --- /dev/null +++ b/packages/db/drizzle/20260314183844_lethal_odin/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE `episodes` ADD `stillThumbHash` text;--> statement-breakpoint +ALTER TABLE `persons` ADD `profileThumbHash` text;--> statement-breakpoint +ALTER TABLE `seasons` ADD `posterThumbHash` text;--> statement-breakpoint +ALTER TABLE `titles` ADD `posterThumbHash` text;--> statement-breakpoint +ALTER TABLE `titles` ADD `backdropThumbHash` text; \ No newline at end of file diff --git a/packages/db/drizzle/20260314183844_lethal_odin/snapshot.json b/packages/db/drizzle/20260314183844_lethal_odin/snapshot.json new file mode 100644 index 0000000..6cb5aa8 --- /dev/null +++ b/packages/db/drizzle/20260314183844_lethal_odin/snapshot.json @@ -0,0 +1,2588 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "765b5a66-9bf8-4e55-8561-df3e25813307", + "prevIds": ["a55f858c-6739-4c19-98f2-2c685741ff65"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "appSettings", + "entityType": "tables" + }, + { + "name": "availabilityOffers", + "entityType": "tables" + }, + { + "name": "cronRuns", + "entityType": "tables" + }, + { + "name": "episodes", + "entityType": "tables" + }, + { + "name": "genres", + "entityType": "tables" + }, + { + "name": "integrationEvents", + "entityType": "tables" + }, + { + "name": "integrations", + "entityType": "tables" + }, + { + "name": "persons", + "entityType": "tables" + }, + { + "name": "seasons", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "titleCast", + "entityType": "tables" + }, + { + "name": "titleGenres", + "entityType": "tables" + }, + { + "name": "titleRecommendations", + "entityType": "tables" + }, + { + "name": "titles", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "userEpisodeWatches", + "entityType": "tables" + }, + { + "name": "userMovieWatches", + "entityType": "tables" + }, + { + "name": "userRatings", + "entityType": "tables" + }, + { + "name": "userTitleStatus", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accountId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "idToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'US'", + "generated": null, + "name": "region", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerId", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerName", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logoPath", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "offerType", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "link", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "availabilityOffers" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "jobName", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "startedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finishedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "durationMs", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonId", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeNumber", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stillPath", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stillThumbHash", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "runtimeMinutes", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "genres" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "genres" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "integrationId", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "eventType", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaType", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaTitle", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "receivedAt", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastEventAt", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "biography", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "birthday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deathday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "placeOfBirth", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profilePath", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profileThumbHash", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "knownForDepartment", + "entityType": "columns", + "table": "persons" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "imdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonNumber", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterThumbHash", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ipAddress", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userAgent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "impersonatedBy", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "character", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Acting'", + "generated": null, + "name": "department", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "displayOrder", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeCount", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleGenres" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "genreId", + "entityType": "columns", + "table": "titleGenres" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "recommendedTitleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rank", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tvdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "originalTitle", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "releaseDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "firstAirDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterThumbHash", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backdropPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backdropThumbHash", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteAverage", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteCount", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "contentRating", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "colorPalette", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "trailerVideoKey", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "emailVerified", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'user'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "banned", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banReason", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banExpires", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratingStars", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratedAt", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "addedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "verification" + }, + { + "columns": ["userId"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_account_userId_user_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": ["titleId"], + "tableTo": "titles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_availabilityOffers_titleId_titles_id_fk", + "entityType": "fks", + "table": "availabilityOffers" + }, + { + "columns": ["seasonId"], + "tableTo": "seasons", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_episodes_seasonId_seasons_id_fk", + "entityType": "fks", + "table": "episodes" + }, + { + "columns": ["integrationId"], + "tableTo": "integrations", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_integrationEvents_integrationId_integrations_id_fk", + "entityType": "fks", + "table": "integrationEvents" + }, + { + "columns": ["userId"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_integrations_userId_user_id_fk", + "entityType": "fks", + "table": "integrations" + }, + { + "columns": ["titleId"], + "tableTo": "titles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_seasons_titleId_titles_id_fk", + "entityType": "fks", + "table": "seasons" + }, + { + "columns": ["userId"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_userId_user_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["titleId"], + "tableTo": "titles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": ["personId"], + "tableTo": "persons", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_personId_persons_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": ["titleId"], + "tableTo": "titles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleGenres_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleGenres" + }, + { + "columns": ["genreId"], + "tableTo": "genres", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleGenres_genreId_genres_id_fk", + "entityType": "fks", + "table": "titleGenres" + }, + { + "columns": ["titleId"], + "tableTo": "titles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": ["recommendedTitleId"], + "tableTo": "titles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_recommendedTitleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": ["userId"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": ["episodeId"], + "tableTo": "episodes", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_episodeId_episodes_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": ["userId"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": ["titleId"], + "tableTo": "titles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_titleId_titles_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": ["userId"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_userId_user_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": ["titleId"], + "tableTo": "titles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_titleId_titles_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": ["userId"], + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_userId_user_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": ["titleId"], + "tableTo": "titles", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_titleId_titles_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["key"], + "nameExplicit": false, + "name": "appSettings_pk", + "table": "appSettings", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "cronRuns_pk", + "table": "cronRuns", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "episodes_pk", + "table": "episodes", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "genres_pk", + "table": "genres", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "integrationEvents_pk", + "table": "integrationEvents", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "integrations_pk", + "table": "integrations", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "persons_pk", + "table": "persons", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "seasons_pk", + "table": "seasons", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "titleCast_pk", + "table": "titleCast", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "titles_pk", + "table": "titles", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "user_pk", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "userEpisodeWatches_pk", + "table": "userEpisodeWatches", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "userMovieWatches_pk", + "table": "userMovieWatches", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "region", + "isExpression": false + }, + { + "value": "providerId", + "isExpression": false + }, + { + "value": "offerType", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "availabilityOffers_unique", + "entityType": "indexes", + "table": "availabilityOffers" + }, + { + "columns": [ + { + "value": "jobName", + "isExpression": false + }, + { + "value": "startedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "cronRuns_jobName_startedAt", + "entityType": "indexes", + "table": "cronRuns" + }, + { + "columns": [ + { + "value": "seasonId", + "isExpression": false + }, + { + "value": "episodeNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "episodes_seasonId_episodeNumber", + "entityType": "indexes", + "table": "episodes" + }, + { + "columns": [ + { + "value": "integrationId", + "isExpression": false + }, + { + "value": "receivedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "integrationEvents_integrationId_receivedAt", + "entityType": "indexes", + "table": "integrationEvents" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "integrations_userId_provider", + "entityType": "indexes", + "table": "integrations" + }, + { + "columns": [ + { + "value": "token", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "integrations_token", + "entityType": "indexes", + "table": "integrations" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "persons_tmdbId_unique", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "name", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "persons_name", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "seasonNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "seasons_titleId_seasonNumber", + "entityType": "indexes", + "table": "seasons" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "personId", + "isExpression": false + }, + { + "value": "department", + "isExpression": false + }, + { + "value": "character", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleCast_unique", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "displayOrder", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_titleId_displayOrder", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "personId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_personId", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "genreId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleGenres_titleId_genreId", + "entityType": "indexes", + "table": "titleGenres" + }, + { + "columns": [ + { + "value": "genreId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleGenres_genreId", + "entityType": "indexes", + "table": "titleGenres" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "recommendedTitleId", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleRecommendations_unique", + "entityType": "indexes", + "table": "titleRecommendations" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "rank", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleRecommendations_titleId_rank", + "entityType": "indexes", + "table": "titleRecommendations" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titles_tmdbId_unique", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "releaseDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_releaseDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "firstAirDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_firstAirDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "lastFetchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_lastFetchedAt", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + }, + { + "value": "lastFetchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_status_lastFetchedAt", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_episodeId", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_episodeId", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_titleId", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_titleId", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userRatings_userId_titleId", + "entityType": "indexes", + "table": "userRatings" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_titleId", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_status", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": ["token"], + "nameExplicit": false, + "name": "session_token_unique", + "entityType": "uniques", + "table": "session" + }, + { + "columns": ["email"], + "nameExplicit": false, + "name": "user_email_unique", + "entityType": "uniques", + "table": "user" + } + ], + "renames": [] +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 9d965dd..98dd12a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -110,7 +110,9 @@ export const titles = sqliteTable( releaseDate: text("releaseDate"), firstAirDate: text("firstAirDate"), posterPath: text("posterPath"), + posterThumbHash: text("posterThumbHash"), backdropPath: text("backdropPath"), + backdropThumbHash: text("backdropThumbHash"), popularity: real("popularity"), voteAverage: real("voteAverage"), voteCount: int("voteCount"), @@ -144,6 +146,7 @@ export const seasons = sqliteTable( name: text("name"), overview: text("overview"), posterPath: text("posterPath"), + posterThumbHash: text("posterThumbHash"), airDate: text("airDate"), lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }), }, @@ -166,6 +169,7 @@ export const episodes = sqliteTable( name: text("name"), overview: text("overview"), stillPath: text("stillPath"), + stillThumbHash: text("stillThumbHash"), airDate: text("airDate"), runtimeMinutes: int("runtimeMinutes"), }, @@ -339,6 +343,7 @@ export const persons = sqliteTable( deathday: text("deathday"), placeOfBirth: text("placeOfBirth"), profilePath: text("profilePath"), + profileThumbHash: text("profileThumbHash"), knownForDepartment: text("knownForDepartment"), popularity: real("popularity"), imdbId: text("imdbId"), diff --git a/packages/tmdb/src/image.ts b/packages/tmdb/src/image.ts index 9eef610..d5f0426 100644 --- a/packages/tmdb/src/image.ts +++ b/packages/tmdb/src/image.ts @@ -8,7 +8,7 @@ export type ImageCategory = const IMAGE_BASE_URL = process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p"; -const CATEGORY_SIZES: Record = { +export const IMAGE_CATEGORY_SIZES: Record = { posters: "w500", backdrops: "w1280", stills: "w1280", @@ -16,6 +16,16 @@ const CATEGORY_SIZES: Record = { profiles: "w185", }; +export function tmdbCdnImageUrl( + path: string | null, + category: ImageCategory, + sizeOverride?: string, +) { + if (!path) return null; + const size = sizeOverride ?? IMAGE_CATEGORY_SIZES[category]; + return `${IMAGE_BASE_URL}/${size}${path}`; +} + export function tmdbImageUrl( path: string | null, category: ImageCategory, @@ -23,10 +33,8 @@ export function tmdbImageUrl( ) { if (!path) return null; - const size = sizeOverride ?? CATEGORY_SIZES[category]; - if (process.env.IMAGE_CACHE_ENABLED === "false") { - return `${IMAGE_BASE_URL}/${size}${path}`; + return tmdbCdnImageUrl(path, category, sizeOverride); } const filename = path.startsWith("/") ? path.slice(1) : path;