mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
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:
1 parent
26558b29e4
commit
a326c968b7
325 files changed
+26970
-28519
No files matched your search
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["esnext"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
and,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
lt,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Vendored
+22844
File diff suppressed because it is too large.
Load diff
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user