mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 07:25:44 -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,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"]
|
||||
}
|
||||
Reference in new issue
Block a user