Migrate web app from Next.js to Vite + TanStack Router SPA (#6)

* Convert to Turborepo monorepo with shared API contract package

Restructure the repository as a monorepo in preparation for adding
future clients (mobile app, CLI). Extract the oRPC contract and Zod
schemas into `@sofa/api` (packages/api/) as a JIT internal package,
and relocate the Next.js app to `@sofa/web` (apps/web/).

- Add Turborepo with Bun workspaces for task orchestration and caching
- Extract `contract.ts` and `schemas.ts` into `@sofa/api` package
- Move all app code, configs, tests, and migrations to `apps/web/`
- Update 17 import paths from `@/lib/orpc/schemas` to `@sofa/api/schemas`
- Add `outputFileTracingRoot` and `transpilePackages` to next.config.ts
- Rewrite Dockerfile with `turbo prune --docker` for efficient builds
- Update CI workflows to use `turbo run` for lint/check-types/test
- Update CLAUDE.md with monorepo structure and commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract standalone Hono API server and split shared packages

Separate all server-side concerns from the Next.js frontend into a new
`apps/server/` Hono app and dedicated shared packages, making `@sofa/web`
a frontend-only app with no direct DB or service access.

- Add `@sofa/server` (`apps/server/`) — Hono API on port 3001 hosting
  oRPC procedures, Better Auth, cron jobs, and non-RPC routes
- Add `@sofa/core` (`packages/core/`) — All 15 business logic services
  moved from `apps/web/lib/services/`; tests moved to `packages/core/test/`
- Add `@sofa/db` (`packages/db/`) — DB client, schema, migrations,
  constants, and logger extracted from `apps/web/lib/db/` and `lib/`
- Add `@sofa/tmdb` (`packages/tmdb/`) — TMDB client and image helpers
  moved from `apps/web/lib/tmdb/`
- Add `@sofa/auth` (`packages/auth/`) — Better Auth server config moved
  from `apps/web/lib/auth/`
- Move oRPC procedures, handler, router, middleware to `apps/server/src/orpc/`
- Move Hono route handlers (avatars, backups, images, lists, webhooks,
  health) to `apps/server/src/routes/`; delete equivalent Next.js API routes
- Strip `apps/web` to frontend-only: no DB imports, no service imports,
  all data via oRPC client calls to the API server
- Add `entrypoint.sh` to start API server, wait for health, then Next.js
- Update `next.config.ts` rewrites to proxy `/rpc/*` and `/api/*` to
  `INTERNAL_API_URL` (default `http://localhost:3001`)
- Update Dockerfile and CLAUDE.md for the new structure

* Migrate web app from Next.js to Vite + TanStack Router SPA and add workspace catalog

Replace Next.js with a pure Vite SPA using TanStack Router for file-based routing,
removing all SSR complexity. The API server (Hono) now serves both API routes and
SPA static files in production, simplifying Docker to a single-process container.

Key changes:
- Vite 7 + @tanstack/react-router with file-based routing via plugin
- Route guards via beforeLoad + authClient.getSession() (replaces server-side auth)
- Route loaders with queryClient.ensureQueryData() (replaces SSR data fetching)
- Self-hosted fonts via @fontsource (replaces next/font/google)
- Tailwind v4 via @tailwindcss/vite (replaces @tailwindcss/postcss)
- Single oRPC client (removed SSR client and server-side session helper)
- Hono serves SPA static files in production (single port 3000)
- Single-process Dockerfile (removed entrypoint.sh)
- Bun workspace catalog for centralized dependency version management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract @sofa/logger and @sofa/config shared packages

- Add `@sofa/logger` (`packages/logger/`) — standalone logger package
  extracted from `@sofa/db/logger`; update all imports across server,
  core, auth, db, and tmdb packages
- Add `@sofa/config` (`packages/config/`) — standalone config/constants
  package extracted from `@sofa/db/constants`; exports `DATA_DIR`,
  `DATABASE_URL`, `CACHE_DIR`, `AVATAR_DIR`, `BACKUP_DIR`
- Move `.env.example` from `apps/web/` to repo root; update server dev
  scripts to load it via `--env-file=../../.env`
- Move image serving from `/api/images` to `/images`; add `serveStatic`
  fast path in `index.ts` for cached files before falling back to the
  TMDB fetch route; add `/images` proxy to Vite dev config
- Fix `Sparkline` component: replace `ResponsiveContainer` with
  `ResizeObserver` to avoid SSR/hydration issues with recharts
- Replace `VITE_SERVER_URL` env var with `window.location.origin` in
  the oRPC client (always same-origin in both dev and production)

* Fix asset caching, SPA 404 fallback, and DATA_DIR resolution

- Add `Cache-Control: immutable` header for hashed `/assets/*` files;
  return 404 for missing asset paths instead of falling back to
  `index.html` (prevents serving stale chunks after deploy)
- Wrap `query.invalidate` in an arrow function in the oRPC QueryClient
  error handler to avoid illegal invocation errors
- Resolve `DATA_DIR` to an absolute path via `path.resolve()` so
  relative paths work regardless of the process working directory

* Migrate @sofa/logger to pino for structured logging

- Replace custom logger implementation in `packages/logger/` with pino
  + pino-pretty; add both as workspace catalog dependencies
- Add `pino` and `pino-pretty` to the workspace catalog in `package.json`
- Fix `log.error()` calls in oRPC and OpenAPI handlers to pass the
  error directly instead of wrapping it in `{ error }` to match pino's
  serializer expectations

* Rename discoverProcedure/statsProcedure exports to discover/stats

* Add TanStackDevtools unified panel and VS Code workspace config

- Replace separate Router/Query devtools with unified `TanStackDevtools`
  from `@tanstack/react-devtools` + `@tanstack/devtools-vite` plugin
- Wrap app in `<StrictMode>` in `main.tsx`
- Add `.vscode/settings.json` (Biome formatter, format-on-save, readonly
  `routeTree.gen.ts`) and `.vscode/extensions.json` (recommended extensions)

* Move test DB helpers to @sofa/db/test-utils and add root bunfig.toml

Extract in-memory SQLite setup and fixture helpers (insertUser, insertTitle,
etc.) from packages/core/test/sqlite.ts into packages/db/src/test-utils.ts
so DB test utilities live alongside the schema they depend on. Use
import.meta.dir for CWD-independent migration path resolution.

Add root bunfig.toml so `bun test` works from the repo root in addition
to `bun run test` (turbo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix devtools plugin order and whitespace-only TMDB token check

Move devtools() to first position in Vite plugins array per TanStack
docs, and trim TMDB_API_READ_ACCESS_TOKEN before boolean coercion so
whitespace-only values are treated as unconfigured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 4.6 authored and GitHub committed 2026-03-10 16:50:34 -04:00
1 parent 26558b29e4
commit a326c968b7
325 files changed
+26970 -28519

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
{
"name": "@sofa/api",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./contract": "./src/contract.ts",
"./schemas": "./src/schemas.ts"
},
"scripts": {
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@orpc/contract": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"typescript": "catalog:"
}
}
+342
View File
@@ -0,0 +1,342 @@
import { oc } from "@orpc/contract";
import { z } from "zod";
import {
AuthConfigOutput,
BackupCreateOutput,
BackupScheduleOutput,
BackupsListOutput,
BatchWatchInput,
ContinueWatchingOutput,
CreateIntegrationInput,
DashboardRecommendationsOutput,
DashboardStatsOutput,
DiscoverInput,
DiscoverOutput,
FilenameParam,
GenresOutput,
HydrateSeasonsOutput,
IdParam,
IntegrationOutput,
IntegrationsListOutput,
IntegrationTokenOutput,
LibraryOutput,
MediaTypeParam,
PersonDetailOutput,
PersonResolveOutput,
PopularOutput,
ProviderParam,
PublicInfoOutput,
QuickAddOutput,
RegistrationOutput,
RestoreBackupInput,
SearchInput,
SearchOutput,
StatsInput,
StatsOutput,
SystemStatusOutput,
TitleDetailOutput,
TitleRecommendationsOutput,
TitleResolveOutput,
TmdbIdTypeParam,
ToggleRegistrationInput,
ToggleUpdateCheckInput,
TrendingOutput,
TrendingTypeParam,
TriggerJobInput,
TriggerJobOutput,
UpdateCheckOutput,
UpdateNameInput,
UpdateRatingInput,
UpdateScheduleInput,
UpdateStatusInput,
UploadAvatarInput,
UploadAvatarOutput,
UserInfoOutput,
} from "./schemas";
export const contract = {
titles: {
detail: oc
.route({ method: "GET", path: "/titles/{id}", tags: ["Titles"] })
.input(IdParam)
.output(TitleDetailOutput),
resolve: oc
.route({ method: "POST", path: "/titles/resolve", tags: ["Titles"] })
.input(TmdbIdTypeParam)
.output(TitleResolveOutput),
updateStatus: oc
.route({ method: "PUT", path: "/titles/{id}/status", tags: ["Titles"] })
.input(UpdateStatusInput)
.output(z.void()),
updateRating: oc
.route({ method: "PUT", path: "/titles/{id}/rating", tags: ["Titles"] })
.input(UpdateRatingInput)
.output(z.void()),
watchMovie: oc
.route({ method: "POST", path: "/titles/{id}/watch", tags: ["Titles"] })
.input(IdParam)
.output(z.void()),
watchAll: oc
.route({
method: "POST",
path: "/titles/{id}/watch-all",
tags: ["Titles"],
})
.input(IdParam)
.output(z.void()),
userInfo: oc
.route({
method: "GET",
path: "/titles/{id}/user-info",
tags: ["Titles"],
})
.input(IdParam)
.output(UserInfoOutput),
recommendations: oc
.route({
method: "GET",
path: "/titles/{id}/recommendations",
tags: ["Titles"],
})
.input(IdParam)
.output(TitleRecommendationsOutput),
hydrateSeasons: oc
.route({
method: "POST",
path: "/titles/{id}/hydrate-seasons",
tags: ["Titles"],
})
.input(z.object({ id: z.string(), tmdbId: z.number().int() }))
.output(HydrateSeasonsOutput),
},
episodes: {
watch: oc
.route({
method: "POST",
path: "/episodes/{id}/watch",
tags: ["Episodes"],
})
.input(IdParam)
.output(z.void()),
unwatch: oc
.route({
method: "POST",
path: "/episodes/{id}/unwatch",
tags: ["Episodes"],
})
.input(IdParam)
.output(z.void()),
batchWatch: oc
.route({
method: "POST",
path: "/episodes/batch-watch",
tags: ["Episodes"],
})
.input(BatchWatchInput)
.output(z.void()),
},
seasons: {
watch: oc
.route({
method: "POST",
path: "/seasons/{id}/watch",
tags: ["Seasons"],
})
.input(IdParam)
.output(z.void()),
unwatch: oc
.route({
method: "POST",
path: "/seasons/{id}/unwatch",
tags: ["Seasons"],
})
.input(IdParam)
.output(z.void()),
},
people: {
detail: oc
.route({ method: "GET", path: "/people/{id}", tags: ["People"] })
.input(IdParam)
.output(PersonDetailOutput),
resolve: oc
.route({ method: "POST", path: "/people/resolve", tags: ["People"] })
.input(z.object({ tmdbId: z.number().int() }))
.output(PersonResolveOutput),
},
dashboard: {
stats: oc
.route({ method: "GET", path: "/dashboard/stats", tags: ["Dashboard"] })
.output(DashboardStatsOutput),
continueWatching: oc
.route({
method: "GET",
path: "/dashboard/continue-watching",
tags: ["Dashboard"],
})
.output(ContinueWatchingOutput),
library: oc
.route({
method: "GET",
path: "/dashboard/library",
tags: ["Dashboard"],
})
.output(LibraryOutput),
recommendations: oc
.route({
method: "GET",
path: "/dashboard/recommendations",
tags: ["Dashboard"],
})
.output(DashboardRecommendationsOutput),
},
explore: {
trending: oc
.route({ method: "GET", path: "/explore/trending", tags: ["Explore"] })
.input(TrendingTypeParam)
.output(TrendingOutput),
popular: oc
.route({ method: "GET", path: "/explore/popular", tags: ["Explore"] })
.input(MediaTypeParam)
.output(PopularOutput),
genres: oc
.route({ method: "GET", path: "/explore/genres", tags: ["Explore"] })
.input(MediaTypeParam)
.output(GenresOutput),
},
search: oc
.route({ method: "GET", path: "/search", tags: ["Search"] })
.input(SearchInput)
.output(SearchOutput),
discover: oc
.route({ method: "GET", path: "/discover", tags: ["Discover"] })
.input(DiscoverInput)
.output(DiscoverOutput),
stats: oc
.route({ method: "GET", path: "/stats", tags: ["Stats"] })
.input(StatsInput)
.output(StatsOutput),
systemStatus: oc
.route({ method: "GET", path: "/system-status", tags: ["System"] })
.output(SystemStatusOutput),
system: {
publicInfo: oc
.route({ method: "GET", path: "/system/public-info", tags: ["System"] })
.output(PublicInfoOutput),
authConfig: oc
.route({ method: "GET", path: "/system/auth-config", tags: ["System"] })
.output(AuthConfigOutput),
},
integrations: {
list: oc
.route({ method: "GET", path: "/integrations", tags: ["Integrations"] })
.output(IntegrationsListOutput),
create: oc
.route({ method: "POST", path: "/integrations", tags: ["Integrations"] })
.input(CreateIntegrationInput)
.output(IntegrationOutput),
delete: oc
.route({
method: "DELETE",
path: "/integrations/{provider}",
tags: ["Integrations"],
})
.input(ProviderParam)
.output(z.void()),
regenerateToken: oc
.route({
method: "POST",
path: "/integrations/{provider}/regenerate-token",
tags: ["Integrations"],
})
.input(ProviderParam)
.output(IntegrationTokenOutput),
},
admin: {
backups: {
list: oc
.route({ method: "GET", path: "/admin/backups", tags: ["Admin"] })
.output(BackupsListOutput),
create: oc
.route({ method: "POST", path: "/admin/backups", tags: ["Admin"] })
.input(z.void())
.output(BackupCreateOutput),
delete: oc
.route({
method: "DELETE",
path: "/admin/backups/{filename}",
tags: ["Admin"],
})
.input(FilenameParam)
.output(z.void()),
restore: oc
.route({
method: "POST",
path: "/admin/backups/restore",
tags: ["Admin"],
})
.input(RestoreBackupInput)
.output(z.void()),
schedule: oc
.route({
method: "GET",
path: "/admin/backups/schedule",
tags: ["Admin"],
})
.output(BackupScheduleOutput),
updateSchedule: oc
.route({
method: "PUT",
path: "/admin/backups/schedule",
tags: ["Admin"],
})
.input(UpdateScheduleInput)
.output(z.void()),
},
registration: oc
.route({ method: "GET", path: "/admin/registration", tags: ["Admin"] })
.output(RegistrationOutput),
toggleRegistration: oc
.route({ method: "PUT", path: "/admin/registration", tags: ["Admin"] })
.input(ToggleRegistrationInput)
.output(z.void()),
updateCheck: oc
.route({ method: "GET", path: "/admin/update-check", tags: ["Admin"] })
.output(UpdateCheckOutput),
toggleUpdateCheck: oc
.route({ method: "PUT", path: "/admin/update-check", tags: ["Admin"] })
.input(ToggleUpdateCheckInput)
.output(z.void()),
triggerJob: oc
.route({
method: "POST",
path: "/admin/jobs/trigger",
tags: ["Admin"],
})
.input(TriggerJobInput)
.output(TriggerJobOutput),
},
account: {
updateName: oc
.route({ method: "PUT", path: "/account/name", tags: ["Account"] })
.input(UpdateNameInput)
.output(z.void()),
uploadAvatar: oc
.route({ method: "POST", path: "/account/avatar", tags: ["Account"] })
.input(UploadAvatarInput)
.output(UploadAvatarOutput),
removeAvatar: oc
.route({ method: "DELETE", path: "/account/avatar", tags: ["Account"] })
.input(z.void())
.output(z.void()),
},
watchlist: {
quickAdd: oc
.route({
method: "POST",
path: "/watchlist/quick-add",
tags: ["Watchlist"],
})
.input(TmdbIdTypeParam)
.output(QuickAddOutput),
},
};
+607
View File
@@ -0,0 +1,607 @@
import { z } from "zod";
// ─── Shared input schemas ─────────────────────────────────────
export const IdParam = z.object({ id: z.string() });
export const ProviderParam = z.object({
provider: z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]),
});
export const FilenameParam = z.object({ filename: z.string() });
export const MediaTypeParam = z.object({
type: z.enum(["movie", "tv"]),
});
export const TrendingTypeParam = z.object({
type: z.enum(["all", "movie", "tv"]),
});
export const TmdbIdTypeParam = z.object({
tmdbId: z.number().int(),
type: z.enum(["movie", "tv"]),
});
// ─── Title inputs ──────────────────────────────────────────────
export const UpdateStatusInput = z.object({
id: z.string(),
status: z.enum(["in_progress"]).nullable(),
});
export const UpdateRatingInput = z.object({
id: z.string(),
stars: z.number().int().min(0).max(5),
});
export const BatchWatchInput = z.object({
episodeIds: z.array(z.string()).min(1),
});
// ─── Search / Discover inputs ──────────────────────────────────
export const SearchInput = z.object({
query: z.string().min(1),
type: z.enum(["movie", "tv", "person"]).optional(),
});
export const DiscoverInput = z.object({
mediaType: z.enum(["movie", "tv"]),
genreId: z.number().int(),
});
// ─── Stats input ───────────────────────────────────────────────
export const StatsInput = z.object({
type: z.enum(["movies", "episodes"]),
period: z.enum(["today", "this_week", "this_month", "this_year"]),
});
// ─── Integration inputs ────────────────────────────────────────
export const CreateIntegrationInput = z.object({
provider: z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]),
enabled: z.boolean().optional(),
});
// ─── Admin inputs ──────────────────────────────────────────────
export const ToggleRegistrationInput = z.object({ open: z.boolean() });
export const ToggleUpdateCheckInput = z.object({ enabled: z.boolean() });
export const TriggerJobInput = z.object({ name: z.string() });
export const UpdateScheduleInput = z.object({
enabled: z.boolean().optional(),
frequency: z.enum(["6h", "12h", "1d", "7d"]).optional(),
time: z
.string()
.regex(/^\d{2}:\d{2}$/, "Invalid time format")
.refine(
(t) => {
const [h, m] = t.split(":").map(Number);
return h >= 0 && h <= 23 && m >= 0 && m <= 59;
},
{ message: "Invalid time value" },
)
.optional(),
dayOfWeek: z.number().int().min(0).max(6).optional(),
maxRetention: z
.number()
.int()
.refine((n) => n === 0 || (n >= 1 && n <= 30), {
message: "Max backups must be between 1 and 30, or 0 for unlimited",
})
.optional(),
});
// ─── Account inputs ────────────────────────────────────────────
export const UpdateNameInput = z.object({ name: z.string().min(1) });
export const UploadAvatarInput = z
.file()
.mime(["image/jpeg", "image/png", "image/webp", "image/gif"])
.max(2 * 1024 * 1024, "File too large (max 2MB)");
export const UploadAvatarOutput = z.object({ imageUrl: z.string() });
// ─── Backup inputs ─────────────────────────────────────────────
export const RestoreBackupInput = z
.file()
.max(100 * 1024 * 1024, "File too large (max 100 MB)");
// ═══════════════════════════════════════════════════════════════
// Output schemas
// ═══════════════════════════════════════════════════════════════
// ─── Shared primitives ─────────────────────────────────────────
const mediaType = z.enum(["movie", "tv"]);
const ColorPaletteSchema = z.object({
vibrant: z.string().nullable(),
darkVibrant: z.string().nullable(),
lightVibrant: z.string().nullable(),
muted: z.string().nullable(),
darkMuted: z.string().nullable(),
lightMuted: z.string().nullable(),
});
const EpisodeSchema = z.object({
id: z.string(),
episodeNumber: z.number(),
name: z.string().nullable(),
overview: z.string().nullable(),
stillPath: z.string().nullable(),
airDate: z.string().nullable(),
runtimeMinutes: z.number().nullable(),
});
const SeasonSchema = z.object({
id: z.string(),
seasonNumber: z.number(),
name: z.string().nullable(),
episodes: z.array(EpisodeSchema),
});
const AvailabilityOfferSchema = z.object({
providerId: z.number(),
providerName: z.string(),
logoPath: z.string().nullable(),
offerType: z.string(),
watchUrl: z.string().nullable(),
});
const CastMemberSchema = z.object({
id: z.string(),
personId: z.string(),
name: z.string(),
character: z.string().nullable(),
department: z.string(),
job: z.string().nullable(),
displayOrder: z.number(),
episodeCount: z.number().nullable(),
profilePath: z.string().nullable(),
tmdbId: z.number(),
});
const ResolvedTitleSchema = z.object({
id: z.string(),
tmdbId: z.number(),
type: mediaType,
title: z.string(),
originalTitle: z.string().nullable(),
overview: z.string().nullable(),
releaseDate: z.string().nullable(),
firstAirDate: z.string().nullable(),
posterPath: z.string().nullable(),
backdropPath: z.string().nullable(),
popularity: z.number().nullable(),
voteAverage: z.number().nullable(),
voteCount: z.number().nullable(),
status: z.string().nullable(),
contentRating: z.string().nullable(),
colorPalette: ColorPaletteSchema.nullable(),
trailerVideoKey: z.string().nullable(),
genres: z.array(z.string()),
});
const PersonSchema = z.object({
id: z.string(),
tmdbId: z.number(),
name: z.string(),
biography: z.string().nullable(),
birthday: z.string().nullable(),
deathday: z.string().nullable(),
placeOfBirth: z.string().nullable(),
profilePath: z.string().nullable(),
knownForDepartment: z.string().nullable(),
imdbId: z.string().nullable(),
});
const PersonCreditSchema = z.object({
titleId: z.string(),
tmdbId: z.number(),
type: mediaType,
title: z.string(),
posterPath: z.string().nullable(),
releaseDate: z.string().nullable(),
firstAirDate: z.string().nullable(),
voteAverage: z.number().nullable(),
character: z.string().nullable(),
department: z.string(),
job: z.string().nullable(),
});
/** Reusable TMDB browse result (trending / popular / discover items) */
const TmdbBrowseItem = z.object({
tmdbId: z.number(),
type: mediaType,
title: z.string(),
posterPath: z.string().nullable(),
releaseDate: z.string().nullable(),
voteAverage: z.number(),
});
const userStatusMap = z.record(
z.string(),
z.enum(["watchlist", "in_progress", "completed"]),
);
const episodeProgressMap = z.record(
z.string(),
z.object({ watched: z.number(), total: z.number() }),
);
/** Standard browse response shape (popular, discover) */
const BrowseOutput = z.object({
items: z.array(TmdbBrowseItem),
userStatuses: userStatusMap,
episodeProgress: episodeProgressMap,
});
// ─── Title outputs ─────────────────────────────────────────────
export const TitleDetailOutput = z.object({
title: ResolvedTitleSchema,
seasons: z.array(SeasonSchema),
needsHydration: z.boolean(),
availability: z.array(AvailabilityOfferSchema),
cast: z.array(CastMemberSchema),
});
export const TitleResolveOutput = z.object({ id: z.string() });
export const UserInfoOutput = z.object({
status: z.enum(["watchlist", "in_progress", "completed"]).nullable(),
rating: z.number().nullable(),
episodeWatches: z.array(z.string()),
});
export const TitleRecommendationsOutput = z.object({
recommendations: z.array(
z.object({
id: z.string(),
tmdbId: z.number(),
type: mediaType,
title: z.string(),
posterPath: z.string().nullable(),
releaseDate: z.string().nullable(),
firstAirDate: z.string().nullable(),
voteAverage: z.number().nullable(),
}),
),
userStatuses: userStatusMap,
});
// ─── People outputs ────────────────────────────────────────────
export const PersonDetailOutput = z.object({
person: PersonSchema,
filmography: z.array(PersonCreditSchema),
userStatuses: userStatusMap,
});
export const PersonResolveOutput = z.object({ id: z.string() });
// ─── Dashboard outputs ─────────────────────────────────────────
export const DashboardStatsOutput = z.object({
moviesThisMonth: z.number(),
episodesThisWeek: z.number(),
librarySize: z.number(),
completed: z.number(),
});
export const ContinueWatchingOutput = z.object({
items: z.array(
z.object({
title: z.object({
id: z.string(),
title: z.string(),
backdropPath: z.string().nullable(),
}),
nextEpisode: z
.object({
seasonNumber: z.number(),
episodeNumber: z.number(),
name: z.string().nullable(),
stillPath: z.string().nullable(),
})
.nullable(),
totalEpisodes: z.number(),
watchedEpisodes: z.number(),
}),
),
});
export const LibraryOutput = z.object({
items: z.array(
z.object({
id: z.string(),
tmdbId: z.number(),
type: mediaType,
title: z.string(),
posterPath: z.string().nullable(),
releaseDate: z.string().nullable(),
voteAverage: z.number().nullable(),
userStatus: z.enum(["watchlist", "in_progress", "completed"]).nullable(),
}),
),
});
export const DashboardRecommendationsOutput = z.object({
items: z.array(
z.object({
id: z.string(),
tmdbId: z.number(),
type: mediaType,
title: z.string(),
posterPath: z.string().nullable(),
releaseDate: z.string().nullable(),
voteAverage: z.number().nullable(),
}),
),
});
// ─── Explore outputs ───────────────────────────────────────────
export const TrendingOutput = z.object({
items: z.array(TmdbBrowseItem),
hero: z
.object({
tmdbId: z.number(),
type: mediaType,
title: z.string(),
overview: z.string(),
backdropPath: z.string().nullable(),
voteAverage: z.number(),
})
.nullable(),
userStatuses: userStatusMap,
episodeProgress: episodeProgressMap,
});
export const PopularOutput = BrowseOutput;
export const GenresOutput = z.object({
genres: z.array(z.object({ id: z.number(), name: z.string() })),
});
// ─── Search output ─────────────────────────────────────────────
export const SearchOutput = z.object({
results: z.array(
z.object({
tmdbId: z.number(),
type: z.enum(["movie", "tv", "person"]),
title: z.string(),
overview: z.string().optional(),
posterPath: z.string().nullable().optional(),
profilePath: z.string().nullable().optional(),
releaseDate: z.string().nullable().optional(),
popularity: z.number().optional(),
voteAverage: z.number().optional(),
knownForDepartment: z.string().optional(),
knownFor: z.array(z.string()).optional(),
}),
),
});
// ─── Discover output ───────────────────────────────────────────
export const DiscoverOutput = BrowseOutput;
// ─── Stats output ──────────────────────────────────────────────
export const StatsOutput = z.object({
count: z.number(),
history: z.array(z.object({ bucket: z.string(), count: z.number() })),
});
// ─── System status output ──────────────────────────────────────
const JobSchema = z.object({
jobName: z.string(),
cronPattern: z.string().nullable(),
nextRunAt: z.string().nullable(),
lastRunAt: z.string().nullable(),
lastDurationMs: z.number().nullable(),
lastStatus: z.enum(["running", "success", "error"]).nullable(),
lastError: z.string().nullable(),
isCurrentlyRunning: z.boolean(),
disabled: z.boolean(),
});
const SystemHealthSchema = z.object({
database: z.object({
dbSizeBytes: z.number(),
walSizeBytes: z.number(),
titleCount: z.number(),
episodeCount: z.number(),
userCount: z.number(),
}),
tmdb: z.object({
connected: z.boolean(),
tokenValid: z.boolean(),
tokenConfigured: z.boolean(),
responseTimeMs: z.number().nullable(),
error: z.string().nullable(),
}),
jobs: z.array(JobSchema),
imageCache: z.object({
enabled: z.boolean(),
totalSizeBytes: z.number(),
imageCount: z.number(),
categories: z.record(
z.string(),
z.object({ count: z.number(), sizeBytes: z.number() }),
),
}),
backups: z.object({
lastBackupAt: z.string().nullable(),
lastBackupAgeHours: z.number().nullable(),
backupCount: z.number(),
totalSizeBytes: z.number(),
}),
environment: z.object({
dataDir: z.string(),
dataDirWritable: z.boolean(),
envVars: z.array(
z.object({ name: z.string(), value: z.string().nullable() }),
),
}),
checkedAt: z.string(),
});
export const SystemStatusOutput = z.object({
tmdbConfigured: z.boolean(),
health: SystemHealthSchema.optional(),
});
// ─── Integration outputs ───────────────────────────────────────
const IntegrationSchema = z.object({
id: z.string(),
provider: z.string(),
type: z.enum(["webhook", "list"]),
token: z.string(),
enabled: z.boolean(),
lastEventAt: z.string().nullable(),
createdAt: z.string(),
});
const IntegrationEventSchema = z.object({
id: z.string(),
eventType: z.string().nullable(),
mediaType: z.string().nullable(),
mediaTitle: z.string().nullable(),
status: z.enum(["success", "ignored", "error"]),
receivedAt: z.string(),
});
export const IntegrationsListOutput = z.object({
integrations: z.array(
IntegrationSchema.extend({
recentEvents: z.array(IntegrationEventSchema),
}),
),
});
export const IntegrationOutput = IntegrationSchema;
export const IntegrationTokenOutput = IntegrationSchema;
// ─── Admin outputs ─────────────────────────────────────────────
const BackupSchema = z.object({
filename: z.string(),
sizeBytes: z.number(),
createdAt: z.string(),
source: z.enum(["manual", "scheduled", "pre-restore"]),
});
export const BackupsListOutput = z.object({
backups: z.array(BackupSchema),
});
export const BackupCreateOutput = BackupSchema;
export const BackupScheduleOutput = z.object({
enabled: z.boolean(),
maxRetention: z.number(),
frequency: z.string(),
time: z.string(),
dayOfWeek: z.number(),
});
export const RegistrationOutput = z.object({ open: z.boolean() });
export const UpdateCheckOutput = z.object({
enabled: z.boolean(),
updateCheck: z
.object({
updateAvailable: z.boolean(),
currentVersion: z.string(),
latestVersion: z.string().nullable(),
releaseUrl: z.string().nullable(),
lastCheckedAt: z.string().nullable(),
})
.nullable(),
});
export const TriggerJobOutput = z.object({ ok: z.literal(true) });
// ─── Watchlist outputs ─────────────────────────────────────────
export const QuickAddOutput = z.object({
id: z.string(),
alreadyAdded: z.boolean(),
});
// ─── System outputs (new) ─────────────────────────────────────
export const PublicInfoOutput = z.object({
tmdbConfigured: z.boolean(),
userCount: z.number(),
registrationOpen: z.boolean(),
posterUrls: z.array(z.string()),
});
export const AuthConfigOutput = z.object({
oidcEnabled: z.boolean(),
oidcProviderName: z.string().nullable(),
passwordLoginDisabled: z.boolean(),
registrationOpen: z.boolean(),
userCount: z.number(),
});
// ─── Title hydrate seasons output ─────────────────────────────
export const HydrateSeasonsOutput = z.object({
seasons: z.array(SeasonSchema),
});
// ─── Types used by web app (moved from services) ─────────────
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
export type BackupInfo = {
filename: string;
sizeBytes: number;
createdAt: string;
source: "manual" | "scheduled" | "pre-restore";
};
export type SystemHealthData = z.infer<typeof SystemHealthSchema>;
// ═══════════════════════════════════════════════════════════════
// Inferred types — use these instead of hand-written interfaces
// ═══════════════════════════════════════════════════════════════
export type Episode = z.infer<typeof EpisodeSchema>;
export type Season = z.infer<typeof SeasonSchema>;
export type AvailabilityOffer = z.infer<typeof AvailabilityOfferSchema>;
export type CastMember = z.infer<typeof CastMemberSchema>;
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
export type ResolvedTitle = z.infer<typeof ResolvedTitleSchema>;
export type ResolvedPerson = z.infer<typeof PersonSchema>;
export type PersonCredit = z.infer<typeof PersonCreditSchema>;
export type UpdateCheckResult = {
updateAvailable: boolean;
currentVersion: string;
latestVersion: string | null;
releaseUrl: string | null;
lastCheckedAt: string | null;
};
export type TimePeriod = "today" | "this_week" | "this_month" | "this_year";
export interface HistoryBucket {
bucket: string;
count: number;
}
export interface DashboardStats {
moviesThisMonth: number;
episodesThisWeek: number;
librarySize: number;
completed: number;
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src"]
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@sofa/auth",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./server": "./src/server.ts",
"./config": "./src/config.ts"
},
"scripts": {
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@better-auth/drizzle-adapter": "1.5.4",
"@sofa/core": "workspace:*",
"@sofa/db": "workspace:*",
"@sofa/logger": "workspace:*",
"better-auth": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Server-side configuration checks.
* Call these in route handlers or server components — never on the client.
*/
export function isOidcConfigured(): boolean {
return !!(
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_ISSUER_URL
);
}
export function getOidcProviderName(): string {
return process.env.OIDC_PROVIDER_NAME || "SSO";
}
export function isOidcAutoRegisterEnabled(): boolean {
return process.env.OIDC_AUTO_REGISTER !== "false";
}
export function isPasswordLoginDisabled(): boolean {
return process.env.DISABLE_PASSWORD_LOGIN === "true" && isOidcConfigured();
}
+124
View File
@@ -0,0 +1,124 @@
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import {
getUserCount,
isRegistrationOpen,
setSetting,
} from "@sofa/core/settings";
import { db } from "@sofa/db/client";
import { createLogger } from "@sofa/logger";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { type BetterAuthOptions, betterAuth } from "better-auth/minimal";
import { admin, genericOAuth } from "better-auth/plugins";
import {
isOidcAutoRegisterEnabled,
isOidcConfigured,
isPasswordLoginDisabled,
} from "./config";
const authLog = createLogger("auth");
export const auth = betterAuth({
logger: {
// Suppress unset secret/low entropy warnings during build
disabled: process.env.NEXT_PHASE === "phase-production-build",
level: "debug",
log: (level, message, ...args) => {
const fn = authLog[level as keyof typeof authLog];
if (fn) fn(message, ...args);
},
},
database: drizzleAdapter(db, {
provider: "sqlite",
}),
emailAndPassword: {
enabled: !isPasswordLoginDisabled(),
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ["oidc"],
},
},
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60,
strategy: "jwt",
},
},
plugins: [
admin(),
...(isOidcConfigured()
? [
genericOAuth({
config: [
{
providerId: "oidc",
clientId: process.env.OIDC_CLIENT_ID ?? "",
clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
discoveryUrl: `${process.env.OIDC_ISSUER_URL}/.well-known/openid-configuration`,
scopes: ["openid", "email", "profile"],
pkce: true,
disableImplicitSignUp: !isOidcAutoRegisterEnabled(),
mapProfileToUser: (profile) => ({
name:
profile.name || profile.preferred_username || profile.email,
}),
},
],
}),
]
: []),
// NOTE: nextCookies() removed — Better Auth works with standard
// Request/Response natively on Hono. The web app proxies auth
// requests to the API server.
],
advanced: {
database: {
generateId: () => Bun.randomUUIDv7(),
},
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
// Block email/password sign-up when registration is closed.
// This is endpoint-level so it doesn't affect OIDC user creation
// (which is gated by the genericOAuth plugin's disableImplicitSignUp).
if (ctx.path === "/sign-up/email") {
const open = isRegistrationOpen();
if (!open) {
throw new APIError("FORBIDDEN", {
message: "Registration is currently closed",
});
}
}
}),
},
databaseHooks: {
user: {
create: {
before: async (user) => {
// First user becomes admin regardless of auth method
const userCount = getUserCount();
if (userCount === 0) {
return {
data: {
...user,
role: "admin",
},
};
}
return { data: user };
},
after: async () => {
// Auto-close registration after first user
const userCount = getUserCount();
if (userCount === 1) {
setSetting("registrationOpen", "false");
}
},
},
},
},
} satisfies BetterAuthOptions);
export type Session = typeof auth.$Infer.Session;
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@sofa/config",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
// ─── Paths ───────────────────────────────────────────────────
export const DATA_DIR = path.resolve(process.env.DATA_DIR || "./data");
export const DATABASE_URL =
process.env.DATABASE_URL || path.join(DATA_DIR, "sqlite.db");
export const CACHE_DIR = process.env.CACHE_DIR
? path.join(process.env.CACHE_DIR, "images")
: path.join(DATA_DIR, "images");
export const BACKUP_DIR = path.join(DATA_DIR, "backups");
export const AVATAR_DIR = path.join(DATA_DIR, "avatars");
// ─── TMDB ────────────────────────────────────────────────────
export const TMDB_API_BASE_URL =
process.env.TMDB_API_BASE_URL || "https://api.themoviedb.org/3";
export const TMDB_IMAGE_BASE_URL =
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./test/preload.ts"]
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@sofa/core",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./availability": "./src/availability.ts",
"./backup": "./src/backup.ts",
"./colors": "./src/colors.ts",
"./credits": "./src/credits.ts",
"./discovery": "./src/discovery.ts",
"./image-cache": "./src/image-cache.ts",
"./lists": "./src/lists.ts",
"./metadata": "./src/metadata.ts",
"./person": "./src/person.ts",
"./providers": "./src/providers.ts",
"./settings": "./src/settings.ts",
"./system-health": "./src/system-health.ts",
"./tracking": "./src/tracking.ts",
"./update-check": "./src/update-check.ts",
"./webhooks": "./src/webhooks.ts"
},
"scripts": {
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit",
"test": "bun test"
},
"dependencies": {
"@sofa/api": "workspace:*",
"@sofa/config": "workspace:*",
"@sofa/db": "workspace:*",
"@sofa/logger": "workspace:*",
"@sofa/tmdb": "workspace:*",
"date-fns": "catalog:",
"node-vibrant": "4.0.4",
"zod": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { availabilityOffers, titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getWatchProviders } from "@sofa/tmdb/client";
const log = createLogger("availability");
export async function refreshAvailability(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
const data = await getWatchProviders(title.tmdbId, title.type);
const us = data.results?.US;
if (!us) {
log.debug(`No US providers for title ${titleId}`);
return;
}
const now = new Date();
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
// Collect all offer rows, then batch insert in a single transaction
const allOfferRows: (typeof availabilityOffers.$inferInsert)[] = [];
for (const offerType of offerTypes) {
const providers = us[offerType];
if (!providers) continue;
for (const p of providers) {
allOfferRows.push({
titleId,
region: "US",
providerId: p.provider_id,
providerName: p.provider_name ?? "",
logoPath: p.logo_path ?? "",
offerType,
link: us.link ?? null,
lastFetchedAt: now,
});
}
}
db.transaction((tx) => {
tx.delete(availabilityOffers)
.where(
and(
eq(availabilityOffers.titleId, titleId),
eq(availabilityOffers.region, "US"),
),
)
.run();
if (allOfferRows.length > 0) {
tx.insert(availabilityOffers)
.values(allOfferRows)
.onConflictDoNothing()
.run();
}
});
const total = offerTypes.reduce((n, t) => n + (us[t]?.length ?? 0), 0);
log.debug(`Refreshed availability for title ${titleId}: ${total} offers`);
}
export function getAvailability(titleId: string) {
return db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
.all();
}
+275
View File
@@ -0,0 +1,275 @@
import { Database } from "bun:sqlite";
import { renameSync, unlinkSync } from "node:fs";
import { mkdir, readdir } from "node:fs/promises";
import path from "node:path";
import { BACKUP_DIR, DATABASE_URL } from "@sofa/config";
import { closeDatabase, db } from "@sofa/db/client";
import { sql } from "@sofa/db/helpers";
import { runMigrations } from "@sofa/db/migrate";
import { createLogger } from "@sofa/logger";
import { format } from "date-fns";
const log = createLogger("backup");
const MANUAL_PATTERN = /^sofa-manual-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
const SCHEDULED_PATTERN =
/^sofa-scheduled-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
const PRE_RESTORE_PATTERN =
/^pre-restore-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
export type BackupSource = "manual" | "scheduled" | "pre-restore";
type BackupPrefix = "sofa-manual" | "sofa-scheduled" | "pre-restore";
export interface BackupInfo {
filename: string;
sizeBytes: number;
createdAt: string;
source: BackupSource;
}
const REQUIRED_TABLES = [
"account",
"appSettings",
"availabilityOffers",
"cronRuns",
"episodes",
"seasons",
"session",
"titleRecommendations",
"titles",
"user",
"userEpisodeWatches",
"userMovieWatches",
"userRatings",
"userTitleStatus",
"verification",
"integrations",
"integrationEvents",
] as const;
let backupOpQueue: Promise<void> = Promise.resolve();
/** @internal */
export function getBackupSource(filename: string): BackupSource {
if (SCHEDULED_PATTERN.test(filename)) return "scheduled";
if (PRE_RESTORE_PATTERN.test(filename)) return "pre-restore";
return "manual";
}
/** @internal */
export function isKnownBackup(filename: string): boolean {
return (
MANUAL_PATTERN.test(filename) ||
SCHEDULED_PATTERN.test(filename) ||
PRE_RESTORE_PATTERN.test(filename)
);
}
export async function ensureBackupDir() {
await mkdir(BACKUP_DIR, { recursive: true });
}
async function withBackupLock<T>(fn: () => Promise<T>): Promise<T> {
const previous = backupOpQueue;
let release: (() => void) | undefined;
backupOpQueue = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await fn();
} finally {
release?.();
}
}
function unlinkIfExistsSync(filePath: string): void {
try {
unlinkSync(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
}
function validateBackupDatabase(filePath: string): void {
const testDb = new Database(filePath, { readonly: true });
try {
const integrityRows = testDb.query("PRAGMA integrity_check").all() as {
integrity_check: string;
}[];
if (
integrityRows.length === 0 ||
integrityRows.some((row) => row.integrity_check !== "ok")
) {
throw new Error("Database integrity check failed");
}
const foreignKeyErrors = testDb.query("PRAGMA foreign_key_check").all();
if (foreignKeyErrors.length > 0) {
throw new Error("Database foreign key check failed");
}
const tableRows = testDb
.query("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[];
const tableSet = new Set(tableRows.map((row) => row.name));
const missing = REQUIRED_TABLES.filter((table) => !tableSet.has(table));
if (missing.length > 0) {
throw new Error(
`Invalid backup: missing required tables (${missing.join(", ")})`,
);
}
} finally {
testDb.close();
}
}
/** @internal */
export function isValidBackupFilename(filename: string): boolean {
const base = path.basename(filename);
return (
base === filename && !filename.includes("..") && isKnownBackup(filename)
);
}
async function createBackupInternal(prefix: BackupPrefix): Promise<BackupInfo> {
await ensureBackupDir();
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmssSSS");
const filename = `${prefix}-${timestamp}.db`;
const dest = path.join(BACKUP_DIR, filename);
// VACUUM INTO atomically creates a clean, self-contained copy (safe for WAL mode)
db.run(sql.raw(`VACUUM INTO '${dest.replace(/'/g, "''")}'`));
const s = await Bun.file(dest).stat();
log.info(`Created backup: ${filename} (${s.size} bytes)`);
return {
filename,
sizeBytes: s.size,
createdAt: s.mtime.toISOString(),
source: getBackupSource(filename),
};
}
export async function createBackup(
prefix: BackupPrefix = "sofa-manual",
): Promise<BackupInfo> {
return withBackupLock(async () => createBackupInternal(prefix));
}
export async function listBackups(): Promise<BackupInfo[]> {
await ensureBackupDir();
const files = (await readdir(BACKUP_DIR)).filter((f) => isKnownBackup(f));
const results: BackupInfo[] = [];
for (const filename of files) {
const s = await Bun.file(path.join(BACKUP_DIR, filename)).stat();
results.push({
filename,
sizeBytes: s.size,
createdAt: s.mtime.toISOString(),
source: getBackupSource(filename),
});
}
return results.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
async function deleteBackupInternal(filename: string): Promise<void> {
if (!isValidBackupFilename(filename)) {
throw new Error("Invalid backup filename");
}
const filePath = path.join(BACKUP_DIR, filename);
if (!(await Bun.file(filePath).exists())) {
throw new Error("Backup not found");
}
await Bun.file(filePath).delete();
log.info(`Deleted backup: ${filename}`);
}
export async function deleteBackup(filename: string): Promise<void> {
await withBackupLock(async () => deleteBackupInternal(filename));
}
export async function getBackupPath(filename: string): Promise<string | null> {
if (!isValidBackupFilename(filename)) return null;
const filePath = path.join(BACKUP_DIR, filename);
return (await Bun.file(filePath).exists()) ? filePath : null;
}
export async function readBackupFile(filename: string): Promise<Buffer | null> {
const filePath = await getBackupPath(filename);
if (!filePath) return null;
return Buffer.from(await Bun.file(filePath).arrayBuffer());
}
export async function restoreFromBackup(
source: Buffer | string,
): Promise<void> {
await withBackupLock(async () => {
await ensureBackupDir();
const dbDir = path.dirname(DATABASE_URL);
await mkdir(dbDir, { recursive: true });
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmssSSS");
const tempPath = path.join(
dbDir,
`.restore-temp-${timestamp}-${crypto.randomUUID()}.db`,
);
try {
if (typeof source === "string") {
renameSync(source, tempPath);
} else {
await Bun.write(tempPath, source);
}
validateBackupDatabase(tempPath);
log.info("Creating pre-restore safety backup...");
await createBackupInternal("pre-restore");
// Keep the close+replace window synchronous to avoid event-loop interleaving.
log.info("Replacing database...");
closeDatabase();
renameSync(tempPath, DATABASE_URL);
unlinkIfExistsSync(`${DATABASE_URL}-wal`);
unlinkIfExistsSync(`${DATABASE_URL}-shm`);
// Ensure restored backups from older app versions are brought up-to-date.
runMigrations();
log.info("Database restored successfully");
} finally {
const tempFile = Bun.file(tempPath);
if (await tempFile.exists()) await tempFile.delete();
}
});
}
export async function pruneBackups(maxKeep: number): Promise<void> {
await withBackupLock(async () => {
const backups = (await listBackups()).filter((b) =>
SCHEDULED_PATTERN.test(b.filename),
);
if (maxKeep === 0 || backups.length <= maxKeep) return;
const toDelete = backups.slice(maxKeep);
for (const backup of toDelete) {
await deleteBackupInternal(backup.filename);
}
log.info(`Pruned ${toDelete.length} old backup(s), kept ${maxKeep}`);
});
}
+82
View File
@@ -0,0 +1,82 @@
import path from "node:path";
import { db } from "@sofa/db/client";
import { eq } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { Vibrant } from "node-vibrant/node";
import {
downloadAndCacheImage,
getLocalImagePath,
imageCacheEnabled,
isImageCached,
} from "./image-cache";
const log = createLogger("colors");
export interface ColorPalette {
vibrant: string | null;
darkVibrant: string | null;
lightVibrant: string | null;
muted: string | null;
darkMuted: string | null;
lightMuted: string | null;
}
export async function extractAndStoreColors(
titleId: string,
posterPath: string | null,
): Promise<ColorPalette | null> {
if (!posterPath) return null;
// Use local cached image, downloading first if needed
let source: string;
const filename = path.basename(posterPath);
if (imageCacheEnabled()) {
if (!(await isImageCached("posters", filename))) {
await downloadAndCacheImage(posterPath, "posters");
}
source = getLocalImagePath("posters", filename);
} else {
const baseUrl =
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
source = `${baseUrl}/w300${posterPath}`;
}
try {
log.debug(
`Extracting colors for title ${titleId} from ${imageCacheEnabled() ? "cache" : "remote"}`,
);
const palette = await Vibrant.from(source).getPalette();
const colors: ColorPalette = {
vibrant: palette.Vibrant?.hex ?? null,
darkVibrant: palette.DarkVibrant?.hex ?? null,
lightVibrant: palette.LightVibrant?.hex ?? null,
muted: palette.Muted?.hex ?? null,
darkMuted: palette.DarkMuted?.hex ?? null,
lightMuted: palette.LightMuted?.hex ?? null,
};
db.update(titles)
.set({ colorPalette: JSON.stringify(colors) })
.where(eq(titles.id, titleId))
.run();
log.debug(
`Extracted colors for title ${titleId}: colors=${JSON.stringify(colors)}`,
);
return colors;
} catch (err) {
log.error(`Failed to extract colors for title ${titleId}:`, err);
return null;
}
}
export function parseColorPalette(raw: string | null): ColorPalette | null {
if (!raw) return null;
try {
return JSON.parse(raw) as ColorPalette;
} catch {
return null;
}
}
+313
View File
@@ -0,0 +1,313 @@
import type { CastMember } from "@sofa/api/schemas";
import { db } from "@sofa/db/client";
import { eq, inArray, sql } from "@sofa/db/helpers";
import { persons, titleCast, titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getMovieCredits, getTvAggregateCredits } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache";
const log = createLogger("credits");
const NOTABLE_DEPARTMENTS = new Set([
"Director",
"Writer",
"Screenplay",
"Creator",
"Executive Producer",
]);
interface PersonData {
tmdbId: number;
name: string;
profilePath: string | null;
popularity?: number;
}
function batchUpsertPersons(people: PersonData[]): Map<number, string> {
if (people.length === 0) return new Map();
// Deduplicate by tmdbId
const uniqueByTmdbId = new Map<number, PersonData>();
for (const p of people) {
if (!uniqueByTmdbId.has(p.tmdbId)) uniqueByTmdbId.set(p.tmdbId, p);
}
const uniquePeople = [...uniqueByTmdbId.values()];
const tmdbIds = uniquePeople.map((p) => p.tmdbId);
// Batch prefetch existing persons (1 query)
const existing = db
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, tmdbIds))
.all();
const idMap = new Map<number, string>(existing.map((p) => [p.tmdbId, p.id]));
// Insert only new persons in a transaction
const newPeople = uniquePeople.filter((p) => !idMap.has(p.tmdbId));
if (newPeople.length > 0) {
db.transaction((tx) => {
for (const p of newPeople) {
const row = tx
.insert(persons)
.values({
tmdbId: p.tmdbId,
name: p.name,
profilePath: p.profilePath,
popularity: p.popularity ?? null,
})
.onConflictDoNothing()
.returning()
.get();
if (row) idMap.set(p.tmdbId, row.id);
}
});
// One fallback query for any that conflicted
const stillMissing = newPeople
.filter((p) => !idMap.has(p.tmdbId))
.map((p) => p.tmdbId);
if (stillMissing.length > 0) {
const fallbacks = db
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, stillMissing))
.all();
for (const f of fallbacks) idMap.set(f.tmdbId, f.id);
}
}
return idMap;
}
export async function refreshCredits(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
log.debug(`Refreshing credits for "${title.title}" (${title.type})`);
try {
let personIds: Map<number, string>;
if (title.type === "movie") {
const credits = await getMovieCredits(title.tmdbId);
const castSlice = (credits.cast ?? []).slice(0, 20);
// Collect notable crew
const seenCrew = new Set<string>();
const notableCrew: typeof credits.crew = [];
for (const c of credits.crew ?? []) {
if (!NOTABLE_DEPARTMENTS.has(c.job ?? "")) continue;
const key = `${c.id}-${c.job}`;
if (seenCrew.has(key)) continue;
seenCrew.add(key);
notableCrew.push(c);
}
// Batch upsert all people at once
const allPeople: PersonData[] = [
...castSlice.map((c) => ({
tmdbId: c.id,
name: c.name ?? "",
profilePath: c.profile_path ?? null,
popularity: c.popularity,
})),
...notableCrew.map((c) => ({
tmdbId: c.id,
name: c.name ?? "",
profilePath: c.profile_path ?? null,
popularity: c.popularity,
})),
];
personIds = batchUpsertPersons(allPeople);
// Collect all titleCast rows (cast + crew) and batch insert
const now = new Date();
const allCastRows: (typeof titleCast.$inferInsert)[] = [];
for (let i = 0; i < castSlice.length; i++) {
const c = castSlice[i];
const personId = personIds.get(c.id);
if (!personId) continue;
allCastRows.push({
titleId,
personId,
character: c.character,
department: "Acting",
job: null,
displayOrder: i,
episodeCount: null,
lastFetchedAt: now,
});
}
let crewOrder = 100;
for (const c of notableCrew) {
const personId = personIds.get(c.id);
if (!personId) continue;
allCastRows.push({
titleId,
personId,
character: null,
department: c.department,
job: c.job,
displayOrder: crewOrder,
episodeCount: null,
lastFetchedAt: now,
});
crewOrder++;
}
if (allCastRows.length > 0) {
db.insert(titleCast)
.values(allCastRows)
.onConflictDoUpdate({
target: [
titleCast.titleId,
titleCast.personId,
titleCast.department,
titleCast.character,
],
set: {
job: sql`excluded.job`,
displayOrder: sql`excluded.displayOrder`,
episodeCount: sql`excluded.episodeCount`,
lastFetchedAt: sql`excluded.lastFetchedAt`,
},
})
.run();
}
} else {
const credits = await getTvAggregateCredits(title.tmdbId);
const tvCast = credits.cast ?? [];
const tvCrew = credits.crew ?? [];
const castSlice = tvCast.slice(0, 20);
// Collect notable crew
const seenCrew = new Set<string>();
const notableCrew: Array<{
person: (typeof tvCrew)[number];
job: string;
episodeCount: number;
}> = [];
for (const c of tvCrew) {
for (const j of c.jobs ?? []) {
if (!NOTABLE_DEPARTMENTS.has(j.job ?? "")) continue;
const key = `${c.id}-${j.job}`;
if (seenCrew.has(key)) continue;
seenCrew.add(key);
notableCrew.push({
person: c,
job: j.job ?? "",
episodeCount: j.episode_count,
});
}
}
// Batch upsert all people at once
const allPeople: PersonData[] = [
...castSlice.map((c) => ({
tmdbId: c.id,
name: c.name ?? "",
profilePath: c.profile_path ?? null,
popularity: c.popularity,
})),
...notableCrew.map((c) => ({
tmdbId: c.person.id,
name: c.person.name ?? "",
profilePath: c.person.profile_path ?? null,
popularity: c.person.popularity,
})),
];
personIds = batchUpsertPersons(allPeople);
// Collect all titleCast rows (cast + crew) and batch insert
const now = new Date();
const allCastRows: (typeof titleCast.$inferInsert)[] = [];
for (let i = 0; i < castSlice.length; i++) {
const c = castSlice[i];
const personId = personIds.get(c.id);
if (!personId) continue;
const character = c.roles?.[0]?.character ?? null;
allCastRows.push({
titleId,
personId,
character,
department: "Acting",
job: null,
displayOrder: i,
episodeCount: c.total_episode_count,
lastFetchedAt: now,
});
}
let crewOrder = 100;
for (const c of notableCrew) {
const personId = personIds.get(c.person.id);
if (!personId) continue;
allCastRows.push({
titleId,
personId,
character: null,
department: c.person.department ?? "",
job: c.job,
displayOrder: crewOrder,
episodeCount: c.episodeCount,
lastFetchedAt: now,
});
crewOrder++;
}
if (allCastRows.length > 0) {
db.insert(titleCast)
.values(allCastRows)
.onConflictDoUpdate({
target: [
titleCast.titleId,
titleCast.personId,
titleCast.department,
titleCast.character,
],
set: {
job: sql`excluded.job`,
displayOrder: sql`excluded.displayOrder`,
episodeCount: sql`excluded.episodeCount`,
lastFetchedAt: sql`excluded.lastFetchedAt`,
},
})
.run();
}
}
log.debug(`Credits refreshed for "${title.title}"`);
if (imageCacheEnabled()) {
cacheProfilePhotos(titleId).catch((err) =>
log.debug("Profile photo caching failed:", err),
);
}
} catch (err) {
log.error(`Failed to refresh credits for title ${titleId}:`, err);
}
}
export function getCastForTitle(titleId: string): CastMember[] {
const rows = db
.select({
id: titleCast.id,
personId: titleCast.personId,
name: persons.name,
character: titleCast.character,
department: titleCast.department,
job: titleCast.job,
displayOrder: titleCast.displayOrder,
episodeCount: titleCast.episodeCount,
profilePath: persons.profilePath,
tmdbId: persons.tmdbId,
})
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(eq(titleCast.titleId, titleId))
.orderBy(titleCast.displayOrder)
.all();
return rows.map((r) => ({
...r,
profilePath: tmdbImageUrl(r.profilePath, "profiles"),
}));
}
+515
View File
@@ -0,0 +1,515 @@
import { db } from "@sofa/db/client";
import { and, desc, eq, inArray, sql } from "@sofa/db/helpers";
import {
availabilityOffers,
episodes,
seasons,
titleRecommendations,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
import { tmdbImageUrl } from "@sofa/tmdb/image";
export type TimePeriod = "today" | "this_week" | "this_month" | "this_year";
export function periodStartTimestamp(period: TimePeriod): number {
const now = new Date();
const start = new Date(now);
switch (period) {
case "today":
start.setHours(now.getHours() - 24, now.getMinutes(), 0, 0);
break;
case "this_week":
start.setDate(now.getDate() - 7);
start.setHours(0, 0, 0, 0);
break;
case "this_month":
start.setDate(now.getDate() - 30);
start.setHours(0, 0, 0, 0);
break;
case "this_year":
start.setFullYear(now.getFullYear() - 1);
start.setHours(0, 0, 0, 0);
break;
}
return Math.floor(start.getTime() / 1000);
}
export function getWatchCount(
userId: string,
table: "movies" | "episodes",
period: TimePeriod,
): number {
const timestamp = periodStartTimestamp(period);
const watchTable = table === "movies" ? userMovieWatches : userEpisodeWatches;
const [row] = db
.select({ count: sql<number>`count(*)` })
.from(watchTable)
.where(
and(
eq(watchTable.userId, userId),
sql`${watchTable.watchedAt} >= ${timestamp}`,
),
)
.all();
return row?.count ?? 0;
}
export interface HistoryBucket {
bucket: string;
count: number;
}
function toLocalDateBucket(date: Date): string {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function getWatchHistory(
userId: string,
table: "movies" | "episodes",
period: TimePeriod,
): HistoryBucket[] {
const startTs = periodStartTimestamp(period);
const watchTable = table === "movies" ? userMovieWatches : userEpisodeWatches;
const now = new Date();
let fmt: string;
let buckets: string[];
switch (period) {
case "today":
fmt = "%H";
buckets = Array.from({ length: 24 }, (_, i) => {
const d = new Date(now);
d.setHours(now.getHours() - 23 + i);
return d.getHours().toString().padStart(2, "0");
});
break;
case "this_week": {
fmt = "%Y-%m-%d";
buckets = Array.from({ length: 7 }, (_, i) => {
const d = new Date(now);
d.setDate(now.getDate() - 6 + i);
return toLocalDateBucket(d);
});
break;
}
case "this_month": {
fmt = "%Y-%m-%d";
buckets = Array.from({ length: 30 }, (_, i) => {
const d = new Date(now);
d.setDate(now.getDate() - 29 + i);
return toLocalDateBucket(d);
});
break;
}
case "this_year":
fmt = "%Y-%m";
buckets = Array.from({ length: 12 }, (_, i) => {
const d = new Date(now);
d.setMonth(now.getMonth() - 11 + i);
return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}`;
});
break;
}
const rows = db
.select({
bucket:
sql<string>`strftime(${fmt}, ${watchTable.watchedAt}, 'unixepoch', 'localtime')`.as(
"bucket",
),
count: sql<number>`count(*)`.as("cnt"),
})
.from(watchTable)
.where(
and(
eq(watchTable.userId, userId),
sql`${watchTable.watchedAt} >= ${startTs}`,
),
)
.groupBy(
sql`strftime(${fmt}, ${watchTable.watchedAt}, 'unixepoch', 'localtime')`,
)
.all();
const countMap = new Map(rows.map((r) => [r.bucket, r.count]));
return buckets.map((b) => ({ bucket: b, count: countMap.get(b) ?? 0 }));
}
export interface DashboardStats {
moviesThisMonth: number;
episodesThisWeek: number;
librarySize: number;
completed: number;
}
export function getUserStats(userId: string): DashboardStats {
const moviesThisMonth = getWatchCount(userId, "movies", "this_month");
const episodesThisWeek = getWatchCount(userId, "episodes", "this_week");
const [statusCounts] = db
.select({
librarySize: sql<number>`count(*)`,
completed: sql<number>`sum(case when ${userTitleStatus.status} = 'completed' then 1 else 0 end)`,
})
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
return {
moviesThisMonth,
episodesThisWeek,
librarySize: statusCounts?.librarySize ?? 0,
completed: statusCounts?.completed ?? 0,
};
}
export interface ContinueWatchingItem {
title: {
id: string;
title: string;
posterPath: string | null;
backdropPath: string | null;
type: string;
};
nextEpisode: {
id: string;
seasonNumber: number;
episodeNumber: number;
name: string | null;
stillPath: string | null;
overview: string | null;
} | null;
lastWatchedAt: Date | null;
totalEpisodes: number;
watchedEpisodes: number;
}
export function getContinueWatchingFeed(
userId: string,
): ContinueWatchingItem[] {
// Get in-progress TV shows
const inProgress = db
.select({
titleId: userTitleStatus.titleId,
updatedAt: userTitleStatus.updatedAt,
})
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.status, "in_progress"),
),
)
.all();
if (inProgress.length === 0) return [];
const titleIds = inProgress.map((r) => r.titleId);
// Batch fetch all TV titles (1 query)
const tvTitles = db
.select()
.from(titles)
.where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
.all();
if (tvTitles.length === 0) return [];
const tvTitleIds = tvTitles.map((t) => t.id);
const titleMap = new Map(tvTitles.map((t) => [t.id, t]));
// Batch fetch all seasons for these titles (1 query)
const allSeasons = db
.select()
.from(seasons)
.where(inArray(seasons.titleId, tvTitleIds))
.orderBy(seasons.titleId, seasons.seasonNumber)
.all();
const seasonIds = allSeasons.map((s) => s.id);
// Batch fetch all episodes for these seasons (1 query)
const allEpisodes =
seasonIds.length > 0
? db
.select()
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.orderBy(episodes.seasonId, episodes.episodeNumber)
.all()
: [];
// Batch fetch all watches for this user for these episodes (1 query)
const episodeIds = allEpisodes.map((ep) => ep.id);
const allWatches =
episodeIds.length > 0
? db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, episodeIds),
),
)
.all()
: [];
// Build lookup maps
const watchedEpisodeIds = new Set(allWatches.map((w) => w.episodeId));
const watchDateMap = new Map<string, Date>();
for (const watch of allWatches) {
const existing = watchDateMap.get(watch.episodeId);
if (!existing || watch.watchedAt > existing) {
watchDateMap.set(watch.episodeId, watch.watchedAt);
}
}
// Group seasons by title
const seasonsByTitle = new Map<string, typeof allSeasons>();
for (const s of allSeasons) {
const arr = seasonsByTitle.get(s.titleId) ?? [];
arr.push(s);
seasonsByTitle.set(s.titleId, arr);
}
// Group episodes by season
const episodesBySeason = new Map<string, typeof allEpisodes>();
for (const ep of allEpisodes) {
const arr = episodesBySeason.get(ep.seasonId) ?? [];
arr.push(ep);
episodesBySeason.set(ep.seasonId, arr);
}
const items: ContinueWatchingItem[] = [];
const today = new Date().toISOString().slice(0, 10);
for (const row of inProgress) {
const title = titleMap.get(row.titleId);
if (!title) continue;
const titleSeasonsArr = seasonsByTitle.get(title.id) ?? [];
let nextEpisode: ContinueWatchingItem["nextEpisode"] = null;
let lastWatchedAt: Date | null = null;
let totalEpisodes = 0;
let watchedEpisodes = 0;
for (const s of titleSeasonsArr) {
const eps = episodesBySeason.get(s.id) ?? [];
totalEpisodes += eps.length;
for (const ep of eps) {
if (watchedEpisodeIds.has(ep.id)) {
watchedEpisodes++;
const watchDate = watchDateMap.get(ep.id);
if (watchDate && (!lastWatchedAt || watchDate > lastWatchedAt)) {
lastWatchedAt = watchDate;
}
} else if (!nextEpisode) {
// Skip episodes not yet aired
if (ep.airDate && ep.airDate > today) continue;
nextEpisode = {
id: ep.id,
seasonNumber: s.seasonNumber,
episodeNumber: ep.episodeNumber,
name: ep.name,
stillPath: ep.stillPath,
overview: ep.overview,
};
}
}
}
if (nextEpisode) {
items.push({
title: {
id: title.id,
title: title.title,
posterPath: title.posterPath,
backdropPath: title.backdropPath,
type: title.type,
},
nextEpisode,
lastWatchedAt,
totalEpisodes,
watchedEpisodes,
});
}
}
// Sort by most recent watch
items.sort((a, b) => {
const aTime = a.lastWatchedAt?.getTime() ?? 0;
const bTime = b.lastWatchedAt?.getTime() ?? 0;
return bTime - aTime;
});
return items;
}
// biome-ignore lint/correctness/noUnusedFunctionParameters: days reserved for future date filtering
export function getNewAvailableFeed(userId: string, days = 14) {
// Get titles the user has in any status that have availability offers
// and recent release/air dates
const results = db
.select({
titleId: titles.id,
title: titles.title,
type: titles.type,
tmdbId: titles.tmdbId,
posterPath: titles.posterPath,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
popularity: titles.popularity,
userStatus: userTitleStatus.status,
})
.from(titles)
.innerJoin(
userTitleStatus,
and(
eq(userTitleStatus.titleId, titles.id),
eq(userTitleStatus.userId, userId),
),
)
.where(
sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`,
)
.orderBy(desc(titles.popularity))
.limit(20)
.all();
return results;
}
export function getRecommendationsFeed(userId: string) {
// Get recommendations from user's highly-rated or completed titles
const userCompletedOrRated = db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.status, "completed"),
),
)
.all()
.map((r) => r.titleId);
const ratedIds = db
.select({ titleId: userRatings.titleId })
.from(userRatings)
.where(
and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`),
)
.all()
.map((r) => r.titleId);
const sourceIds = [...new Set([...userCompletedOrRated, ...ratedIds])];
if (sourceIds.length === 0) return [];
// Get all tracked title IDs to exclude
const trackedIds = new Set(
db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all()
.map((r) => r.titleId),
);
// Batch fetch all recommendations for all source IDs (1 query)
const allRecRows = db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
rank: titleRecommendations.rank,
})
.from(titleRecommendations)
.where(inArray(titleRecommendations.titleId, sourceIds))
.all();
const recs: Map<string, { titleId: string; score: number }> = new Map();
for (const rec of allRecRows) {
if (trackedIds.has(rec.recommendedTitleId)) continue;
const existing = recs.get(rec.recommendedTitleId);
const score = 100 - rec.rank;
if (existing) {
existing.score += score;
} else {
recs.set(rec.recommendedTitleId, {
titleId: rec.recommendedTitleId,
score,
});
}
}
const sorted = [...recs.values()]
.sort((a, b) => b.score - a.score)
.slice(0, 20);
if (sorted.length === 0) return [];
// Batch fetch all recommended titles (1 query)
const recTitleIds = sorted.map((r) => r.titleId);
const recTitles = db
.select()
.from(titles)
.where(inArray(titles.id, recTitleIds))
.all();
const recTitleMap = new Map(recTitles.map((t) => [t.id, t]));
return sorted.map((r) => recTitleMap.get(r.titleId)).filter(Boolean);
}
export function getRecommendationsForTitle(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return [];
const recs = db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
source: titleRecommendations.source,
rank: titleRecommendations.rank,
})
.from(titleRecommendations)
.where(eq(titleRecommendations.titleId, titleId))
.orderBy(titleRecommendations.rank)
.all();
if (recs.length === 0) return [];
// Batch fetch all recommended titles (1 query)
const recTitleIds = recs.map((r) => r.recommendedTitleId);
const recTitles = db
.select()
.from(titles)
.where(inArray(titles.id, recTitleIds))
.all();
const recTitleMap = new Map(recTitles.map((t) => [t.id, t]));
return recs
.map((rec) => {
const r = recTitleMap.get(rec.recommendedTitleId);
if (!r) return null;
return {
id: r.id,
tmdbId: r.tmdbId,
type: r.type as "movie" | "tv",
title: r.title,
posterPath: tmdbImageUrl(r.posterPath, "posters"),
releaseDate: r.releaseDate,
firstAirDate: r.firstAirDate,
voteAverage: r.voteAverage,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
}
+266
View File
@@ -0,0 +1,266 @@
import { mkdir, rename } from "node:fs/promises";
import path from "node:path";
import { CACHE_DIR, TMDB_IMAGE_BASE_URL } from "@sofa/config";
import { db } from "@sofa/db/client";
import { eq, inArray } from "@sofa/db/helpers";
import {
availabilityOffers,
episodes,
persons,
seasons,
titleCast,
titles,
} from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import type { ImageCategory } from "@sofa/tmdb/image";
const log = createLogger("image-cache");
export type { ImageCategory };
const CATEGORY_SIZES: Record<ImageCategory, string> = {
posters: "w500",
backdrops: "w1280",
stills: "w1280",
logos: "w92",
profiles: "w185",
};
export function imageCacheEnabled(): boolean {
return process.env.IMAGE_CACHE_ENABLED !== "false";
}
export async function ensureImageDirs() {
for (const category of Object.keys(CATEGORY_SIZES)) {
await mkdir(path.join(CACHE_DIR, category), { recursive: true });
}
}
export async function isImageCached(
category: ImageCategory,
filename: string,
): Promise<boolean> {
return Bun.file(getLocalImagePath(category, filename)).exists();
}
export function getLocalImagePath(
category: ImageCategory,
filename: string,
): string {
return path.join(CACHE_DIR, category, path.basename(filename));
}
export async function readCachedImage(
category: ImageCategory,
filename: string,
): Promise<Buffer | null> {
const filePath = getLocalImagePath(category, filename);
const file = Bun.file(filePath);
if (!(await file.exists())) return null;
return Buffer.from(await file.arrayBuffer());
}
export async function downloadAndCacheImage(
tmdbPath: string,
category: ImageCategory,
): Promise<Buffer | null> {
const size = CATEGORY_SIZES[category];
const url = `${TMDB_IMAGE_BASE_URL}/${size}${tmdbPath}`;
const res = await fetch(url);
if (!res.ok) {
log.warn(`Download failed: ${url} -> ${res.status}`);
return null;
}
const buffer = Buffer.from(await res.arrayBuffer());
const filename = path.basename(tmdbPath);
const finalPath = getLocalImagePath(category, filename);
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
try {
await Bun.write(tmpPath, buffer);
await rename(tmpPath, finalPath);
log.debug(`Cached ${category}/${filename} (${buffer.length} bytes)`);
} catch (err) {
log.warn(`Failed to write cached image ${filename}:`, err);
}
return buffer;
}
export async function fetchAndMaybeCache(
tmdbPath: string,
category: ImageCategory,
): Promise<{ buffer: Buffer; contentType: string } | null> {
// Try local first
const filename = path.basename(tmdbPath);
const cached = await readCachedImage(category, filename);
if (cached) {
log.debug(`Cache hit: ${category}/${filename}`);
const ext = path.extname(filename).toLowerCase();
const contentType =
ext === ".png"
? "image/png"
: ext === ".webp"
? "image/webp"
: "image/jpeg";
return { buffer: cached, contentType };
}
// Fetch from TMDB
const size = CATEGORY_SIZES[category];
const url = `${TMDB_IMAGE_BASE_URL}/${size}${tmdbPath}`;
const res = await fetch(url);
if (!res.ok) {
log.warn(`Fetch failed: ${url} -> ${res.status}`);
return null;
}
const buffer = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type") || "image/jpeg";
// Fire-and-forget save to disk
const finalPath = getLocalImagePath(category, filename);
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
Bun.write(tmpPath, buffer)
.then(() => rename(tmpPath, finalPath))
.catch((err) => log.warn(`Failed to cache ${category}/${filename}:`, err));
return { buffer, contentType };
}
export async function cacheImagesForTitle(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
// Collect all candidate images, then check cache in parallel
const candidates: { imgPath: string; category: ImageCategory }[] = [];
if (title.posterPath)
candidates.push({ imgPath: title.posterPath, category: "posters" });
if (title.backdropPath)
candidates.push({ imgPath: title.backdropPath, category: "backdrops" });
if (title.type === "tv") {
const allSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
for (const s of allSeasons) {
if (s.posterPath)
candidates.push({ imgPath: s.posterPath, category: "posters" });
}
}
// Parallel cache checks instead of sequential awaits
const checks = await Promise.all(
candidates.map(async (c) => ({
...c,
cached: await isImageCached(c.category, path.basename(c.imgPath)),
})),
);
const tasks = checks
.filter((c) => !c.cached)
.map((c) => downloadAndCacheImage(c.imgPath, c.category));
if (tasks.length > 0) {
log.debug(`Caching ${tasks.length} images for title ${titleId}`);
}
await Promise.allSettled(tasks);
}
export async function cacheEpisodeStills(titleId: string) {
const allSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
const seasonIds = allSeasons.map((s) => s.id);
if (seasonIds.length === 0) return;
const allEps = db
.select()
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.all();
const epsWithStills = allEps.filter(
(ep): ep is typeof ep & { stillPath: string } => ep.stillPath != null,
);
// Parallel cache checks instead of sequential awaits
const checks = await Promise.all(
epsWithStills.map(async (ep) => ({
stillPath: ep.stillPath,
cached: await isImageCached("stills", path.basename(ep.stillPath)),
})),
);
const tasks = checks
.filter((c) => !c.cached)
.map((c) => downloadAndCacheImage(c.stillPath, "stills"));
await Promise.allSettled(tasks);
}
export async function cacheProviderLogos(titleId: string) {
const offers = db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
.all();
// Deduplicate and parallel cache checks
const uniqueLogos = new Map<string, string>();
for (const offer of offers) {
if (offer.logoPath) {
const basename = path.basename(offer.logoPath);
if (!uniqueLogos.has(basename)) uniqueLogos.set(basename, offer.logoPath);
}
}
const checks = await Promise.all(
[...uniqueLogos.entries()].map(async ([basename, logoPath]) => ({
logoPath,
cached: await isImageCached("logos", basename),
})),
);
const tasks = checks
.filter((c) => !c.cached)
.map((c) => downloadAndCacheImage(c.logoPath, "logos"));
await Promise.allSettled(tasks);
}
export async function cacheProfilePhotos(titleId: string) {
const castRows = db
.select({ profilePath: persons.profilePath })
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(eq(titleCast.titleId, titleId))
.all();
// Deduplicate and parallel cache checks
const uniqueProfiles = new Map<string, string>();
for (const row of castRows) {
if (row.profilePath) {
const basename = path.basename(row.profilePath);
if (!uniqueProfiles.has(basename))
uniqueProfiles.set(basename, row.profilePath);
}
}
const checks = await Promise.all(
[...uniqueProfiles.entries()].map(async ([basename, profilePath]) => ({
profilePath,
cached: await isImageCached("profiles", basename),
})),
);
const tasks = checks
.filter((c) => !c.cached)
.map((c) => downloadAndCacheImage(c.profilePath, "profiles"));
if (tasks.length > 0) {
log.debug(`Caching ${tasks.length} profile photos for title ${titleId}`);
}
await Promise.allSettled(tasks);
}
+119
View File
@@ -0,0 +1,119 @@
import { db } from "@sofa/db/client";
import { and, eq, inArray } from "@sofa/db/helpers";
import { integrations, titles, userTitleStatus } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getTvExternalIds } from "@sofa/tmdb/client";
import { z } from "zod";
const log = createLogger("lists");
/** Look up a list integration token and return the userId + provider, or null. */
export function resolveListToken(
token: string,
): { userId: string; provider: "sonarr" | "radarr" } | null {
const row = db
.select({
userId: integrations.userId,
provider: integrations.provider,
})
.from(integrations)
.where(
and(
eq(integrations.token, token),
eq(integrations.type, "list"),
eq(integrations.enabled, true),
),
)
.get();
if (!row) return null;
return {
userId: row.userId,
provider: row.provider as "sonarr" | "radarr",
};
}
const statusSchema = z.enum(["watchlist", "in_progress", "completed"]);
type Status = z.infer<typeof statusSchema>;
/** Parse a comma-separated status query param into validated statuses. */
export function parseStatusParam(param: string | null): Status[] {
if (!param) return ["watchlist"];
const parsed = param
.split(",")
.filter((s) => statusSchema.safeParse(s).success) as Status[];
return parsed.length > 0 ? parsed : ["watchlist"];
}
/** Radarr custom list format: `[{ Id: tmdbId }]` for movies. */
export function getRadarrList(
userId: string,
statuses: Status[] = ["watchlist"],
): { Id: number }[] {
const rows = db
.select({ tmdbId: titles.tmdbId })
.from(userTitleStatus)
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
.where(
and(
eq(userTitleStatus.userId, userId),
eq(titles.type, "movie"),
inArray(userTitleStatus.status, statuses),
),
)
.all();
return rows.map((r) => ({ Id: r.tmdbId }));
}
/** Sonarr custom list format: `[{ TvdbId, Title }]` for TV shows.
* Lazily resolves missing TVDB IDs via TMDB API and caches them. */
export async function getSonarrList(
userId: string,
statuses: Status[] = ["watchlist"],
): Promise<{ TvdbId: number; Title: string }[]> {
const rows = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
tvdbId: titles.tvdbId,
title: titles.title,
})
.from(userTitleStatus)
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
.where(
and(
eq(userTitleStatus.userId, userId),
eq(titles.type, "tv"),
inArray(userTitleStatus.status, statuses),
),
)
.all();
// Resolve missing TVDB IDs in parallel instead of sequentially
const needsResolution = rows.filter((r) => r.tvdbId == null);
if (needsResolution.length > 0) {
const resolved = await Promise.all(
needsResolution.map(async (row) => {
try {
const externalIds = await getTvExternalIds(row.tmdbId);
return { row, tvdbId: externalIds.tvdb_id };
} catch (err) {
log.warn(`Failed to resolve TVDB ID for TMDB ${row.tmdbId}:`, err);
return { row, tvdbId: null };
}
}),
);
// Batch update resolved IDs in a single transaction
db.transaction((tx) => {
for (const { row, tvdbId } of resolved) {
if (tvdbId != null) {
row.tvdbId = tvdbId;
tx.update(titles).set({ tvdbId }).where(eq(titles.id, row.id)).run();
}
}
});
}
return rows
.filter((r): r is typeof r & { tvdbId: number } => r.tvdbId != null)
.map((r) => ({ TvdbId: r.tvdbId, Title: r.title }));
}
+921
View File
@@ -0,0 +1,921 @@
import type {
AvailabilityOffer,
CastMember,
Episode,
ResolvedTitle,
Season,
} from "@sofa/api/schemas";
import { db } from "@sofa/db/client";
import { eq, inArray, sql } from "@sofa/db/helpers";
import {
availabilityOffers,
episodes,
genres,
seasons,
titleGenres,
titleRecommendations,
titles,
} from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import type {
TmdbGenre,
TmdbMovieDetails,
TmdbTvDetails,
TmdbVideo,
} from "@sofa/tmdb/client";
import {
getMovieDetails,
getRecommendations,
getSimilar,
getTvDetails,
getTvSeasonDetails,
getVideos,
} from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { refreshAvailability } from "./availability";
import { extractAndStoreColors, parseColorPalette } from "./colors";
import { getCastForTitle, refreshCredits } from "./credits";
import {
cacheEpisodeStills,
cacheImagesForTitle,
imageCacheEnabled,
} from "./image-cache";
import { generateProviderUrl } from "./providers";
const log = createLogger("metadata");
/**
* Insert a title row, or return the existing one if a concurrent insert won the race.
* Catches SQLITE_CONSTRAINT_UNIQUE and falls back to a SELECT.
*/
function upsertTitle(values: typeof titles.$inferInsert, tmdbId: number) {
try {
return db.insert(titles).values(values).returning().get();
} catch (err: unknown) {
if (
err instanceof Error &&
"code" in err &&
err.code === "SQLITE_CONSTRAINT_UNIQUE"
) {
log.debug(`TMDB ${tmdbId} was inserted concurrently, returning existing`);
return db.select().from(titles).where(eq(titles.tmdbId, tmdbId)).get();
}
throw err;
}
}
function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
const validGenres = tmdbGenres.filter(
(g): g is TmdbGenre & { name: string } => !!g.name,
);
if (validGenres.length === 0) return;
db.transaction((tx) => {
for (const g of validGenres) {
tx.insert(genres)
.values({ id: g.id, name: g.name })
.onConflictDoUpdate({ target: genres.id, set: { name: g.name } })
.run();
}
tx.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
for (const g of validGenres) {
tx.insert(titleGenres)
.values({ titleId, genreId: g.id })
.onConflictDoNothing()
.run();
}
});
}
/** @internal */
export function extractMovieContentRating(
movie: TmdbMovieDetails,
): string | null {
const us = movie.release_dates?.results?.find((r) => r.iso_3166_1 === "US");
if (!us) return null;
for (const rd of us.release_dates ?? []) {
if (rd.certification) return rd.certification;
}
return null;
}
/** @internal */
export function extractTvContentRating(show: TmdbTvDetails): string | null {
const us = show.content_ratings?.results?.find((r) => r.iso_3166_1 === "US");
return us?.rating || null;
}
type ImportResult = ReturnType<typeof _getOrFetchTitleByTmdbId>;
/** In-flight import promises keyed by tmdbId — coalesces concurrent calls */
const inflightImports = new Map<number, ImportResult>();
export function getOrFetchTitleByTmdbId(
tmdbId: number,
type: "movie" | "tv",
): ImportResult {
const inflight = inflightImports.get(tmdbId);
if (inflight) {
log.debug(`Import already in-flight for TMDB ${tmdbId}, coalescing`);
return inflight;
}
const promise = _getOrFetchTitleByTmdbId(tmdbId, type).finally(() => {
inflightImports.delete(tmdbId);
}) as ImportResult;
inflightImports.set(tmdbId, promise);
return promise;
}
async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
log.debug(`Importing ${type} TMDB ${tmdbId}`);
const existing = db
.select()
.from(titles)
.where(eq(titles.tmdbId, tmdbId))
.get();
if (existing) {
// For TV shows, check if seasons/episodes were actually loaded.
// They may be missing if a prior fetch failed or the title was created
// as a shell by the recommendations system (lastFetchedAt: null).
if (existing.type === "tv") {
const hasSeason = db
.select({ id: seasons.id })
.from(seasons)
.where(eq(seasons.titleId, existing.id))
.limit(1)
.get();
if (!hasSeason) {
const show = await getTvDetails(tmdbId);
if (!existing.lastFetchedAt) {
db.update(titles)
.set({
overview: show.overview,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
status: show.status,
contentRating: extractTvContentRating(show),
tvdbId: show.external_ids?.tvdb_id ?? null,
lastFetchedAt: new Date(),
})
.where(eq(titles.id, existing.id))
.run();
}
upsertGenres(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
refreshAvailability(existing.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(existing.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
extractAndStoreColors(existing.id, show.poster_path ?? null).catch(
(err) => log.debug("Color extraction failed:", err),
);
refreshCredits(existing.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(existing.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
if (imageCacheEnabled()) {
cacheImagesForTitle(existing.id).catch((err) =>
log.debug("Image caching failed:", err),
);
cacheEpisodeStills(existing.id).catch((err) =>
log.debug("Episode stills caching failed:", err),
);
}
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
}
}
return existing;
}
const now = new Date();
if (type === "movie") {
const movie = await getMovieDetails(tmdbId);
const row = upsertTitle(
{
tmdbId: movie.id,
type: "movie",
title: movie.title ?? "",
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
popularity: movie.popularity,
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
lastFetchedAt: now,
},
tmdbId,
);
if (!row) return undefined;
upsertGenres(row.id, movie.genres ?? []);
refreshAvailability(row.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
extractAndStoreColors(row.id, movie.poster_path ?? null).catch((err) =>
log.debug("Color extraction failed:", err),
);
refreshCredits(row.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(row.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
if (imageCacheEnabled()) {
cacheImagesForTitle(row.id).catch((err) =>
log.debug("Image caching failed:", err),
);
}
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
return row;
}
const show = await getTvDetails(tmdbId);
const row = upsertTitle(
{
tmdbId: show.id,
tvdbId: show.external_ids?.tvdb_id ?? null,
type: "tv",
title: show.name ?? "",
originalTitle: show.original_name,
overview: show.overview,
firstAirDate: show.first_air_date || null,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
popularity: show.popularity,
voteAverage: show.vote_average,
voteCount: show.vote_count,
status: show.status,
contentRating: extractTvContentRating(show),
lastFetchedAt: now,
},
tmdbId,
);
if (!row) return undefined;
upsertGenres(row.id, show.genres ?? []);
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
refreshAvailability(row.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
extractAndStoreColors(row.id, show.poster_path ?? null).catch((err) =>
log.debug("Color extraction failed:", err),
);
refreshCredits(row.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(row.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
if (imageCacheEnabled()) {
cacheImagesForTitle(row.id).catch((err) =>
log.debug("Image caching failed:", err),
);
cacheEpisodeStills(row.id).catch((err) =>
log.debug("Episode stills caching failed:", err),
);
}
log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`);
return row;
}
export async function refreshTitle(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return null;
const now = new Date();
if (title.type === "movie") {
const movie = await getMovieDetails(title.tmdbId);
db.update(titles)
.set({
title: movie.title,
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
popularity: movie.popularity,
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
lastFetchedAt: now,
})
.where(eq(titles.id, titleId))
.run();
upsertGenres(titleId, movie.genres ?? []);
} else {
const show = await getTvDetails(title.tmdbId);
db.update(titles)
.set({
title: show.name,
originalTitle: show.original_name,
overview: show.overview,
firstAirDate: show.first_air_date || null,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
popularity: show.popularity,
voteAverage: show.vote_average,
voteCount: show.vote_count,
status: show.status,
contentRating: extractTvContentRating(show),
tvdbId: show.external_ids?.tvdb_id ?? null,
lastFetchedAt: now,
})
.where(eq(titles.id, titleId))
.run();
upsertGenres(titleId, show.genres ?? []);
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
}
const updated = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (updated) {
extractAndStoreColors(updated.id, updated.posterPath).catch((err) =>
log.debug("Color extraction failed:", err),
);
refreshTrailer(updated.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
refreshCredits(updated.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
if (imageCacheEnabled()) {
cacheImagesForTitle(updated.id).catch((err) =>
log.debug("Image caching failed:", err),
);
if (updated.type === "tv") {
cacheEpisodeStills(updated.id).catch((err) =>
log.debug("Episode stills caching failed:", err),
);
}
}
}
return updated;
}
export async function refreshTvChildren(
titleId: string,
tmdbId: number,
numberOfSeasons: number,
) {
const now = new Date();
for (let sn = 1; sn <= numberOfSeasons; sn++) {
// Rate-limit: 250ms between TMDB calls
if (sn > 1) await delay(250);
try {
const seasonData = await getTvSeasonDetails(tmdbId, sn);
const seasonRow = db
.insert(seasons)
.values({
titleId,
seasonNumber: seasonData.season_number,
name: seasonData.name,
overview: seasonData.overview,
posterPath: seasonData.poster_path,
airDate: seasonData.air_date,
lastFetchedAt: now,
})
.onConflictDoUpdate({
target: [seasons.titleId, seasons.seasonNumber],
set: {
name: seasonData.name,
overview: seasonData.overview,
posterPath: seasonData.poster_path,
airDate: seasonData.air_date,
lastFetchedAt: now,
},
})
.returning()
.get();
// Batch all episode upserts in a single transaction per season
const eps = seasonData.episodes ?? [];
if (eps.length > 0) {
db.transaction((tx) => {
for (const ep of eps) {
tx.insert(episodes)
.values({
seasonId: seasonRow.id,
episodeNumber: ep.episode_number,
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
})
.onConflictDoUpdate({
target: [episodes.seasonId, episodes.episodeNumber],
set: {
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
},
})
.run();
}
});
}
} catch (err) {
// Skip this season and continue with the rest — partial data is
// better than aborting entirely. The next refresh cycle will retry.
log.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}:`, err);
}
}
}
export async function refreshRecommendations(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
const now = new Date();
// Fetch both recommendations and similar
const [recs, similar] = await Promise.all([
getRecommendations(title.tmdbId, title.type),
getSimilar(title.tmdbId, title.type),
]);
const recsResults = recs.results ?? [];
const similarResults = similar.results ?? [];
log.debug(
`Fetched ${recsResults.length} recommendations and ${similarResults.length} similar for title ${titleId}`,
);
// Collect all valid results with their source/rank
interface RecItem {
result: (typeof recsResults)[number];
type: "movie" | "tv";
source: "tmdb_recommendations" | "tmdb_similar";
rank: number;
}
const allItems: RecItem[] = [];
for (let i = 0; i < recsResults.length && i < 20; i++) {
const r = recsResults[i];
const type = r.media_type ?? title.type;
if (type === "movie" || type === "tv") {
allItems.push({
result: r,
type,
source: "tmdb_recommendations",
rank: i + 1,
});
}
}
for (let i = 0; i < similarResults.length && i < 20; i++) {
const r = similarResults[i];
const type = r.media_type ?? title.type;
if (type === "movie" || type === "tv") {
allItems.push({ result: r, type, source: "tmdb_similar", rank: i + 1 });
}
}
if (allItems.length === 0) return;
// Batch prefetch existing titles (1 query)
const tmdbIds = [...new Set(allItems.map((item) => item.result.id))];
const existingTitles = db
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all();
const titleIdMap = new Map<number, string>(
existingTitles.map((t) => [t.tmdbId, t.id]),
);
// Insert missing titles + upsert recommendations in a single transaction
db.transaction((tx) => {
// Insert only new titles
const newItems = allItems.filter((item) => !titleIdMap.has(item.result.id));
const insertedTmdbIds = new Set<number>();
for (const item of newItems) {
if (insertedTmdbIds.has(item.result.id)) continue;
insertedTmdbIds.add(item.result.id);
const r = item.result;
const row = tx
.insert(titles)
.values({
tmdbId: r.id,
type: item.type,
title: r.title ?? r.name ?? "Unknown",
originalTitle: r.original_title ?? r.original_name,
overview: r.overview,
releaseDate: r.release_date,
firstAirDate: r.first_air_date,
posterPath: r.poster_path,
backdropPath: r.backdrop_path,
popularity: r.popularity,
voteAverage: r.vote_average,
voteCount: r.vote_count,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning()
.get();
if (row) titleIdMap.set(r.id, row.id);
}
// One fallback query for any that conflicted
const stillMissing = [...insertedTmdbIds].filter(
(id) => !titleIdMap.has(id),
);
if (stillMissing.length > 0) {
const fallbacks = tx
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, stillMissing))
.all();
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
}
// Batch upsert all recommendation rows (N inserts → 1)
const recRows = allItems
.map((item) => {
const recTitleId = titleIdMap.get(item.result.id);
if (!recTitleId) return null;
return {
titleId,
recommendedTitleId: recTitleId,
source: item.source,
rank: item.rank,
lastFetchedAt: now,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (recRows.length > 0) {
tx.insert(titleRecommendations)
.values(recRows)
.onConflictDoUpdate({
target: [
titleRecommendations.titleId,
titleRecommendations.recommendedTitleId,
titleRecommendations.source,
],
set: {
rank: sql`excluded.rank`,
lastFetchedAt: sql`excluded.lastFetchedAt`,
},
})
.run();
}
});
}
/** Fetch seasons from the DB, building the Season[] structure. */
function fetchSeasonsFromDb(titleId: string): Season[] {
const seasonRows = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.orderBy(seasons.seasonNumber)
.all();
if (seasonRows.length === 0) return [];
const seasonIds = seasonRows.map((s) => s.id);
const allEps = db
.select()
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.orderBy(episodes.seasonId, episodes.episodeNumber)
.all();
const epsBySeason = new Map<string, Episode[]>();
for (const ep of allEps) {
const arr = epsBySeason.get(ep.seasonId) ?? [];
arr.push({
id: ep.id,
episodeNumber: ep.episodeNumber,
name: ep.name,
overview: ep.overview,
stillPath: tmdbImageUrl(ep.stillPath, "stills"),
airDate: ep.airDate,
runtimeMinutes: ep.runtimeMinutes,
});
epsBySeason.set(ep.seasonId, arr);
}
return seasonRows.map((s) => ({
id: s.id,
seasonNumber: s.seasonNumber,
name: s.name,
episodes: epsBySeason.get(s.id) ?? [],
}));
}
/**
* Ensure a TV title is fully hydrated (seasons/episodes fetched from TMDB).
* Returns the hydrated seasons data.
*/
export async function ensureTvHydrated(
titleId: string,
tmdbId: number,
): Promise<Season[]> {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title || title.type !== "tv") return [];
// Shell title: fetch details + children
if (!title.lastFetchedAt) {
try {
const show = await getTvDetails(tmdbId);
db.update(titles)
.set({
overview: show.overview,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
status: show.status,
contentRating: extractTvContentRating(show),
lastFetchedAt: new Date(),
})
.where(eq(titles.id, titleId))
.run();
upsertGenres(titleId, show.genres ?? []);
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
} catch (err) {
log.debug(`Failed to hydrate shell TV title ${titleId}:`, err);
}
}
let result = fetchSeasonsFromDb(titleId);
// Retry hydration when seasons are still missing
if (result.length === 0) {
try {
const show = await getTvDetails(tmdbId);
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
result = fetchSeasonsFromDb(titleId);
} catch (err) {
log.debug(
`Failed to backfill missing seasons for title ${titleId}:`,
err,
);
}
}
return result;
}
/**
* Ensure a title has all enrichment data. Accepts already-read data to avoid
* redundant queries — only does lightweight existence checks for data not
* already loaded (recommendations). Returns true if any work was performed.
*/
async function ensureEnriched(
titleId: string,
title: typeof titles.$inferSelect,
existing: { hasCast: boolean; hasAvailability: boolean },
): Promise<boolean> {
const tasks: Promise<void>[] = [];
if (!existing.hasCast) {
tasks.push(
refreshCredits(titleId).catch((err) =>
log.debug("Credits enrichment failed:", err),
),
);
}
if (!existing.hasAvailability) {
tasks.push(
refreshAvailability(titleId).catch((err) =>
log.debug("Availability enrichment failed:", err),
),
);
}
// Recommendations are loaded separately (Suspense), so check here
const hasRecommendations =
db
.select({ titleId: titleRecommendations.titleId })
.from(titleRecommendations)
.where(eq(titleRecommendations.titleId, titleId))
.limit(1)
.get() != null;
if (!hasRecommendations) {
tasks.push(
refreshRecommendations(titleId).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
),
);
}
if (!title.colorPalette && title.posterPath) {
tasks.push(
extractAndStoreColors(titleId, title.posterPath).then(
() => {},
(err) => log.debug("Color enrichment failed:", err),
),
);
}
if (!title.trailerVideoKey) {
tasks.push(
refreshTrailer(titleId).catch((err) =>
log.debug("Trailer enrichment failed:", err),
),
);
}
if (tasks.length > 0) {
log.debug(
`Backfilling ${tasks.length} enrichment task(s) for "${title.title}"`,
);
await Promise.all(tasks);
return true;
}
return false;
}
function readAvailability(
titleId: string,
titleName: string,
): AvailabilityOffer[] {
return db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
.all()
.map((a) => ({
providerId: a.providerId,
providerName: a.providerName,
logoPath: tmdbImageUrl(a.logoPath, "logos"),
offerType: a.offerType,
watchUrl: generateProviderUrl(a.providerId, titleName),
}));
}
export async function getOrFetchTitle(id: string): Promise<{
title: ResolvedTitle;
seasons: Season[];
needsHydration: boolean;
availability: AvailabilityOffer[];
cast: CastMember[];
} | null> {
let title = db.select().from(titles).where(eq(titles.id, id)).get();
if (!title) return null;
// For TV titles, check if seasons need hydration (reuse result below)
const existingSeasons =
title.type === "tv" && title.lastFetchedAt ? fetchSeasonsFromDb(id) : null;
const needsTvHydration =
title.type === "tv" &&
(!title.lastFetchedAt || existingSeasons?.length === 0);
// If this is a shell movie title, fetch full details now (movies are fast)
if (title.type === "movie" && !title.lastFetchedAt) {
try {
const movie = await getMovieDetails(title.tmdbId);
db.update(titles)
.set({
title: movie.title,
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
popularity: movie.popularity,
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
lastFetchedAt: new Date(),
})
.where(eq(titles.id, id))
.run();
upsertGenres(id, movie.genres ?? []);
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
} catch (err) {
log.debug(`Failed to hydrate shell movie title ${id}:`, err);
}
}
const titleSeasons = needsTvHydration ? [] : (existingSeasons ?? []);
// Read enrichment data, then backfill anything missing
let availability = readAvailability(title.id, title.title);
let cast = getCastForTitle(id);
if (title.lastFetchedAt) {
const enriched = await ensureEnriched(id, title, {
hasCast: cast.length > 0,
hasAvailability: availability.length > 0,
});
if (enriched) {
// Re-read only what was missing
if (cast.length === 0) cast = getCastForTitle(id);
if (availability.length === 0)
availability = readAvailability(title.id, title.title);
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
}
}
const palette = parseColorPalette(title.colorPalette);
const titleGenreRows = db
.select({ name: genres.name })
.from(titleGenres)
.innerJoin(genres, eq(titleGenres.genreId, genres.id))
.where(eq(titleGenres.titleId, id))
.all();
const resolvedTitle: ResolvedTitle = {
id: title.id,
tmdbId: title.tmdbId,
type: title.type as "movie" | "tv",
title: title.title,
originalTitle: title.originalTitle,
overview: title.overview,
releaseDate: title.releaseDate,
firstAirDate: title.firstAirDate,
posterPath: tmdbImageUrl(title.posterPath, "posters"),
backdropPath: tmdbImageUrl(title.backdropPath, "backdrops"),
popularity: title.popularity,
voteAverage: title.voteAverage,
voteCount: title.voteCount,
status: title.status,
contentRating: title.contentRating,
colorPalette: palette,
trailerVideoKey: title.trailerVideoKey,
genres: titleGenreRows.map((r) => r.name),
};
return {
title: resolvedTitle,
seasons: titleSeasons,
needsHydration: needsTvHydration,
availability,
cast,
};
}
export function pickBestTrailer(videos: TmdbVideo[]): string | null {
let candidates = videos.filter((v) => v.site === "YouTube");
if (candidates.length === 0) return null;
// Prefer English, fall back to any language
const english = candidates.filter((v) => v.iso_639_1 === "en");
if (english.length > 0) candidates = english;
const byDate = (a: TmdbVideo, b: TmdbVideo) =>
(b.published_at ?? "").localeCompare(a.published_at ?? "");
// Tier 1: official trailers
const officialTrailers = candidates
.filter((v) => v.official && v.type === "Trailer")
.sort(byDate);
if (officialTrailers.length > 0) return officialTrailers[0].key ?? null;
// Tier 2: any trailer
const trailers = candidates.filter((v) => v.type === "Trailer").sort(byDate);
if (trailers.length > 0) return trailers[0].key ?? null;
// Tier 3: teasers
const teasers = candidates.filter((v) => v.type === "Teaser").sort(byDate);
if (teasers.length > 0) return teasers[0].key ?? null;
return null;
}
export async function refreshTrailer(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
try {
const response = await getVideos(title.tmdbId, title.type);
const key = pickBestTrailer(response.results ?? []);
db.update(titles)
.set({ trailerVideoKey: key })
.where(eq(titles.id, titleId))
.run();
log.debug(
`Trailer for "${title.title}": ${key ? `YouTube ${key}` : "none found"}`,
);
} catch (err) {
log.debug(`Failed to fetch trailer for title ${titleId}:`, err);
}
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+264
View File
@@ -0,0 +1,264 @@
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
import { db } from "@sofa/db/client";
import { eq, inArray } from "@sofa/db/helpers";
import { persons, titleCast, titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@sofa/tmdb/image";
const log = createLogger("person");
export async function getOrFetchPerson(
personId: string,
): Promise<ResolvedPerson | null> {
const person = db
.select()
.from(persons)
.where(eq(persons.id, personId))
.get();
if (!person) return null;
// Shell record — lazily hydrate from TMDB
if (!person.lastFetchedAt) {
try {
const details = await getPersonDetails(person.tmdbId);
db.update(persons)
.set({
name: details.name || person.name,
biography: details.biography || null,
birthday: details.birthday,
deathday: details.deathday,
placeOfBirth: details.place_of_birth,
profilePath: details.profile_path,
knownForDepartment: details.known_for_department,
popularity: details.popularity,
imdbId: details.imdb_id,
lastFetchedAt: new Date(),
})
.where(eq(persons.id, personId))
.run();
return {
id: person.id,
tmdbId: person.tmdbId,
name: details.name || person.name,
biography: details.biography || null,
birthday: details.birthday ?? null,
deathday: details.deathday ?? null,
placeOfBirth: details.place_of_birth ?? null,
profilePath: tmdbImageUrl(details.profile_path ?? null, "profiles"),
knownForDepartment: details.known_for_department ?? null,
imdbId: details.imdb_id ?? null,
};
} catch (err) {
log.error(`Failed to hydrate person ${personId}:`, err);
}
}
return {
id: person.id,
tmdbId: person.tmdbId,
name: person.name,
biography: person.biography,
birthday: person.birthday,
deathday: person.deathday,
placeOfBirth: person.placeOfBirth,
profilePath: tmdbImageUrl(person.profilePath, "profiles"),
knownForDepartment: person.knownForDepartment,
imdbId: person.imdbId,
};
}
export async function getOrFetchPersonByTmdbId(
tmdbId: number,
): Promise<ResolvedPerson | null> {
const existing = db
.select()
.from(persons)
.where(eq(persons.tmdbId, tmdbId))
.get();
if (existing) {
return getOrFetchPerson(existing.id);
}
// Create from TMDB
try {
const details = await getPersonDetails(tmdbId);
const row = db
.insert(persons)
.values({
tmdbId,
name: details.name ?? "",
biography: details.biography || null,
birthday: details.birthday ?? null,
deathday: details.deathday ?? null,
placeOfBirth: details.place_of_birth ?? null,
profilePath: details.profile_path ?? null,
knownForDepartment: details.known_for_department ?? null,
popularity: details.popularity,
imdbId: details.imdb_id ?? null,
lastFetchedAt: new Date(),
})
.onConflictDoNothing()
.returning()
.get();
const person =
row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
if (!person) return null;
return {
id: person.id,
tmdbId: person.tmdbId,
name: person.name,
biography: person.biography,
birthday: person.birthday,
deathday: person.deathday,
placeOfBirth: person.placeOfBirth,
profilePath: tmdbImageUrl(person.profilePath, "profiles"),
knownForDepartment: person.knownForDepartment,
imdbId: person.imdbId,
};
} catch (err) {
log.error(`Failed to fetch person TMDB ${tmdbId}:`, err);
return null;
}
}
export function getLocalFilmography(personId: string): PersonCredit[] {
const rows = db
.select({
titleId: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
title: titles.title,
posterPath: titles.posterPath,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
character: titleCast.character,
department: titleCast.department,
job: titleCast.job,
})
.from(titleCast)
.innerJoin(titles, eq(titleCast.titleId, titles.id))
.where(eq(titleCast.personId, personId))
.all();
return rows.map((r) => ({
titleId: r.titleId,
tmdbId: r.tmdbId,
type: r.type as "movie" | "tv",
title: r.title,
posterPath: tmdbImageUrl(r.posterPath, "posters"),
releaseDate: r.releaseDate,
firstAirDate: r.firstAirDate,
voteAverage: r.voteAverage,
character: r.character,
department: r.department,
job: r.job,
}));
}
export async function fetchFullFilmography(
personId: string,
): Promise<PersonCredit[]> {
const person = db
.select()
.from(persons)
.where(eq(persons.id, personId))
.get();
if (!person) return [];
const credits = await getPersonCombinedCredits(person.tmdbId);
// Filter to valid cast entries
// Schema types combined credits cast as movie-only; TV entries also carry
// `name` and `first_air_date` at runtime, so widen the type minimally.
type CastEntry = (typeof credits)["cast"] extends (infer E)[] | undefined
? E & { name?: string; first_air_date?: string }
: never;
const validCast = ((credits.cast ?? []) as CastEntry[]).filter(
(c) => c.media_type === "movie" || c.media_type === "tv",
);
if (validCast.length === 0) return [];
// Batch prefetch existing titles (1 query)
const tmdbIds = [...new Set(validCast.map((c) => c.id))];
const existingTitles = db
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all();
const titleIdMap = new Map<number, string>(
existingTitles.map((t) => [t.tmdbId, t.id]),
);
// Batch insert missing titles in a transaction
const newCast = validCast.filter((c) => !titleIdMap.has(c.id));
if (newCast.length > 0) {
const insertedTmdbIds = new Set<number>();
db.transaction((tx) => {
for (const c of newCast) {
if (insertedTmdbIds.has(c.id)) continue;
insertedTmdbIds.add(c.id);
const row = tx
.insert(titles)
.values({
tmdbId: c.id,
type: c.media_type as "movie" | "tv",
title: c.title ?? c.name ?? "Unknown",
overview: c.overview,
releaseDate: c.release_date,
firstAirDate: c.first_air_date,
posterPath: c.poster_path,
backdropPath: c.backdrop_path,
popularity: c.popularity,
voteAverage: c.vote_average,
voteCount: c.vote_count,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning()
.get();
if (row) titleIdMap.set(c.id, row.id);
}
});
// One fallback query for any that conflicted
const stillMissing = [...insertedTmdbIds].filter(
(id) => !titleIdMap.has(id),
);
if (stillMissing.length > 0) {
const fallbacks = db
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, stillMissing))
.all();
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
}
}
// Build results from map (0 queries)
const results: PersonCredit[] = [];
for (const c of validCast) {
const tid = titleIdMap.get(c.id);
if (!tid) continue;
results.push({
titleId: tid,
tmdbId: c.id,
type: c.media_type as "movie" | "tv",
title: c.title ?? c.name ?? "Unknown",
posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
releaseDate: c.release_date ?? null,
firstAirDate: c.first_air_date ?? null,
voteAverage: c.vote_average,
character: c.character ?? null,
department: "Acting",
job: null,
});
}
return results;
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Provider registry mapping TMDB provider IDs to search URL templates.
*
* To add a new provider:
* 1. Find the TMDB provider_id (visible in availability data or TMDB API)
* 2. Add an entry below with the service's search URL using {title} placeholder
*/
interface ProviderConfig {
name: string;
searchUrl: string;
}
// TMDB provider_id → search URL config
const providers: Record<number, ProviderConfig> = {
// Netflix
8: { name: "Netflix", searchUrl: "https://www.netflix.com/search?q={title}" },
1796: {
name: "Netflix basic with Ads",
searchUrl: "https://www.netflix.com/search?q={title}",
},
// Amazon
9: {
name: "Amazon Prime Video",
searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}",
},
10: {
name: "Amazon Video",
searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}",
},
119: {
name: "Amazon Prime Video",
searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}",
},
// Disney+
337: {
name: "Disney+",
searchUrl: "https://www.disneyplus.com/search/{title}",
},
// Apple
2: {
name: "Apple iTunes",
searchUrl: "https://tv.apple.com/search?term={title}",
},
350: {
name: "Apple TV+",
searchUrl: "https://tv.apple.com/search?term={title}",
},
// Hulu
15: { name: "Hulu", searchUrl: "https://www.hulu.com/search?q={title}" },
// Max (HBO)
384: { name: "HBO Max", searchUrl: "https://play.max.com/search?q={title}" },
1899: { name: "Max", searchUrl: "https://play.max.com/search?q={title}" },
// Paramount+
531: {
name: "Paramount+",
searchUrl: "https://www.paramountplus.com/search/?q={title}",
},
// Peacock
386: {
name: "Peacock",
searchUrl: "https://www.peacocktv.com/search?q={title}",
},
// Google Play
3: {
name: "Google Play Movies",
searchUrl: "https://play.google.com/store/search?q={title}&c=movies",
},
// YouTube
192: {
name: "YouTube",
searchUrl: "https://www.youtube.com/results?search_query={title}",
},
// Crunchyroll
283: {
name: "Crunchyroll",
searchUrl: "https://www.crunchyroll.com/search?q={title}",
},
// Free / ad-supported
73: { name: "Tubi", searchUrl: "https://tubitv.com/search/{title}" },
300: {
name: "Pluto TV",
searchUrl: "https://pluto.tv/search/details?q={title}",
},
// Other
257: { name: "fuboTV", searchUrl: "https://www.fubo.tv/search/{title}" },
43: {
name: "Starz",
searchUrl: "https://www.starz.com/search?query={title}",
},
37: {
name: "Showtime",
searchUrl: "https://www.sho.com/search?q={title}",
},
};
const providerRegistry: ReadonlyMap<number, ProviderConfig> = new Map(
Object.entries(providers).map(([id, config]) => [Number(id), config]),
);
export function generateProviderUrl(
providerId: number,
titleName: string,
): string | null {
const config = providerRegistry.get(providerId);
if (!config) return null;
return config.searchUrl.replace("{title}", encodeURIComponent(titleName));
}
+32
View File
@@ -0,0 +1,32 @@
import { db } from "@sofa/db/client";
import { count, eq } from "@sofa/db/helpers";
import { appSettings, user } from "@sofa/db/schema";
export function getSetting(key: string): string | null {
const row = db
.select()
.from(appSettings)
.where(eq(appSettings.key, key))
.get();
return row?.value ?? null;
}
export function setSetting(key: string, value: string): void {
db.insert(appSettings)
.values({ key, value })
.onConflictDoUpdate({ target: appSettings.key, set: { value } })
.run();
}
export function getUserCount(): number {
const result = db.select({ count: count() }).from(user).get();
return result?.count ?? 0;
}
export function isRegistrationOpen(): boolean {
const userCount = getUserCount();
if (userCount === 0) return true;
const setting = getSetting("registrationOpen");
return setting === "true";
}
+344
View File
@@ -0,0 +1,344 @@
import { access, constants, readdir } from "node:fs/promises";
import path from "node:path";
import {
CACHE_DIR,
DATA_DIR,
DATABASE_URL,
TMDB_API_BASE_URL,
} from "@sofa/config";
import { db } from "@sofa/db/client";
import { count, desc, eq } from "@sofa/db/helpers";
import { cronRuns, episodes, titles, user } from "@sofa/db/schema";
import { listBackups } from "./backup";
import { imageCacheEnabled } from "./image-cache";
import { getSetting } from "./settings";
import { isUpdateCheckEnabled } from "./update-check";
export interface SystemHealthData {
database: {
dbSizeBytes: number;
walSizeBytes: number;
titleCount: number;
episodeCount: number;
userCount: number;
};
tmdb: {
connected: boolean;
tokenValid: boolean;
tokenConfigured: boolean;
responseTimeMs: number | null;
error: string | null;
};
jobs: {
jobName: string;
cronPattern: string | null;
nextRunAt: string | null;
lastRunAt: string | null;
lastDurationMs: number | null;
lastStatus: "running" | "success" | "error" | null;
lastError: string | null;
isCurrentlyRunning: boolean;
disabled: boolean;
}[];
imageCache: {
enabled: boolean;
totalSizeBytes: number;
imageCount: number;
categories: Record<string, { count: number; sizeBytes: number }>;
};
backups: {
lastBackupAt: string | null;
lastBackupAgeHours: number | null;
backupCount: number;
totalSizeBytes: number;
};
environment: {
dataDir: string;
dataDirWritable: boolean;
envVars: { name: string; value: string | null }[];
};
checkedAt: string;
}
const JOB_NAMES = [
"nightlyRefreshLibrary",
"refreshAvailability",
"refreshRecommendations",
"refreshTvChildren",
"cacheImages",
"scheduledBackup",
"updateCheck",
];
function getDatabaseHealth(): SystemHealthData["database"] {
let dbSizeBytes = 0;
let walSizeBytes = 0;
try {
dbSizeBytes = Bun.file(DATABASE_URL).size;
} catch {}
try {
walSizeBytes = Bun.file(`${DATABASE_URL}-wal`).size;
} catch {}
const [titleCount] = db.select({ count: count() }).from(titles).all();
const [episodeCount] = db.select({ count: count() }).from(episodes).all();
const [userCount] = db.select({ count: count() }).from(user).all();
return {
dbSizeBytes,
walSizeBytes,
titleCount: titleCount.count,
episodeCount: episodeCount.count,
userCount: userCount.count,
};
}
async function getTmdbHealth(): Promise<SystemHealthData["tmdb"]> {
const token = process.env.TMDB_API_READ_ACCESS_TOKEN;
if (!token) {
return {
connected: false,
tokenValid: false,
tokenConfigured: false,
responseTimeMs: null,
error: null,
};
}
try {
const start = performance.now();
const res = await fetch(`${TMDB_API_BASE_URL}/configuration`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
});
const responseTimeMs = Math.round(performance.now() - start);
if (res.ok) {
return {
connected: true,
tokenValid: true,
tokenConfigured: true,
responseTimeMs,
error: null,
};
}
return {
connected: true,
tokenValid: false,
tokenConfigured: true,
responseTimeMs,
error: `HTTP ${res.status}`,
};
} catch (err) {
return {
connected: false,
tokenValid: false,
tokenConfigured: true,
responseTimeMs: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
type JobSchedule = {
jobName: string;
pattern: string;
nextRunAt: string | null;
};
let _getJobSchedules: (() => JobSchedule[]) | null = null;
/** Register the job schedule provider (called by the API server on startup). */
export function registerJobScheduleProvider(fn: () => JobSchedule[]) {
_getJobSchedules = fn;
}
function getJobsHealth(): SystemHealthData["jobs"] {
const schedules = _getJobSchedules?.() ?? [];
const scheduleMap = new Map(schedules.map((s) => [s.jobName, s]));
// Fetch only the latest cron run per job (index-optimized LIMIT 1 each)
const latestByJob = new Map<string, typeof cronRuns.$inferSelect>();
for (const jobName of JOB_NAMES) {
const latest = db
.select()
.from(cronRuns)
.where(eq(cronRuns.jobName, jobName))
.orderBy(desc(cronRuns.startedAt))
.limit(1)
.get();
if (latest) latestByJob.set(jobName, latest);
}
return JOB_NAMES.map((jobName) => {
const latest = latestByJob.get(jobName);
const isCurrentlyRunning = latest?.status === "running";
const schedule = scheduleMap.get(jobName);
// Prefer the in-memory durationMs column; fall back to timestamp diff
let lastDurationMs: number | null = latest?.durationMs ?? null;
if (lastDurationMs === null && latest?.finishedAt && latest.startedAt) {
lastDurationMs = latest.finishedAt.getTime() - latest.startedAt.getTime();
}
const disabled =
(jobName === "scheduledBackup" &&
getSetting("scheduledBackups") !== "true") ||
(jobName === "updateCheck" && !isUpdateCheckEnabled());
return {
jobName,
cronPattern: disabled ? null : (schedule?.pattern ?? null),
nextRunAt: disabled ? null : (schedule?.nextRunAt ?? null),
lastRunAt:
latest?.finishedAt?.toISOString() ??
latest?.startedAt?.toISOString() ??
null,
lastDurationMs,
lastStatus: (latest?.status as "running" | "success" | "error") ?? null,
lastError: latest?.errorMessage ?? null,
isCurrentlyRunning,
disabled,
};
});
}
async function getImageCacheHealth(): Promise<SystemHealthData["imageCache"]> {
const enabled = imageCacheEnabled();
if (!enabled) {
return { enabled: false, totalSizeBytes: 0, imageCount: 0, categories: {} };
}
const categoryNames = ["posters", "backdrops", "stills", "logos"];
const categories: Record<string, { count: number; sizeBytes: number }> = {};
let totalSizeBytes = 0;
let imageCount = 0;
await Promise.all(
categoryNames.map(async (category) => {
const dir = path.join(CACHE_DIR, category);
try {
const files = await readdir(dir);
const sizes = await Promise.all(
files.map((file) => Bun.file(path.join(dir, file)).size),
);
const sizeBytes = sizes.reduce((sum, s) => sum + s, 0);
categories[category] = { count: files.length, sizeBytes };
totalSizeBytes += sizeBytes;
imageCount += files.length;
} catch {
categories[category] = { count: 0, sizeBytes: 0 };
}
}),
);
return { enabled: true, totalSizeBytes, imageCount, categories };
}
async function getBackupsHealth(): Promise<SystemHealthData["backups"]> {
try {
const backups = await listBackups();
const totalSizeBytes = backups.reduce((sum, b) => sum + b.sizeBytes, 0);
const lastBackup = backups[0] ?? null;
const lastBackupAt = lastBackup?.createdAt ?? null;
const lastBackupAgeHours = lastBackupAt
? Math.round(
(Date.now() - new Date(lastBackupAt).getTime()) / (1000 * 60 * 60),
)
: null;
return {
lastBackupAt,
lastBackupAgeHours,
backupCount: backups.length,
totalSizeBytes,
};
} catch {
return {
lastBackupAt: null,
lastBackupAgeHours: null,
backupCount: 0,
totalSizeBytes: 0,
};
}
}
async function getEnvironmentHealth(): Promise<
SystemHealthData["environment"]
> {
const resolvedDataDir = path.resolve(DATA_DIR);
let dataDirWritable = false;
try {
await access(resolvedDataDir, constants.W_OK);
dataDirWritable = true;
} catch {}
const redact = (val: string | undefined): string | null => {
if (!val) return null;
if (val.length <= 8) return "••••••••";
return `${val.slice(0, 4)}${"•".repeat(Math.min(val.length - 4, 24))}`;
};
const env = (name: string) => process.env[name] ?? null;
return {
dataDir: resolvedDataDir,
dataDirWritable,
envVars: [
// Core
{ name: "DATA_DIR", value: resolvedDataDir },
{ name: "LOG_LEVEL", value: env("LOG_LEVEL") ?? "info" },
// Auth
{ name: "BETTER_AUTH_URL", value: env("BETTER_AUTH_URL") },
{
name: "BETTER_AUTH_SECRET",
value: redact(env("BETTER_AUTH_SECRET") ?? undefined),
},
// TMDB
{
name: "TMDB_API_READ_ACCESS_TOKEN",
value: redact(env("TMDB_API_READ_ACCESS_TOKEN") ?? undefined),
},
{ name: "TMDB_API_BASE_URL", value: env("TMDB_API_BASE_URL") },
{ name: "TMDB_IMAGE_BASE_URL", value: env("TMDB_IMAGE_BASE_URL") },
// Image cache
{
name: "IMAGE_CACHE_ENABLED",
value: env("IMAGE_CACHE_ENABLED") ?? "true",
},
// OIDC
{ name: "OIDC_ISSUER_URL", value: env("OIDC_ISSUER_URL") },
{ name: "OIDC_CLIENT_ID", value: env("OIDC_CLIENT_ID") },
{
name: "OIDC_CLIENT_SECRET",
value: redact(env("OIDC_CLIENT_SECRET") ?? undefined),
},
{ name: "OIDC_PROVIDER_NAME", value: env("OIDC_PROVIDER_NAME") },
{ name: "OIDC_AUTO_REGISTER", value: env("OIDC_AUTO_REGISTER") },
{ name: "DISABLE_PASSWORD_LOGIN", value: env("DISABLE_PASSWORD_LOGIN") },
],
};
}
export async function getSystemHealth(): Promise<SystemHealthData> {
const [tmdb, imageCache, backups, environment] = await Promise.all([
getTmdbHealth(),
getImageCacheHealth(),
getBackupsHealth(),
getEnvironmentHealth(),
]);
return {
database: getDatabaseHealth(),
tmdb,
jobs: getJobsHealth(),
imageCache,
backups,
environment,
checkedAt: new Date().toISOString(),
};
}
+566
View File
@@ -0,0 +1,566 @@
import { db } from "@sofa/db/client";
import { and, eq, inArray, sql } from "@sofa/db/helpers";
import {
episodes,
seasons,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
export function setTitleStatus(
userId: string,
titleId: string,
status: "watchlist" | "in_progress" | "completed",
// biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
) {
const now = new Date();
db.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status, updatedAt: now },
})
.run();
}
export function removeTitleStatus(userId: string, titleId: string) {
db.delete(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.run();
}
export function logMovieWatch(
userId: string,
titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
) {
const now = new Date();
db.insert(userMovieWatches)
.values({ userId, titleId, watchedAt: now, source })
.run();
// Auto-set status to completed
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
if (!existing) {
setTitleStatus(userId, titleId, "completed", source);
} else if (existing.status !== "completed") {
setTitleStatus(userId, titleId, "completed", source);
}
}
export function logEpisodeWatch(
userId: string,
episodeId: string,
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
) {
const now = new Date();
db.insert(userEpisodeWatches)
.values({ userId, episodeId, watchedAt: now, source })
.run();
// Find the title for this episode (single JOIN instead of 2 queries)
const row = db
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(episodes.id, episodeId))
.get();
if (!row) return;
const { titleId } = row;
// Auto-set status to in_progress if not set
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
if (!existing || existing.status === "watchlist") {
setTitleStatus(userId, titleId, "in_progress", source);
}
// Check if all episodes are watched -> auto-complete
checkAllEpisodesWatched(userId, titleId);
}
export function logEpisodeWatchBatch(
userId: string,
episodeIds: string[],
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
) {
if (episodeIds.length === 0) return;
db.transaction((tx) => {
const now = new Date();
// Batch INSERT all watch records
for (const episodeId of episodeIds) {
tx.insert(userEpisodeWatches)
.values({ userId, episodeId, watchedAt: now, source })
.run();
}
// Resolve episode → season → title hierarchy with batch queries
const eps = tx
.select()
.from(episodes)
.where(inArray(episodes.id, episodeIds))
.all();
if (eps.length === 0) return;
const seasonIds = [...new Set(eps.map((e) => e.seasonId))];
const seasonRows = tx
.select()
.from(seasons)
.where(inArray(seasons.id, seasonIds))
.all();
if (seasonRows.length === 0) return;
const { titleId } = seasonRows[0];
// Set status to in_progress if not already set
const existing = tx
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
if (!existing || existing.status === "watchlist") {
const statusNow = new Date();
tx.insert(userTitleStatus)
.values({
userId,
titleId,
status: "in_progress",
addedAt: statusNow,
updatedAt: statusNow,
})
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status: "in_progress", updatedAt: statusNow },
})
.run();
}
// Check completion once (not per-episode)
const allSeasons = tx
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
if (allSeasons.length === 0) return;
const allSeasonIds = allSeasons.map((s) => s.id);
const allEps = tx
.select()
.from(episodes)
.where(inArray(episodes.seasonId, allSeasonIds))
.all();
const totalEpisodes = allEps.length;
if (totalEpisodes === 0) return;
const allEpIds = allEps.map((ep) => ep.id);
const [watchCount] = tx
.select({
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
})
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, allEpIds),
),
)
.all();
if (watchCount.count >= totalEpisodes) {
const completeNow = new Date();
tx.insert(userTitleStatus)
.values({
userId,
titleId,
status: "completed",
addedAt: completeNow,
updatedAt: completeNow,
})
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status: "completed", updatedAt: completeNow },
})
.run();
}
});
}
export function markAllEpisodesWatched(
userId: string,
titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title || title.type !== "tv") return;
const now = new Date();
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
const allEps = db
.select({ id: episodes.id })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId))
.all();
const epIds = allEps.map((ep) => ep.id);
const existingWatches =
epIds.length > 0
? new Set(
db
.select({ episodeId: userEpisodeWatches.episodeId })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, epIds),
),
)
.all()
.map((w) => w.episodeId),
)
: new Set<string>();
db.transaction((tx) => {
for (const ep of allEps) {
if (!existingWatches.has(ep.id)) {
tx.insert(userEpisodeWatches)
.values({ userId, episodeId: ep.id, watchedAt: now, source })
.run();
}
}
});
setTitleStatus(userId, titleId, "completed", source);
}
function checkAllEpisodesWatched(userId: string, titleId: string) {
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
const allEps = db
.select({ id: episodes.id })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId))
.all();
const totalEpisodes = allEps.length;
if (totalEpisodes === 0) return;
const epIds = allEps.map((ep) => ep.id);
const [watchCount] = db
.select({
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
})
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, epIds),
),
)
.all();
if (watchCount.count >= totalEpisodes) {
setTitleStatus(userId, titleId, "completed");
}
}
export function unwatchEpisode(userId: string, episodeId: string) {
db.delete(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, episodeId),
),
)
.run();
// Find parent title and downgrade from completed to in_progress
const row = db
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(episodes.id, episodeId))
.get();
if (!row) return;
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, row.titleId),
),
)
.get();
if (existing?.status === "completed") {
setTitleStatus(userId, row.titleId, "in_progress");
}
}
export function unwatchSeason(userId: string, seasonId: string) {
const seasonEps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, seasonId))
.all();
const epIds = seasonEps.map((ep) => ep.id);
if (epIds.length > 0) {
db.delete(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, epIds),
),
)
.run();
}
// Find parent title and downgrade from completed to in_progress
const season = db
.select()
.from(seasons)
.where(eq(seasons.id, seasonId))
.get();
if (!season) return;
const existing = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, season.titleId),
),
)
.get();
if (existing?.status === "completed") {
setTitleStatus(userId, season.titleId, "in_progress");
}
}
export function rateTitleStars(
userId: string,
titleId: string,
ratingStars: number,
) {
const now = new Date();
if (ratingStars === 0) {
db.delete(userRatings)
.where(
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
)
.run();
return;
}
db.insert(userRatings)
.values({ userId, titleId, ratingStars, ratedAt: now })
.onConflictDoUpdate({
target: [userRatings.userId, userRatings.titleId],
set: { ratingStars, ratedAt: now },
})
.run();
}
export function getUserStatusesByTmdbIds(
userId: string,
tmdbIds: { tmdbId: number; type: string }[],
): Record<string, "watchlist" | "in_progress" | "completed"> {
if (tmdbIds.length === 0) return {};
const allTmdbIds = tmdbIds.map((t) => t.tmdbId);
const rows = db
.select({
tmdbId: titles.tmdbId,
type: titles.type,
status: userTitleStatus.status,
})
.from(userTitleStatus)
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
.where(
and(
eq(userTitleStatus.userId, userId),
inArray(titles.tmdbId, allTmdbIds),
),
)
.all();
const result: Record<string, "watchlist" | "in_progress" | "completed"> = {};
for (const row of rows) {
result[`${row.tmdbId}-${row.type}`] = row.status as
| "watchlist"
| "in_progress"
| "completed";
}
return result;
}
export function getUserStatusesByTitleIds(
userId: string,
titleIds: string[],
): Record<string, "watchlist" | "in_progress" | "completed"> {
if (titleIds.length === 0) return {};
const rows = db
.select({
titleId: userTitleStatus.titleId,
status: userTitleStatus.status,
})
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
inArray(userTitleStatus.titleId, titleIds),
),
)
.all();
const result: Record<string, "watchlist" | "in_progress" | "completed"> = {};
for (const row of rows) {
result[row.titleId] = row.status as
| "watchlist"
| "in_progress"
| "completed";
}
return result;
}
export function getEpisodeProgressByTmdbIds(
userId: string,
tmdbIds: { tmdbId: number; type: string }[],
): Record<string, { watched: number; total: number }> {
const tvIds = tmdbIds.filter((t) => t.type === "tv").map((t) => t.tmdbId);
if (tvIds.length === 0) return {};
const rows = db
.select({
tmdbId: titles.tmdbId,
totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as(
"totalEpisodes",
),
watchedEpisodes:
sql<number>`count(distinct case when ${userEpisodeWatches.id} is not null then ${episodes.id} end)`.as(
"watchedEpisodes",
),
})
.from(titles)
.innerJoin(seasons, eq(seasons.titleId, titles.id))
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
.leftJoin(
userEpisodeWatches,
and(
eq(userEpisodeWatches.episodeId, episodes.id),
eq(userEpisodeWatches.userId, userId),
),
)
.where(and(inArray(titles.tmdbId, tvIds), eq(titles.type, "tv")))
.groupBy(titles.tmdbId)
.all();
const result: Record<string, { watched: number; total: number }> = {};
for (const row of rows) {
if (row.watchedEpisodes > 0) {
result[`${row.tmdbId}-tv`] = {
watched: row.watchedEpisodes,
total: row.totalEpisodes,
};
}
}
return result;
}
export function getUserTitleInfo(userId: string, titleId: string) {
const status = db
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
const rating = db
.select()
.from(userRatings)
.where(
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
)
.get();
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
const allEps = db
.select({ id: episodes.id })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId))
.all();
const epIds = allEps.map((ep) => ep.id);
// Batch fetch all watches for these episodes
const watchedEpisodeIds =
epIds.length > 0
? Array.from(
new Set(
db
.select({ episodeId: userEpisodeWatches.episodeId })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, epIds),
),
)
.all()
.map((w) => w.episodeId),
),
)
: [];
return {
status: status?.status ?? null,
rating: rating?.ratingStars ?? null,
episodeWatches: watchedEpisodeIds,
};
}
+104
View File
@@ -0,0 +1,104 @@
import { createLogger } from "@sofa/logger";
import { getSetting, setSetting } from "./settings";
const APP_VERSION = process.env.APP_VERSION || "0.0.0";
const log = createLogger("update-check");
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/jakejarvis/sofa/releases/latest";
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
export interface UpdateCheckResult {
updateAvailable: boolean;
currentVersion: string;
latestVersion: string | null;
releaseUrl: string | null;
lastCheckedAt: string | null;
}
export function isUpdateCheckEnabled(): boolean {
const setting = getSetting("updateCheckEnabled");
// Default to true when no setting exists
return setting !== "false";
}
/** @internal Returns true if `latest` is strictly newer than `current` using semver comparison. */
export function isNewerVersion(latest: string, current: string): boolean {
const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number);
const [lMajor = 0, lMinor = 0, lPatch = 0] = parse(latest);
const [cMajor = 0, cMinor = 0, cPatch = 0] = parse(current);
if (lMajor !== cMajor) return lMajor > cMajor;
if (lMinor !== cMinor) return lMinor > cMinor;
return lPatch > cPatch;
}
export function getCachedUpdateCheck(): UpdateCheckResult {
const latestVersion = getSetting("updateCheckLatestVersion");
const releaseUrl = getSetting("updateCheckReleaseUrl");
const lastCheckedAt = getSetting("updateCheckLastCheckedAt");
return {
updateAvailable: latestVersion
? isNewerVersion(latestVersion, APP_VERSION)
: false,
currentVersion: APP_VERSION,
latestVersion,
releaseUrl,
lastCheckedAt,
};
}
export async function performUpdateCheck(): Promise<UpdateCheckResult> {
if (!isUpdateCheckEnabled()) {
return {
updateAvailable: false,
currentVersion: APP_VERSION,
latestVersion: null,
releaseUrl: null,
lastCheckedAt: null,
};
}
// Respect cache interval
const lastChecked = getSetting("updateCheckLastCheckedAt");
if (lastChecked) {
const elapsed = Date.now() - new Date(lastChecked).getTime();
if (elapsed < CHECK_INTERVAL_MS) {
return getCachedUpdateCheck();
}
}
try {
const res = await fetch(GITHUB_RELEASES_URL, {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "sofa-update-check",
},
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
const data = (await res.json()) as { tag_name: string; html_url: string };
const version = data.tag_name.replace(/^v/, "");
setSetting("updateCheckLatestVersion", version);
setSetting("updateCheckReleaseUrl", data.html_url);
setSetting("updateCheckLastCheckedAt", new Date().toISOString());
log.info(
`Update check complete: current=${APP_VERSION}, latest=${version}`,
);
return {
updateAvailable: isNewerVersion(version, APP_VERSION),
currentVersion: APP_VERSION,
latestVersion: version,
releaseUrl: data.html_url,
lastCheckedAt: new Date().toISOString(),
};
} catch (err) {
log.warn("Update check failed:", err);
return getCachedUpdateCheck();
}
}
+441
View File
@@ -0,0 +1,441 @@
import { db } from "@sofa/db/client";
import { and, eq, gte } from "@sofa/db/helpers";
import {
episodes,
integrationEvents,
integrations,
seasons,
userEpisodeWatches,
userMovieWatches,
} from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { findByExternalId, searchTv } from "@sofa/tmdb/client";
import { getOrFetchTitleByTmdbId } from "./metadata";
import { logEpisodeWatch, logMovieWatch } from "./tracking";
const log = createLogger("webhooks");
// ─── Types ──────────────────────────────────────────────────────────
export interface WebhookEvent {
provider: "plex" | "jellyfin" | "emby";
mediaType: "movie" | "episode";
title: string;
tmdbId?: number;
imdbId?: string;
tvdbId?: string;
seasonNumber?: number;
episodeNumber?: number;
showTitle?: string;
}
/** @internal */
export function toOptionalInt(value: unknown): number | undefined {
if (typeof value === "number" && Number.isInteger(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseInt(value, 10);
if (!Number.isNaN(parsed)) return parsed;
}
return undefined;
}
// ─── Payload Parsers ────────────────────────────────────────────────
export function parsePlexPayload(formData: FormData): WebhookEvent | null {
const raw = formData.get("payload");
if (!raw || typeof raw !== "string") return null;
let payload: Record<string, unknown>;
try {
payload = JSON.parse(raw);
} catch {
return null;
}
if (payload.event !== "media.scrobble") return null;
const metadata = payload.Metadata as Record<string, unknown> | undefined;
if (!metadata) return null;
const metaType = metadata.type as string;
const isMovie = metaType === "movie";
const isEpisode = metaType === "episode";
if (!isMovie && !isEpisode) return null;
// Extract external IDs from Guid array
const guids = (metadata.Guid ?? metadata.guid) as
| { id: string }[]
| undefined;
let tmdbId: number | undefined;
let imdbId: string | undefined;
let tvdbId: string | undefined;
if (Array.isArray(guids)) {
for (const g of guids) {
const id = g.id ?? "";
if (id.startsWith("tmdb://")) tmdbId = toOptionalInt(id.slice(7));
else if (id.startsWith("imdb://")) imdbId = id.slice(7);
else if (id.startsWith("tvdb://")) tvdbId = id.slice(7);
}
}
return {
provider: "plex",
mediaType: isMovie ? "movie" : "episode",
title: (metadata.title ?? metadata.Title ?? "") as string,
tmdbId,
imdbId,
tvdbId,
seasonNumber: toOptionalInt(metadata.parentIndex),
episodeNumber: toOptionalInt(metadata.index),
showTitle: (metadata.grandparentTitle ?? metadata.parentTitle) as
| string
| undefined,
};
}
export function parseJellyfinPayload(
body: Record<string, unknown>,
): WebhookEvent | null {
const notifType = body.NotificationType as string | undefined;
if (notifType !== "PlaybackStop") return null;
if (body.PlayedToCompletion !== true) return null;
const itemType = body.ItemType as string | undefined;
const isMovie = itemType === "Movie";
const isEpisode = itemType === "Episode";
if (!isMovie && !isEpisode) return null;
const tmdbId = toOptionalInt(body.Provider_tmdb);
return {
provider: "jellyfin",
mediaType: isMovie ? "movie" : "episode",
title: (body.Name ?? "") as string,
tmdbId,
imdbId: (body.Provider_imdb as string) || undefined,
tvdbId: (body.Provider_tvdb as string) || undefined,
seasonNumber: toOptionalInt(body.SeasonNumber),
episodeNumber: toOptionalInt(body.EpisodeNumber),
showTitle: (body.SeriesName ?? body.ShowName) as string | undefined,
};
}
export function parseEmbyPayload(
body: Record<string, unknown>,
): WebhookEvent | null {
// Emby sends "playback.stop" or "PlaybackStop" depending on webhook plugin version
const event = body.Event as string | undefined;
if (event !== "playback.stop" && event !== "PlaybackStop") return null;
if (body.PlayedToCompletion !== true) return null;
const item = body.Item as Record<string, unknown> | undefined;
if (!item) return null;
const itemType = item.Type as string | undefined;
const isMovie = itemType === "Movie";
const isEpisode = itemType === "Episode";
if (!isMovie && !isEpisode) return null;
const providerIds = (item.ProviderIds ?? {}) as Record<string, string>;
const tmdbId = toOptionalInt(providerIds.Tmdb);
return {
provider: "emby",
mediaType: isMovie ? "movie" : "episode",
title: (item.Name ?? "") as string,
tmdbId,
imdbId: providerIds.Imdb || undefined,
tvdbId: providerIds.Tvdb || undefined,
seasonNumber: toOptionalInt(item.ParentIndexNumber),
episodeNumber: toOptionalInt(item.IndexNumber),
showTitle: (item.SeriesName ?? item.ShowName) as string | undefined,
};
}
// ─── Title Resolution ───────────────────────────────────────────────
async function resolveMovieTmdbId(event: WebhookEvent): Promise<number | null> {
if (event.tmdbId) return event.tmdbId;
if (event.imdbId) {
const result = await findByExternalId(event.imdbId, "imdb_id");
const movie = result.movie_results?.[0];
if (movie) return movie.id;
}
if (event.tvdbId) {
const result = await findByExternalId(event.tvdbId, "tvdb_id");
const movie = result.movie_results?.[0];
if (movie) return movie.id;
}
return null;
}
async function resolveEpisode(event: WebhookEvent): Promise<{
showTmdbId: number;
seasonNumber: number;
episodeNumber: number;
} | null> {
const { seasonNumber, episodeNumber } = event;
if (seasonNumber == null || episodeNumber == null) return null;
// Strategy 1: Use IMDB ID to find the episode and get show_id
if (event.imdbId) {
const result = await findByExternalId(event.imdbId, "imdb_id");
const ep = result.tv_episode_results?.[0];
if (ep) {
return { showTmdbId: ep.show_id, seasonNumber, episodeNumber };
}
// IMDB ID might reference the show itself
const show = result.tv_results?.[0];
if (show) {
return { showTmdbId: show.id, seasonNumber, episodeNumber };
}
}
// Strategy 2: Use TVDB ID
if (event.tvdbId) {
const result = await findByExternalId(event.tvdbId, "tvdb_id");
const ep = result.tv_episode_results?.[0];
if (ep) {
return { showTmdbId: ep.show_id, seasonNumber, episodeNumber };
}
const show = result.tv_results?.[0];
if (show) {
return { showTmdbId: show.id, seasonNumber, episodeNumber };
}
}
// Strategy 3: Use TMDB ID directly if it's the show ID
if (event.tmdbId) {
return { showTmdbId: event.tmdbId, seasonNumber, episodeNumber };
}
// Strategy 4: Search by show title
if (event.showTitle) {
const searchResult = await searchTv(event.showTitle);
const tvMatch = searchResult.results?.[0];
if (tvMatch) {
return { showTmdbId: tvMatch.id, seasonNumber, episodeNumber };
}
}
return null;
}
// ─── Deduplication ──────────────────────────────────────────────────
function isDuplicateMovieWatch(userId: string, titleId: string): boolean {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const recent = db
.select()
.from(userMovieWatches)
.where(
and(
eq(userMovieWatches.userId, userId),
eq(userMovieWatches.titleId, titleId),
gte(userMovieWatches.watchedAt, fiveMinutesAgo),
),
)
.get();
return !!recent;
}
function isDuplicateEpisodeWatch(userId: string, episodeId: string): boolean {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const recent = db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, episodeId),
gte(userEpisodeWatches.watchedAt, fiveMinutesAgo),
),
)
.get();
return !!recent;
}
// ─── Event Logging ──────────────────────────────────────────────────
function logEvent(
connectionId: string,
event: WebhookEvent | null,
status: "success" | "ignored" | "error",
errorMessage?: string,
) {
db.insert(integrationEvents)
.values({
integrationId: connectionId,
eventType:
event?.provider === "plex"
? "media.scrobble"
: event?.provider === "emby"
? "playback.stop"
: "PlaybackStop",
mediaType: event?.mediaType ?? null,
mediaTitle: event?.title ?? null,
status,
errorMessage: errorMessage ?? null,
receivedAt: new Date(),
})
.run();
db.update(integrations)
.set({ lastEventAt: new Date() })
.where(eq(integrations.id, connectionId))
.run();
}
// ─── Main Processing ────────────────────────────────────────────────
export async function processWebhook(
connectionId: string,
userId: string,
provider: "plex" | "jellyfin" | "emby",
event: WebhookEvent,
): Promise<{ status: "success" | "ignored" | "error"; message: string }> {
log.info(`Received ${provider} webhook: ${event.mediaType} "${event.title}"`);
try {
if (event.mediaType === "movie") {
const tmdbId = await resolveMovieTmdbId(event);
if (!tmdbId) {
log.warn(
`Could not resolve TMDB ID for movie "${event.title}" from ${provider}`,
);
logEvent(
connectionId,
event,
"error",
"Could not resolve TMDB ID for movie",
);
return { status: "error", message: "Could not resolve TMDB ID" };
}
const title = await getOrFetchTitleByTmdbId(tmdbId, "movie");
if (!title) {
logEvent(connectionId, event, "error", "Failed to import movie");
return { status: "error", message: "Failed to import movie" };
}
if (isDuplicateMovieWatch(userId, title.id)) {
log.debug(`Duplicate movie watch ignored: "${event.title}"`);
logEvent(
connectionId,
event,
"ignored",
"Duplicate watch within 5 minutes",
);
return { status: "ignored", message: "Duplicate watch" };
}
logMovieWatch(userId, title.id, provider);
log.info(`Logged movie watch: "${event.title}" (TMDB ${tmdbId})`);
logEvent(connectionId, event, "success");
return { status: "success", message: `Logged watch for ${event.title}` };
}
if (event.mediaType === "episode") {
const resolved = await resolveEpisode(event);
if (!resolved) {
log.warn(`Could not resolve episode "${event.title}" from ${provider}`);
logEvent(connectionId, event, "error", "Could not resolve episode");
return { status: "error", message: "Could not resolve episode" };
}
const title = await getOrFetchTitleByTmdbId(resolved.showTmdbId, "tv");
if (!title) {
logEvent(connectionId, event, "error", "Failed to import TV show");
return { status: "error", message: "Failed to import TV show" };
}
// Find the episode in our DB
const season = db
.select()
.from(seasons)
.where(
and(
eq(seasons.titleId, title.id),
eq(seasons.seasonNumber, resolved.seasonNumber),
),
)
.get();
if (!season) {
logEvent(
connectionId,
event,
"error",
`Season ${resolved.seasonNumber} not found`,
);
return {
status: "error",
message: `Season ${resolved.seasonNumber} not found`,
};
}
const episode = db
.select()
.from(episodes)
.where(
and(
eq(episodes.seasonId, season.id),
eq(episodes.episodeNumber, resolved.episodeNumber),
),
)
.get();
if (!episode) {
logEvent(
connectionId,
event,
"error",
`S${resolved.seasonNumber}E${resolved.episodeNumber} not found`,
);
return {
status: "error",
message: `S${resolved.seasonNumber}E${resolved.episodeNumber} not found`,
};
}
if (isDuplicateEpisodeWatch(userId, episode.id)) {
log.debug(`Duplicate episode watch ignored: "${event.title}"`);
logEvent(
connectionId,
event,
"ignored",
"Duplicate watch within 5 minutes",
);
return { status: "ignored", message: "Duplicate watch" };
}
logEpisodeWatch(userId, episode.id, provider);
log.info(
`Logged episode watch: "${event.title}" S${resolved.seasonNumber}E${resolved.episodeNumber}`,
);
logEvent(connectionId, event, "success");
return { status: "success", message: `Logged watch for ${event.title}` };
}
logEvent(
connectionId,
event,
"ignored",
`Unsupported media type: ${event.mediaType}`,
);
return { status: "ignored", message: "Unsupported media type" };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
try {
logEvent(connectionId, event, "error", message);
} catch {
// best-effort
}
return { status: "error", message };
}
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test";
import {
getBackupSource,
isKnownBackup,
isValidBackupFilename,
} from "../src/backup";
describe("getBackupSource", () => {
test("detects manual backup", () => {
expect(getBackupSource("sofa-manual-2024-01-15-120000.db")).toBe("manual");
});
test("detects manual backup with milliseconds", () => {
expect(getBackupSource("sofa-manual-2024-01-15-120000123.db")).toBe(
"manual",
);
});
test("detects scheduled backup", () => {
expect(getBackupSource("sofa-scheduled-2024-06-01-030000.db")).toBe(
"scheduled",
);
});
test("detects pre-restore backup", () => {
expect(getBackupSource("pre-restore-2024-06-01-030000.db")).toBe(
"pre-restore",
);
});
test("defaults to manual for unknown patterns", () => {
expect(getBackupSource("random-file.db")).toBe("manual");
});
});
describe("isKnownBackup", () => {
test("recognizes manual backup", () => {
expect(isKnownBackup("sofa-manual-2024-01-15-120000.db")).toBe(true);
});
test("recognizes scheduled backup", () => {
expect(isKnownBackup("sofa-scheduled-2024-06-01-030000.db")).toBe(true);
});
test("recognizes pre-restore backup", () => {
expect(isKnownBackup("pre-restore-2024-06-01-030000.db")).toBe(true);
});
test("recognizes backup with milliseconds", () => {
expect(isKnownBackup("sofa-manual-2024-01-15-120000456.db")).toBe(true);
});
test("rejects random filename", () => {
expect(isKnownBackup("random-file.db")).toBe(false);
});
test("rejects similar but wrong prefix", () => {
expect(isKnownBackup("sofa-auto-2024-01-15-120000.db")).toBe(false);
});
test("rejects wrong extension", () => {
expect(isKnownBackup("sofa-manual-2024-01-15-120000.sql")).toBe(false);
});
});
describe("isValidBackupFilename", () => {
test("accepts valid manual backup", () => {
expect(isValidBackupFilename("sofa-manual-2024-01-15-120000.db")).toBe(
true,
);
});
test("accepts valid scheduled backup", () => {
expect(isValidBackupFilename("sofa-scheduled-2024-06-01-030000.db")).toBe(
true,
);
});
test("rejects path traversal", () => {
expect(isValidBackupFilename("../sofa-manual-2024-01-15-120000.db")).toBe(
false,
);
});
test("rejects directory separators", () => {
expect(
isValidBackupFilename("subdir/sofa-manual-2024-01-15-120000.db"),
).toBe(false);
});
test("rejects unknown filenames", () => {
expect(isValidBackupFilename("evil.db")).toBe(false);
});
test("rejects filenames with double dots", () => {
expect(isValidBackupFilename("sofa-manual-2024..01-15-120000.db")).toBe(
false,
);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, mock, test } from "bun:test";
mock.module("../src/image-cache", () => ({
downloadAndCacheImage: async () => {},
getLocalImagePath: () => "",
imageCacheEnabled: () => false,
isImageCached: async () => false,
}));
import { parseColorPalette } from "../src/colors";
describe("parseColorPalette", () => {
test("returns null for null input", () => {
expect(parseColorPalette(null)).toBeNull();
});
test("returns null for empty string", () => {
expect(parseColorPalette("")).toBeNull();
});
test("parses valid JSON", () => {
const palette = {
vibrant: "#e63946",
darkVibrant: "#1d3557",
lightVibrant: "#f1faee",
muted: "#a8dadc",
darkMuted: "#457b9d",
lightMuted: "#f4f4f4",
};
expect(parseColorPalette(JSON.stringify(palette))).toEqual(palette);
});
test("returns null for malformed JSON", () => {
expect(parseColorPalette("{not valid json")).toBeNull();
});
});
+89
View File
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { persons, titleCast } from "@sofa/db/schema";
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
mock.module("../src/image-cache", () => ({
imageCacheEnabled: () => false,
cacheProfilePhotos: async () => {},
}));
import { getCastForTitle } from "../src/credits";
beforeEach(() => {
clearAllTables();
});
function insertPerson(
id: string,
tmdbId: number,
name: string,
profilePath: string | null = null,
) {
testDb.insert(persons).values({ id, tmdbId, name, profilePath }).run();
return id;
}
function insertCastEntry(
titleId: string,
personId: string,
overrides: {
character?: string | null;
department?: string;
job?: string | null;
displayOrder?: number;
episodeCount?: number | null;
} = {},
) {
testDb
.insert(titleCast)
.values({
titleId,
personId,
character: overrides.character ?? "Character",
department: overrides.department ?? "Acting",
job: overrides.job ?? null,
displayOrder: overrides.displayOrder ?? 0,
episodeCount: overrides.episodeCount ?? null,
lastFetchedAt: new Date(),
})
.run();
}
describe("getCastForTitle", () => {
test("returns cast members ordered by displayOrder", () => {
insertTitle({ id: "m1", tmdbId: 1 });
insertPerson("p1", 100, "Actor One", "/path1.jpg");
insertPerson("p2", 200, "Actor Two", "/path2.jpg");
insertCastEntry("m1", "p1", { character: "Hero", displayOrder: 0 });
insertCastEntry("m1", "p2", { character: "Villain", displayOrder: 1 });
const cast = getCastForTitle("m1");
expect(cast).toHaveLength(2);
expect(cast[0].name).toBe("Actor One");
expect(cast[0].character).toBe("Hero");
expect(cast[0].profilePath).toBe("/images/profiles/path1.jpg");
expect(cast[1].name).toBe("Actor Two");
});
test("includes crew members", () => {
insertTitle({ id: "m1", tmdbId: 1 });
insertPerson("p1", 100, "Director Person");
insertCastEntry("m1", "p1", {
character: null,
department: "Directing",
job: "Director",
displayOrder: 100,
});
const cast = getCastForTitle("m1");
expect(cast).toHaveLength(1);
expect(cast[0].department).toBe("Directing");
expect(cast[0].job).toBe("Director");
});
test("returns empty array for title with no cast", () => {
insertTitle({ id: "m1", tmdbId: 1 });
const cast = getCastForTitle("m1");
expect(cast).toHaveLength(0);
});
});
+339
View File
@@ -0,0 +1,339 @@
import { beforeEach, describe, expect, test } from "bun:test";
import {
clearAllTables,
insertAvailabilityOffer,
insertEpisodeWatch,
insertMovieWatch,
insertRating,
insertRecommendation,
insertStatus,
insertTitle,
insertTvShow,
insertUser,
} from "@sofa/db/test-utils";
import {
getContinueWatchingFeed,
getNewAvailableFeed,
getRecommendationsFeed,
getRecommendationsForTitle,
getUserStats,
getWatchCount,
getWatchHistory,
} from "../src/discovery";
beforeEach(() => {
clearAllTables();
});
// ── getWatchCount ───────────────────────────────────────────────────
describe("getWatchCount", () => {
test("counts movie watches within period", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertMovieWatch("user-1", "m1");
insertMovieWatch("user-1", "m2");
const count = getWatchCount("user-1", "movies", "this_month");
expect(count).toBe(2);
});
test("counts episode watches within period", () => {
insertUser();
const { episodeIds } = insertTvShow();
insertEpisodeWatch("user-1", episodeIds[0]);
insertEpisodeWatch("user-1", episodeIds[1]);
const count = getWatchCount("user-1", "episodes", "this_week");
expect(count).toBe(2);
});
test("excludes watches outside period", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
// Watch from 2 years ago
const oldDate = new Date();
oldDate.setFullYear(oldDate.getFullYear() - 2);
insertMovieWatch("user-1", "m1", oldDate);
const count = getWatchCount("user-1", "movies", "this_year");
expect(count).toBe(0);
});
test("returns 0 when no watches exist", () => {
insertUser();
const count = getWatchCount("user-1", "movies", "today");
expect(count).toBe(0);
});
});
// ── getWatchHistory ─────────────────────────────────────────────────
describe("getWatchHistory", () => {
test("returns bucketed history with correct total count", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertMovieWatch("user-1", "m1");
insertMovieWatch("user-1", "m2");
const history = getWatchHistory("user-1", "movies", "this_week");
expect(history).toBeArrayOfSize(7);
const totalCount = history.reduce((sum, b) => sum + b.count, 0);
expect(totalCount).toBe(2);
});
test("returns all-zero buckets when no watches", () => {
insertUser();
const history = getWatchHistory("user-1", "movies", "this_month");
expect(history).toBeArrayOfSize(30);
expect(history.every((b) => b.count === 0)).toBe(true);
});
test("returns correct bucket count for today period", () => {
insertUser();
const history = getWatchHistory("user-1", "episodes", "today");
expect(history).toBeArrayOfSize(24);
});
test("returns correct bucket count for this_year period", () => {
insertUser();
const history = getWatchHistory("user-1", "movies", "this_year");
expect(history).toBeArrayOfSize(12);
});
});
// ── getUserStats ────────────────────────────────────────────────────
describe("getUserStats", () => {
test("returns correct stats", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
const { titleId } = insertTvShow("tv-1", 99999, 1, 3);
insertMovieWatch("user-1", "m1");
insertMovieWatch("user-1", "m2");
insertEpisodeWatch("user-1", "tv-1-s1e1");
insertStatus("user-1", "m1", "completed");
insertStatus("user-1", "m2", "completed");
insertStatus("user-1", titleId, "in_progress");
const stats = getUserStats("user-1");
expect(stats.moviesThisMonth).toBe(2);
expect(stats.episodesThisWeek).toBe(1);
expect(stats.librarySize).toBe(3);
expect(stats.completed).toBe(2);
});
test("returns zeros when no data", () => {
insertUser();
const stats = getUserStats("user-1");
expect(stats.moviesThisMonth).toBe(0);
expect(stats.episodesThisWeek).toBe(0);
expect(stats.librarySize).toBe(0);
expect(stats.completed).toBe(0);
});
});
// ── getContinueWatchingFeed ─────────────────────────────────────────
describe("getContinueWatchingFeed", () => {
test("returns in-progress shows with next unwatched episode", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
insertStatus("user-1", titleId, "in_progress");
insertEpisodeWatch("user-1", episodeIds[0]);
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(1);
expect(feed[0].title.id).toBe(titleId);
expect(feed[0].nextEpisode?.episodeNumber).toBe(2);
expect(feed[0].watchedEpisodes).toBe(1);
expect(feed[0].totalEpisodes).toBe(3);
});
test("excludes completed shows", () => {
insertUser();
const { titleId } = insertTvShow("tv-1", 99999, 1, 3);
insertStatus("user-1", titleId, "completed");
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
test("excludes movies", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1, type: "movie" });
insertStatus("user-1", "m1", "in_progress");
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
test("returns empty when no in-progress shows", () => {
insertUser();
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
test("sorts by most recent watch", () => {
insertUser();
const show1 = insertTvShow("tv-1", 11111, 1, 3);
const show2 = insertTvShow("tv-2", 22222, 1, 3);
insertStatus("user-1", show1.titleId, "in_progress");
insertStatus("user-1", show2.titleId, "in_progress");
const older = new Date("2026-01-01");
const newer = new Date("2026-03-01");
insertEpisodeWatch("user-1", show1.episodeIds[0], older);
insertEpisodeWatch("user-1", show2.episodeIds[0], newer);
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(2);
expect(feed[0].title.id).toBe("tv-2");
expect(feed[1].title.id).toBe("tv-1");
});
test("skips show when all episodes are watched (no next episode)", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 2);
insertStatus("user-1", titleId, "in_progress");
for (const epId of episodeIds) {
insertEpisodeWatch("user-1", epId);
}
const feed = getContinueWatchingFeed("user-1");
expect(feed).toHaveLength(0);
});
});
// ── getNewAvailableFeed ─────────────────────────────────────────────
describe("getNewAvailableFeed", () => {
test("returns titles with availability offers", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertStatus("user-1", "m1", "watchlist");
insertAvailabilityOffer("m1");
const feed = getNewAvailableFeed("user-1");
expect(feed).toHaveLength(1);
expect(feed[0].titleId).toBe("m1");
});
test("excludes titles without availability", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertStatus("user-1", "m1", "watchlist");
const feed = getNewAvailableFeed("user-1");
expect(feed).toHaveLength(0);
});
test("excludes titles not in user library", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertAvailabilityOffer("m1");
const feed = getNewAvailableFeed("user-1");
expect(feed).toHaveLength(0);
});
});
// ── getRecommendationsFeed ──────────────────────────────────────────
describe("getRecommendationsFeed", () => {
test("returns recommendations from completed titles", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1, title: "Source Movie" });
insertTitle({ id: "m2", tmdbId: 2, title: "Recommended Movie" });
insertStatus("user-1", "m1", "completed");
insertRecommendation("m1", "m2", { rank: 1 });
const feed = getRecommendationsFeed("user-1");
expect(feed).toHaveLength(1);
expect(feed[0]?.id).toBe("m2");
});
test("returns recommendations from highly-rated titles", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertStatus("user-1", "m1", "watchlist");
insertRating("user-1", "m1", 5);
insertRecommendation("m1", "m2", { rank: 1 });
const feed = getRecommendationsFeed("user-1");
expect(feed).toHaveLength(1);
});
test("excludes already-tracked titles", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertStatus("user-1", "m1", "completed");
insertStatus("user-1", "m2", "watchlist");
insertRecommendation("m1", "m2", { rank: 1 });
const feed = getRecommendationsFeed("user-1");
expect(feed).toHaveLength(0);
});
test("returns empty when no source titles", () => {
insertUser();
const feed = getRecommendationsFeed("user-1");
expect(feed).toHaveLength(0);
});
test("scores higher when recommended by multiple sources", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertTitle({ id: "m3", tmdbId: 3 });
insertTitle({ id: "rec1", tmdbId: 10, title: "Double Recommended" });
insertTitle({ id: "rec2", tmdbId: 20, title: "Single Recommended" });
insertStatus("user-1", "m1", "completed");
insertStatus("user-1", "m2", "completed");
// rec1 recommended by both m1 and m2
insertRecommendation("m1", "rec1", { rank: 1 });
insertRecommendation("m2", "rec1", { rank: 1 });
// rec2 recommended only by m1
insertRecommendation("m1", "rec2", { rank: 1 });
const feed = getRecommendationsFeed("user-1");
expect(feed).toHaveLength(2);
expect(feed[0]?.id).toBe("rec1");
});
});
// ── getRecommendationsForTitle ──────────────────────────────────────
describe("getRecommendationsForTitle", () => {
test("returns ordered recommendations for a title", () => {
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "rec1", tmdbId: 10, title: "Rec One" });
insertTitle({ id: "rec2", tmdbId: 20, title: "Rec Two" });
insertRecommendation("m1", "rec1", { rank: 2 });
insertRecommendation("m1", "rec2", { rank: 1 });
const recs = getRecommendationsForTitle("m1");
expect(recs).toHaveLength(2);
expect(recs[0].title).toBe("Rec Two");
expect(recs[1].title).toBe("Rec One");
});
test("returns empty for unknown title", () => {
const recs = getRecommendationsForTitle("nonexistent");
expect(recs).toHaveLength(0);
});
test("returns empty when no recommendations exist", () => {
insertTitle({ id: "m1", tmdbId: 1 });
const recs = getRecommendationsForTitle("m1");
expect(recs).toHaveLength(0);
});
});
+213
View File
@@ -0,0 +1,213 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import {
clearAllTables,
insertIntegration,
insertStatus,
insertTitle,
insertUser,
} from "@sofa/db/test-utils";
import {
getRadarrList,
getSonarrList,
parseStatusParam,
resolveListToken,
} from "../src/lists";
// Mock getTvExternalIds for lazy resolution tests
const mockGetTvExternalIds = mock(
(): Promise<{ tvdb_id: number | null; imdb_id: string | null }> =>
Promise.resolve({ tvdb_id: 55555, imdb_id: "tt9999999" }),
);
mock.module("@sofa/tmdb/client", () => ({
getTvExternalIds: mockGetTvExternalIds,
}));
beforeEach(() => {
clearAllTables();
mockGetTvExternalIds.mockClear();
});
describe("resolveListToken", () => {
test("returns userId and provider for valid sonarr token", () => {
insertUser("user-1");
insertIntegration("user-1", "sonarr", "sonarr-token");
expect(resolveListToken("sonarr-token")).toEqual({
userId: "user-1",
provider: "sonarr",
});
});
test("returns userId and provider for valid radarr token", () => {
insertUser("user-1");
insertIntegration("user-1", "radarr", "radarr-token");
expect(resolveListToken("radarr-token")).toEqual({
userId: "user-1",
provider: "radarr",
});
});
test("returns null for invalid token", () => {
expect(resolveListToken("nonexistent")).toBeNull();
});
test("returns null for webhook-type token", () => {
insertUser("user-1");
insertIntegration("user-1", "plex", "plex-token");
expect(resolveListToken("plex-token")).toBeNull();
});
});
describe("parseStatusParam", () => {
test("defaults to watchlist when null", () => {
expect(parseStatusParam(null)).toEqual(["watchlist"]);
});
test("parses comma-separated statuses", () => {
expect(parseStatusParam("watchlist,in_progress")).toEqual([
"watchlist",
"in_progress",
]);
});
test("filters invalid statuses", () => {
expect(parseStatusParam("watchlist,invalid,completed")).toEqual([
"watchlist",
"completed",
]);
});
test("defaults to watchlist when all invalid", () => {
expect(parseStatusParam("foo,bar")).toEqual(["watchlist"]);
});
});
describe("getRadarrList", () => {
test("returns movie TMDB IDs on watchlist", () => {
insertUser("user-1");
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
insertTitle({ id: "m2", tmdbId: 200, type: "movie" });
insertStatus("user-1", "m1", "watchlist");
insertStatus("user-1", "m2", "watchlist");
const list = getRadarrList("user-1");
expect(list).toEqual(expect.arrayContaining([{ Id: 100 }, { Id: 200 }]));
expect(list).toHaveLength(2);
});
test("excludes TV shows", () => {
insertUser("user-1");
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
insertTitle({ id: "tv1", tmdbId: 200, type: "tv" });
insertStatus("user-1", "m1", "watchlist");
insertStatus("user-1", "tv1", "watchlist");
const list = getRadarrList("user-1");
expect(list).toEqual([{ Id: 100 }]);
});
test("filters by status", () => {
insertUser("user-1");
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
insertTitle({ id: "m2", tmdbId: 200, type: "movie" });
insertStatus("user-1", "m1", "watchlist");
insertStatus("user-1", "m2", "completed");
expect(getRadarrList("user-1", ["watchlist"])).toEqual([{ Id: 100 }]);
expect(getRadarrList("user-1", ["completed"])).toEqual([{ Id: 200 }]);
expect(getRadarrList("user-1", ["watchlist", "completed"])).toHaveLength(2);
});
test("returns empty array for user with no movies", () => {
insertUser("user-1");
expect(getRadarrList("user-1")).toEqual([]);
});
});
describe("getSonarrList", () => {
test("returns TV shows with TVDB IDs", async () => {
insertUser("user-1");
insertTitle({
id: "tv1",
tmdbId: 300,
tvdbId: 12345,
type: "tv",
title: "Show A",
});
insertStatus("user-1", "tv1", "watchlist");
const list = await getSonarrList("user-1");
expect(list).toEqual([{ TvdbId: 12345, Title: "Show A" }]);
});
test("excludes movies", async () => {
insertUser("user-1");
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
insertTitle({
id: "tv1",
tmdbId: 300,
tvdbId: 12345,
type: "tv",
title: "Show A",
});
insertStatus("user-1", "m1", "watchlist");
insertStatus("user-1", "tv1", "watchlist");
const list = await getSonarrList("user-1");
expect(list).toEqual([{ TvdbId: 12345, Title: "Show A" }]);
});
test("lazily resolves missing TVDB ID", async () => {
insertUser("user-1");
insertTitle({ id: "tv1", tmdbId: 300, type: "tv", title: "Show B" });
insertStatus("user-1", "tv1", "watchlist");
mockGetTvExternalIds.mockResolvedValueOnce({
tvdb_id: 55555,
imdb_id: "tt1234567",
});
const list = await getSonarrList("user-1");
expect(list).toEqual([{ TvdbId: 55555, Title: "Show B" }]);
expect(mockGetTvExternalIds).toHaveBeenCalledWith(300);
});
test("skips shows where TVDB ID cannot be resolved", async () => {
insertUser("user-1");
insertTitle({ id: "tv1", tmdbId: 300, type: "tv", title: "Show C" });
insertStatus("user-1", "tv1", "watchlist");
mockGetTvExternalIds.mockResolvedValueOnce({
tvdb_id: null,
imdb_id: null,
});
const list = await getSonarrList("user-1");
expect(list).toEqual([]);
});
test("filters by status", async () => {
insertUser("user-1");
insertTitle({
id: "tv1",
tmdbId: 300,
tvdbId: 111,
type: "tv",
title: "Show A",
});
insertTitle({
id: "tv2",
tmdbId: 400,
tvdbId: 222,
type: "tv",
title: "Show B",
});
insertStatus("user-1", "tv1", "watchlist");
insertStatus("user-1", "tv2", "completed");
const watchlist = await getSonarrList("user-1", ["watchlist"]);
expect(watchlist).toEqual([{ TvdbId: 111, Title: "Show A" }]);
const all = await getSonarrList("user-1", ["watchlist", "completed"]);
expect(all).toHaveLength(2);
});
});
+217
View File
@@ -0,0 +1,217 @@
import { describe, expect, test } from "bun:test";
import type {
TmdbMovieDetails,
TmdbTvDetails,
TmdbVideo,
} from "@sofa/tmdb/client";
import {
extractMovieContentRating,
extractTvContentRating,
pickBestTrailer,
} from "../src/metadata";
function makeVideo(overrides: Partial<TmdbVideo> = {}): TmdbVideo {
return {
id: "v1",
key: "abc123",
name: "Trailer",
site: "YouTube",
type: "Trailer",
official: false,
published_at: "2024-01-01T00:00:00.000Z",
size: 1080,
iso_639_1: "en",
iso_3166_1: "US",
...overrides,
};
}
describe("pickBestTrailer", () => {
test("returns null for empty array", () => {
expect(pickBestTrailer([])).toBeNull();
});
test("returns null when no YouTube videos", () => {
const videos = [makeVideo({ site: "Vimeo", key: "vimeo1" })];
expect(pickBestTrailer(videos)).toBeNull();
});
test("prefers official trailers over unofficial", () => {
const videos = [
makeVideo({ key: "unofficial", official: false, type: "Trailer" }),
makeVideo({ key: "official", official: true, type: "Trailer" }),
];
expect(pickBestTrailer(videos)).toBe("official");
});
test("prefers trailers over teasers", () => {
const videos = [
makeVideo({ key: "teaser", type: "Teaser" }),
makeVideo({ key: "trailer", type: "Trailer" }),
];
expect(pickBestTrailer(videos)).toBe("trailer");
});
test("falls back to teaser when no trailers", () => {
const videos = [
makeVideo({ key: "teaser", type: "Teaser" }),
makeVideo({ key: "featurette", type: "Featurette" }),
];
expect(pickBestTrailer(videos)).toBe("teaser");
});
test("returns null when only non-trailer/teaser types exist", () => {
const videos = [
makeVideo({ key: "feat", type: "Featurette" }),
makeVideo({ key: "clip", type: "Clip" }),
];
expect(pickBestTrailer(videos)).toBeNull();
});
test("prefers English over other languages", () => {
const videos = [
makeVideo({ key: "french", iso_639_1: "fr", type: "Trailer" }),
makeVideo({ key: "english", iso_639_1: "en", type: "Trailer" }),
];
expect(pickBestTrailer(videos)).toBe("english");
});
test("sorts by newest published_at", () => {
const videos = [
makeVideo({
key: "older",
type: "Trailer",
official: true,
published_at: "2023-01-01T00:00:00.000Z",
}),
makeVideo({
key: "newer",
type: "Trailer",
official: true,
published_at: "2024-06-01T00:00:00.000Z",
}),
];
expect(pickBestTrailer(videos)).toBe("newer");
});
test("handles missing published_at", () => {
const videos = [
makeVideo({
key: "no-date",
type: "Trailer",
official: true,
published_at: undefined as unknown as string,
}),
makeVideo({
key: "with-date",
type: "Trailer",
official: true,
published_at: "2024-01-01T00:00:00.000Z",
}),
];
expect(pickBestTrailer(videos)).toBe("with-date");
});
});
describe("extractMovieContentRating", () => {
test("returns US certification", () => {
const movie = {
release_dates: {
results: [
{
iso_3166_1: "US",
release_dates: [{ certification: "PG-13", type: 3 }],
},
],
},
} as unknown as TmdbMovieDetails;
expect(extractMovieContentRating(movie)).toBe("PG-13");
});
test("returns first non-empty certification", () => {
const movie = {
release_dates: {
results: [
{
iso_3166_1: "US",
release_dates: [
{ certification: "", type: 1 },
{ certification: "R", type: 3 },
],
},
],
},
} as unknown as TmdbMovieDetails;
expect(extractMovieContentRating(movie)).toBe("R");
});
test("returns null when no US entry", () => {
const movie = {
release_dates: {
results: [
{
iso_3166_1: "GB",
release_dates: [{ certification: "15", type: 3 }],
},
],
},
} as unknown as TmdbMovieDetails;
expect(extractMovieContentRating(movie)).toBeNull();
});
test("returns null when release_dates is undefined", () => {
const movie = {} as unknown as TmdbMovieDetails;
expect(extractMovieContentRating(movie)).toBeNull();
});
test("returns null when all US certifications are empty", () => {
const movie = {
release_dates: {
results: [
{
iso_3166_1: "US",
release_dates: [
{ certification: "", type: 1 },
{ certification: "", type: 3 },
],
},
],
},
} as unknown as TmdbMovieDetails;
expect(extractMovieContentRating(movie)).toBeNull();
});
});
describe("extractTvContentRating", () => {
test("returns US rating", () => {
const show = {
content_ratings: {
results: [{ iso_3166_1: "US", rating: "TV-MA" }],
},
} as unknown as TmdbTvDetails;
expect(extractTvContentRating(show)).toBe("TV-MA");
});
test("returns null when no US entry", () => {
const show = {
content_ratings: {
results: [{ iso_3166_1: "DE", rating: "16" }],
},
} as unknown as TmdbTvDetails;
expect(extractTvContentRating(show)).toBeNull();
});
test("returns null when content_ratings is undefined", () => {
const show = {} as unknown as TmdbTvDetails;
expect(extractTvContentRating(show)).toBeNull();
});
test("returns null when US rating is empty", () => {
const show = {
content_ratings: {
results: [{ iso_3166_1: "US", rating: "" }],
},
} as unknown as TmdbTvDetails;
expect(extractTvContentRating(show)).toBeNull();
});
});
+87
View File
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { persons, titleCast } from "@sofa/db/schema";
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
import { getLocalFilmography } from "../src/person";
beforeEach(() => {
clearAllTables();
});
function insertPerson(id: string, tmdbId: number, name: string) {
testDb.insert(persons).values({ id, tmdbId, name }).run();
return id;
}
function insertCastEntry(
titleId: string,
personId: string,
overrides: {
character?: string | null;
department?: string;
job?: string | null;
displayOrder?: number;
} = {},
) {
testDb
.insert(titleCast)
.values({
titleId,
personId,
character: overrides.character ?? "Character",
department: overrides.department ?? "Acting",
job: overrides.job ?? null,
displayOrder: overrides.displayOrder ?? 0,
lastFetchedAt: new Date(),
})
.run();
}
describe("getLocalFilmography", () => {
test("returns filmography for a person", () => {
insertTitle({ id: "m1", tmdbId: 1, title: "Movie One" });
insertTitle({ id: "m2", tmdbId: 2, title: "Movie Two" });
insertPerson("p1", 100, "Test Actor");
insertCastEntry("m1", "p1", { character: "Hero" });
insertCastEntry("m2", "p1", { character: "Sidekick" });
const filmography = getLocalFilmography("p1");
expect(filmography).toHaveLength(2);
expect(filmography.map((f) => f.title).sort()).toEqual([
"Movie One",
"Movie Two",
]);
});
test("includes crew roles", () => {
insertTitle({ id: "m1", tmdbId: 1, title: "Directed Movie" });
insertPerson("p1", 100, "Director Person");
insertCastEntry("m1", "p1", {
character: null,
department: "Directing",
job: "Director",
});
const filmography = getLocalFilmography("p1");
expect(filmography).toHaveLength(1);
expect(filmography[0].department).toBe("Directing");
expect(filmography[0].job).toBe("Director");
});
test("returns empty for person with no credits", () => {
insertPerson("p1", 100, "Unknown Actor");
const filmography = getLocalFilmography("p1");
expect(filmography).toHaveLength(0);
});
test("returns correct title types", () => {
insertTitle({ id: "m1", tmdbId: 1, type: "movie", title: "A Movie" });
insertTitle({ id: "tv1", tmdbId: 2, type: "tv", title: "A Show" });
insertPerson("p1", 100, "Versatile Actor");
insertCastEntry("m1", "p1");
insertCastEntry("tv1", "p1");
const filmography = getLocalFilmography("p1");
const types = filmography.map((f) => f.type).sort();
expect(types).toEqual(["movie", "tv"]);
});
});
+9
View File
@@ -0,0 +1,9 @@
import { mock } from "bun:test";
import { applyMigrations, testDb } from "@sofa/db/test-utils";
mock.module("@sofa/db/client", () => ({
db: testDb,
closeDatabase: () => {},
}));
applyMigrations();
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { clearAllTables, insertUser } from "@sofa/db/test-utils";
import {
getSetting,
getUserCount,
isRegistrationOpen,
setSetting,
} from "../src/settings";
beforeEach(() => {
clearAllTables();
});
// ── getSetting / setSetting ─────────────────────────────────────────
describe("getSetting / setSetting", () => {
test("returns null for missing key", () => {
expect(getSetting("nonexistent")).toBeNull();
});
test("stores and retrieves a value", () => {
setSetting("theme", "dark");
expect(getSetting("theme")).toBe("dark");
});
test("upserts: overwrites existing value", () => {
setSetting("theme", "dark");
setSetting("theme", "light");
expect(getSetting("theme")).toBe("light");
});
});
// ── getUserCount ────────────────────────────────────────────────────
describe("getUserCount", () => {
test("returns 0 when no users", () => {
expect(getUserCount()).toBe(0);
});
test("returns correct count", () => {
insertUser("user-1");
insertUser("user-2");
expect(getUserCount()).toBe(2);
});
});
// ── isRegistrationOpen ──────────────────────────────────────────────
describe("isRegistrationOpen", () => {
test("returns true when no users exist (first-run)", () => {
expect(isRegistrationOpen()).toBe(true);
});
test("returns false when users exist and setting is not set", () => {
insertUser();
expect(isRegistrationOpen()).toBe(false);
});
test("returns true when users exist and setting is 'true'", () => {
insertUser();
setSetting("registrationOpen", "true");
expect(isRegistrationOpen()).toBe(true);
});
test("returns false when users exist and setting is 'false'", () => {
insertUser();
setSetting("registrationOpen", "false");
expect(isRegistrationOpen()).toBe(false);
});
});
+668
View File
@@ -0,0 +1,668 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { and, eq } from "@sofa/db/helpers";
import {
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
import {
clearAllTables,
insertTitle,
insertTvShow,
insertUser,
testDb,
} from "@sofa/db/test-utils";
import {
getUserTitleInfo,
logEpisodeWatch,
logEpisodeWatchBatch,
logMovieWatch,
markAllEpisodesWatched,
rateTitleStars,
removeTitleStatus,
setTitleStatus,
unwatchEpisode,
unwatchSeason,
} from "../src/tracking";
beforeEach(() => {
clearAllTables();
});
// ── setTitleStatus ──────────────────────────────────────────────────
describe("setTitleStatus", () => {
test("sets new status for a user/title pair", () => {
insertUser();
insertTitle();
setTitleStatus("user-1", "title-1", "watchlist");
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, "title-1"),
),
)
.get();
expect(row?.status).toBe("watchlist");
});
test("updates existing status", () => {
insertUser();
insertTitle();
setTitleStatus("user-1", "title-1", "watchlist");
setTitleStatus("user-1", "title-1", "in_progress");
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, "title-1"),
),
)
.get();
expect(row?.status).toBe("in_progress");
});
test("upsert: only one row per user/title", () => {
insertUser();
insertTitle();
setTitleStatus("user-1", "title-1", "watchlist");
setTitleStatus("user-1", "title-1", "completed");
const rows = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, "title-1"),
),
)
.all();
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe("completed");
});
});
// ── removeTitleStatus ───────────────────────────────────────────────
describe("removeTitleStatus", () => {
test("removes existing status", () => {
insertUser();
insertTitle();
setTitleStatus("user-1", "title-1", "watchlist");
removeTitleStatus("user-1", "title-1");
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, "title-1"),
),
)
.get();
expect(row).toBeUndefined();
});
test("no-op when status doesn't exist", () => {
insertUser();
insertTitle();
// Should not throw
removeTitleStatus("user-1", "title-1");
});
});
// ── logMovieWatch ───────────────────────────────────────────────────
describe("logMovieWatch", () => {
test("inserts watch record", () => {
insertUser();
insertTitle();
logMovieWatch("user-1", "title-1");
const watches = testDb
.select()
.from(userMovieWatches)
.where(eq(userMovieWatches.userId, "user-1"))
.all();
expect(watches).toHaveLength(1);
expect(watches[0].titleId).toBe("title-1");
});
test("auto-sets status to completed when no prior status", () => {
insertUser();
insertTitle();
logMovieWatch("user-1", "title-1");
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, "title-1"),
),
)
.get();
expect(row?.status).toBe("completed");
});
test("auto-upgrades watchlist to completed", () => {
insertUser();
insertTitle();
setTitleStatus("user-1", "title-1", "watchlist");
logMovieWatch("user-1", "title-1");
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, "title-1"),
),
)
.get();
expect(row?.status).toBe("completed");
});
test("auto-upgrades in_progress to completed", () => {
insertUser();
insertTitle();
setTitleStatus("user-1", "title-1", "in_progress");
logMovieWatch("user-1", "title-1");
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, "title-1"),
),
)
.get();
expect(row?.status).toBe("completed");
});
test("doesn't downgrade already-completed status", () => {
insertUser();
insertTitle();
setTitleStatus("user-1", "title-1", "completed");
logMovieWatch("user-1", "title-1");
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, "title-1"),
),
)
.get();
expect(row?.status).toBe("completed");
});
});
// ── logEpisodeWatch ─────────────────────────────────────────────────
describe("logEpisodeWatch", () => {
test("inserts watch record", () => {
insertUser();
const { episodeIds } = insertTvShow();
logEpisodeWatch("user-1", episodeIds[0]);
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, "user-1"))
.all();
expect(watches).toHaveLength(1);
expect(watches[0].episodeId).toBe(episodeIds[0]);
});
test("auto-sets status to in_progress", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow();
logEpisodeWatch("user-1", episodeIds[0]);
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("in_progress");
});
test("upgrades watchlist to in_progress", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow();
setTitleStatus("user-1", titleId, "watchlist");
logEpisodeWatch("user-1", episodeIds[0]);
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("in_progress");
});
test("doesn't downgrade completed to in_progress", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow();
setTitleStatus("user-1", titleId, "completed");
logEpisodeWatch("user-1", episodeIds[0]);
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("completed");
});
test("auto-completes when all episodes are watched", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
for (const epId of episodeIds) {
logEpisodeWatch("user-1", epId);
}
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("completed");
});
});
// ── logEpisodeWatchBatch ────────────────────────────────────────────
describe("logEpisodeWatchBatch", () => {
test("batch inserts multiple watches", () => {
insertUser();
const { episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
logEpisodeWatchBatch("user-1", episodeIds.slice(0, 2));
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, "user-1"))
.all();
expect(watches).toHaveLength(2);
});
test("sets in_progress status", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
logEpisodeWatchBatch("user-1", [episodeIds[0]]);
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("in_progress");
});
test("auto-completes if batch covers all episodes", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
logEpisodeWatchBatch("user-1", episodeIds);
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("completed");
});
test("no-op for empty array", () => {
insertUser();
logEpisodeWatchBatch("user-1", []);
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, "user-1"))
.all();
expect(watches).toHaveLength(0);
});
});
// ── markAllEpisodesWatched ──────────────────────────────────────────
describe("markAllEpisodesWatched", () => {
test("marks all episodes as watched and sets completed", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
markAllEpisodesWatched("user-1", titleId);
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, "user-1"))
.all();
expect(watches).toHaveLength(episodeIds.length);
const row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("completed");
});
test("skips already-watched episodes (no duplicates)", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
// Watch first episode manually
logEpisodeWatch("user-1", episodeIds[0]);
// Then mark all
markAllEpisodesWatched("user-1", titleId);
// Should have exactly 3 episode watches (1 manual + 2 new), not 4
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, "user-1"))
.all();
expect(watches).toHaveLength(3);
});
test("no-op for movies", () => {
insertUser();
insertTitle({ id: "movie-1", type: "movie" });
markAllEpisodesWatched("user-1", "movie-1");
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, "user-1"))
.all();
expect(watches).toHaveLength(0);
});
});
// ── unwatchEpisode ──────────────────────────────────────────────────
describe("unwatchEpisode", () => {
test("removes watch record", () => {
insertUser();
const { episodeIds } = insertTvShow();
logEpisodeWatch("user-1", episodeIds[0]);
unwatchEpisode("user-1", episodeIds[0]);
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, "user-1"))
.all();
expect(watches).toHaveLength(0);
});
test("downgrades completed to in_progress", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
markAllEpisodesWatched("user-1", titleId);
// Verify completed first
let row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("completed");
// Unwatch one episode
unwatchEpisode("user-1", episodeIds[0]);
row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("in_progress");
});
test("doesn't change in_progress status", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
logEpisodeWatch("user-1", episodeIds[0]);
logEpisodeWatch("user-1", episodeIds[1]);
// Status should be in_progress
let row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("in_progress");
unwatchEpisode("user-1", episodeIds[0]);
row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("in_progress");
});
});
// ── unwatchSeason ───────────────────────────────────────────────────
describe("unwatchSeason", () => {
test("removes all episode watches for a season", () => {
insertUser();
const { episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
for (const epId of episodeIds) {
logEpisodeWatch("user-1", epId);
}
unwatchSeason("user-1", "tv-1-s1");
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, "user-1"))
.all();
expect(watches).toHaveLength(0);
});
test("downgrades completed to in_progress", () => {
insertUser();
const { titleId } = insertTvShow("tv-1", 99999, 2, 2);
// Watch all episodes across both seasons
markAllEpisodesWatched("user-1", titleId);
let row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("completed");
// Unwatch season 1
unwatchSeason("user-1", "tv-1-s1");
row = testDb
.select()
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, "user-1"),
eq(userTitleStatus.titleId, titleId),
),
)
.get();
expect(row?.status).toBe("in_progress");
});
});
// ── rateTitleStars ──────────────────────────────────────────────────
describe("rateTitleStars", () => {
test("sets rating", () => {
insertUser();
insertTitle();
rateTitleStars("user-1", "title-1", 4);
const row = testDb
.select()
.from(userRatings)
.where(
and(
eq(userRatings.userId, "user-1"),
eq(userRatings.titleId, "title-1"),
),
)
.get();
expect(row?.ratingStars).toBe(4);
});
test("updates existing rating", () => {
insertUser();
insertTitle();
rateTitleStars("user-1", "title-1", 3);
rateTitleStars("user-1", "title-1", 5);
const rows = testDb
.select()
.from(userRatings)
.where(
and(
eq(userRatings.userId, "user-1"),
eq(userRatings.titleId, "title-1"),
),
)
.all();
expect(rows).toHaveLength(1);
expect(rows[0].ratingStars).toBe(5);
});
test("deletes rating when 0", () => {
insertUser();
insertTitle();
rateTitleStars("user-1", "title-1", 4);
rateTitleStars("user-1", "title-1", 0);
const row = testDb
.select()
.from(userRatings)
.where(
and(
eq(userRatings.userId, "user-1"),
eq(userRatings.titleId, "title-1"),
),
)
.get();
expect(row).toBeUndefined();
});
});
// ── getUserTitleInfo ────────────────────────────────────────────────
describe("getUserTitleInfo", () => {
test("returns status, rating, and watched episode IDs", () => {
insertUser();
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
setTitleStatus("user-1", titleId, "in_progress");
rateTitleStars("user-1", titleId, 4);
logEpisodeWatch("user-1", episodeIds[0]);
logEpisodeWatch("user-1", episodeIds[1]);
const info = getUserTitleInfo("user-1", titleId);
expect(info.status).toBe("in_progress");
expect(info.rating).toBe(4);
expect(info.episodeWatches).toHaveLength(2);
expect(info.episodeWatches).toContain(episodeIds[0]);
expect(info.episodeWatches).toContain(episodeIds[1]);
});
test("returns nulls when no data exists", () => {
insertUser();
insertTitle();
const info = getUserTitleInfo("user-1", "title-1");
expect(info.status).toBeNull();
expect(info.rating).toBeNull();
expect(info.episodeWatches).toHaveLength(0);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { isNewerVersion } from "../src/update-check";
describe("isNewerVersion", () => {
test("newer major version", () => {
expect(isNewerVersion("2.0.0", "1.0.0")).toBe(true);
});
test("older major version", () => {
expect(isNewerVersion("1.0.0", "2.0.0")).toBe(false);
});
test("newer minor version", () => {
expect(isNewerVersion("1.2.0", "1.1.0")).toBe(true);
});
test("older minor version", () => {
expect(isNewerVersion("1.1.0", "1.2.0")).toBe(false);
});
test("newer patch version", () => {
expect(isNewerVersion("1.0.2", "1.0.1")).toBe(true);
});
test("older patch version", () => {
expect(isNewerVersion("1.0.1", "1.0.2")).toBe(false);
});
test("equal versions return false", () => {
expect(isNewerVersion("1.2.3", "1.2.3")).toBe(false);
});
test("handles 'v' prefix on latest", () => {
expect(isNewerVersion("v2.0.0", "1.0.0")).toBe(true);
});
test("handles 'v' prefix on current", () => {
expect(isNewerVersion("2.0.0", "v1.0.0")).toBe(true);
});
test("handles 'v' prefix on both", () => {
expect(isNewerVersion("v1.0.0", "v1.0.0")).toBe(false);
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src", "test"]
}
+13
View File
@@ -0,0 +1,13 @@
import path from "node:path";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/schema.ts",
out: "./drizzle",
dbCredentials: {
url:
process.env.DATABASE_URL ||
path.join(process.env.DATA_DIR || "./data", "sqlite.db"),
},
});
@@ -0,0 +1,164 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`accountId` text NOT NULL,
`providerId` text NOT NULL,
`accessToken` text,
`refreshToken` text,
`accessTokenExpiresAt` integer,
`refreshTokenExpiresAt` integer,
`scope` text,
`password` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
CONSTRAINT `fk_account_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `availabilityOffers` (
`titleId` text NOT NULL,
`region` text DEFAULT 'US' NOT NULL,
`providerId` integer NOT NULL,
`providerName` text NOT NULL,
`logoPath` text,
`offerType` text NOT NULL,
`link` text,
`lastFetchedAt` integer,
CONSTRAINT `fk_availabilityOffers_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `episodes` (
`id` text PRIMARY KEY,
`seasonId` text NOT NULL,
`episodeNumber` integer NOT NULL,
`name` text,
`overview` text,
`stillPath` text,
`airDate` text,
`runtimeMinutes` integer,
CONSTRAINT `fk_episodes_seasonId_seasons_id_fk` FOREIGN KEY (`seasonId`) REFERENCES `seasons`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `seasons` (
`id` text PRIMARY KEY,
`titleId` text NOT NULL,
`seasonNumber` integer NOT NULL,
`name` text,
`overview` text,
`posterPath` text,
`airDate` text,
`lastFetchedAt` integer,
CONSTRAINT `fk_seasons_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`token` text NOT NULL UNIQUE,
`expiresAt` integer NOT NULL,
`ipAddress` text,
`userAgent` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
CONSTRAINT `fk_session_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `titleRecommendations` (
`titleId` text NOT NULL,
`recommendedTitleId` text NOT NULL,
`source` text NOT NULL,
`rank` integer NOT NULL,
`lastFetchedAt` integer,
CONSTRAINT `fk_titleRecommendations_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_titleRecommendations_recommendedTitleId_titles_id_fk` FOREIGN KEY (`recommendedTitleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `titles` (
`id` text PRIMARY KEY,
`tmdbId` integer NOT NULL,
`type` text NOT NULL,
`title` text NOT NULL,
`originalTitle` text,
`overview` text,
`releaseDate` text,
`firstAirDate` text,
`posterPath` text,
`backdropPath` text,
`popularity` real,
`voteAverage` real,
`voteCount` integer,
`status` text,
`lastFetchedAt` integer
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY,
`name` text NOT NULL,
`email` text NOT NULL UNIQUE,
`emailVerified` integer DEFAULT false NOT NULL,
`image` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `userEpisodeWatches` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`episodeId` text NOT NULL,
`watchedAt` integer NOT NULL,
`source` text DEFAULT 'manual' NOT NULL,
CONSTRAINT `fk_userEpisodeWatches_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userEpisodeWatches_episodeId_episodes_id_fk` FOREIGN KEY (`episodeId`) REFERENCES `episodes`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `userMovieWatches` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`titleId` text NOT NULL,
`watchedAt` integer NOT NULL,
`source` text DEFAULT 'manual' NOT NULL,
CONSTRAINT `fk_userMovieWatches_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userMovieWatches_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `userRatings` (
`userId` text NOT NULL,
`titleId` text NOT NULL,
`ratingStars` integer NOT NULL,
`ratedAt` integer NOT NULL,
CONSTRAINT `fk_userRatings_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userRatings_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `userTitleStatus` (
`userId` text NOT NULL,
`titleId` text NOT NULL,
`status` text NOT NULL,
`addedAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
CONSTRAINT `fk_userTitleStatus_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_userTitleStatus_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expiresAt` integer NOT NULL,
`createdAt` integer,
`updatedAt` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `availabilityOffers_unique` ON `availabilityOffers` (`titleId`,`region`,`providerId`,`offerType`);--> statement-breakpoint
CREATE UNIQUE INDEX `episodes_seasonId_episodeNumber` ON `episodes` (`seasonId`,`episodeNumber`);--> statement-breakpoint
CREATE UNIQUE INDEX `seasons_titleId_seasonNumber` ON `seasons` (`titleId`,`seasonNumber`);--> statement-breakpoint
CREATE UNIQUE INDEX `titleRecommendations_unique` ON `titleRecommendations` (`titleId`,`recommendedTitleId`,`source`);--> statement-breakpoint
CREATE UNIQUE INDEX `titles_tmdbId_unique` ON `titles` (`tmdbId`);--> statement-breakpoint
CREATE INDEX `titles_type_releaseDate` ON `titles` (`type`,`releaseDate`);--> statement-breakpoint
CREATE INDEX `titles_type_firstAirDate` ON `titles` (`type`,`firstAirDate`);--> statement-breakpoint
CREATE INDEX `userEpisodeWatches_userId_watchedAt` ON `userEpisodeWatches` (`userId`,`watchedAt`);--> statement-breakpoint
CREATE INDEX `userEpisodeWatches_episodeId` ON `userEpisodeWatches` (`episodeId`);--> statement-breakpoint
CREATE INDEX `userMovieWatches_userId_watchedAt` ON `userMovieWatches` (`userId`,`watchedAt`);--> statement-breakpoint
CREATE INDEX `userMovieWatches_titleId` ON `userMovieWatches` (`titleId`);--> statement-breakpoint
CREATE UNIQUE INDEX `userRatings_userId_titleId` ON `userRatings` (`userId`,`titleId`);--> statement-breakpoint
CREATE UNIQUE INDEX `userTitleStatus_userId_titleId` ON `userTitleStatus` (`userId`,`titleId`);--> statement-breakpoint
CREATE INDEX `userTitleStatus_userId_status` ON `userTitleStatus` (`userId`,`status`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,10 @@
CREATE TABLE `appSettings` (
`key` text PRIMARY KEY,
`value` text
);
--> statement-breakpoint
ALTER TABLE `session` ADD `impersonatedBy` text;--> statement-breakpoint
ALTER TABLE `user` ADD `role` text DEFAULT 'user';--> statement-breakpoint
ALTER TABLE `user` ADD `banned` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `user` ADD `banReason` text;--> statement-breakpoint
ALTER TABLE `user` ADD `banExpires` integer;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,27 @@
CREATE TABLE `webhookConnections` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`provider` text NOT NULL,
`token` text NOT NULL,
`mediaServerUsername` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`createdAt` integer NOT NULL,
`lastEventAt` integer,
CONSTRAINT `fk_webhookConnections_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `webhookEventLog` (
`id` text PRIMARY KEY,
`connectionId` text NOT NULL,
`eventType` text,
`mediaType` text,
`mediaTitle` text,
`status` text NOT NULL,
`errorMessage` text,
`receivedAt` integer NOT NULL,
CONSTRAINT `fk_webhookEventLog_connectionId_webhookConnections_id_fk` FOREIGN KEY (`connectionId`) REFERENCES `webhookConnections`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `webhookConnections_userId_provider` ON `webhookConnections` (`userId`,`provider`);--> statement-breakpoint
CREATE UNIQUE INDEX `webhookConnections_token` ON `webhookConnections` (`token`);--> statement-breakpoint
CREATE INDEX `webhookEventLog_connectionId_receivedAt` ON `webhookEventLog` (`connectionId`,`receivedAt`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `titles` ADD `colorPalette` text;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `account` ADD `idToken` text;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,10 @@
CREATE TABLE `cronRuns` (
`id` text PRIMARY KEY,
`jobName` text NOT NULL,
`status` text NOT NULL,
`startedAt` integer NOT NULL,
`finishedAt` integer,
`errorMessage` text
);
--> statement-breakpoint
CREATE INDEX `cronRuns_jobName_startedAt` ON `cronRuns` (`jobName`,`startedAt`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `webhookConnections` DROP COLUMN `mediaServerUsername`;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `cronRuns` ADD `durationMs` integer;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `titles` ADD `trailerVideoKey` text;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,34 @@
CREATE TABLE `persons` (
`id` text PRIMARY KEY,
`tmdbId` integer NOT NULL,
`name` text NOT NULL,
`biography` text,
`birthday` text,
`deathday` text,
`placeOfBirth` text,
`profilePath` text,
`knownForDepartment` text,
`popularity` real,
`imdbId` text,
`lastFetchedAt` integer
);
--> statement-breakpoint
CREATE TABLE `titleCast` (
`id` text PRIMARY KEY,
`titleId` text NOT NULL,
`personId` text NOT NULL,
`character` text,
`department` text DEFAULT 'Acting' NOT NULL,
`job` text,
`displayOrder` integer DEFAULT 0 NOT NULL,
`episodeCount` integer,
`lastFetchedAt` integer,
CONSTRAINT `fk_titleCast_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_titleCast_personId_persons_id_fk` FOREIGN KEY (`personId`) REFERENCES `persons`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `persons_tmdbId_unique` ON `persons` (`tmdbId`);--> statement-breakpoint
CREATE INDEX `persons_name` ON `persons` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `titleCast_unique` ON `titleCast` (`titleId`,`personId`,`department`,`character`);--> statement-breakpoint
CREATE INDEX `titleCast_titleId_displayOrder` ON `titleCast` (`titleId`,`displayOrder`);--> statement-breakpoint
CREATE INDEX `titleCast_personId` ON `titleCast` (`personId`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,16 @@
CREATE TABLE `genres` (
`id` integer PRIMARY KEY,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `titleGenres` (
`titleId` text NOT NULL,
`genreId` integer NOT NULL,
CONSTRAINT `fk_titleGenres_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_titleGenres_genreId_genres_id_fk` FOREIGN KEY (`genreId`) REFERENCES `genres`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `titleGenres_titleId_genreId` ON `titleGenres` (`titleId`,`genreId`);--> statement-breakpoint
CREATE INDEX `titleGenres_genreId` ON `titleGenres` (`genreId`);--> statement-breakpoint
CREATE INDEX `userEpisodeWatches_userId_episodeId` ON `userEpisodeWatches` (`userId`,`episodeId`);--> statement-breakpoint
CREATE INDEX `userMovieWatches_userId_titleId` ON `userMovieWatches` (`userId`,`titleId`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
ALTER TABLE `titles` ADD `contentRating` text;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,33 @@
CREATE TABLE `integrationEvents` (
`id` text PRIMARY KEY,
`integrationId` text NOT NULL,
`eventType` text,
`mediaType` text,
`mediaTitle` text,
`status` text NOT NULL,
`errorMessage` text,
`receivedAt` integer NOT NULL,
CONSTRAINT `fk_integrationEvents_integrationId_integrations_id_fk` FOREIGN KEY (`integrationId`) REFERENCES `integrations`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `integrations` (
`id` text PRIMARY KEY,
`userId` text NOT NULL,
`provider` text NOT NULL,
`type` text NOT NULL,
`token` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`createdAt` integer NOT NULL,
`lastEventAt` integer,
CONSTRAINT `fk_integrations_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
ALTER TABLE `titles` ADD `tvdbId` integer;--> statement-breakpoint
DROP INDEX IF EXISTS `webhookConnections_userId_provider`;--> statement-breakpoint
DROP INDEX IF EXISTS `webhookConnections_token`;--> statement-breakpoint
DROP INDEX IF EXISTS `webhookEventLog_connectionId_receivedAt`;--> statement-breakpoint
CREATE INDEX `integrationEvents_integrationId_receivedAt` ON `integrationEvents` (`integrationId`,`receivedAt`);--> statement-breakpoint
CREATE UNIQUE INDEX `integrations_userId_provider` ON `integrations` (`userId`,`provider`);--> statement-breakpoint
CREATE UNIQUE INDEX `integrations_token` ON `integrations` (`token`);--> statement-breakpoint
DROP TABLE `webhookConnections`;--> statement-breakpoint
DROP TABLE `webhookEventLog`;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
CREATE INDEX `titleRecommendations_titleId_rank` ON `titleRecommendations` (`titleId`,`rank`);--> statement-breakpoint
CREATE INDEX `titles_lastFetchedAt` ON `titles` (`lastFetchedAt`);--> statement-breakpoint
CREATE INDEX `titles_type_status_lastFetchedAt` ON `titles` (`type`,`status`,`lastFetchedAt`);
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@sofa/db",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./client": "./src/client.ts",
"./schema": "./src/schema.ts",
"./helpers": "./src/helpers.ts",
"./migrate": "./src/migrate.ts",
"./test-utils": "./src/test-utils.ts"
},
"scripts": {
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit",
"db:generate": "bun --bun drizzle-kit generate",
"db:migrate": "bun --bun drizzle-kit migrate",
"db:push": "bun --bun drizzle-kit push",
"db:studio": "bun --bun drizzle-kit studio"
},
"dependencies": {
"@sofa/config": "workspace:*",
"@sofa/logger": "workspace:*",
"drizzle-orm": "1.0.0-beta.16-ea816b6"
},
"devDependencies": {
"@types/bun": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"typescript": "catalog:"
}
}
+64
View File
@@ -0,0 +1,64 @@
import { Database } from "bun:sqlite";
import { DATABASE_URL } from "@sofa/config";
import { createLogger } from "@sofa/logger";
import type { Logger } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";
const log = createLogger("drizzle");
const drizzleLogger: Logger = {
logQuery(query: string, params: unknown[]) {
log.debug(query, params.length ? params : "");
},
};
// Lazy-init singleton via globalThis. Next.js evaluates module-level code at
// build time (when no database exists) and re-imports modules on HMR in dev
// (which would create duplicate connections). Stashing the instances on
// globalThis and wrapping `db` in a Proxy defers all real work to the first
// property access at runtime, sidestepping both problems.
//
// Only the Drizzle instance (`db`) is exported as a Proxy — the raw bun:sqlite
// Database is kept internal because its native C++ methods lose their `this`
// binding when accessed through Reflect.get, so a Proxy around it would break.
// Use `closeDatabase()` for graceful shutdown instead.
const globalForDb = globalThis as unknown as {
_db: ReturnType<typeof drizzle> | undefined;
_client: Database | undefined;
};
function getClient() {
if (!globalForDb._client) {
globalForDb._client = new Database(DATABASE_URL);
globalForDb._client.run("PRAGMA journal_mode = WAL");
globalForDb._client.run("PRAGMA foreign_keys = ON");
globalForDb._client.run("PRAGMA busy_timeout = 5000");
}
return globalForDb._client;
}
function getDb() {
if (!globalForDb._db) {
globalForDb._db = drizzle({
client: getClient(),
schema,
logger: drizzleLogger,
});
}
return globalForDb._db;
}
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(_, prop) {
return Reflect.get(getDb(), prop);
},
});
/** Close the current connection, and clear singletons so the Proxy re-initializes on next access. */
export function closeDatabase() {
globalForDb._client?.close();
globalForDb._client = undefined;
globalForDb._db = undefined;
}
+12
View File
@@ -0,0 +1,12 @@
export {
and,
count,
desc,
eq,
gte,
inArray,
isNotNull,
lt,
or,
sql,
} from "drizzle-orm";
+14
View File
@@ -0,0 +1,14 @@
import path from "node:path";
import { createLogger } from "@sofa/logger";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { db } from "./client";
const log = createLogger("db");
export function runMigrations(
migrationsFolder = path.join(import.meta.dir, "../drizzle"),
) {
log.info("Running database migrations...");
migrate(db, { migrationsFolder });
log.info("Database migrations complete");
}
+459
View File
@@ -0,0 +1,459 @@
import {
index,
int,
real,
sqliteTable,
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
// Helper for UUID primary keys
const uuidPk = () =>
text("id")
.primaryKey()
.$defaultFn(() => Bun.randomUUIDv7());
// ─── Better Auth tables ──────────────────────────────────────────────
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: int("emailVerified", { mode: "boolean" })
.notNull()
.default(false),
image: text("image"),
role: text("role").default("user"),
banned: int("banned", { mode: "boolean" }).default(false),
banReason: text("banReason"),
banExpires: int("banExpires", { mode: "timestamp" }),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: int("expiresAt", { mode: "timestamp" }).notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
impersonatedBy: text("impersonatedBy"),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: int("accessTokenExpiresAt", { mode: "timestamp" }),
refreshTokenExpiresAt: int("refreshTokenExpiresAt", { mode: "timestamp" }),
scope: text("scope"),
password: text("password"),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: int("expiresAt", { mode: "timestamp" }).notNull(),
createdAt: int("createdAt", { mode: "timestamp" }),
updatedAt: int("updatedAt", { mode: "timestamp" }),
});
// ─── Genres ─────────────────────────────────────────────────────────
export const genres = sqliteTable("genres", {
id: int("id").primaryKey(),
name: text("name").notNull(),
});
export const titleGenres = sqliteTable(
"titleGenres",
{
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
genreId: int("genreId")
.notNull()
.references(() => genres.id, { onDelete: "cascade" }),
},
(table) => [
uniqueIndex("titleGenres_titleId_genreId").on(table.titleId, table.genreId),
index("titleGenres_genreId").on(table.genreId),
],
);
// ─── App tables ──────────────────────────────────────────────────────
export const titles = sqliteTable(
"titles",
{
id: uuidPk(),
tmdbId: int("tmdbId").notNull(),
tvdbId: int("tvdbId"),
type: text("type", { enum: ["movie", "tv"] }).notNull(),
title: text("title").notNull(),
originalTitle: text("originalTitle"),
overview: text("overview"),
releaseDate: text("releaseDate"),
firstAirDate: text("firstAirDate"),
posterPath: text("posterPath"),
backdropPath: text("backdropPath"),
popularity: real("popularity"),
voteAverage: real("voteAverage"),
voteCount: int("voteCount"),
status: text("status"),
contentRating: text("contentRating"),
colorPalette: text("colorPalette"),
trailerVideoKey: text("trailerVideoKey"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titles_tmdbId_unique").on(table.tmdbId),
index("titles_type_releaseDate").on(table.type, table.releaseDate),
index("titles_type_firstAirDate").on(table.type, table.firstAirDate),
index("titles_lastFetchedAt").on(table.lastFetchedAt),
index("titles_type_status_lastFetchedAt").on(
table.type,
table.status,
table.lastFetchedAt,
),
],
);
export const seasons = sqliteTable(
"seasons",
{
id: uuidPk(),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
seasonNumber: int("seasonNumber").notNull(),
name: text("name"),
overview: text("overview"),
posterPath: text("posterPath"),
airDate: text("airDate"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("seasons_titleId_seasonNumber").on(
table.titleId,
table.seasonNumber,
),
],
);
export const episodes = sqliteTable(
"episodes",
{
id: uuidPk(),
seasonId: text("seasonId")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
episodeNumber: int("episodeNumber").notNull(),
name: text("name"),
overview: text("overview"),
stillPath: text("stillPath"),
airDate: text("airDate"),
runtimeMinutes: int("runtimeMinutes"),
},
(table) => [
uniqueIndex("episodes_seasonId_episodeNumber").on(
table.seasonId,
table.episodeNumber,
),
],
);
export const userTitleStatus = sqliteTable(
"userTitleStatus",
{
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
status: text("status", {
enum: ["watchlist", "in_progress", "completed"],
}).notNull(),
addedAt: int("addedAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
},
(table) => [
uniqueIndex("userTitleStatus_userId_titleId").on(
table.userId,
table.titleId,
),
index("userTitleStatus_userId_status").on(table.userId, table.status),
],
);
export const userMovieWatches = sqliteTable(
"userMovieWatches",
{
id: uuidPk(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
source: text("source", {
enum: ["manual", "import", "plex", "jellyfin", "emby"],
})
.notNull()
.default("manual"),
},
(table) => [
index("userMovieWatches_userId_watchedAt").on(
table.userId,
table.watchedAt,
),
index("userMovieWatches_titleId").on(table.titleId),
index("userMovieWatches_userId_titleId").on(table.userId, table.titleId),
],
);
export const userEpisodeWatches = sqliteTable(
"userEpisodeWatches",
{
id: uuidPk(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
episodeId: text("episodeId")
.notNull()
.references(() => episodes.id, { onDelete: "cascade" }),
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
source: text("source", {
enum: ["manual", "import", "plex", "jellyfin", "emby"],
})
.notNull()
.default("manual"),
},
(table) => [
index("userEpisodeWatches_userId_watchedAt").on(
table.userId,
table.watchedAt,
),
index("userEpisodeWatches_episodeId").on(table.episodeId),
index("userEpisodeWatches_userId_episodeId").on(
table.userId,
table.episodeId,
),
],
);
export const userRatings = sqliteTable(
"userRatings",
{
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
ratingStars: int("ratingStars").notNull(),
ratedAt: int("ratedAt", { mode: "timestamp" }).notNull(),
},
(table) => [
uniqueIndex("userRatings_userId_titleId").on(table.userId, table.titleId),
],
);
export const availabilityOffers = sqliteTable(
"availabilityOffers",
{
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
region: text("region").notNull().default("US"),
providerId: int("providerId").notNull(),
providerName: text("providerName").notNull(),
logoPath: text("logoPath"),
offerType: text("offerType", {
enum: ["flatrate", "rent", "buy", "free", "ads"],
}).notNull(),
link: text("link"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("availabilityOffers_unique").on(
table.titleId,
table.region,
table.providerId,
table.offerType,
),
],
);
export const titleRecommendations = sqliteTable(
"titleRecommendations",
{
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
recommendedTitleId: text("recommendedTitleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
source: text("source", {
enum: ["tmdb_similar", "tmdb_recommendations"],
}).notNull(),
rank: int("rank").notNull(),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titleRecommendations_unique").on(
table.titleId,
table.recommendedTitleId,
table.source,
),
index("titleRecommendations_titleId_rank").on(table.titleId, table.rank),
],
);
// ─── Persons & Cast ─────────────────────────────────────────────────
export const persons = sqliteTable(
"persons",
{
id: uuidPk(),
tmdbId: int("tmdbId").notNull(),
name: text("name").notNull(),
biography: text("biography"),
birthday: text("birthday"),
deathday: text("deathday"),
placeOfBirth: text("placeOfBirth"),
profilePath: text("profilePath"),
knownForDepartment: text("knownForDepartment"),
popularity: real("popularity"),
imdbId: text("imdbId"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("persons_tmdbId_unique").on(table.tmdbId),
index("persons_name").on(table.name),
],
);
export const titleCast = sqliteTable(
"titleCast",
{
id: uuidPk(),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
personId: text("personId")
.notNull()
.references(() => persons.id, { onDelete: "cascade" }),
character: text("character"),
department: text("department").notNull().default("Acting"),
job: text("job"),
displayOrder: int("displayOrder").notNull().default(0),
episodeCount: int("episodeCount"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titleCast_unique").on(
table.titleId,
table.personId,
table.department,
table.character,
),
index("titleCast_titleId_displayOrder").on(
table.titleId,
table.displayOrder,
),
index("titleCast_personId").on(table.personId),
],
);
// ─── Integrations ───────────────────────────────────────────────────
export const integrations = sqliteTable(
"integrations",
{
id: uuidPk(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
type: text("type", { enum: ["webhook", "list"] }).notNull(),
token: text("token").notNull().unique(),
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
lastEventAt: int("lastEventAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("integrations_userId_provider").on(
table.userId,
table.provider,
),
uniqueIndex("integrations_token").on(table.token),
],
);
export const integrationEvents = sqliteTable(
"integrationEvents",
{
id: uuidPk(),
integrationId: text("integrationId")
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
eventType: text("eventType"),
mediaType: text("mediaType"),
mediaTitle: text("mediaTitle"),
status: text("status", {
enum: ["success", "ignored", "error"],
}).notNull(),
errorMessage: text("errorMessage"),
receivedAt: int("receivedAt", { mode: "timestamp" }).notNull(),
},
(table) => [
index("integrationEvents_integrationId_receivedAt").on(
table.integrationId,
table.receivedAt,
),
],
);
// ─── Cron Run History ────────────────────────────────────────────────
export const cronRuns = sqliteTable(
"cronRuns",
{
id: uuidPk(),
jobName: text("jobName").notNull(),
status: text("status", {
enum: ["running", "success", "error"],
}).notNull(),
startedAt: int("startedAt", { mode: "timestamp" }).notNull(),
finishedAt: int("finishedAt", { mode: "timestamp" }),
durationMs: int("durationMs"),
errorMessage: text("errorMessage"),
},
(table) => [
index("cronRuns_jobName_startedAt").on(table.jobName, table.startedAt),
],
);
// ─── App Settings ───────────────────────────────────────────────────
export const appSettings = sqliteTable("appSettings", {
key: text("key").primaryKey(),
value: text("value"),
});
+225
View File
@@ -0,0 +1,225 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import * as schema from "./schema";
const {
user,
titles,
seasons,
episodes,
userMovieWatches,
userEpisodeWatches,
userTitleStatus,
userRatings,
availabilityOffers,
titleRecommendations,
integrations,
} = schema;
export const testClient = new Database(":memory:");
testClient.run("PRAGMA foreign_keys = ON");
export const testDb = drizzle({ client: testClient, schema });
export function applyMigrations() {
const migrationsFolder = `${import.meta.dir}/../drizzle`;
migrate(testDb, { migrationsFolder });
}
export function clearAllTables() {
const tables = testClient
.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__drizzle%'",
)
.all() as { name: string }[];
testClient.run("PRAGMA foreign_keys = OFF");
for (const { name } of tables) {
testClient.run(`DELETE FROM "${name}"`);
}
testClient.run("PRAGMA foreign_keys = ON");
}
const now = new Date();
export function insertUser(id = "user-1") {
testDb
.insert(user)
.values({
id,
name: "Test User",
email: `${id}@test.com`,
emailVerified: true,
createdAt: now,
updatedAt: now,
})
.run();
return id;
}
export function insertTitle(
overrides: {
id?: string;
tmdbId?: number;
tvdbId?: number;
type?: "movie" | "tv";
title?: string;
} = {},
) {
const id = overrides.id ?? "title-1";
testDb
.insert(titles)
.values({
id,
tmdbId: overrides.tmdbId ?? 12345,
tvdbId: overrides.tvdbId,
type: overrides.type ?? "movie",
title: overrides.title ?? "Test Movie",
})
.run();
return id;
}
export function insertTvShow(
titleId = "tv-1",
tmdbId = 99999,
seasonCount = 1,
epsPerSeason = 3,
) {
insertTitle({ id: titleId, tmdbId, type: "tv", title: "Test Show" });
const episodeIds: string[] = [];
for (let s = 1; s <= seasonCount; s++) {
const seasonId = `${titleId}-s${s}`;
testDb
.insert(seasons)
.values({ id: seasonId, titleId, seasonNumber: s })
.run();
for (let e = 1; e <= epsPerSeason; e++) {
const epId = `${titleId}-s${s}e${e}`;
testDb
.insert(episodes)
.values({
id: epId,
seasonId,
episodeNumber: e,
name: `S${s}E${e}`,
})
.run();
episodeIds.push(epId);
}
}
return { titleId, episodeIds };
}
export function insertMovieWatch(
userId: string,
titleId: string,
watchedAt?: Date,
) {
testDb
.insert(userMovieWatches)
.values({
userId,
titleId,
watchedAt: watchedAt ?? new Date(),
source: "manual",
})
.run();
}
export function insertEpisodeWatch(
userId: string,
episodeId: string,
watchedAt?: Date,
) {
testDb
.insert(userEpisodeWatches)
.values({
userId,
episodeId,
watchedAt: watchedAt ?? new Date(),
source: "manual",
})
.run();
}
export function insertStatus(
userId: string,
titleId: string,
status: "watchlist" | "in_progress" | "completed",
) {
const now = new Date();
testDb
.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.run();
}
export function insertRating(
userId: string,
titleId: string,
ratingStars: number,
) {
testDb
.insert(userRatings)
.values({ userId, titleId, ratingStars, ratedAt: new Date() })
.run();
}
export function insertAvailabilityOffer(
titleId: string,
overrides: {
providerId?: number;
providerName?: string;
offerType?: "flatrate" | "rent" | "buy" | "free" | "ads";
} = {},
) {
testDb
.insert(availabilityOffers)
.values({
titleId,
providerId: overrides.providerId ?? 8,
providerName: overrides.providerName ?? "Netflix",
offerType: overrides.offerType ?? "flatrate",
})
.run();
}
export function insertIntegration(
userId: string,
provider: string,
token = "test-token",
) {
const type =
provider === "sonarr" || provider === "radarr" ? "list" : "webhook";
return testDb
.insert(integrations)
.values({
userId,
provider,
type,
token,
enabled: true,
createdAt: new Date(),
})
.returning()
.get();
}
export function insertRecommendation(
titleId: string,
recommendedTitleId: string,
overrides: {
source?: "tmdb_recommendations" | "tmdb_similar";
rank?: number;
} = {},
) {
testDb
.insert(titleRecommendations)
.values({
titleId,
recommendedTitleId,
source: overrides.source ?? "tmdb_recommendations",
rank: overrides.rank ?? 1,
})
.run();
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@sofa/logger",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"pino": "10.3.1",
"pino-pretty": "13.1.3"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
+64
View File
@@ -0,0 +1,64 @@
import pino from "pino";
const VALID_LEVELS = ["fatal", "error", "warn", "info", "debug", "trace"];
const envLevel = process.env.LOG_LEVEL?.toLowerCase() ?? "info";
const level = VALID_LEVELS.includes(envLevel) ? envLevel : "info";
const isDev = process.env.NODE_ENV !== "production";
export const rootLogger = pino({
level,
base: undefined,
timestamp: pino.stdTimeFunctions.isoTime,
...(isDev && {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:HH:MM:ss.l",
ignore: "pid,hostname",
messageFormat: "[{module}] {msg}",
},
},
}),
});
export interface Logger {
error(msg: string, ...args: unknown[]): void;
warn(msg: string, ...args: unknown[]): void;
info(msg: string, ...args: unknown[]): void;
debug(msg: string, ...args: unknown[]): void;
}
function wrapLevel(
child: pino.Logger,
lvl: "error" | "warn" | "info" | "debug",
) {
return (msg: string, ...args: unknown[]) => {
if (args.length === 0) {
child[lvl](msg);
} else if (args.length === 1 && args[0] instanceof Error) {
child[lvl]({ err: args[0] }, msg);
} else if (
args.length === 1 &&
typeof args[0] === "object" &&
args[0] !== null &&
!Array.isArray(args[0])
) {
child[lvl](args[0] as object, msg);
} else {
child[lvl]({ extra: args }, msg);
}
};
}
export function createLogger(name: string): Logger {
const child = rootLogger.child({ module: name });
return {
error: wrapLevel(child, "error"),
warn: wrapLevel(child, "warn"),
info: wrapLevel(child, "info"),
debug: wrapLevel(child, "debug"),
};
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@sofa/tmdb",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./client": "./src/client.ts",
"./image": "./src/image.ts",
"./config": "./src/config.ts"
},
"scripts": {
"lint": "biome check",
"format": "biome format --write",
"check-types": "tsc --noEmit",
"generate-schema": "bunx openapi-typescript https://developer.themoviedb.org/openapi/tmdb-api.json -o ./src/schema.d.ts"
},
"dependencies": {
"@sofa/db": "workspace:*",
"@sofa/logger": "workspace:*",
"openapi-fetch": "0.17.0"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
+465
View File
@@ -0,0 +1,465 @@
import { createLogger } from "@sofa/logger";
import createClient, { type Middleware } from "openapi-fetch";
import type { operations, paths } from "./schema";
const log = createLogger("tmdb");
// ─── Schema-derived types ───────────────────────────────────────────
/** Extract the 200 JSON response body from an operation */
type OpResponse<K extends keyof operations> = operations[K] extends {
responses: {
200: { content: { "application/json": infer T } };
};
}
? T
: never;
// Search
export type TmdbSearchResponse = OpResponse<"search-multi">;
export type TmdbSearchResult = NonNullable<
TmdbSearchResponse["results"]
>[number];
// Movie details — augmented with append_to_response data
export type TmdbMovieDetails = OpResponse<"movie-details"> & {
release_dates?: {
results: NonNullable<OpResponse<"movie-release-dates">["results"]>;
};
};
// TV details — augmented with append_to_response data
export type TmdbTvDetails = OpResponse<"tv-series-details"> & {
content_ratings?: OpResponse<"tv-series-content-ratings">;
external_ids?: OpResponse<"tv-series-external-ids">;
};
// Watch providers — manually defined because the schema types `results` as an
// object with literal country-code keys rather than Record<string, ...>, which
// makes dynamic lookups (e.g. results["US"]) impractical.
export interface TmdbProvider {
logo_path?: string;
provider_id: number;
provider_name?: string;
display_priority: number;
}
export interface TmdbWatchProviderRegion {
link?: string;
flatrate?: TmdbProvider[];
rent?: TmdbProvider[];
buy?: TmdbProvider[];
free?: TmdbProvider[];
ads?: TmdbProvider[];
}
export interface TmdbWatchProviderResponse {
id: number;
results?: Record<string, TmdbWatchProviderRegion>;
}
// Recommendations — movie-recommendations is Record<string, never> in schema,
// so we base on tv-series-recommendations and add movie fields TMDB returns.
type TvRecResult = NonNullable<
OpResponse<"tv-series-recommendations">["results"]
>[number];
export type TmdbRecommendationResponse = Omit<
OpResponse<"tv-series-recommendations">,
"results"
> & {
results?: (TvRecResult & {
title?: string;
original_title?: string;
release_date?: string;
})[];
};
// Find — schema types tv_results and tv_episode_results as unknown[]
export type TmdbFindResult = Omit<
OpResponse<"find-by-id">,
"tv_results" | "tv_episode_results"
> & {
tv_results?: TmdbSearchResult[];
tv_episode_results?: {
id: number;
episode_number: number;
name: string;
season_number: number;
show_id: number;
}[];
};
// Videos
export type TmdbVideo = NonNullable<
OpResponse<"movie-videos">["results"]
>[number];
// Genres
export type TmdbGenre = NonNullable<
OpResponse<"genre-movie-list">["genres"]
>[number];
// Person — schema types deathday as unknown
export type TmdbPersonDetails = Omit<
OpResponse<"person-details">,
"deathday"
> & {
deathday?: string | null;
};
// ─── Client setup ───────────────────────────────────────────────────
function getApiKey() {
const key = process.env.TMDB_API_READ_ACCESS_TOKEN;
if (!key) throw new Error("TMDB_API_READ_ACCESS_TOKEN is not set");
return key;
}
// The default TMDB base URL ends with /3, matching the schema's /3/… paths.
// Custom proxy URLs (e.g. https://tmdb.internal) may omit /3 — those worked
// before because the old client built paths like /search/multi against the
// custom base. To preserve that, we always strip /3 from the configured URL
// and use the schema paths as-is (they already include /3/).
const DEFAULT_TMDB_BASE = "https://api.themoviedb.org/3";
const configuredBase = process.env.TMDB_API_BASE_URL || DEFAULT_TMDB_BASE;
const isCustomBase = configuredBase !== DEFAULT_TMDB_BASE;
const baseUrl = configuredBase.replace(/\/3\/?$/, "");
const baseUrlRewriteMiddleware: Middleware | null = isCustomBase
? {
async onRequest({ request }) {
// Custom proxy: strip the /3 prefix from schema paths so requests
// go to e.g. https://tmdb.internal/search/multi instead of /3/search/multi
const url = new URL(request.url);
url.pathname = url.pathname.replace(/^\/3\//, "/");
return new Request(url.toString(), request);
},
}
: null;
const authMiddleware: Middleware = {
async onRequest({ request }) {
request.headers.set("Authorization", `Bearer ${getApiKey()}`);
request.headers.set("Accept", "application/json");
return request;
},
};
const requestTimings = new WeakMap<Request, number>();
const loggingMiddleware: Middleware = {
async onRequest({ request }) {
requestTimings.set(request, performance.now());
return request;
},
async onResponse({ request, response }) {
const start = requestTimings.get(request);
const elapsed = start ? Math.round(performance.now() - start) : 0;
if (!response.ok) {
log.warn(
`${request.url} -> ${response.status} ${response.statusText} (${elapsed}ms)`,
);
} else {
log.debug(`${request.url} -> ${response.status} (${elapsed}ms)`);
}
return undefined;
},
};
const client = createClient<paths>({ baseUrl });
if (baseUrlRewriteMiddleware) client.use(baseUrlRewriteMiddleware);
client.use(authMiddleware, loggingMiddleware);
// ─── Search ─────────────────────────────────────────────────────────
export async function searchMulti(query: string, page = 1) {
const { data, error } = await client.GET("/3/search/multi", {
params: { query: { query, page, include_adult: false } },
});
if (error) throw new Error("TMDB API error: search/multi");
return data;
}
export async function searchMovies(query: string, page = 1) {
const { data, error } = await client.GET("/3/search/movie", {
params: { query: { query, page } },
});
if (error) throw new Error("TMDB API error: search/movie");
return data;
}
export async function searchTv(query: string, page = 1) {
const { data, error } = await client.GET("/3/search/tv", {
params: { query: { query, page } },
});
if (error) throw new Error("TMDB API error: search/tv");
return data;
}
// ─── Details ────────────────────────────────────────────────────────
export async function getMovieDetails(tmdbId: number) {
const { data, error } = await client.GET("/3/movie/{movie_id}", {
params: {
path: { movie_id: tmdbId },
query: { append_to_response: "release_dates" },
},
});
if (error) throw new Error(`TMDB API error: movie/${tmdbId}`);
return data as TmdbMovieDetails;
}
export async function getTvDetails(tmdbId: number) {
const { data, error } = await client.GET("/3/tv/{series_id}", {
params: {
path: { series_id: tmdbId },
query: { append_to_response: "content_ratings,external_ids" },
},
});
if (error) throw new Error(`TMDB API error: tv/${tmdbId}`);
return data as TmdbTvDetails;
}
export async function getTvExternalIds(tmdbId: number) {
const { data, error } = await client.GET("/3/tv/{series_id}/external_ids", {
params: { path: { series_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/external_ids`);
return data;
}
export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
const { data, error } = await client.GET(
"/3/tv/{series_id}/season/{season_number}",
{
params: {
path: { series_id: tmdbId, season_number: seasonNumber },
},
},
);
if (error)
throw new Error(`TMDB API error: tv/${tmdbId}/season/${seasonNumber}`);
return data;
}
// ─── Watch Providers ────────────────────────────────────────────────
export async function getWatchProviders(tmdbId: number, type: "movie" | "tv") {
if (type === "movie") {
const { data, error } = await client.GET(
"/3/movie/{movie_id}/watch/providers",
{ params: { path: { movie_id: tmdbId } } },
);
if (error)
throw new Error(`TMDB API error: movie/${tmdbId}/watch/providers`);
return data as TmdbWatchProviderResponse;
}
const { data, error } = await client.GET(
"/3/tv/{series_id}/watch/providers",
{ params: { path: { series_id: tmdbId } } },
);
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/watch/providers`);
return data as TmdbWatchProviderResponse;
}
// ─── Recommendations & Similar ──────────────────────────────────────
export async function getRecommendations(tmdbId: number, type: "movie" | "tv") {
if (type === "movie") {
const { data, error } = await client.GET(
"/3/movie/{movie_id}/recommendations",
{ params: { path: { movie_id: tmdbId } } },
);
if (error)
throw new Error(`TMDB API error: movie/${tmdbId}/recommendations`);
return data as TmdbRecommendationResponse;
}
const { data, error } = await client.GET(
"/3/tv/{series_id}/recommendations",
{ params: { path: { series_id: tmdbId } } },
);
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/recommendations`);
return data as TmdbRecommendationResponse;
}
export async function getSimilar(tmdbId: number, type: "movie" | "tv") {
if (type === "movie") {
const { data, error } = await client.GET("/3/movie/{movie_id}/similar", {
params: { path: { movie_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/similar`);
return data as TmdbRecommendationResponse;
}
// Schema incorrectly types series_id as string here (number everywhere else)
const { data, error } = await client.GET("/3/tv/{series_id}/similar", {
params: { path: { series_id: String(tmdbId) } },
});
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/similar`);
return data as TmdbRecommendationResponse;
}
// ─── Trending & Popular ─────────────────────────────────────────────
export async function getTrending(
mediaType: "all" | "movie" | "tv",
timeWindow: "day" | "week" = "day",
) {
const opts = {
params: { path: { time_window: timeWindow } },
} as const;
if (mediaType === "movie") {
const { data, error } = await client.GET(
"/3/trending/movie/{time_window}",
opts,
);
if (error) throw new Error("TMDB API error: trending/movie");
return data;
}
if (mediaType === "tv") {
const { data, error } = await client.GET(
"/3/trending/tv/{time_window}",
opts,
);
if (error) throw new Error("TMDB API error: trending/tv");
return data;
}
const { data, error } = await client.GET(
"/3/trending/all/{time_window}",
opts,
);
if (error) throw new Error("TMDB API error: trending/all");
return data;
}
export async function getPopular(type: "movie" | "tv", page = 1) {
if (type === "movie") {
const { data, error } = await client.GET("/3/movie/popular", {
params: { query: { page } },
});
if (error) throw new Error("TMDB API error: movie/popular");
return data;
}
const { data, error } = await client.GET("/3/tv/popular", {
params: { query: { page } },
});
if (error) throw new Error("TMDB API error: tv/popular");
return data;
}
// ─── Genres ─────────────────────────────────────────────────────────
export async function getGenres(type: "movie" | "tv") {
if (type === "movie") {
const { data, error } = await client.GET("/3/genre/movie/list", {});
if (error) throw new Error("TMDB API error: genre/movie/list");
return data;
}
const { data, error } = await client.GET("/3/genre/tv/list", {});
if (error) throw new Error("TMDB API error: genre/tv/list");
return data;
}
// ─── Discover ───────────────────────────────────────────────────────
export async function discover(
type: "movie" | "tv",
params: Record<string, string>,
page = 1,
) {
// Discover accepts many dynamic filter params (with_genres, vote_count.gte, etc.)
// that aren't individually typed in the schema, so widen to Record<string, unknown>.
if (type === "movie") {
const { data, error } = await client.GET("/3/discover/movie", {
params: { query: { ...params, page } as Record<string, unknown> },
});
if (error) throw new Error("TMDB API error: discover/movie");
return data;
}
const { data, error } = await client.GET("/3/discover/tv", {
params: { query: { ...params, page } as Record<string, unknown> },
});
if (error) throw new Error("TMDB API error: discover/tv");
return data;
}
// ─── Videos ─────────────────────────────────────────────────────────
export async function getVideos(tmdbId: number, type: "movie" | "tv") {
if (type === "movie") {
const { data, error } = await client.GET("/3/movie/{movie_id}/videos", {
params: { path: { movie_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/videos`);
return data;
}
const { data, error } = await client.GET("/3/tv/{series_id}/videos", {
params: { path: { series_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/videos`);
return data;
}
// ─── Find by External ID ───────────────────────────────────────────
export async function findByExternalId(
externalId: string,
source: "imdb_id" | "tvdb_id",
) {
const { data, error } = await client.GET("/3/find/{external_id}", {
params: {
path: { external_id: externalId },
query: { external_source: source },
},
});
if (error) throw new Error(`TMDB API error: find/${externalId}`);
return data as TmdbFindResult;
}
// ─── Person / Credits ───────────────────────────────────────────────
export async function getMovieCredits(tmdbId: number) {
const { data, error } = await client.GET("/3/movie/{movie_id}/credits", {
params: { path: { movie_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/credits`);
return data;
}
export async function getTvAggregateCredits(tmdbId: number) {
const { data, error } = await client.GET(
"/3/tv/{series_id}/aggregate_credits",
{ params: { path: { series_id: tmdbId } } },
);
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/aggregate_credits`);
return data;
}
export async function getPersonDetails(tmdbId: number) {
const { data, error } = await client.GET("/3/person/{person_id}", {
params: { path: { person_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: person/${tmdbId}`);
return data as TmdbPersonDetails;
}
export async function getPersonCombinedCredits(tmdbId: number) {
const { data, error } = await client.GET(
"/3/person/{person_id}/combined_credits",
// Schema incorrectly types person_id as string here (number everywhere else)
{ params: { path: { person_id: String(tmdbId) } } },
);
if (error)
throw new Error(`TMDB API error: person/${tmdbId}/combined_credits`);
return data;
}
export async function searchPerson(query: string, page = 1) {
const { data, error } = await client.GET("/3/search/person", {
params: { query: { query, page } },
});
if (error) throw new Error("TMDB API error: search/person");
return data;
}
export { tmdbImageUrl } from "./image";
+8
View File
@@ -0,0 +1,8 @@
/**
* Server-side configuration checks.
* Call these in route handlers or server components — never on the client.
*/
export function isTmdbConfigured(): boolean {
return !!process.env.TMDB_API_READ_ACCESS_TOKEN?.trim();
}
+34
View File
@@ -0,0 +1,34 @@
export type ImageCategory =
| "posters"
| "backdrops"
| "stills"
| "logos"
| "profiles";
const IMAGE_BASE_URL =
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
const CATEGORY_SIZES: Record<ImageCategory, string> = {
posters: "w500",
backdrops: "w1280",
stills: "w1280",
logos: "w92",
profiles: "w185",
};
export function tmdbImageUrl(
path: string | null,
category: ImageCategory,
sizeOverride?: string,
) {
if (!path) return null;
const size = sizeOverride ?? CATEGORY_SIZES[category];
if (process.env.IMAGE_CACHE_ENABLED === "false") {
return `${IMAGE_BASE_URL}/${size}${path}`;
}
const filename = path.startsWith("/") ? path.slice(1) : path;
return `/images/${category}/${filename}`;
}
+22844
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}