mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
feat: generate and display thumbhash blur placeholders for TMDB images
This commit is contained in:
@@ -10,6 +10,7 @@ bun run lint # Biome lint check
|
|||||||
bun run format # Biome format (auto-fix)
|
bun run format # Biome format (auto-fix)
|
||||||
bun run check-types # TypeScript type check
|
bun run check-types # TypeScript type check
|
||||||
bun run test # Run tests
|
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/)
|
# Database commands (run from packages/db/)
|
||||||
cd packages/db && bun run db:push # Push schema changes to SQLite database
|
cd packages/db && bun run db:push # Push schema changes to SQLite database
|
||||||
@@ -38,8 +39,10 @@ bun run test
|
|||||||
couch-potato/
|
couch-potato/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── native/ # @sofa/native — Expo 55 React Native app (Expo Router, UniWind)
|
│ ├── 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)
|
│ ├── server/ # @sofa/server — Hono API server (oRPC, auth, cron, webhooks)
|
||||||
│ └── web/ # @sofa/web — Vite SPA (TanStack Router, TanStack Query)
|
│ └── web/ # @sofa/web — Vite SPA (TanStack Router, TanStack Query)
|
||||||
|
├── docs/ # sofa-docs — Fumadocs (Next.js) documentation site + OpenAPI reference
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared, JIT)
|
│ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared, JIT)
|
||||||
│ ├── auth/ # @sofa/auth — Better Auth server config (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/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/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/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
|
### 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)
|
- **Database**: SQLite via `bun:sqlite` + Drizzle ORM (WAL mode, sync queries, auto-migrations)
|
||||||
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO
|
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO
|
||||||
- **Monorepo**: Turborepo with Bun workspaces
|
- **Monorepo**: Turborepo with Bun workspaces
|
||||||
|
- **Docs**: Fumadocs (Next.js), fumadocs-openapi for API reference
|
||||||
- **Linting**: Biome (2-space indent, organized imports)
|
- **Linting**: Biome (2-space indent, organized imports)
|
||||||
- **External API**: TMDB (The Movie Database)
|
- **External API**: TMDB (The Movie Database)
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"@react-navigation/elements": "2.9.10",
|
"@react-navigation/elements": "2.9.10",
|
||||||
"@shopify/flash-list": "2.3.0",
|
"@shopify/flash-list": "2.3.0",
|
||||||
"@sofa/api": "workspace:*",
|
"@sofa/api": "workspace:*",
|
||||||
"@sofa/tmdb": "workspace:*",
|
|
||||||
"@tabler/icons-react-native": "3.40.0",
|
"@tabler/icons-react-native": "3.40.0",
|
||||||
"@tanstack/query-async-storage-persister": "5.90.24",
|
"@tanstack/query-async-storage-persister": "5.90.24",
|
||||||
"@tanstack/react-form": "1.28.5",
|
"@tanstack/react-form": "1.28.5",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export default function PersonDetailScreen() {
|
|||||||
title={credit.title}
|
title={credit.title}
|
||||||
type={credit.type}
|
type={credit.type}
|
||||||
posterPath={credit.posterPath}
|
posterPath={credit.posterPath}
|
||||||
|
posterThumbHash={credit.posterThumbHash}
|
||||||
releaseDate={credit.releaseDate ?? credit.firstAirDate}
|
releaseDate={credit.releaseDate ?? credit.firstAirDate}
|
||||||
voteAverage={credit.voteAverage}
|
voteAverage={credit.voteAverage}
|
||||||
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
|
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
|
||||||
@@ -189,6 +190,7 @@ export default function PersonDetailScreen() {
|
|||||||
{person.profilePath && (
|
{person.profilePath && (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: person.profilePath }}
|
source={{ uri: person.profilePath }}
|
||||||
|
thumbHash={person.profileThumbHash}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export default function TitleDetailScreen() {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
posterPath: item.posterPath,
|
posterPath: item.posterPath,
|
||||||
|
posterThumbHash: item.posterThumbHash,
|
||||||
releaseDate: item.releaseDate,
|
releaseDate: item.releaseDate,
|
||||||
firstAirDate: item.firstAirDate,
|
firstAirDate: item.firstAirDate,
|
||||||
voteAverage: item.voteAverage,
|
voteAverage: item.voteAverage,
|
||||||
@@ -277,6 +278,7 @@ export default function TitleDetailScreen() {
|
|||||||
{title.backdropPath && (
|
{title.backdropPath && (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: title.backdropPath }}
|
source={{ uri: title.backdropPath }}
|
||||||
|
thumbHash={title.backdropThumbHash}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
@@ -366,6 +368,7 @@ export default function TitleDetailScreen() {
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: title.posterPath }}
|
source={{ uri: title.posterPath }}
|
||||||
|
thumbHash={title.posterThumbHash}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
@@ -546,6 +549,7 @@ export default function TitleDetailScreen() {
|
|||||||
watchedEpisodeIds={watchedEpisodeIds}
|
watchedEpisodeIds={watchedEpisodeIds}
|
||||||
userStatus={userInfo.data?.status ?? null}
|
userStatus={userInfo.data?.status ?? null}
|
||||||
backdropPath={title.backdropPath}
|
backdropPath={title.backdropPath}
|
||||||
|
backdropThumbHash={title.backdropThumbHash}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface ContinueWatchingItem {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
backdropPath: string | null;
|
backdropPath: string | null;
|
||||||
|
backdropThumbHash?: string | null;
|
||||||
};
|
};
|
||||||
watchedEpisodes: number;
|
watchedEpisodes: number;
|
||||||
totalEpisodes: number;
|
totalEpisodes: number;
|
||||||
@@ -23,6 +24,7 @@ export interface ContinueWatchingItem {
|
|||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
stillPath: string | null;
|
stillPath: string | null;
|
||||||
|
stillThumbHash?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +63,10 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
|
|||||||
uri: (item.nextEpisode?.stillPath ??
|
uri: (item.nextEpisode?.stillPath ??
|
||||||
item.title.backdropPath) as string,
|
item.title.backdropPath) as string,
|
||||||
}}
|
}}
|
||||||
|
thumbHash={
|
||||||
|
item.nextEpisode?.stillThumbHash ??
|
||||||
|
item.title.backdropThumbHash
|
||||||
|
}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface PosterRowItem {
|
|||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
posterPath: string | null;
|
posterPath: string | null;
|
||||||
|
posterThumbHash?: string | null;
|
||||||
releaseDate?: string | null;
|
releaseDate?: string | null;
|
||||||
firstAirDate?: string | null;
|
firstAirDate?: string | null;
|
||||||
voteAverage?: number | null;
|
voteAverage?: number | null;
|
||||||
@@ -55,6 +56,7 @@ export function HorizontalPosterRow({
|
|||||||
title={item.title}
|
title={item.title}
|
||||||
type={item.type as "movie" | "tv"}
|
type={item.type as "movie" | "tv"}
|
||||||
posterPath={item.posterPath}
|
posterPath={item.posterPath}
|
||||||
|
posterThumbHash={item.posterThumbHash}
|
||||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||||
voteAverage={item.voteAverage}
|
voteAverage={item.voteAverage}
|
||||||
userStatus={item.userStatus}
|
userStatus={item.userStatus}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function CastCard({
|
|||||||
name: string;
|
name: string;
|
||||||
character: string | null;
|
character: string | null;
|
||||||
profilePath: string | null;
|
profilePath: string | null;
|
||||||
|
profileThumbHash?: string | null;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -23,6 +24,7 @@ export function CastCard({
|
|||||||
{person.profilePath && (
|
{person.profilePath && (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: person.profilePath }}
|
source={{ uri: person.profilePath }}
|
||||||
|
thumbHash={person.profileThumbHash}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ export function ContinueWatchingBanner({
|
|||||||
watchedEpisodeIds,
|
watchedEpisodeIds,
|
||||||
userStatus,
|
userStatus,
|
||||||
backdropPath,
|
backdropPath,
|
||||||
|
backdropThumbHash,
|
||||||
}: {
|
}: {
|
||||||
seasons: Season[];
|
seasons: Season[];
|
||||||
watchedEpisodeIds: Set<string>;
|
watchedEpisodeIds: Set<string>;
|
||||||
userStatus: string | null;
|
userStatus: string | null;
|
||||||
backdropPath: string | null;
|
backdropPath: string | null;
|
||||||
|
backdropThumbHash?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
|
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ export function ContinueWatchingBanner({
|
|||||||
{stillUrl && (
|
{stillUrl && (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: stillUrl }}
|
source={{ uri: stillUrl }}
|
||||||
|
thumbHash={nextEpisode.stillThumbHash ?? backdropThumbHash}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { resolveUrl } from "@/lib/server-url";
|
|||||||
export function Image({
|
export function Image({
|
||||||
source,
|
source,
|
||||||
className,
|
className,
|
||||||
|
thumbHash,
|
||||||
...props
|
...props
|
||||||
}: ImageProps & { className?: string }) {
|
}: ImageProps & { className?: string; thumbHash?: string | null }) {
|
||||||
const resolved =
|
const resolved =
|
||||||
source && typeof source === "object" && "uri" in source && source.uri
|
source && typeof source === "object" && "uri" in source && source.uri
|
||||||
? { ...source, uri: resolveUrl(source.uri) ?? undefined }
|
? { ...source, uri: resolveUrl(source.uri) ?? undefined }
|
||||||
@@ -15,6 +16,11 @@ export function Image({
|
|||||||
const style = useResolveClassNames(className ?? "");
|
const style = useResolveClassNames(className ?? "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpoImage source={resolved} style={[style, props.style]} {...props} />
|
<ExpoImage
|
||||||
|
source={resolved}
|
||||||
|
placeholder={thumbHash ? { thumbhash: thumbHash } : undefined}
|
||||||
|
style={[style, props.style]}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface PosterCardProps {
|
|||||||
title: string;
|
title: string;
|
||||||
type: "movie" | "tv";
|
type: "movie" | "tv";
|
||||||
posterPath: string | null;
|
posterPath: string | null;
|
||||||
|
posterThumbHash?: string | null;
|
||||||
releaseDate?: string | null;
|
releaseDate?: string | null;
|
||||||
voteAverage?: number | null;
|
voteAverage?: number | null;
|
||||||
userStatus?: TitleStatus | null;
|
userStatus?: TitleStatus | null;
|
||||||
@@ -55,6 +56,7 @@ export function PosterCard({
|
|||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
posterPath,
|
posterPath,
|
||||||
|
posterThumbHash,
|
||||||
releaseDate,
|
releaseDate,
|
||||||
voteAverage,
|
voteAverage,
|
||||||
userStatus,
|
userStatus,
|
||||||
@@ -117,6 +119,7 @@ export function PosterCard({
|
|||||||
{posterPath ? (
|
{posterPath ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: posterPath }}
|
source={{ uri: posterPath }}
|
||||||
|
thumbHash={posterThumbHash}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
recyclingKey={`poster-${tmdbId}`}
|
recyclingKey={`poster-${tmdbId}`}
|
||||||
|
|||||||
+130
-13
@@ -1,6 +1,6 @@
|
|||||||
import { refreshAvailability } from "@sofa/core/availability";
|
import { refreshAvailability } from "@sofa/core/availability";
|
||||||
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
|
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
|
||||||
import { refreshCredits } from "@sofa/core/credits";
|
import { refreshCredits, syncCastProfileThumbHashes } from "@sofa/core/credits";
|
||||||
import {
|
import {
|
||||||
cacheEpisodeStills,
|
cacheEpisodeStills,
|
||||||
cacheImagesForTitle,
|
cacheImagesForTitle,
|
||||||
@@ -12,15 +12,22 @@ import {
|
|||||||
refreshRecommendations,
|
refreshRecommendations,
|
||||||
refreshTitle,
|
refreshTitle,
|
||||||
refreshTvChildren,
|
refreshTvChildren,
|
||||||
|
syncTvChildArt,
|
||||||
} from "@sofa/core/metadata";
|
} from "@sofa/core/metadata";
|
||||||
import { getSetting } from "@sofa/core/settings";
|
import { getSetting } from "@sofa/core/settings";
|
||||||
import { performTelemetryReport } from "@sofa/core/telemetry";
|
import { performTelemetryReport } from "@sofa/core/telemetry";
|
||||||
|
import {
|
||||||
|
generateTitleBackdropThumbHash,
|
||||||
|
generateTitlePosterThumbHash,
|
||||||
|
} from "@sofa/core/thumbhash";
|
||||||
import { performUpdateCheck } from "@sofa/core/update-check";
|
import { performUpdateCheck } from "@sofa/core/update-check";
|
||||||
import { db } from "@sofa/db/client";
|
import { db } from "@sofa/db/client";
|
||||||
import { and, eq, inArray, isNotNull, lt, or } from "@sofa/db/helpers";
|
import { and, eq, inArray, isNotNull, lt, or, sql } from "@sofa/db/helpers";
|
||||||
import {
|
import {
|
||||||
availabilityOffers,
|
availabilityOffers,
|
||||||
cronRuns,
|
cronRuns,
|
||||||
|
episodes,
|
||||||
|
persons,
|
||||||
seasons,
|
seasons,
|
||||||
titleCast,
|
titleCast,
|
||||||
titles,
|
titles,
|
||||||
@@ -112,6 +119,83 @@ function getLibraryTitleIds(): string[] {
|
|||||||
return rows.map((r) => r.titleId);
|
return rows.map((r) => 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
|
// Refresh titles where lastFetchedAt is stale
|
||||||
async function nightlyRefreshLibrary() {
|
async function nightlyRefreshLibrary() {
|
||||||
const libraryIds = getLibraryTitleIds();
|
const libraryIds = getLibraryTitleIds();
|
||||||
@@ -248,25 +332,58 @@ async function refreshTvChildrenJob() {
|
|||||||
if (titlesWithStaleSeasons.has(show.id)) {
|
if (titlesWithStaleSeasons.has(show.id)) {
|
||||||
const details = await getTvDetails(show.tmdbId);
|
const details = await getTvDetails(show.tmdbId);
|
||||||
await refreshTvChildren(show.id, show.tmdbId, details.number_of_seasons);
|
await refreshTvChildren(show.id, show.tmdbId, details.number_of_seasons);
|
||||||
|
await syncTvChildArt(show.id, { warmCache: true });
|
||||||
await Bun.sleep(RATE_LIMIT_MS);
|
await Bun.sleep(RATE_LIMIT_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cacheImagesJob() {
|
async function cacheImagesJob() {
|
||||||
if (!imageCacheEnabled()) return;
|
const titleIds = getThumbhashBackfillTitleIds();
|
||||||
|
log.debug(
|
||||||
|
`Caching images for ${titleIds.length} titles needing art backfill`,
|
||||||
|
);
|
||||||
|
|
||||||
const libraryIds = getLibraryTitleIds();
|
for (const titleId of titleIds) {
|
||||||
log.debug(`Caching images for ${libraryIds.length} library titles`);
|
|
||||||
|
|
||||||
for (const titleId of libraryIds) {
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
const title = db
|
||||||
cacheImagesForTitle(titleId),
|
.select()
|
||||||
cacheEpisodeStills(titleId),
|
.from(titles)
|
||||||
cacheProviderLogos(titleId),
|
.where(eq(titles.id, titleId))
|
||||||
cacheProfilePhotos(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<unknown>[] = [];
|
||||||
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
log.warn(`Failed to cache images for title ${titleId}:`, err);
|
log.warn(`Failed to cache images for title ${titleId}:`, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string | null>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export const continueWatching = os.dashboard.continueWatching
|
|||||||
id: item.title.id,
|
id: item.title.id,
|
||||||
title: item.title.title,
|
title: item.title.title,
|
||||||
backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"),
|
backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"),
|
||||||
|
backdropThumbHash: item.title.backdropThumbHash,
|
||||||
},
|
},
|
||||||
nextEpisode: item.nextEpisode
|
nextEpisode: item.nextEpisode
|
||||||
? {
|
? {
|
||||||
@@ -32,6 +33,7 @@ export const continueWatching = os.dashboard.continueWatching
|
|||||||
episodeNumber: item.nextEpisode.episodeNumber,
|
episodeNumber: item.nextEpisode.episodeNumber,
|
||||||
name: item.nextEpisode.name,
|
name: item.nextEpisode.name,
|
||||||
stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"),
|
stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"),
|
||||||
|
stillThumbHash: item.nextEpisode.stillThumbHash,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
totalEpisodes: item.totalEpisodes,
|
totalEpisodes: item.totalEpisodes,
|
||||||
@@ -50,6 +52,7 @@ export const library = os.dashboard.library
|
|||||||
type: t.type,
|
type: t.type,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
||||||
|
posterThumbHash: t.posterThumbHash ?? null,
|
||||||
releaseDate: t.releaseDate ?? null,
|
releaseDate: t.releaseDate ?? null,
|
||||||
firstAirDate: t.firstAirDate ?? null,
|
firstAirDate: t.firstAirDate ?? null,
|
||||||
voteAverage: t.voteAverage,
|
voteAverage: t.voteAverage,
|
||||||
@@ -71,6 +74,7 @@ export const recommendations = os.dashboard.recommendations
|
|||||||
type: t.type,
|
type: t.type,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
posterPath: tmdbImageUrl(t.posterPath, "posters"),
|
||||||
|
posterThumbHash: t.posterThumbHash ?? null,
|
||||||
releaseDate: t.releaseDate ?? null,
|
releaseDate: t.releaseDate ?? null,
|
||||||
firstAirDate: t.firstAirDate ?? null,
|
firstAirDate: t.firstAirDate ?? null,
|
||||||
voteAverage: t.voteAverage,
|
voteAverage: t.voteAverage,
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { isTmdbConfigured } from "@sofa/tmdb/config";
|
|||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
import {
|
||||||
|
browseLookupKey,
|
||||||
|
getBrowsePosterThumbHashes,
|
||||||
|
} from "./browse-thumbhashes";
|
||||||
|
|
||||||
export const discover = os.discover
|
export const discover = os.discover
|
||||||
.use(authed)
|
.use(authed)
|
||||||
@@ -31,7 +35,7 @@ export const discover = os.discover
|
|||||||
first_air_date?: string;
|
first_air_date?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = ((results.results ?? []) as DiscoverResult[])
|
const baseItems = ((results.results ?? []) as DiscoverResult[])
|
||||||
.filter((r) => r.poster_path)
|
.filter((r) => r.poster_path)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
tmdbId: r.id,
|
tmdbId: r.id,
|
||||||
@@ -42,6 +46,11 @@ export const discover = os.discover
|
|||||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
||||||
voteAverage: r.vote_average ?? 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 lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
|
||||||
const [userStatuses, episodeProgress] =
|
const [userStatuses, episodeProgress] =
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { isTmdbConfigured } from "@sofa/tmdb/config";
|
|||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
import {
|
||||||
|
browseLookupKey,
|
||||||
|
getBrowsePosterThumbHashes,
|
||||||
|
} from "./browse-thumbhashes";
|
||||||
|
|
||||||
function requireTmdb() {
|
function requireTmdb() {
|
||||||
if (!isTmdbConfigured()) {
|
if (!isTmdbConfigured()) {
|
||||||
@@ -25,7 +29,7 @@ export const trending = os.explore.trending
|
|||||||
const data = await getTrending(input.type, "day");
|
const data = await getTrending(input.type, "day");
|
||||||
const results = (data.results ?? []) as Record<string, unknown>[];
|
const results = (data.results ?? []) as Record<string, unknown>[];
|
||||||
|
|
||||||
const items = results
|
const baseItems = results
|
||||||
.filter((r) => r.poster_path)
|
.filter((r) => r.poster_path)
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const mediaType =
|
const mediaType =
|
||||||
@@ -45,6 +49,11 @@ export const trending = os.explore.trending
|
|||||||
voteAverage: (r.vote_average as number | 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 heroResult = results.find(
|
const heroResult = results.find(
|
||||||
(r) =>
|
(r) =>
|
||||||
@@ -83,7 +92,7 @@ export const popular = os.explore.popular
|
|||||||
requireTmdb();
|
requireTmdb();
|
||||||
|
|
||||||
const data = await getPopular(input.type);
|
const data = await getPopular(input.type);
|
||||||
const items = ((data.results ?? []) as Record<string, unknown>[])
|
const baseItems = ((data.results ?? []) as Record<string, unknown>[])
|
||||||
.filter((r) => r.poster_path)
|
.filter((r) => r.poster_path)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
tmdbId: r.id as number,
|
tmdbId: r.id as number,
|
||||||
@@ -94,6 +103,11 @@ export const popular = os.explore.popular
|
|||||||
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
firstAirDate: (r.first_air_date as string | undefined) ?? null,
|
||||||
voteAverage: (r.vote_average as number | 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 lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
|
||||||
const [userStatuses, episodeProgress] =
|
const [userStatuses, episodeProgress] =
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { getInstanceId } from "@sofa/core/settings";
|
import { getInstanceId } from "@sofa/core/settings";
|
||||||
import { db } from "@sofa/db/client";
|
|
||||||
import { sql } from "@sofa/db/helpers";
|
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
@@ -10,7 +8,6 @@ const app = new Hono();
|
|||||||
|
|
||||||
app.get("/", (c) => {
|
app.get("/", (c) => {
|
||||||
try {
|
try {
|
||||||
db.run(sql`SELECT 1`);
|
|
||||||
return c.json({ status: "healthy", instanceId: getInstanceId() }, 200);
|
return c.json({ status: "healthy", instanceId: getInstanceId() }, 200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Health check failed:", err);
|
log.error("Health check failed:", err);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"shadcn": "4.0.6",
|
"shadcn": "4.0.6",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
|
"thumbhash": "catalog:",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"youtube-video-element": "1.9.0",
|
"youtube-video-element": "1.9.0",
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { IconPlayerPlay } from "@tabler/icons-react";
|
import { IconPlayerPlay } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { thumbHashToUrl } from "@/lib/thumbhash";
|
||||||
|
|
||||||
export interface ContinueWatchingItemProps {
|
export interface ContinueWatchingItemProps {
|
||||||
title: {
|
title: {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
backdropPath: string | null;
|
backdropPath: string | null;
|
||||||
|
backdropThumbHash?: string | null;
|
||||||
};
|
};
|
||||||
nextEpisode: {
|
nextEpisode: {
|
||||||
seasonNumber: number;
|
seasonNumber: number;
|
||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
stillPath: string | null;
|
stillPath: string | null;
|
||||||
|
stillThumbHash?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
totalEpisodes: number;
|
totalEpisodes: number;
|
||||||
watchedEpisodes: number;
|
watchedEpisodes: number;
|
||||||
@@ -36,7 +39,17 @@ export function ContinueWatchingCard({
|
|||||||
params={{ id: item.title.id }}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-video overflow-hidden rounded-t-xl bg-muted">
|
<div
|
||||||
|
className="relative aspect-video overflow-hidden rounded-t-xl bg-muted"
|
||||||
|
style={(() => {
|
||||||
|
const hash =
|
||||||
|
item.nextEpisode?.stillThumbHash ?? item.title.backdropThumbHash;
|
||||||
|
const url = thumbHashToUrl(hash);
|
||||||
|
return url
|
||||||
|
? { backgroundImage: `url(${url})`, backgroundSize: "cover" }
|
||||||
|
: undefined;
|
||||||
|
})()}
|
||||||
|
>
|
||||||
{stillUrl ? (
|
{stillUrl ? (
|
||||||
<img
|
<img
|
||||||
src={stillUrl}
|
src={stillUrl}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface TitleGridItem {
|
|||||||
type: string;
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
posterPath: string | null;
|
posterPath: string | null;
|
||||||
|
posterThumbHash?: string | null;
|
||||||
releaseDate?: string | null;
|
releaseDate?: string | null;
|
||||||
firstAirDate?: string | null;
|
firstAirDate?: string | null;
|
||||||
voteAverage?: number | null;
|
voteAverage?: number | null;
|
||||||
@@ -46,6 +47,7 @@ export function TitleGrid({ items }: { items: TitleGridItem[] }) {
|
|||||||
type={t.type}
|
type={t.type}
|
||||||
title={t.title}
|
title={t.title}
|
||||||
posterPath={t.posterPath}
|
posterPath={t.posterPath}
|
||||||
|
posterThumbHash={t.posterThumbHash}
|
||||||
releaseDate={t.releaseDate ?? t.firstAirDate}
|
releaseDate={t.releaseDate ?? t.firstAirDate}
|
||||||
voteAverage={t.voteAverage}
|
voteAverage={t.voteAverage}
|
||||||
userStatus={t.userStatus}
|
userStatus={t.userStatus}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface TitleRowItem {
|
|||||||
type: "movie" | "tv";
|
type: "movie" | "tv";
|
||||||
title: string;
|
title: string;
|
||||||
posterPath: string | null;
|
posterPath: string | null;
|
||||||
|
posterThumbHash?: string | null;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
firstAirDate: string | null;
|
firstAirDate: string | null;
|
||||||
voteAverage: number | null;
|
voteAverage: number | null;
|
||||||
@@ -141,6 +142,7 @@ export function FilterableTitleRow({
|
|||||||
type={item.type}
|
type={item.type}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
posterPath={item.posterPath}
|
posterPath={item.posterPath}
|
||||||
|
posterThumbHash={item.posterThumbHash}
|
||||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||||
voteAverage={item.voteAverage}
|
voteAverage={item.voteAverage}
|
||||||
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
|
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface TitleRowItem {
|
|||||||
type: "movie" | "tv";
|
type: "movie" | "tv";
|
||||||
title: string;
|
title: string;
|
||||||
posterPath: string | null;
|
posterPath: string | null;
|
||||||
|
posterThumbHash?: string | null;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
firstAirDate: string | null;
|
firstAirDate: string | null;
|
||||||
voteAverage: number | null;
|
voteAverage: number | null;
|
||||||
@@ -52,6 +53,7 @@ export function TitleRow({
|
|||||||
type={item.type}
|
type={item.type}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
posterPath={item.posterPath}
|
posterPath={item.posterPath}
|
||||||
|
posterThumbHash={item.posterThumbHash}
|
||||||
releaseDate={item.releaseDate ?? item.firstAirDate}
|
releaseDate={item.releaseDate ?? item.firstAirDate}
|
||||||
voteAverage={item.voteAverage}
|
voteAverage={item.voteAverage}
|
||||||
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
|
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export function FilmographyGrid({
|
|||||||
type={credit.type}
|
type={credit.type}
|
||||||
title={credit.title}
|
title={credit.title}
|
||||||
posterPath={credit.posterPath}
|
posterPath={credit.posterPath}
|
||||||
|
posterThumbHash={credit.posterThumbHash}
|
||||||
releaseDate={credit.releaseDate ?? credit.firstAirDate}
|
releaseDate={credit.releaseDate ?? credit.firstAirDate}
|
||||||
voteAverage={credit.voteAverage}
|
voteAverage={credit.voteAverage}
|
||||||
userStatus={userStatuses?.[credit.titleId]}
|
userStatus={userStatuses?.[credit.titleId]}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { format, parseISO } from "date-fns";
|
|||||||
|
|
||||||
import { ExpandableText } from "@/components/expandable-text";
|
import { ExpandableText } from "@/components/expandable-text";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { thumbHashToUrl } from "@/lib/thumbhash";
|
||||||
|
|
||||||
interface PersonHeroProps {
|
interface PersonHeroProps {
|
||||||
person: ResolvedPerson;
|
person: ResolvedPerson;
|
||||||
@@ -27,7 +28,17 @@ export function PersonHero({ person }: PersonHeroProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex animate-stagger-item flex-col gap-6 sm:flex-row sm:gap-8">
|
<div className="flex animate-stagger-item flex-col gap-6 sm:flex-row sm:gap-8">
|
||||||
<div className="size-40 shrink-0 self-center overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/10 sm:size-56 sm:self-start">
|
<div
|
||||||
|
className="size-40 shrink-0 self-center overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/10 sm:size-56 sm:self-start"
|
||||||
|
style={
|
||||||
|
person.profileThumbHash
|
||||||
|
? {
|
||||||
|
backgroundImage: `url(${thumbHashToUrl(person.profileThumbHash)})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{person.profilePath ? (
|
{person.profilePath ? (
|
||||||
<img
|
<img
|
||||||
src={person.profilePath}
|
src={person.profilePath}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useTiltEffect } from "@/hooks/use-tilt-effect";
|
import { useTiltEffect } from "@/hooks/use-tilt-effect";
|
||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
|
import { thumbHashToUrl } from "@/lib/thumbhash";
|
||||||
|
|
||||||
export function TitleCardSkeleton() {
|
export function TitleCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -47,6 +48,7 @@ interface CardInnerProps {
|
|||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
posterPath: string | null;
|
posterPath: string | null;
|
||||||
|
posterThumbHash?: string | null;
|
||||||
releaseDate?: string | null;
|
releaseDate?: string | null;
|
||||||
voteAverage?: number | null;
|
voteAverage?: number | null;
|
||||||
userStatus?: TitleStatus | null;
|
userStatus?: TitleStatus | null;
|
||||||
@@ -169,6 +171,7 @@ function CardInner({
|
|||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
posterPath,
|
posterPath,
|
||||||
|
posterThumbHash,
|
||||||
releaseDate,
|
releaseDate,
|
||||||
voteAverage,
|
voteAverage,
|
||||||
userStatus,
|
userStatus,
|
||||||
@@ -177,6 +180,7 @@ function CardInner({
|
|||||||
}: CardInnerProps) {
|
}: CardInnerProps) {
|
||||||
const year = releaseDate?.slice(0, 4);
|
const year = releaseDate?.slice(0, 4);
|
||||||
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
|
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
|
||||||
|
const placeholderUrl = thumbHashToUrl(posterThumbHash);
|
||||||
|
|
||||||
const ringClass = userStatus
|
const ringClass = userStatus
|
||||||
? "ring-primary/25 shadow-sm shadow-primary/5"
|
? "ring-primary/25 shadow-sm shadow-primary/5"
|
||||||
@@ -186,7 +190,17 @@ function CardInner({
|
|||||||
<div
|
<div
|
||||||
className={`relative overflow-hidden rounded-xl bg-card ring-1 transition-[box-shadow,ring-color] duration-200 ease-out hover:shadow-lg hover:shadow-primary/5 hover:ring-primary/25 ${ringClass}`}
|
className={`relative overflow-hidden rounded-xl bg-card ring-1 transition-[box-shadow,ring-color] duration-200 ease-out hover:shadow-lg hover:shadow-primary/5 hover:ring-primary/25 ${ringClass}`}
|
||||||
>
|
>
|
||||||
<div className="aspect-[2/3] overflow-hidden bg-card">
|
<div
|
||||||
|
className="aspect-[2/3] overflow-hidden bg-card"
|
||||||
|
style={
|
||||||
|
placeholderUrl
|
||||||
|
? {
|
||||||
|
backgroundImage: `url(${placeholderUrl})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{posterPath ? (
|
{posterPath ? (
|
||||||
<motion.div style={tiltStyles?.imageStyle}>
|
<motion.div style={tiltStyles?.imageStyle}>
|
||||||
<img
|
<img
|
||||||
@@ -282,6 +296,7 @@ export function TitleCard({
|
|||||||
type,
|
type,
|
||||||
title,
|
title,
|
||||||
posterPath,
|
posterPath,
|
||||||
|
posterThumbHash,
|
||||||
releaseDate,
|
releaseDate,
|
||||||
voteAverage,
|
voteAverage,
|
||||||
userStatus,
|
userStatus,
|
||||||
@@ -310,6 +325,7 @@ export function TitleCard({
|
|||||||
title={title}
|
title={title}
|
||||||
type={type}
|
type={type}
|
||||||
posterPath={posterPath}
|
posterPath={posterPath}
|
||||||
|
posterThumbHash={posterThumbHash}
|
||||||
releaseDate={releaseDate}
|
releaseDate={releaseDate}
|
||||||
voteAverage={voteAverage}
|
voteAverage={voteAverage}
|
||||||
userStatus={userStatus}
|
userStatus={userStatus}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { CastMember } from "@sofa/api/schemas";
|
import type { CastMember } from "@sofa/api/schemas";
|
||||||
import { IconUser, IconUsers } from "@tabler/icons-react";
|
import { IconUser, IconUsers } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { thumbHashToUrl } from "@/lib/thumbhash";
|
||||||
|
|
||||||
interface CastCarouselProps {
|
interface CastCarouselProps {
|
||||||
actors: CastMember[];
|
actors: CastMember[];
|
||||||
@@ -31,7 +32,17 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
|||||||
params={{ id: member.personId }}
|
params={{ id: member.personId }}
|
||||||
className="group flex flex-col items-center gap-2"
|
className="group flex flex-col items-center gap-2"
|
||||||
>
|
>
|
||||||
<div className="size-20 overflow-hidden rounded-full ring-1 ring-white/10 transition-all group-hover:ring-primary/25 sm:size-24">
|
<div
|
||||||
|
className="size-20 overflow-hidden rounded-full ring-1 ring-white/10 transition-all group-hover:ring-primary/25 sm:size-24"
|
||||||
|
style={
|
||||||
|
member.profileThumbHash
|
||||||
|
? {
|
||||||
|
backgroundImage: `url(${thumbHashToUrl(member.profileThumbHash)})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{member.profilePath ? (
|
{member.profilePath ? (
|
||||||
<img
|
<img
|
||||||
src={member.profilePath}
|
src={member.profilePath}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import {
|
|||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { ExpandableText } from "@/components/expandable-text";
|
import { ExpandableText } from "@/components/expandable-text";
|
||||||
import { TmdbLogo } from "@/components/tmdb-logo";
|
import { TmdbLogo } from "@/components/tmdb-logo";
|
||||||
|
import { thumbHashToUrl } from "@/lib/thumbhash";
|
||||||
import { GenreCollapse } from "./genre-collapse";
|
import { GenreCollapse } from "./genre-collapse";
|
||||||
import { TrailerDialog } from "./trailer-dialog";
|
import { TrailerDialog } from "./trailer-dialog";
|
||||||
|
|
||||||
@@ -35,7 +36,17 @@ export function TitleHero({
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop hero */}
|
{/* Backdrop hero */}
|
||||||
{title.backdropPath && (
|
{title.backdropPath && (
|
||||||
<div className="relative -mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 overflow-hidden md:h-[28rem]">
|
<div
|
||||||
|
className="relative -mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 overflow-hidden md:h-[28rem]"
|
||||||
|
style={
|
||||||
|
title.backdropThumbHash
|
||||||
|
? {
|
||||||
|
backgroundImage: `url(${thumbHashToUrl(title.backdropThumbHash)})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={title.backdropPath}
|
src={title.backdropPath}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -92,6 +103,12 @@ export function TitleHero({
|
|||||||
boxShadow: palette?.darkVibrant
|
boxShadow: palette?.darkVibrant
|
||||||
? `0 25px 60px -12px ${palette.darkVibrant}50, 0 12px 28px -8px rgba(0,0,0,0.5)`
|
? `0 25px 60px -12px ${palette.darkVibrant}50, 0 12px 28px -8px rgba(0,0,0,0.5)`
|
||||||
: "0 25px 50px -12px rgba(0,0,0,0.5)",
|
: "0 25px 50px -12px rgba(0,0,0,0.5)",
|
||||||
|
...(title.posterThumbHash
|
||||||
|
? {
|
||||||
|
backgroundImage: `url(${thumbHashToUrl(title.posterThumbHash)})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function TitleRecommendations({ titleId }: { titleId: string }) {
|
|||||||
type={rec.type}
|
type={rec.type}
|
||||||
title={rec.title}
|
title={rec.title}
|
||||||
posterPath={rec.posterPath}
|
posterPath={rec.posterPath}
|
||||||
|
posterThumbHash={rec.posterThumbHash}
|
||||||
releaseDate={rec.releaseDate ?? rec.firstAirDate}
|
releaseDate={rec.releaseDate ?? rec.firstAirDate}
|
||||||
voteAverage={rec.voteAverage}
|
voteAverage={rec.voteAverage}
|
||||||
userStatus={data.userStatuses[rec.id]}
|
userStatus={data.userStatuses[rec.id]}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { thumbHashToDataURL } from "thumbhash";
|
||||||
|
|
||||||
|
export function thumbHashToUrl(
|
||||||
|
hash: string | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!hash) return undefined;
|
||||||
|
const binary = Uint8Array.from(atob(hash), (c) => c.charCodeAt(0));
|
||||||
|
return thumbHashToDataURL(binary);
|
||||||
|
}
|
||||||
@@ -27,7 +27,6 @@
|
|||||||
"@react-navigation/elements": "2.9.10",
|
"@react-navigation/elements": "2.9.10",
|
||||||
"@shopify/flash-list": "2.3.0",
|
"@shopify/flash-list": "2.3.0",
|
||||||
"@sofa/api": "workspace:*",
|
"@sofa/api": "workspace:*",
|
||||||
"@sofa/tmdb": "workspace:*",
|
|
||||||
"@tabler/icons-react-native": "3.40.0",
|
"@tabler/icons-react-native": "3.40.0",
|
||||||
"@tanstack/query-async-storage-persister": "5.90.24",
|
"@tanstack/query-async-storage-persister": "5.90.24",
|
||||||
"@tanstack/react-form": "1.28.5",
|
"@tanstack/react-form": "1.28.5",
|
||||||
@@ -154,6 +153,7 @@
|
|||||||
"shadcn": "4.0.6",
|
"shadcn": "4.0.6",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
|
"thumbhash": "catalog:",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"youtube-video-element": "1.9.0",
|
"youtube-video-element": "1.9.0",
|
||||||
@@ -224,6 +224,8 @@
|
|||||||
"@sofa/tmdb": "workspace:*",
|
"@sofa/tmdb": "workspace:*",
|
||||||
"date-fns": "catalog:",
|
"date-fns": "catalog:",
|
||||||
"node-vibrant": "4.0.4",
|
"node-vibrant": "4.0.4",
|
||||||
|
"sharp": "0.34.5",
|
||||||
|
"thumbhash": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -286,6 +288,7 @@
|
|||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"tailwindcss": "4.2.1",
|
"tailwindcss": "4.2.1",
|
||||||
|
"thumbhash": "0.1.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"zod": "4.3.6",
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
@@ -2510,6 +2567,8 @@
|
|||||||
|
|
||||||
"throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="],
|
"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=="],
|
"timm": ["timm@1.7.1", "", {}, "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw=="],
|
||||||
|
|
||||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
"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/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/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/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/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/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/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/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=="],
|
"@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/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/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/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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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-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/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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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/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=="],
|
"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=="],
|
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|||||||
+134
-2
@@ -163,6 +163,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"posterThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"backdropPath": {
|
"backdropPath": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -173,6 +183,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"backdropThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"popularity": {
|
"popularity": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -330,7 +350,9 @@
|
|||||||
"releaseDate",
|
"releaseDate",
|
||||||
"firstAirDate",
|
"firstAirDate",
|
||||||
"posterPath",
|
"posterPath",
|
||||||
|
"posterThumbHash",
|
||||||
"backdropPath",
|
"backdropPath",
|
||||||
|
"backdropThumbHash",
|
||||||
"popularity",
|
"popularity",
|
||||||
"voteAverage",
|
"voteAverage",
|
||||||
"voteCount",
|
"voteCount",
|
||||||
@@ -403,6 +425,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"stillThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"airDate": {
|
"airDate": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -430,6 +462,7 @@
|
|||||||
"name",
|
"name",
|
||||||
"overview",
|
"overview",
|
||||||
"stillPath",
|
"stillPath",
|
||||||
|
"stillThumbHash",
|
||||||
"airDate",
|
"airDate",
|
||||||
"runtimeMinutes"
|
"runtimeMinutes"
|
||||||
]
|
]
|
||||||
@@ -551,6 +584,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"profileThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"tmdbId": {
|
"tmdbId": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
@@ -565,6 +608,7 @@
|
|||||||
"displayOrder",
|
"displayOrder",
|
||||||
"episodeCount",
|
"episodeCount",
|
||||||
"profilePath",
|
"profilePath",
|
||||||
|
"profileThumbHash",
|
||||||
"tmdbId"
|
"tmdbId"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -986,6 +1030,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"posterThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"releaseDate": {
|
"releaseDate": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1023,6 +1077,7 @@
|
|||||||
"type",
|
"type",
|
||||||
"title",
|
"title",
|
||||||
"posterPath",
|
"posterPath",
|
||||||
|
"posterThumbHash",
|
||||||
"releaseDate",
|
"releaseDate",
|
||||||
"firstAirDate",
|
"firstAirDate",
|
||||||
"voteAverage"
|
"voteAverage"
|
||||||
@@ -1161,6 +1216,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"stillThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"airDate": {
|
"airDate": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1188,6 +1253,7 @@
|
|||||||
"name",
|
"name",
|
||||||
"overview",
|
"overview",
|
||||||
"stillPath",
|
"stillPath",
|
||||||
|
"stillThumbHash",
|
||||||
"airDate",
|
"airDate",
|
||||||
"runtimeMinutes"
|
"runtimeMinutes"
|
||||||
]
|
]
|
||||||
@@ -1608,6 +1674,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"profileThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"knownForDepartment": {
|
"knownForDepartment": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1638,6 +1714,7 @@
|
|||||||
"deathday",
|
"deathday",
|
||||||
"placeOfBirth",
|
"placeOfBirth",
|
||||||
"profilePath",
|
"profilePath",
|
||||||
|
"profileThumbHash",
|
||||||
"knownForDepartment",
|
"knownForDepartment",
|
||||||
"imdbId"
|
"imdbId"
|
||||||
]
|
]
|
||||||
@@ -1672,6 +1749,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"posterThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"releaseDate": {
|
"releaseDate": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -1732,6 +1819,7 @@
|
|||||||
"type",
|
"type",
|
||||||
"title",
|
"title",
|
||||||
"posterPath",
|
"posterPath",
|
||||||
|
"posterThumbHash",
|
||||||
"releaseDate",
|
"releaseDate",
|
||||||
"firstAirDate",
|
"firstAirDate",
|
||||||
"voteAverage",
|
"voteAverage",
|
||||||
@@ -1892,12 +1980,23 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"backdropThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"title",
|
"title",
|
||||||
"backdropPath"
|
"backdropPath",
|
||||||
|
"backdropThumbHash"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nextEpisode": {
|
"nextEpisode": {
|
||||||
@@ -1930,13 +2029,24 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"stillThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"seasonNumber",
|
"seasonNumber",
|
||||||
"episodeNumber",
|
"episodeNumber",
|
||||||
"name",
|
"name",
|
||||||
"stillPath"
|
"stillPath",
|
||||||
|
"stillThumbHash"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2014,6 +2124,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"posterThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"releaseDate": {
|
"releaseDate": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -2065,6 +2185,7 @@
|
|||||||
"type",
|
"type",
|
||||||
"title",
|
"title",
|
||||||
"posterPath",
|
"posterPath",
|
||||||
|
"posterThumbHash",
|
||||||
"releaseDate",
|
"releaseDate",
|
||||||
"firstAirDate",
|
"firstAirDate",
|
||||||
"voteAverage",
|
"voteAverage",
|
||||||
@@ -2127,6 +2248,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"posterThumbHash": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"releaseDate": {
|
"releaseDate": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -2164,6 +2295,7 @@
|
|||||||
"type",
|
"type",
|
||||||
"title",
|
"title",
|
||||||
"posterPath",
|
"posterPath",
|
||||||
|
"posterThumbHash",
|
||||||
"releaseDate",
|
"releaseDate",
|
||||||
"firstAirDate",
|
"firstAirDate",
|
||||||
"voteAverage"
|
"voteAverage"
|
||||||
|
|||||||
+2
-1
@@ -21,6 +21,7 @@
|
|||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"tailwindcss": "4.2.1",
|
"tailwindcss": "4.2.1",
|
||||||
|
"thumbhash": "0.1.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
}
|
}
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
"format": "turbo run format",
|
"format": "turbo run format",
|
||||||
"check-types": "turbo run check-types",
|
"check-types": "turbo run check-types",
|
||||||
"test": "turbo run test",
|
"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": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.7",
|
"@biomejs/biome": "2.4.7",
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ const EpisodeSchema = z.object({
|
|||||||
name: z.string().nullable(),
|
name: z.string().nullable(),
|
||||||
overview: z.string().nullable(),
|
overview: z.string().nullable(),
|
||||||
stillPath: z.string().nullable(),
|
stillPath: z.string().nullable(),
|
||||||
|
stillThumbHash: z.string().nullable(),
|
||||||
airDate: z.string().nullable(),
|
airDate: z.string().nullable(),
|
||||||
runtimeMinutes: z.number().nullable(),
|
runtimeMinutes: z.number().nullable(),
|
||||||
});
|
});
|
||||||
@@ -185,6 +186,7 @@ const CastMemberSchema = z.object({
|
|||||||
displayOrder: z.number(),
|
displayOrder: z.number(),
|
||||||
episodeCount: z.number().nullable(),
|
episodeCount: z.number().nullable(),
|
||||||
profilePath: z.string().nullable(),
|
profilePath: z.string().nullable(),
|
||||||
|
profileThumbHash: z.string().nullable(),
|
||||||
tmdbId: z.number(),
|
tmdbId: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,7 +200,9 @@ const ResolvedTitleSchema = z.object({
|
|||||||
releaseDate: z.string().nullable(),
|
releaseDate: z.string().nullable(),
|
||||||
firstAirDate: z.string().nullable(),
|
firstAirDate: z.string().nullable(),
|
||||||
posterPath: z.string().nullable(),
|
posterPath: z.string().nullable(),
|
||||||
|
posterThumbHash: z.string().nullable(),
|
||||||
backdropPath: z.string().nullable(),
|
backdropPath: z.string().nullable(),
|
||||||
|
backdropThumbHash: z.string().nullable(),
|
||||||
popularity: z.number().nullable(),
|
popularity: z.number().nullable(),
|
||||||
voteAverage: z.number().nullable(),
|
voteAverage: z.number().nullable(),
|
||||||
voteCount: z.number().nullable(),
|
voteCount: z.number().nullable(),
|
||||||
@@ -218,6 +222,7 @@ const PersonSchema = z.object({
|
|||||||
deathday: z.string().nullable(),
|
deathday: z.string().nullable(),
|
||||||
placeOfBirth: z.string().nullable(),
|
placeOfBirth: z.string().nullable(),
|
||||||
profilePath: z.string().nullable(),
|
profilePath: z.string().nullable(),
|
||||||
|
profileThumbHash: z.string().nullable(),
|
||||||
knownForDepartment: z.string().nullable(),
|
knownForDepartment: z.string().nullable(),
|
||||||
imdbId: z.string().nullable(),
|
imdbId: z.string().nullable(),
|
||||||
});
|
});
|
||||||
@@ -228,6 +233,7 @@ const PersonCreditSchema = z.object({
|
|||||||
type: mediaType,
|
type: mediaType,
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
posterPath: z.string().nullable(),
|
posterPath: z.string().nullable(),
|
||||||
|
posterThumbHash: z.string().nullable(),
|
||||||
releaseDate: z.string().nullable(),
|
releaseDate: z.string().nullable(),
|
||||||
firstAirDate: z.string().nullable(),
|
firstAirDate: z.string().nullable(),
|
||||||
voteAverage: z.number().nullable(),
|
voteAverage: z.number().nullable(),
|
||||||
@@ -242,6 +248,7 @@ const TmdbBrowseItem = z.object({
|
|||||||
type: mediaType,
|
type: mediaType,
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
posterPath: z.string().nullable(),
|
posterPath: z.string().nullable(),
|
||||||
|
posterThumbHash: z.string().nullable(),
|
||||||
releaseDate: z.string().nullable(),
|
releaseDate: z.string().nullable(),
|
||||||
firstAirDate: z.string().nullable(),
|
firstAirDate: z.string().nullable(),
|
||||||
voteAverage: z.number().nullable(),
|
voteAverage: z.number().nullable(),
|
||||||
@@ -254,6 +261,7 @@ const RecommendationItemSchema = z.object({
|
|||||||
type: mediaType,
|
type: mediaType,
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
posterPath: z.string().nullable(),
|
posterPath: z.string().nullable(),
|
||||||
|
posterThumbHash: z.string().nullable(),
|
||||||
releaseDate: z.string().nullable(),
|
releaseDate: z.string().nullable(),
|
||||||
firstAirDate: z.string().nullable(),
|
firstAirDate: z.string().nullable(),
|
||||||
voteAverage: z.number().nullable(),
|
voteAverage: z.number().nullable(),
|
||||||
@@ -324,6 +332,7 @@ export const ContinueWatchingOutput = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
backdropPath: z.string().nullable(),
|
backdropPath: z.string().nullable(),
|
||||||
|
backdropThumbHash: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
nextEpisode: z
|
nextEpisode: z
|
||||||
.object({
|
.object({
|
||||||
@@ -331,6 +340,7 @@ export const ContinueWatchingOutput = z.object({
|
|||||||
episodeNumber: z.number(),
|
episodeNumber: z.number(),
|
||||||
name: z.string().nullable(),
|
name: z.string().nullable(),
|
||||||
stillPath: z.string().nullable(),
|
stillPath: z.string().nullable(),
|
||||||
|
stillThumbHash: z.string().nullable(),
|
||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
totalEpisodes: z.number(),
|
totalEpisodes: z.number(),
|
||||||
@@ -347,6 +357,7 @@ export const LibraryOutput = z.object({
|
|||||||
type: mediaType,
|
type: mediaType,
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
posterPath: z.string().nullable(),
|
posterPath: z.string().nullable(),
|
||||||
|
posterThumbHash: z.string().nullable(),
|
||||||
releaseDate: z.string().nullable(),
|
releaseDate: z.string().nullable(),
|
||||||
firstAirDate: z.string().nullable(),
|
firstAirDate: z.string().nullable(),
|
||||||
voteAverage: z.number().nullable(),
|
voteAverage: z.number().nullable(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface NextEpisodeInfo {
|
|||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
stillPath: string | null;
|
stillPath: string | null;
|
||||||
|
stillThumbHash: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NextEpisodeResult {
|
export interface NextEpisodeResult {
|
||||||
@@ -41,6 +42,7 @@ export function getNextEpisode(
|
|||||||
episodeNumber: ep.episodeNumber,
|
episodeNumber: ep.episodeNumber,
|
||||||
name: ep.name,
|
name: ep.name,
|
||||||
stillPath: ep.stillPath,
|
stillPath: ep.stillPath,
|
||||||
|
stillThumbHash: ep.stillThumbHash,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"./settings": "./src/settings.ts",
|
"./settings": "./src/settings.ts",
|
||||||
"./system-health": "./src/system-health.ts",
|
"./system-health": "./src/system-health.ts",
|
||||||
"./telemetry": "./src/telemetry.ts",
|
"./telemetry": "./src/telemetry.ts",
|
||||||
|
"./thumbhash": "./src/thumbhash.ts",
|
||||||
"./tracking": "./src/tracking.ts",
|
"./tracking": "./src/tracking.ts",
|
||||||
"./update-check": "./src/update-check.ts",
|
"./update-check": "./src/update-check.ts",
|
||||||
"./webhooks": "./src/webhooks.ts"
|
"./webhooks": "./src/webhooks.ts"
|
||||||
@@ -35,6 +36,8 @@
|
|||||||
"@sofa/tmdb": "workspace:*",
|
"@sofa/tmdb": "workspace:*",
|
||||||
"date-fns": "catalog:",
|
"date-fns": "catalog:",
|
||||||
"node-vibrant": "4.0.4",
|
"node-vibrant": "4.0.4",
|
||||||
|
"sharp": "0.34.5",
|
||||||
|
"thumbhash": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+14
-24
@@ -1,15 +1,9 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { db } from "@sofa/db/client";
|
import { db } from "@sofa/db/client";
|
||||||
import { eq } from "@sofa/db/helpers";
|
import { eq } from "@sofa/db/helpers";
|
||||||
import { titles } from "@sofa/db/schema";
|
import { titles } from "@sofa/db/schema";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { Vibrant } from "node-vibrant/node";
|
import { Vibrant } from "node-vibrant/node";
|
||||||
import {
|
import { loadImageBuffer } from "./image-cache";
|
||||||
downloadAndCacheImage,
|
|
||||||
getLocalImagePath,
|
|
||||||
imageCacheEnabled,
|
|
||||||
isImageCached,
|
|
||||||
} from "./image-cache";
|
|
||||||
|
|
||||||
const log = createLogger("colors");
|
const log = createLogger("colors");
|
||||||
|
|
||||||
@@ -25,27 +19,23 @@ export interface ColorPalette {
|
|||||||
export async function extractAndStoreColors(
|
export async function extractAndStoreColors(
|
||||||
titleId: string,
|
titleId: string,
|
||||||
posterPath: string | null,
|
posterPath: string | null,
|
||||||
|
sourceBuffer?: Buffer | null,
|
||||||
): Promise<ColorPalette | null> {
|
): Promise<ColorPalette | null> {
|
||||||
if (!posterPath) return null;
|
if (!posterPath) {
|
||||||
|
db.update(titles)
|
||||||
// Use local cached image, downloading first if needed
|
.set({ colorPalette: null })
|
||||||
let source: string;
|
.where(eq(titles.id, titleId))
|
||||||
const filename = path.basename(posterPath);
|
.run();
|
||||||
if (imageCacheEnabled()) {
|
return null;
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const source =
|
||||||
|
sourceBuffer === undefined
|
||||||
|
? await loadImageBuffer(posterPath, "posters")
|
||||||
|
: sourceBuffer;
|
||||||
|
if (!source) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.debug(
|
|
||||||
`Extracting colors for title ${titleId} from ${imageCacheEnabled() ? "cache" : "remote"}`,
|
|
||||||
);
|
|
||||||
const palette = await Vibrant.from(source).getPalette();
|
const palette = await Vibrant.from(source).getPalette();
|
||||||
|
|
||||||
const colors: ColorPalette = {
|
const colors: ColorPalette = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createLogger } from "@sofa/logger";
|
|||||||
import { getMovieCredits, getTvAggregateCredits } from "@sofa/tmdb/client";
|
import { getMovieCredits, getTvAggregateCredits } from "@sofa/tmdb/client";
|
||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache";
|
import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache";
|
||||||
|
import { generatePersonThumbHash } from "./thumbhash";
|
||||||
|
|
||||||
const log = createLogger("credits");
|
const log = createLogger("credits");
|
||||||
|
|
||||||
@@ -37,49 +38,107 @@ function batchUpsertPersons(people: PersonData[]): Map<number, string> {
|
|||||||
|
|
||||||
// Batch prefetch existing persons (1 query)
|
// Batch prefetch existing persons (1 query)
|
||||||
const existing = db
|
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)
|
.from(persons)
|
||||||
.where(inArray(persons.tmdbId, tmdbIds))
|
.where(inArray(persons.tmdbId, tmdbIds))
|
||||||
.all();
|
.all();
|
||||||
|
const existingMap = new Map(existing.map((p) => [p.tmdbId, p]));
|
||||||
const idMap = new Map<number, string>(existing.map((p) => [p.tmdbId, p.id]));
|
const idMap = new Map<number, string>(existing.map((p) => [p.tmdbId, p.id]));
|
||||||
|
|
||||||
// Insert only new persons in a transaction
|
db.transaction((tx) => {
|
||||||
const newPeople = uniquePeople.filter((p) => !idMap.has(p.tmdbId));
|
for (const p of uniquePeople) {
|
||||||
if (newPeople.length > 0) {
|
const existingPerson = existingMap.get(p.tmdbId);
|
||||||
db.transaction((tx) => {
|
if (existingPerson) {
|
||||||
for (const p of newPeople) {
|
const nextPopularity = p.popularity ?? null;
|
||||||
const row = tx
|
const pathChanged = existingPerson.profilePath !== p.profilePath;
|
||||||
.insert(persons)
|
const needsUpdate =
|
||||||
.values({
|
existingPerson.name !== p.name ||
|
||||||
tmdbId: p.tmdbId,
|
pathChanged ||
|
||||||
|
existingPerson.popularity !== nextPopularity;
|
||||||
|
if (!needsUpdate) continue;
|
||||||
|
|
||||||
|
tx.update(persons)
|
||||||
|
.set({
|
||||||
name: p.name,
|
name: p.name,
|
||||||
profilePath: p.profilePath,
|
profilePath: p.profilePath,
|
||||||
popularity: p.popularity ?? null,
|
popularity: nextPopularity,
|
||||||
|
profileThumbHash: pathChanged
|
||||||
|
? null
|
||||||
|
: existingPerson.profileThumbHash,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.where(eq(persons.id, existingPerson.id))
|
||||||
.returning()
|
.run();
|
||||||
.get();
|
continue;
|
||||||
if (row) idMap.set(p.tmdbId, row.id);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// One fallback query for any that conflicted
|
const row = tx
|
||||||
const stillMissing = newPeople
|
.insert(persons)
|
||||||
.filter((p) => !idMap.has(p.tmdbId))
|
.values({
|
||||||
.map((p) => p.tmdbId);
|
tmdbId: p.tmdbId,
|
||||||
if (stillMissing.length > 0) {
|
name: p.name,
|
||||||
const fallbacks = db
|
profilePath: p.profilePath,
|
||||||
.select({ id: persons.id, tmdbId: persons.tmdbId })
|
popularity: p.popularity ?? null,
|
||||||
.from(persons)
|
})
|
||||||
.where(inArray(persons.tmdbId, stillMissing))
|
.onConflictDoNothing()
|
||||||
.all();
|
.returning()
|
||||||
for (const f of fallbacks) idMap.set(f.tmdbId, f.id);
|
.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;
|
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) {
|
export async function refreshCredits(titleId: string) {
|
||||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
@@ -276,11 +335,9 @@ export async function refreshCredits(titleId: string) {
|
|||||||
|
|
||||||
log.debug(`Credits refreshed for "${title.title}"`);
|
log.debug(`Credits refreshed for "${title.title}"`);
|
||||||
|
|
||||||
if (imageCacheEnabled()) {
|
(async () => {
|
||||||
cacheProfilePhotos(titleId).catch((err) =>
|
await syncCastProfileThumbHashes(titleId, [...personIds.values()]);
|
||||||
log.debug("Profile photo caching failed:", err),
|
})().catch((err) => log.debug("Profile cache/thumbhash failed:", err));
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(`Failed to refresh credits for title ${titleId}:`, err);
|
log.error(`Failed to refresh credits for title ${titleId}:`, err);
|
||||||
}
|
}
|
||||||
@@ -298,6 +355,7 @@ export function getCastForTitle(titleId: string): CastMember[] {
|
|||||||
displayOrder: titleCast.displayOrder,
|
displayOrder: titleCast.displayOrder,
|
||||||
episodeCount: titleCast.episodeCount,
|
episodeCount: titleCast.episodeCount,
|
||||||
profilePath: persons.profilePath,
|
profilePath: persons.profilePath,
|
||||||
|
profileThumbHash: persons.profileThumbHash,
|
||||||
tmdbId: persons.tmdbId,
|
tmdbId: persons.tmdbId,
|
||||||
})
|
})
|
||||||
.from(titleCast)
|
.from(titleCast)
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export interface ContinueWatchingItem {
|
|||||||
title: string;
|
title: string;
|
||||||
posterPath: string | null;
|
posterPath: string | null;
|
||||||
backdropPath: string | null;
|
backdropPath: string | null;
|
||||||
|
backdropThumbHash: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
nextEpisode: {
|
nextEpisode: {
|
||||||
@@ -185,6 +186,7 @@ export interface ContinueWatchingItem {
|
|||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
stillPath: string | null;
|
stillPath: string | null;
|
||||||
|
stillThumbHash: string | null;
|
||||||
overview: string | null;
|
overview: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
lastWatchedAt: Date | null;
|
lastWatchedAt: Date | null;
|
||||||
@@ -322,6 +324,7 @@ export function getContinueWatchingFeed(
|
|||||||
episodeNumber: ep.episodeNumber,
|
episodeNumber: ep.episodeNumber,
|
||||||
name: ep.name,
|
name: ep.name,
|
||||||
stillPath: ep.stillPath,
|
stillPath: ep.stillPath,
|
||||||
|
stillThumbHash: ep.stillThumbHash,
|
||||||
overview: ep.overview,
|
overview: ep.overview,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -335,6 +338,7 @@ export function getContinueWatchingFeed(
|
|||||||
title: title.title,
|
title: title.title,
|
||||||
posterPath: title.posterPath,
|
posterPath: title.posterPath,
|
||||||
backdropPath: title.backdropPath,
|
backdropPath: title.backdropPath,
|
||||||
|
backdropThumbHash: title.backdropThumbHash,
|
||||||
type: title.type,
|
type: title.type,
|
||||||
},
|
},
|
||||||
nextEpisode,
|
nextEpisode,
|
||||||
@@ -366,6 +370,7 @@ export function getNewAvailableFeed(userId: string, days = 14) {
|
|||||||
type: titles.type,
|
type: titles.type,
|
||||||
tmdbId: titles.tmdbId,
|
tmdbId: titles.tmdbId,
|
||||||
posterPath: titles.posterPath,
|
posterPath: titles.posterPath,
|
||||||
|
posterThumbHash: titles.posterThumbHash,
|
||||||
releaseDate: titles.releaseDate,
|
releaseDate: titles.releaseDate,
|
||||||
firstAirDate: titles.firstAirDate,
|
firstAirDate: titles.firstAirDate,
|
||||||
voteAverage: titles.voteAverage,
|
voteAverage: titles.voteAverage,
|
||||||
@@ -524,6 +529,7 @@ export function getRecommendationsForTitle(titleId: string) {
|
|||||||
type: r.type as "movie" | "tv",
|
type: r.type as "movie" | "tv",
|
||||||
title: r.title,
|
title: r.title,
|
||||||
posterPath: tmdbImageUrl(r.posterPath, "posters"),
|
posterPath: tmdbImageUrl(r.posterPath, "posters"),
|
||||||
|
posterThumbHash: r.posterThumbHash,
|
||||||
releaseDate: r.releaseDate,
|
releaseDate: r.releaseDate,
|
||||||
firstAirDate: r.firstAirDate,
|
firstAirDate: r.firstAirDate,
|
||||||
voteAverage: r.voteAverage,
|
voteAverage: r.voteAverage,
|
||||||
|
|||||||
@@ -12,26 +12,22 @@ import {
|
|||||||
titles,
|
titles,
|
||||||
} from "@sofa/db/schema";
|
} from "@sofa/db/schema";
|
||||||
import { createLogger } from "@sofa/logger";
|
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");
|
const log = createLogger("image-cache");
|
||||||
|
|
||||||
export type { ImageCategory };
|
export type { ImageCategory };
|
||||||
|
|
||||||
const CATEGORY_SIZES: Record<ImageCategory, string> = {
|
|
||||||
posters: "w500",
|
|
||||||
backdrops: "w1280",
|
|
||||||
stills: "w1280",
|
|
||||||
logos: "w92",
|
|
||||||
profiles: "w185",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function imageCacheEnabled(): boolean {
|
export function imageCacheEnabled(): boolean {
|
||||||
return process.env.IMAGE_CACHE_ENABLED !== "false";
|
return process.env.IMAGE_CACHE_ENABLED !== "false";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureImageDirs() {
|
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 });
|
await mkdir(path.join(CACHE_DIR, category), { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,20 +56,33 @@ export async function readCachedImage(
|
|||||||
return Buffer.from(await file.arrayBuffer());
|
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(
|
export async function downloadAndCacheImage(
|
||||||
tmdbPath: string,
|
tmdbPath: string,
|
||||||
category: ImageCategory,
|
category: ImageCategory,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const size = CATEGORY_SIZES[category];
|
const remote = await fetchRemoteImage(tmdbPath, category);
|
||||||
const url = `${TMDB_IMAGE_BASE_URL}/${size}${tmdbPath}`;
|
if (!remote) return null;
|
||||||
|
|
||||||
const res = await fetch(url);
|
const { buffer } = remote;
|
||||||
if (!res.ok) {
|
|
||||||
log.warn(`Download failed: ${url} -> ${res.status}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
|
||||||
const filename = path.basename(tmdbPath);
|
const filename = path.basename(tmdbPath);
|
||||||
const finalPath = getLocalImagePath(category, filename);
|
const finalPath = getLocalImagePath(category, filename);
|
||||||
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
|
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
|
||||||
@@ -109,16 +118,10 @@ export async function fetchAndMaybeCache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from TMDB
|
// Fetch from TMDB
|
||||||
const size = CATEGORY_SIZES[category];
|
const remote = await fetchRemoteImage(tmdbPath, category);
|
||||||
const url = `${TMDB_IMAGE_BASE_URL}/${size}${tmdbPath}`;
|
if (!remote) return null;
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
log.warn(`Fetch failed: ${url} -> ${res.status}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const { buffer, contentType } = remote;
|
||||||
const contentType = res.headers.get("content-type") || "image/jpeg";
|
|
||||||
|
|
||||||
// Fire-and-forget save to disk
|
// Fire-and-forget save to disk
|
||||||
const finalPath = getLocalImagePath(category, filename);
|
const finalPath = getLocalImagePath(category, filename);
|
||||||
@@ -130,6 +133,22 @@ export async function fetchAndMaybeCache(
|
|||||||
return { buffer, contentType };
|
return { buffer, contentType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadImageBuffer(
|
||||||
|
tmdbPath: string,
|
||||||
|
category: ImageCategory,
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
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) {
|
export async function cacheImagesForTitle(titleId: string) {
|
||||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|||||||
+402
-143
@@ -6,7 +6,7 @@ import type {
|
|||||||
Season,
|
Season,
|
||||||
} from "@sofa/api/schemas";
|
} from "@sofa/api/schemas";
|
||||||
import { db } from "@sofa/db/client";
|
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 {
|
import {
|
||||||
availabilityOffers,
|
availabilityOffers,
|
||||||
episodes,
|
episodes,
|
||||||
@@ -39,8 +39,15 @@ import {
|
|||||||
cacheEpisodeStills,
|
cacheEpisodeStills,
|
||||||
cacheImagesForTitle,
|
cacheImagesForTitle,
|
||||||
imageCacheEnabled,
|
imageCacheEnabled,
|
||||||
|
loadImageBuffer,
|
||||||
} from "./image-cache";
|
} from "./image-cache";
|
||||||
import { generateProviderUrl } from "./providers";
|
import { generateProviderUrl } from "./providers";
|
||||||
|
import {
|
||||||
|
generateEpisodeThumbHash,
|
||||||
|
generateSeasonThumbHash,
|
||||||
|
generateTitleBackdropThumbHash,
|
||||||
|
generateTitlePosterThumbHash,
|
||||||
|
} from "./thumbhash";
|
||||||
|
|
||||||
const log = createLogger("metadata");
|
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<typeof titles.$inferInsert>,
|
||||||
|
) {
|
||||||
|
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 */
|
/** @internal */
|
||||||
export function extractMovieContentRating(
|
export function extractMovieContentRating(
|
||||||
movie: TmdbMovieDetails,
|
movie: TmdbMovieDetails,
|
||||||
@@ -148,18 +196,15 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
|||||||
if (!hasSeason) {
|
if (!hasSeason) {
|
||||||
const show = await getTvDetails(tmdbId);
|
const show = await getTvDetails(tmdbId);
|
||||||
if (!existing.lastFetchedAt) {
|
if (!existing.lastFetchedAt) {
|
||||||
db.update(titles)
|
updateTitleWithArtInvalidation(existing, {
|
||||||
.set({
|
overview: show.overview,
|
||||||
overview: show.overview,
|
posterPath: show.poster_path,
|
||||||
posterPath: show.poster_path,
|
backdropPath: show.backdrop_path,
|
||||||
backdropPath: show.backdrop_path,
|
status: show.status,
|
||||||
status: show.status,
|
contentRating: extractTvContentRating(show),
|
||||||
contentRating: extractTvContentRating(show),
|
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||||
tvdbId: show.external_ids?.tvdb_id ?? null,
|
lastFetchedAt: new Date(),
|
||||||
lastFetchedAt: new Date(),
|
});
|
||||||
})
|
|
||||||
.where(eq(titles.id, existing.id))
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
upsertGenres(existing.id, show.genres ?? []);
|
upsertGenres(existing.id, show.genres ?? []);
|
||||||
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
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) =>
|
refreshRecommendations(existing.id).catch((err) =>
|
||||||
log.debug("Recommendations enrichment failed:", err),
|
log.debug("Recommendations enrichment failed:", err),
|
||||||
);
|
);
|
||||||
extractAndStoreColors(existing.id, show.poster_path ?? null).catch(
|
syncTitleArt(
|
||||||
(err) => log.debug("Color extraction failed:", err),
|
existing.id,
|
||||||
);
|
show.poster_path,
|
||||||
|
show.backdrop_path,
|
||||||
|
"tv",
|
||||||
|
).catch((err) => log.debug("Cache/thumbhash failed:", err));
|
||||||
refreshCredits(existing.id).catch((err) =>
|
refreshCredits(existing.id).catch((err) =>
|
||||||
log.debug("Credits enrichment failed:", err),
|
log.debug("Credits enrichment failed:", err),
|
||||||
);
|
);
|
||||||
refreshTrailer(existing.id).catch((err) =>
|
refreshTrailer(existing.id).catch((err) =>
|
||||||
log.debug("Trailer enrichment failed:", 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();
|
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) =>
|
refreshRecommendations(row.id).catch((err) =>
|
||||||
log.debug("Recommendations enrichment failed:", err),
|
log.debug("Recommendations enrichment failed:", err),
|
||||||
);
|
);
|
||||||
extractAndStoreColors(row.id, movie.poster_path ?? null).catch((err) =>
|
syncTitleArt(row.id, movie.poster_path, movie.backdrop_path, "movie").catch(
|
||||||
log.debug("Color extraction failed:", err),
|
(err) => log.debug("Cache/thumbhash failed:", err),
|
||||||
);
|
);
|
||||||
refreshCredits(row.id).catch((err) =>
|
refreshCredits(row.id).catch((err) =>
|
||||||
log.debug("Credits enrichment failed:", err),
|
log.debug("Credits enrichment failed:", err),
|
||||||
@@ -232,11 +272,6 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
|||||||
refreshTrailer(row.id).catch((err) =>
|
refreshTrailer(row.id).catch((err) =>
|
||||||
log.debug("Trailer enrichment failed:", 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})`);
|
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -272,8 +307,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
|||||||
refreshRecommendations(row.id).catch((err) =>
|
refreshRecommendations(row.id).catch((err) =>
|
||||||
log.debug("Recommendations enrichment failed:", err),
|
log.debug("Recommendations enrichment failed:", err),
|
||||||
);
|
);
|
||||||
extractAndStoreColors(row.id, show.poster_path ?? null).catch((err) =>
|
syncTitleArt(row.id, show.poster_path, show.backdrop_path, "tv").catch(
|
||||||
log.debug("Color extraction failed:", err),
|
(err) => log.debug("Cache/thumbhash failed:", err),
|
||||||
);
|
);
|
||||||
refreshCredits(row.id).catch((err) =>
|
refreshCredits(row.id).catch((err) =>
|
||||||
log.debug("Credits enrichment failed:", err),
|
log.debug("Credits enrichment failed:", err),
|
||||||
@@ -281,14 +316,6 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
|||||||
refreshTrailer(row.id).catch((err) =>
|
refreshTrailer(row.id).catch((err) =>
|
||||||
log.debug("Trailer enrichment failed:", 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})`);
|
log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -301,69 +328,56 @@ export async function refreshTitle(titleId: string) {
|
|||||||
|
|
||||||
if (title.type === "movie") {
|
if (title.type === "movie") {
|
||||||
const movie = await getMovieDetails(title.tmdbId);
|
const movie = await getMovieDetails(title.tmdbId);
|
||||||
db.update(titles)
|
updateTitleWithArtInvalidation(title, {
|
||||||
.set({
|
title: movie.title,
|
||||||
title: movie.title,
|
originalTitle: movie.original_title,
|
||||||
originalTitle: movie.original_title,
|
overview: movie.overview,
|
||||||
overview: movie.overview,
|
releaseDate: movie.release_date || null,
|
||||||
releaseDate: movie.release_date || null,
|
posterPath: movie.poster_path,
|
||||||
posterPath: movie.poster_path,
|
backdropPath: movie.backdrop_path,
|
||||||
backdropPath: movie.backdrop_path,
|
popularity: movie.popularity,
|
||||||
popularity: movie.popularity,
|
voteAverage: movie.vote_average,
|
||||||
voteAverage: movie.vote_average,
|
voteCount: movie.vote_count,
|
||||||
voteCount: movie.vote_count,
|
status: movie.status,
|
||||||
status: movie.status,
|
contentRating: extractMovieContentRating(movie),
|
||||||
contentRating: extractMovieContentRating(movie),
|
lastFetchedAt: now,
|
||||||
lastFetchedAt: now,
|
});
|
||||||
})
|
|
||||||
.where(eq(titles.id, titleId))
|
|
||||||
.run();
|
|
||||||
upsertGenres(titleId, movie.genres ?? []);
|
upsertGenres(titleId, movie.genres ?? []);
|
||||||
} else {
|
} else {
|
||||||
const show = await getTvDetails(title.tmdbId);
|
const show = await getTvDetails(title.tmdbId);
|
||||||
db.update(titles)
|
updateTitleWithArtInvalidation(title, {
|
||||||
.set({
|
title: show.name,
|
||||||
title: show.name,
|
originalTitle: show.original_name,
|
||||||
originalTitle: show.original_name,
|
overview: show.overview,
|
||||||
overview: show.overview,
|
firstAirDate: show.first_air_date || null,
|
||||||
firstAirDate: show.first_air_date || null,
|
posterPath: show.poster_path,
|
||||||
posterPath: show.poster_path,
|
backdropPath: show.backdrop_path,
|
||||||
backdropPath: show.backdrop_path,
|
popularity: show.popularity,
|
||||||
popularity: show.popularity,
|
voteAverage: show.vote_average,
|
||||||
voteAverage: show.vote_average,
|
voteCount: show.vote_count,
|
||||||
voteCount: show.vote_count,
|
status: show.status,
|
||||||
status: show.status,
|
contentRating: extractTvContentRating(show),
|
||||||
contentRating: extractTvContentRating(show),
|
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||||
tvdbId: show.external_ids?.tvdb_id ?? null,
|
lastFetchedAt: now,
|
||||||
lastFetchedAt: now,
|
});
|
||||||
})
|
|
||||||
.where(eq(titles.id, titleId))
|
|
||||||
.run();
|
|
||||||
upsertGenres(titleId, show.genres ?? []);
|
upsertGenres(titleId, show.genres ?? []);
|
||||||
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
|
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
const updated = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||||
if (updated) {
|
if (updated) {
|
||||||
extractAndStoreColors(updated.id, updated.posterPath).catch((err) =>
|
syncTitleArt(
|
||||||
log.debug("Color extraction failed:", err),
|
updated.id,
|
||||||
);
|
updated.posterPath,
|
||||||
|
updated.backdropPath,
|
||||||
|
updated.type as "movie" | "tv",
|
||||||
|
).catch((err) => log.debug("Cache/thumbhash failed:", err));
|
||||||
refreshTrailer(updated.id).catch((err) =>
|
refreshTrailer(updated.id).catch((err) =>
|
||||||
log.debug("Trailer enrichment failed:", err),
|
log.debug("Trailer enrichment failed:", err),
|
||||||
);
|
);
|
||||||
refreshCredits(updated.id).catch((err) =>
|
refreshCredits(updated.id).catch((err) =>
|
||||||
log.debug("Credits enrichment failed:", 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;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -381,6 +395,14 @@ export async function refreshTvChildren(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const seasonData = await getTvSeasonDetails(tmdbId, sn);
|
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
|
const seasonRow = db
|
||||||
.insert(seasons)
|
.insert(seasons)
|
||||||
@@ -406,6 +428,19 @@ export async function refreshTvChildren(
|
|||||||
.returning()
|
.returning()
|
||||||
.get();
|
.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
|
// Batch all episode upserts in a single transaction per season
|
||||||
const eps = seasonData.episodes ?? [];
|
const eps = seasonData.episodes ?? [];
|
||||||
if (eps.length > 0) {
|
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) {
|
} catch (err) {
|
||||||
// Skip this season and continue with the rest — partial data is
|
// Skip this season and continue with the rest — partial data is
|
||||||
// better than aborting entirely. The next refresh cycle will retry.
|
// 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;
|
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)
|
// Batch prefetch existing titles (1 query)
|
||||||
const tmdbIds = [...new Set(allItems.map((item) => item.result.id))];
|
const tmdbIds = [...uniqueTitles.keys()];
|
||||||
const existingTitles = db
|
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)
|
.from(titles)
|
||||||
.where(inArray(titles.tmdbId, tmdbIds))
|
.where(inArray(titles.tmdbId, tmdbIds))
|
||||||
.all();
|
.all();
|
||||||
|
const existingTitleMap = new Map(existingTitles.map((t) => [t.tmdbId, t]));
|
||||||
const titleIdMap = new Map<number, string>(
|
const titleIdMap = new Map<number, string>(
|
||||||
existingTitles.map((t) => [t.tmdbId, t.id]),
|
existingTitles.map((t) => [t.tmdbId, t.id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Insert missing titles + upsert recommendations in a single transaction
|
// Insert missing titles + upsert recommendations in a single transaction
|
||||||
db.transaction((tx) => {
|
db.transaction((tx) => {
|
||||||
// Insert only new titles
|
|
||||||
const newItems = allItems.filter((item) => !titleIdMap.has(item.result.id));
|
|
||||||
const insertedTmdbIds = new Set<number>();
|
const insertedTmdbIds = new Set<number>();
|
||||||
for (const item of newItems) {
|
for (const item of uniqueTitles.values()) {
|
||||||
if (insertedTmdbIds.has(item.result.id)) continue;
|
const existingTitle = existingTitleMap.get(item.tmdbId);
|
||||||
insertedTmdbIds.add(item.result.id);
|
if (existingTitle) {
|
||||||
const r = item.result;
|
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
|
const row = tx
|
||||||
.insert(titles)
|
.insert(titles)
|
||||||
.values({
|
.values({
|
||||||
tmdbId: r.id,
|
tmdbId: item.tmdbId,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
title: r.title ?? r.name ?? "Unknown",
|
title: item.title,
|
||||||
originalTitle: r.original_title ?? r.original_name,
|
originalTitle: item.originalTitle,
|
||||||
overview: r.overview,
|
overview: item.overview,
|
||||||
releaseDate: r.release_date,
|
releaseDate: item.releaseDate,
|
||||||
firstAirDate: r.first_air_date,
|
firstAirDate: item.firstAirDate,
|
||||||
posterPath: r.poster_path,
|
posterPath: item.posterPath,
|
||||||
backdropPath: r.backdrop_path,
|
backdropPath: item.backdropPath,
|
||||||
popularity: r.popularity,
|
popularity: item.popularity,
|
||||||
voteAverage: r.vote_average,
|
voteAverage: item.voteAverage,
|
||||||
voteCount: r.vote_count,
|
voteCount: item.voteCount,
|
||||||
lastFetchedAt: null,
|
lastFetchedAt: null,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.returning()
|
.returning()
|
||||||
.get();
|
.get();
|
||||||
if (row) titleIdMap.set(r.id, row.id);
|
if (row) titleIdMap.set(item.tmdbId, row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One fallback query for any that conflicted
|
// One fallback query for any that conflicted
|
||||||
@@ -580,6 +723,31 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
.run();
|
.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. */
|
/** Fetch seasons from the DB, building the Season[] structure. */
|
||||||
@@ -610,6 +778,7 @@ function fetchSeasonsFromDb(titleId: string): Season[] {
|
|||||||
name: ep.name,
|
name: ep.name,
|
||||||
overview: ep.overview,
|
overview: ep.overview,
|
||||||
stillPath: tmdbImageUrl(ep.stillPath, "stills"),
|
stillPath: tmdbImageUrl(ep.stillPath, "stills"),
|
||||||
|
stillThumbHash: ep.stillThumbHash,
|
||||||
airDate: ep.airDate,
|
airDate: ep.airDate,
|
||||||
runtimeMinutes: ep.runtimeMinutes,
|
runtimeMinutes: ep.runtimeMinutes,
|
||||||
});
|
});
|
||||||
@@ -639,17 +808,14 @@ export async function ensureTvHydrated(
|
|||||||
if (!title.lastFetchedAt) {
|
if (!title.lastFetchedAt) {
|
||||||
try {
|
try {
|
||||||
const show = await getTvDetails(tmdbId);
|
const show = await getTvDetails(tmdbId);
|
||||||
db.update(titles)
|
updateTitleWithArtInvalidation(title, {
|
||||||
.set({
|
overview: show.overview,
|
||||||
overview: show.overview,
|
posterPath: show.poster_path,
|
||||||
posterPath: show.poster_path,
|
backdropPath: show.backdrop_path,
|
||||||
backdropPath: show.backdrop_path,
|
status: show.status,
|
||||||
status: show.status,
|
contentRating: extractTvContentRating(show),
|
||||||
contentRating: extractTvContentRating(show),
|
lastFetchedAt: new Date(),
|
||||||
lastFetchedAt: new Date(),
|
});
|
||||||
})
|
|
||||||
.where(eq(titles.id, titleId))
|
|
||||||
.run();
|
|
||||||
upsertGenres(titleId, show.genres ?? []);
|
upsertGenres(titleId, show.genres ?? []);
|
||||||
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -686,7 +852,7 @@ async function ensureEnriched(
|
|||||||
title: typeof titles.$inferSelect,
|
title: typeof titles.$inferSelect,
|
||||||
existing: { hasCast: boolean; hasAvailability: boolean },
|
existing: { hasCast: boolean; hasAvailability: boolean },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const tasks: Promise<void>[] = [];
|
const tasks: Promise<unknown>[] = [];
|
||||||
|
|
||||||
if (!existing.hasCast) {
|
if (!existing.hasCast) {
|
||||||
tasks.push(
|
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(
|
tasks.push(
|
||||||
extractAndStoreColors(titleId, title.posterPath).then(
|
syncTitleArt(
|
||||||
() => {},
|
titleId,
|
||||||
(err) => log.debug("Color enrichment failed:", err),
|
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) {
|
if (title.type === "movie" && !title.lastFetchedAt) {
|
||||||
try {
|
try {
|
||||||
const movie = await getMovieDetails(title.tmdbId);
|
const movie = await getMovieDetails(title.tmdbId);
|
||||||
db.update(titles)
|
updateTitleWithArtInvalidation(title, {
|
||||||
.set({
|
title: movie.title,
|
||||||
title: movie.title,
|
originalTitle: movie.original_title,
|
||||||
originalTitle: movie.original_title,
|
overview: movie.overview,
|
||||||
overview: movie.overview,
|
releaseDate: movie.release_date || null,
|
||||||
releaseDate: movie.release_date || null,
|
posterPath: movie.poster_path,
|
||||||
posterPath: movie.poster_path,
|
backdropPath: movie.backdrop_path,
|
||||||
backdropPath: movie.backdrop_path,
|
popularity: movie.popularity,
|
||||||
popularity: movie.popularity,
|
voteAverage: movie.vote_average,
|
||||||
voteAverage: movie.vote_average,
|
voteCount: movie.vote_count,
|
||||||
voteCount: movie.vote_count,
|
status: movie.status,
|
||||||
status: movie.status,
|
contentRating: extractMovieContentRating(movie),
|
||||||
contentRating: extractMovieContentRating(movie),
|
lastFetchedAt: new Date(),
|
||||||
lastFetchedAt: new Date(),
|
});
|
||||||
})
|
|
||||||
.where(eq(titles.id, id))
|
|
||||||
.run();
|
|
||||||
upsertGenres(id, movie.genres ?? []);
|
upsertGenres(id, movie.genres ?? []);
|
||||||
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -849,7 +1020,9 @@ export async function getOrFetchTitle(id: string): Promise<{
|
|||||||
releaseDate: title.releaseDate,
|
releaseDate: title.releaseDate,
|
||||||
firstAirDate: title.firstAirDate,
|
firstAirDate: title.firstAirDate,
|
||||||
posterPath: tmdbImageUrl(title.posterPath, "posters"),
|
posterPath: tmdbImageUrl(title.posterPath, "posters"),
|
||||||
|
posterThumbHash: title.posterThumbHash,
|
||||||
backdropPath: tmdbImageUrl(title.backdropPath, "backdrops"),
|
backdropPath: tmdbImageUrl(title.backdropPath, "backdrops"),
|
||||||
|
backdropThumbHash: title.backdropThumbHash,
|
||||||
popularity: title.popularity,
|
popularity: title.popularity,
|
||||||
voteAverage: title.voteAverage,
|
voteAverage: title.voteAverage,
|
||||||
voteCount: title.voteCount,
|
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<unknown>[] = [];
|
||||||
|
|
||||||
|
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) {
|
function delay(ms: number) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,30 @@ import { persons, titleCast, titles } from "@sofa/db/schema";
|
|||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
|
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
|
||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
|
import { generatePersonThumbHash } from "./thumbhash";
|
||||||
|
|
||||||
const log = createLogger("person");
|
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(
|
export async function getOrFetchPerson(
|
||||||
personId: string,
|
personId: string,
|
||||||
): Promise<ResolvedPerson | null> {
|
): Promise<ResolvedPerson | null> {
|
||||||
@@ -38,6 +59,20 @@ export async function getOrFetchPerson(
|
|||||||
.where(eq(persons.id, personId))
|
.where(eq(persons.id, personId))
|
||||||
.run();
|
.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 {
|
return {
|
||||||
id: person.id,
|
id: person.id,
|
||||||
tmdbId: person.tmdbId,
|
tmdbId: person.tmdbId,
|
||||||
@@ -47,6 +82,7 @@ export async function getOrFetchPerson(
|
|||||||
deathday: details.deathday ?? null,
|
deathday: details.deathday ?? null,
|
||||||
placeOfBirth: details.place_of_birth ?? null,
|
placeOfBirth: details.place_of_birth ?? null,
|
||||||
profilePath: tmdbImageUrl(details.profile_path ?? null, "profiles"),
|
profilePath: tmdbImageUrl(details.profile_path ?? null, "profiles"),
|
||||||
|
profileThumbHash,
|
||||||
knownForDepartment: details.known_for_department ?? null,
|
knownForDepartment: details.known_for_department ?? null,
|
||||||
imdbId: details.imdb_id ?? null,
|
imdbId: details.imdb_id ?? null,
|
||||||
};
|
};
|
||||||
@@ -55,6 +91,8 @@ export async function getOrFetchPerson(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileThumbHash = await ensureProfileThumbHash(person);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: person.id,
|
id: person.id,
|
||||||
tmdbId: person.tmdbId,
|
tmdbId: person.tmdbId,
|
||||||
@@ -64,6 +102,7 @@ export async function getOrFetchPerson(
|
|||||||
deathday: person.deathday,
|
deathday: person.deathday,
|
||||||
placeOfBirth: person.placeOfBirth,
|
placeOfBirth: person.placeOfBirth,
|
||||||
profilePath: tmdbImageUrl(person.profilePath, "profiles"),
|
profilePath: tmdbImageUrl(person.profilePath, "profiles"),
|
||||||
|
profileThumbHash,
|
||||||
knownForDepartment: person.knownForDepartment,
|
knownForDepartment: person.knownForDepartment,
|
||||||
imdbId: person.imdbId,
|
imdbId: person.imdbId,
|
||||||
};
|
};
|
||||||
@@ -108,6 +147,8 @@ export async function getOrFetchPersonByTmdbId(
|
|||||||
row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
||||||
if (!person) return null;
|
if (!person) return null;
|
||||||
|
|
||||||
|
const profileThumbHash = await ensureProfileThumbHash(person);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: person.id,
|
id: person.id,
|
||||||
tmdbId: person.tmdbId,
|
tmdbId: person.tmdbId,
|
||||||
@@ -117,6 +158,7 @@ export async function getOrFetchPersonByTmdbId(
|
|||||||
deathday: person.deathday,
|
deathday: person.deathday,
|
||||||
placeOfBirth: person.placeOfBirth,
|
placeOfBirth: person.placeOfBirth,
|
||||||
profilePath: tmdbImageUrl(person.profilePath, "profiles"),
|
profilePath: tmdbImageUrl(person.profilePath, "profiles"),
|
||||||
|
profileThumbHash,
|
||||||
knownForDepartment: person.knownForDepartment,
|
knownForDepartment: person.knownForDepartment,
|
||||||
imdbId: person.imdbId,
|
imdbId: person.imdbId,
|
||||||
};
|
};
|
||||||
@@ -134,6 +176,7 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
|
|||||||
type: titles.type,
|
type: titles.type,
|
||||||
title: titles.title,
|
title: titles.title,
|
||||||
posterPath: titles.posterPath,
|
posterPath: titles.posterPath,
|
||||||
|
posterThumbHash: titles.posterThumbHash,
|
||||||
releaseDate: titles.releaseDate,
|
releaseDate: titles.releaseDate,
|
||||||
firstAirDate: titles.firstAirDate,
|
firstAirDate: titles.firstAirDate,
|
||||||
voteAverage: titles.voteAverage,
|
voteAverage: titles.voteAverage,
|
||||||
@@ -152,6 +195,7 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
|
|||||||
type: r.type as "movie" | "tv",
|
type: r.type as "movie" | "tv",
|
||||||
title: r.title,
|
title: r.title,
|
||||||
posterPath: tmdbImageUrl(r.posterPath, "posters"),
|
posterPath: tmdbImageUrl(r.posterPath, "posters"),
|
||||||
|
posterThumbHash: r.posterThumbHash,
|
||||||
releaseDate: r.releaseDate,
|
releaseDate: r.releaseDate,
|
||||||
firstAirDate: r.firstAirDate,
|
firstAirDate: r.firstAirDate,
|
||||||
voteAverage: r.voteAverage,
|
voteAverage: r.voteAverage,
|
||||||
@@ -251,6 +295,7 @@ export async function fetchFullFilmography(
|
|||||||
type: c.media_type as "movie" | "tv",
|
type: c.media_type as "movie" | "tv",
|
||||||
title: c.title ?? c.name ?? "Unknown",
|
title: c.title ?? c.name ?? "Unknown",
|
||||||
posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
|
posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
|
||||||
|
posterThumbHash: null,
|
||||||
releaseDate: c.release_date ?? null,
|
releaseDate: c.release_date ?? null,
|
||||||
firstAirDate: c.first_air_date ?? null,
|
firstAirDate: c.first_air_date ?? null,
|
||||||
voteAverage: c.vote_average,
|
voteAverage: c.vote_average,
|
||||||
|
|||||||
@@ -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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
const hash = profilePath
|
||||||
|
? await generateThumbHash(profilePath, "profiles")
|
||||||
|
: null;
|
||||||
|
db.update(persons)
|
||||||
|
.set({ profileThumbHash: hash })
|
||||||
|
.where(eq(persons.id, personId))
|
||||||
|
.run();
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -110,7 +110,9 @@ export const titles = sqliteTable(
|
|||||||
releaseDate: text("releaseDate"),
|
releaseDate: text("releaseDate"),
|
||||||
firstAirDate: text("firstAirDate"),
|
firstAirDate: text("firstAirDate"),
|
||||||
posterPath: text("posterPath"),
|
posterPath: text("posterPath"),
|
||||||
|
posterThumbHash: text("posterThumbHash"),
|
||||||
backdropPath: text("backdropPath"),
|
backdropPath: text("backdropPath"),
|
||||||
|
backdropThumbHash: text("backdropThumbHash"),
|
||||||
popularity: real("popularity"),
|
popularity: real("popularity"),
|
||||||
voteAverage: real("voteAverage"),
|
voteAverage: real("voteAverage"),
|
||||||
voteCount: int("voteCount"),
|
voteCount: int("voteCount"),
|
||||||
@@ -144,6 +146,7 @@ export const seasons = sqliteTable(
|
|||||||
name: text("name"),
|
name: text("name"),
|
||||||
overview: text("overview"),
|
overview: text("overview"),
|
||||||
posterPath: text("posterPath"),
|
posterPath: text("posterPath"),
|
||||||
|
posterThumbHash: text("posterThumbHash"),
|
||||||
airDate: text("airDate"),
|
airDate: text("airDate"),
|
||||||
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
||||||
},
|
},
|
||||||
@@ -166,6 +169,7 @@ export const episodes = sqliteTable(
|
|||||||
name: text("name"),
|
name: text("name"),
|
||||||
overview: text("overview"),
|
overview: text("overview"),
|
||||||
stillPath: text("stillPath"),
|
stillPath: text("stillPath"),
|
||||||
|
stillThumbHash: text("stillThumbHash"),
|
||||||
airDate: text("airDate"),
|
airDate: text("airDate"),
|
||||||
runtimeMinutes: int("runtimeMinutes"),
|
runtimeMinutes: int("runtimeMinutes"),
|
||||||
},
|
},
|
||||||
@@ -339,6 +343,7 @@ export const persons = sqliteTable(
|
|||||||
deathday: text("deathday"),
|
deathday: text("deathday"),
|
||||||
placeOfBirth: text("placeOfBirth"),
|
placeOfBirth: text("placeOfBirth"),
|
||||||
profilePath: text("profilePath"),
|
profilePath: text("profilePath"),
|
||||||
|
profileThumbHash: text("profileThumbHash"),
|
||||||
knownForDepartment: text("knownForDepartment"),
|
knownForDepartment: text("knownForDepartment"),
|
||||||
popularity: real("popularity"),
|
popularity: real("popularity"),
|
||||||
imdbId: text("imdbId"),
|
imdbId: text("imdbId"),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type ImageCategory =
|
|||||||
const IMAGE_BASE_URL =
|
const IMAGE_BASE_URL =
|
||||||
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||||
|
|
||||||
const CATEGORY_SIZES: Record<ImageCategory, string> = {
|
export const IMAGE_CATEGORY_SIZES: Record<ImageCategory, string> = {
|
||||||
posters: "w500",
|
posters: "w500",
|
||||||
backdrops: "w1280",
|
backdrops: "w1280",
|
||||||
stills: "w1280",
|
stills: "w1280",
|
||||||
@@ -16,6 +16,16 @@ const CATEGORY_SIZES: Record<ImageCategory, string> = {
|
|||||||
profiles: "w185",
|
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(
|
export function tmdbImageUrl(
|
||||||
path: string | null,
|
path: string | null,
|
||||||
category: ImageCategory,
|
category: ImageCategory,
|
||||||
@@ -23,10 +33,8 @@ export function tmdbImageUrl(
|
|||||||
) {
|
) {
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
|
|
||||||
const size = sizeOverride ?? CATEGORY_SIZES[category];
|
|
||||||
|
|
||||||
if (process.env.IMAGE_CACHE_ENABLED === "false") {
|
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;
|
const filename = path.startsWith("/") ? path.slice(1) : path;
|
||||||
|
|||||||
Reference in New Issue
Block a user