mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
refactor: replace Biome with oxlint + oxfmt
Migrate the entire monorepo from Biome 2.4.7 to oxlint 1.56.0 (linter) and oxfmt 0.41.0 (formatter) for faster lint/format and broader rule coverage. - Add `.oxlintrc.json` with React, TypeScript, unicorn, import plugins and correctness/suspicious categories - Add `.oxfmtrc.json` with 2-space indent, import sorting, and Tailwind class sorting (all 30+ custom className attributes migrated) - Add `docs/.oxlintrc.json` and `docs/.oxfmtrc.json` with Next.js plugin - Update all 12 workspace package.json scripts: `oxlint`, `oxfmt`, `oxfmt --check` - Add `format:check` turbo task and CI step - Update VS Code settings/extensions to use `oxc.oxc-vscode` - Update CI path triggers from `biome.json` to new config files - Remove all `biome-ignore` comments and fix shadowed variables - Delete `biome.json` and `docs/biome.json` - Reformat entire codebase with oxfmt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
359 files changed
+3894
-7208
No files matched your search
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"]
|
||||
}
|
||||
@@ -10,8 +10,9 @@
|
||||
"./utils": "./src/utils.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eventIterator, oc } from "@orpc/contract";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppErrorCode, appErrorData } from "./errors";
|
||||
import {
|
||||
AuthConfigOutput,
|
||||
@@ -73,8 +74,7 @@ export const contract = {
|
||||
summary: "Get title details",
|
||||
description:
|
||||
"Fetch full title metadata including seasons, cast, and streaming availability. Imports from TMDB on first access if not yet cached locally.",
|
||||
successDescription:
|
||||
"Full title with seasons, cast, and availability offers",
|
||||
successDescription: "Full title with seasons, cast, and availability offers",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(TitleDetailOutput)
|
||||
@@ -101,8 +101,7 @@ export const contract = {
|
||||
path: "/titles/{id}/rating",
|
||||
tags: ["Titles"],
|
||||
summary: "Rate a title",
|
||||
description:
|
||||
"Set a 0-5 star rating for a title. Use 0 to clear the rating.",
|
||||
description: "Set a 0-5 star rating for a title. Use 0 to clear the rating.",
|
||||
})
|
||||
.input(UpdateRatingInput)
|
||||
.output(z.void()),
|
||||
@@ -112,8 +111,7 @@ export const contract = {
|
||||
path: "/titles/{id}/watch",
|
||||
tags: ["Titles"],
|
||||
summary: "Mark movie as watched",
|
||||
description:
|
||||
"Log a watch event for a movie. Automatically sets status to completed.",
|
||||
description: "Log a watch event for a movie. Automatically sets status to completed.",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
@@ -136,8 +134,7 @@ export const contract = {
|
||||
summary: "Get user's title info",
|
||||
description:
|
||||
"Fetch the current user's tracking status, rating, and watched episode IDs for a title.",
|
||||
successDescription:
|
||||
"User's status, rating, and list of watched episode IDs",
|
||||
successDescription: "User's status, rating, and list of watched episode IDs",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(UserInfoOutput),
|
||||
@@ -161,8 +158,7 @@ export const contract = {
|
||||
summary: "Quick add title to library",
|
||||
description:
|
||||
"Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.",
|
||||
successDescription:
|
||||
"Title ID and whether it was already in the library",
|
||||
successDescription: "Title ID and whether it was already in the library",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(QuickAddOutput)
|
||||
@@ -213,8 +209,7 @@ export const contract = {
|
||||
path: "/seasons/{id}/watch",
|
||||
tags: ["Seasons"],
|
||||
summary: "Mark season watched",
|
||||
description:
|
||||
"Mark all episodes in a season as watched in a single operation.",
|
||||
description: "Mark all episodes in a season as watched in a single operation.",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
@@ -224,8 +219,7 @@ export const contract = {
|
||||
path: "/seasons/{id}/unwatch",
|
||||
tags: ["Seasons"],
|
||||
summary: "Mark season unwatched",
|
||||
description:
|
||||
"Remove all watch records for episodes in a season in a single operation.",
|
||||
description: "Remove all watch records for episodes in a season in a single operation.",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(z.void()),
|
||||
@@ -271,8 +265,7 @@ export const contract = {
|
||||
summary: "Get continue watching list",
|
||||
description:
|
||||
"Fetch TV shows the user is currently watching, with the next unwatched episode and progress for each.",
|
||||
successDescription:
|
||||
"In-progress shows with next episode and watch progress",
|
||||
successDescription: "In-progress shows with next episode and watch progress",
|
||||
})
|
||||
.output(ContinueWatchingOutput),
|
||||
library: oc
|
||||
@@ -281,8 +274,7 @@ export const contract = {
|
||||
path: "/dashboard/library",
|
||||
tags: ["Dashboard"],
|
||||
summary: "Get user library",
|
||||
description:
|
||||
"Fetch paginated titles in the user's library with their tracking statuses.",
|
||||
description: "Fetch paginated titles in the user's library with their tracking statuses.",
|
||||
successDescription: "Paginated library items with user statuses",
|
||||
})
|
||||
.input(PaginatedInput)
|
||||
@@ -465,8 +457,7 @@ export const contract = {
|
||||
path: "/integrations/{provider}",
|
||||
tags: ["Integrations"],
|
||||
summary: "Delete integration",
|
||||
description:
|
||||
"Remove a media server integration and all its event history.",
|
||||
description: "Remove a media server integration and all its event history.",
|
||||
})
|
||||
.input(ProviderParam)
|
||||
.output(z.void()),
|
||||
@@ -497,8 +488,7 @@ export const contract = {
|
||||
path: "/admin/backups",
|
||||
tags: ["Admin"],
|
||||
summary: "List backups",
|
||||
description:
|
||||
"Fetch all database backups with their sizes and creation times.",
|
||||
description: "Fetch all database backups with their sizes and creation times.",
|
||||
successDescription: "Available backups",
|
||||
})
|
||||
.output(BackupsListOutput),
|
||||
@@ -557,8 +547,7 @@ export const contract = {
|
||||
path: "/admin/backups/schedule",
|
||||
tags: ["Admin"],
|
||||
summary: "Get backup schedule",
|
||||
description:
|
||||
"Fetch the current automated backup schedule configuration.",
|
||||
description: "Fetch the current automated backup schedule configuration.",
|
||||
successDescription: "Backup schedule settings",
|
||||
})
|
||||
.output(BackupScheduleOutput),
|
||||
@@ -580,8 +569,7 @@ export const contract = {
|
||||
path: "/admin/registration",
|
||||
tags: ["Admin"],
|
||||
summary: "Get registration status",
|
||||
description:
|
||||
"Check whether new user registration is currently open or closed.",
|
||||
description: "Check whether new user registration is currently open or closed.",
|
||||
successDescription: "Registration open/closed state",
|
||||
})
|
||||
.output(RegistrationOutput),
|
||||
@@ -612,8 +600,7 @@ export const contract = {
|
||||
path: "/admin/update-check",
|
||||
tags: ["Admin"],
|
||||
summary: "Toggle update checks",
|
||||
description:
|
||||
"Enable or disable automatic update checks against the public API.",
|
||||
description: "Enable or disable automatic update checks against the public API.",
|
||||
})
|
||||
.input(ToggleUpdateCheckInput)
|
||||
.output(z.void()),
|
||||
@@ -644,8 +631,7 @@ export const contract = {
|
||||
path: "/admin/jobs/trigger",
|
||||
tags: ["Admin"],
|
||||
summary: "Trigger cron job",
|
||||
description:
|
||||
"Manually trigger a background cron job by name. The job runs asynchronously.",
|
||||
description: "Manually trigger a background cron job by name. The job runs asynchronously.",
|
||||
})
|
||||
.input(TriggerJobInput)
|
||||
.output(TriggerJobOutput)
|
||||
@@ -720,8 +706,7 @@ export const contract = {
|
||||
path: "/account/avatar",
|
||||
tags: ["Account"],
|
||||
summary: "Remove avatar",
|
||||
description:
|
||||
"Delete the current user's profile avatar, reverting to the default.",
|
||||
description: "Delete the current user's profile avatar, reverting to the default.",
|
||||
})
|
||||
.input(z.void())
|
||||
.output(z.void()),
|
||||
@@ -763,8 +748,7 @@ export const contract = {
|
||||
path: "/imports/jobs",
|
||||
tags: ["Imports"],
|
||||
summary: "Create import job",
|
||||
description:
|
||||
"Create and start a background import job from previously parsed data.",
|
||||
description: "Create and start a background import job from previously parsed data.",
|
||||
successDescription: "Created import job",
|
||||
})
|
||||
.input(CreateImportJobInput)
|
||||
|
||||
+83
-299
@@ -17,32 +17,18 @@ export const MediaTypeParam = z.object({
|
||||
type: z.enum(["movie", "tv"]).describe("Media type filter"),
|
||||
});
|
||||
export const TrendingTypeParam = z.object({
|
||||
type: z
|
||||
.enum(["all", "movie", "tv"])
|
||||
.describe("Trending category: all, movie, or tv"),
|
||||
type: z.enum(["all", "movie", "tv"]).describe("Trending category: all, movie, or tv"),
|
||||
});
|
||||
// ─── Pagination ──────────────────────────────────────────────
|
||||
|
||||
/** Page param for TMDB-backed endpoints (fixed ~20 items/page from TMDB) */
|
||||
export const PageParam = z.object({
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.default(1)
|
||||
.describe("Page number (1-indexed)"),
|
||||
page: z.number().int().min(1).max(500).default(1).describe("Page number (1-indexed)"),
|
||||
});
|
||||
|
||||
/** Page + limit for locally-paginated endpoints */
|
||||
export const PaginatedInput = z.object({
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.default(1)
|
||||
.describe("Page number (1-indexed)"),
|
||||
page: z.number().int().min(1).max(500).default(1).describe("Page number (1-indexed)"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -75,21 +61,13 @@ export const UpdateStatusInput = z
|
||||
export const UpdateRatingInput = z
|
||||
.object({
|
||||
id: z.string().min(1).describe("Title ID"),
|
||||
stars: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(5)
|
||||
.describe("Star rating from 0 (clear) to 5"),
|
||||
stars: z.number().int().min(0).max(5).describe("Star rating from 0 (clear) to 5"),
|
||||
})
|
||||
.meta({ description: "Set or clear a star rating for a title" });
|
||||
|
||||
export const BatchWatchInput = z
|
||||
.object({
|
||||
episodeIds: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe("List of episode IDs to mark as watched"),
|
||||
episodeIds: z.array(z.string()).min(1).describe("List of episode IDs to mark as watched"),
|
||||
})
|
||||
.meta({ description: "Batch of episode IDs to mark as watched" });
|
||||
|
||||
@@ -118,9 +96,7 @@ export const DiscoverInput = z
|
||||
|
||||
export const WatchHistoryInput = z
|
||||
.object({
|
||||
type: z
|
||||
.enum(["movie", "episode"])
|
||||
.describe("What to count: movie watches or episode watches"),
|
||||
type: z.enum(["movie", "episode"]).describe("What to count: movie watches or episode watches"),
|
||||
period: z
|
||||
.enum(["today", "this_week", "this_month", "this_year"])
|
||||
.describe("Time range for the histogram"),
|
||||
@@ -150,9 +126,7 @@ export const ToggleUpdateCheckInput = z.object({
|
||||
enabled: z.boolean().describe("Whether automatic update checks are enabled"),
|
||||
});
|
||||
export const ToggleTelemetryInput = z.object({
|
||||
enabled: z
|
||||
.boolean()
|
||||
.describe("Whether anonymous telemetry reporting is enabled"),
|
||||
enabled: z.boolean().describe("Whether anonymous telemetry reporting is enabled"),
|
||||
});
|
||||
export const TelemetryOutput = z
|
||||
.object({
|
||||
@@ -160,9 +134,7 @@ export const TelemetryOutput = z
|
||||
lastReportedAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"ISO 8601 timestamp of the last telemetry report, or null if never sent",
|
||||
),
|
||||
.describe("ISO 8601 timestamp of the last telemetry report, or null if never sent"),
|
||||
})
|
||||
.meta({ description: "Telemetry configuration and last report time" });
|
||||
|
||||
@@ -184,16 +156,11 @@ export const TriggerJobInput = z
|
||||
})
|
||||
.meta({ description: "Specify which background job to trigger manually" });
|
||||
|
||||
const backupFrequency = z
|
||||
.enum(["6h", "12h", "1d", "7d"])
|
||||
.describe("Backup interval");
|
||||
const backupFrequency = z.enum(["6h", "12h", "1d", "7d"]).describe("Backup interval");
|
||||
|
||||
export const UpdateScheduleInput = z
|
||||
.object({
|
||||
enabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Enable or disable scheduled backups"),
|
||||
enabled: z.boolean().optional().describe("Enable or disable scheduled backups"),
|
||||
frequency: backupFrequency.optional(),
|
||||
time: z
|
||||
.string()
|
||||
@@ -221,9 +188,7 @@ export const UpdateScheduleInput = z
|
||||
message: "Max backups must be between 1 and 30, or 0 for unlimited",
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
"Maximum number of backups to keep (0 = unlimited, 1-30 otherwise)",
|
||||
),
|
||||
.describe("Maximum number of backups to keep (0 = unlimited, 1-30 otherwise)"),
|
||||
})
|
||||
.meta({
|
||||
description: "Partial update to the automated backup schedule",
|
||||
@@ -246,9 +211,7 @@ export const UploadAvatarOutput = z.object({
|
||||
|
||||
// ─── Backup inputs ─────────────────────────────────────────────
|
||||
|
||||
export const RestoreBackupInput = z
|
||||
.file()
|
||||
.max(100 * 1024 * 1024, "File too large (max 100 MB)");
|
||||
export const RestoreBackupInput = z.file().max(100 * 1024 * 1024, "File too large (max 100 MB)");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Output schemas
|
||||
@@ -268,8 +231,7 @@ export const ColorPaletteSchema = z
|
||||
lightMuted: z.string().nullable(),
|
||||
})
|
||||
.meta({
|
||||
description:
|
||||
"Extracted color palette from the title poster image (CSS hex values)",
|
||||
description: "Extracted color palette from the title poster image (CSS hex values)",
|
||||
});
|
||||
|
||||
export const EpisodeSchema = z
|
||||
@@ -284,10 +246,7 @@ export const EpisodeSchema = z
|
||||
.nullable()
|
||||
.describe("ThumbHash blur placeholder for the still image"),
|
||||
airDate: z.string().nullable().describe("Original air date (ISO 8601)"),
|
||||
runtimeMinutes: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("Episode runtime in minutes"),
|
||||
runtimeMinutes: z.number().nullable().describe("Episode runtime in minutes"),
|
||||
})
|
||||
.meta({ description: "A single TV episode" });
|
||||
|
||||
@@ -305,13 +264,8 @@ export const AvailabilityOfferSchema = z
|
||||
providerId: z.number().describe("JustWatch provider ID"),
|
||||
providerName: z.string().describe("Display name (e.g. Netflix, Hulu)"),
|
||||
logoPath: z.string().nullable().describe("Provider logo image path"),
|
||||
offerType: z
|
||||
.string()
|
||||
.describe("Offer type: flatrate, rent, buy, free, ads"),
|
||||
watchUrl: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Direct link to watch on this provider"),
|
||||
offerType: z.string().describe("Offer type: flatrate, rent, buy, free, ads"),
|
||||
watchUrl: z.string().nullable().describe("Direct link to watch on this provider"),
|
||||
})
|
||||
.meta({ description: "A streaming availability offer from a provider" });
|
||||
|
||||
@@ -320,17 +274,11 @@ export const CastMemberSchema = z
|
||||
id: z.string().describe("Credit ID"),
|
||||
personId: z.string().describe("Internal person ID"),
|
||||
name: z.string().describe("Person's name"),
|
||||
character: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Character name (for acting credits)"),
|
||||
character: z.string().nullable().describe("Character name (for acting credits)"),
|
||||
department: z.string().describe("Department (e.g. Acting, Directing)"),
|
||||
job: z.string().nullable().describe("Job title (for crew credits)"),
|
||||
displayOrder: z.number().describe("Sort order in the credits list"),
|
||||
episodeCount: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("Number of episodes (TV only)"),
|
||||
episodeCount: z.number().nullable().describe("Number of episodes (TV only)"),
|
||||
profilePath: z.string().nullable().describe("Profile photo image path"),
|
||||
profileThumbHash: z
|
||||
.string()
|
||||
@@ -348,19 +296,10 @@ export const ResolvedTitleSchema = z
|
||||
title: z.string().describe("Display title (localized)"),
|
||||
originalTitle: z.string().nullable().describe("Original language title"),
|
||||
overview: z.string().nullable().describe("Plot synopsis"),
|
||||
releaseDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Theatrical release date (movies, ISO 8601)"),
|
||||
firstAirDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("First air date (TV shows, ISO 8601)"),
|
||||
releaseDate: z.string().nullable().describe("Theatrical release date (movies, ISO 8601)"),
|
||||
firstAirDate: z.string().nullable().describe("First air date (TV shows, ISO 8601)"),
|
||||
posterPath: z.string().nullable().describe("Poster image path"),
|
||||
posterThumbHash: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("ThumbHash blur placeholder for the poster"),
|
||||
posterThumbHash: z.string().nullable().describe("ThumbHash blur placeholder for the poster"),
|
||||
backdropPath: z.string().nullable().describe("Backdrop image path"),
|
||||
backdropThumbHash: z
|
||||
.string()
|
||||
@@ -369,29 +308,14 @@ export const ResolvedTitleSchema = z
|
||||
popularity: z.number().nullable().describe("TMDB popularity score"),
|
||||
voteAverage: z.number().nullable().describe("Average user rating (0-10)"),
|
||||
voteCount: z.number().nullable().describe("Total number of votes"),
|
||||
status: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Production status (e.g. Released, Returning Series)"),
|
||||
contentRating: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Content rating (e.g. PG-13, TV-MA)"),
|
||||
status: z.string().nullable().describe("Production status (e.g. Released, Returning Series)"),
|
||||
contentRating: z.string().nullable().describe("Content rating (e.g. PG-13, TV-MA)"),
|
||||
imdbId: z.string().nullable().describe("IMDb title ID (e.g. tt0137523)"),
|
||||
tvdbId: z.number().nullable().describe("TVDB ID (TV shows only)"),
|
||||
originalLanguage: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Original language ISO 639-1 code (e.g. en)"),
|
||||
runtimeMinutes: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("Runtime in minutes (movies only)"),
|
||||
originalLanguage: z.string().nullable().describe("Original language ISO 639-1 code (e.g. en)"),
|
||||
runtimeMinutes: z.number().nullable().describe("Runtime in minutes (movies only)"),
|
||||
colorPalette: ColorPaletteSchema.nullable(),
|
||||
trailerVideoKey: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("YouTube video key for the trailer"),
|
||||
trailerVideoKey: z.string().nullable().describe("YouTube video key for the trailer"),
|
||||
genres: z.array(z.string()).describe("Genre names"),
|
||||
})
|
||||
.meta({ description: "A fully resolved movie or TV show from TMDB" });
|
||||
@@ -425,17 +349,11 @@ export const PersonCreditSchema = z
|
||||
type: mediaType,
|
||||
title: z.string().describe("Title name"),
|
||||
posterPath: z.string().nullable().describe("Poster image path"),
|
||||
posterThumbHash: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("ThumbHash blur placeholder for the poster"),
|
||||
posterThumbHash: z.string().nullable().describe("ThumbHash blur placeholder for the poster"),
|
||||
releaseDate: z.string().nullable().describe("Release date (ISO 8601)"),
|
||||
firstAirDate: z.string().nullable().describe("First air date (ISO 8601)"),
|
||||
voteAverage: z.number().nullable().describe("Average rating (0-10)"),
|
||||
character: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Character name (for acting credits)"),
|
||||
character: z.string().nullable().describe("Character name (for acting credits)"),
|
||||
department: z.string().describe("Department (e.g. Acting, Directing)"),
|
||||
job: z.string().nullable().describe("Job title (for crew credits)"),
|
||||
})
|
||||
@@ -449,10 +367,7 @@ export const TmdbBrowseItem = z
|
||||
type: mediaType,
|
||||
title: z.string().describe("Display title"),
|
||||
posterPath: z.string().nullable().describe("Poster image path"),
|
||||
posterThumbHash: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("ThumbHash blur placeholder for the poster"),
|
||||
posterThumbHash: z.string().nullable().describe("ThumbHash blur placeholder for the poster"),
|
||||
releaseDate: z.string().nullable().describe("Release date (ISO 8601)"),
|
||||
firstAirDate: z.string().nullable().describe("First air date (ISO 8601)"),
|
||||
voteAverage: z.number().nullable().describe("Average rating (0-10)"),
|
||||
@@ -469,10 +384,7 @@ export const RecommendationItemSchema = z
|
||||
type: mediaType,
|
||||
title: z.string().describe("Display title"),
|
||||
posterPath: z.string().nullable().describe("Poster image path"),
|
||||
posterThumbHash: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("ThumbHash blur placeholder for the poster"),
|
||||
posterThumbHash: z.string().nullable().describe("ThumbHash blur placeholder for the poster"),
|
||||
releaseDate: z.string().nullable().describe("Release date (ISO 8601)"),
|
||||
firstAirDate: z.string().nullable().describe("First air date (ISO 8601)"),
|
||||
voteAverage: z.number().nullable().describe("Average rating (0-10)"),
|
||||
@@ -495,8 +407,7 @@ const BrowseOutput = z
|
||||
})
|
||||
.merge(PaginationMeta)
|
||||
.meta({
|
||||
description:
|
||||
"Browse results with user tracking statuses and episode progress",
|
||||
description: "Browse results with user tracking statuses and episode progress",
|
||||
});
|
||||
|
||||
// ─── Title outputs ─────────────────────────────────────────────
|
||||
@@ -505,14 +416,11 @@ export const TitleDetailOutput = z
|
||||
.object({
|
||||
title: ResolvedTitleSchema,
|
||||
seasons: z.array(SeasonSchema).describe("TV seasons (empty for movies)"),
|
||||
availability: z
|
||||
.array(AvailabilityOfferSchema)
|
||||
.describe("Streaming availability offers"),
|
||||
availability: z.array(AvailabilityOfferSchema).describe("Streaming availability offers"),
|
||||
cast: z.array(CastMemberSchema).describe("Cast and crew credits"),
|
||||
})
|
||||
.meta({
|
||||
description:
|
||||
"Full title details with seasons, cast, and streaming availability",
|
||||
description: "Full title details with seasons, cast, and streaming availability",
|
||||
});
|
||||
|
||||
export const UserInfoOutput = z
|
||||
@@ -521,13 +429,8 @@ export const UserInfoOutput = z
|
||||
.enum(["watchlist", "in_progress", "completed"])
|
||||
.nullable()
|
||||
.describe("User's tracking status, or null if not in library"),
|
||||
rating: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("User's star rating (0-5), or null if unrated"),
|
||||
episodeWatches: z
|
||||
.array(z.string())
|
||||
.describe("IDs of episodes the user has watched"),
|
||||
rating: z.number().nullable().describe("User's star rating (0-5), or null if unrated"),
|
||||
episodeWatches: z.array(z.string()).describe("IDs of episodes the user has watched"),
|
||||
})
|
||||
.meta({
|
||||
description: "The current user's tracking info for a title",
|
||||
@@ -547,27 +450,20 @@ export const TitleRecommendationsOutput = z
|
||||
export const PersonDetailOutput = z
|
||||
.object({
|
||||
person: PersonSchema,
|
||||
filmography: z
|
||||
.array(PersonCreditSchema)
|
||||
.describe("Credits for this person (paginated)"),
|
||||
filmography: z.array(PersonCreditSchema).describe("Credits for this person (paginated)"),
|
||||
userStatuses: userStatusMap,
|
||||
})
|
||||
.merge(PaginationMeta)
|
||||
.meta({
|
||||
description:
|
||||
"Person profile with paginated filmography and user's statuses for their titles",
|
||||
description: "Person profile with paginated filmography and user's statuses for their titles",
|
||||
});
|
||||
|
||||
// ─── Dashboard outputs ─────────────────────────────────────────
|
||||
|
||||
export const DashboardStatsOutput = z
|
||||
.object({
|
||||
moviesThisMonth: z
|
||||
.number()
|
||||
.describe("Movies watched in the current calendar month"),
|
||||
episodesThisWeek: z
|
||||
.number()
|
||||
.describe("Episodes watched in the current calendar week"),
|
||||
moviesThisMonth: z.number().describe("Movies watched in the current calendar month"),
|
||||
episodesThisWeek: z.number().describe("Episodes watched in the current calendar week"),
|
||||
librarySize: z.number().describe("Total titles in the user's library"),
|
||||
completed: z.number().describe("Total titles with completed status"),
|
||||
})
|
||||
@@ -592,10 +488,7 @@ export const ContinueWatchingOutput = z
|
||||
seasonNumber: z.number().describe("Season number"),
|
||||
episodeNumber: z.number().describe("Episode number"),
|
||||
name: z.string().nullable().describe("Episode title"),
|
||||
stillPath: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Episode still image path"),
|
||||
stillPath: z.string().nullable().describe("Episode still image path"),
|
||||
stillThumbHash: z
|
||||
.string()
|
||||
.nullable()
|
||||
@@ -603,17 +496,14 @@ export const ContinueWatchingOutput = z
|
||||
})
|
||||
.nullable()
|
||||
.describe("Next unwatched episode, or null if all caught up"),
|
||||
totalEpisodes: z
|
||||
.number()
|
||||
.describe("Total episodes across all seasons"),
|
||||
totalEpisodes: z.number().describe("Total episodes across all seasons"),
|
||||
watchedEpisodes: z.number().describe("Episodes the user has watched"),
|
||||
})
|
||||
.meta({ description: "An in-progress show with watch progress" }),
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
description:
|
||||
"TV shows the user is currently watching with next episode info",
|
||||
description: "TV shows the user is currently watching with next episode info",
|
||||
});
|
||||
|
||||
export const LibraryOutput = z
|
||||
@@ -630,14 +520,8 @@ export const LibraryOutput = z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("ThumbHash blur placeholder for the poster"),
|
||||
releaseDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Release date (ISO 8601)"),
|
||||
firstAirDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("First air date (ISO 8601)"),
|
||||
releaseDate: z.string().nullable().describe("Release date (ISO 8601)"),
|
||||
firstAirDate: z.string().nullable().describe("First air date (ISO 8601)"),
|
||||
voteAverage: z.number().nullable().describe("Average rating (0-10)"),
|
||||
userStatus: z
|
||||
.enum(["watchlist", "in_progress", "completed"])
|
||||
@@ -706,34 +590,17 @@ export const SearchOutput = z
|
||||
id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Internal title ID (present for movie/tv results, absent for people)",
|
||||
),
|
||||
.describe("Internal title ID (present for movie/tv results, absent for people)"),
|
||||
tmdbId: z.number().describe("TMDB numeric ID"),
|
||||
type: z.enum(["movie", "tv", "person"]).describe("Result type"),
|
||||
title: z.string().describe("Title or person name"),
|
||||
overview: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Plot summary or null for people"),
|
||||
posterPath: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Poster image path (movies/TV)"),
|
||||
profilePath: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Profile photo path (people)"),
|
||||
releaseDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Release date (ISO 8601)"),
|
||||
overview: z.string().nullable().describe("Plot summary or null for people"),
|
||||
posterPath: z.string().nullable().describe("Poster image path (movies/TV)"),
|
||||
profilePath: z.string().nullable().describe("Profile photo path (people)"),
|
||||
releaseDate: z.string().nullable().describe("Release date (ISO 8601)"),
|
||||
popularity: z.number().nullable().describe("TMDB popularity score"),
|
||||
voteAverage: z.number().nullable().describe("Average rating (0-10)"),
|
||||
knownForDepartment: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Primary department (people only)"),
|
||||
knownForDepartment: z.string().nullable().describe("Primary department (people only)"),
|
||||
knownFor: z
|
||||
.array(z.string())
|
||||
.nullable()
|
||||
@@ -761,9 +628,7 @@ export const HistoryBucketSchema = z
|
||||
export const WatchHistoryOutput = z
|
||||
.object({
|
||||
count: z.number().describe("Total watches in the selected period"),
|
||||
history: z
|
||||
.array(HistoryBucketSchema)
|
||||
.describe("Watch counts bucketed by time period"),
|
||||
history: z.array(HistoryBucketSchema).describe("Watch counts bucketed by time period"),
|
||||
})
|
||||
.meta({ description: "Watch history with time-bucketed counts" });
|
||||
|
||||
@@ -772,27 +637,16 @@ export const WatchHistoryOutput = z
|
||||
export const JobSchema = z
|
||||
.object({
|
||||
jobName: z.string().describe("Cron job identifier"),
|
||||
cronPattern: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Cron expression (e.g. 0 2 * * *)"),
|
||||
cronPattern: z.string().nullable().describe("Cron expression (e.g. 0 2 * * *)"),
|
||||
nextRunAt: z.string().nullable().describe("Next scheduled run (ISO 8601)"),
|
||||
lastRunAt: z.string().nullable().describe("Last run start time (ISO 8601)"),
|
||||
lastDurationMs: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("Duration of last run in milliseconds"),
|
||||
lastDurationMs: z.number().nullable().describe("Duration of last run in milliseconds"),
|
||||
lastStatus: z
|
||||
.enum(["running", "success", "error"])
|
||||
.nullable()
|
||||
.describe("Outcome of the last run"),
|
||||
lastError: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Error message from the last failed run"),
|
||||
isCurrentlyRunning: z
|
||||
.boolean()
|
||||
.describe("Whether the job is currently executing"),
|
||||
lastError: z.string().nullable().describe("Error message from the last failed run"),
|
||||
isCurrentlyRunning: z.boolean().describe("Whether the job is currently executing"),
|
||||
disabled: z.boolean().describe("Whether the job is disabled"),
|
||||
})
|
||||
.meta({ description: "Status of a background cron job" });
|
||||
@@ -813,14 +667,8 @@ export const SystemHealthSchema = z
|
||||
connected: z.boolean().describe("Whether TMDB API is reachable"),
|
||||
tokenValid: z.boolean().describe("Whether the API token is valid"),
|
||||
tokenConfigured: z.boolean().describe("Whether a token is set"),
|
||||
responseTimeMs: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("TMDB API response time in milliseconds"),
|
||||
error: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Error message if connectivity check failed"),
|
||||
responseTimeMs: z.number().nullable().describe("TMDB API response time in milliseconds"),
|
||||
error: z.string().nullable().describe("Error message if connectivity check failed"),
|
||||
})
|
||||
.meta({ description: "TMDB API connectivity status" }),
|
||||
jobs: z.array(JobSchema).describe("Status of all cron jobs"),
|
||||
@@ -842,14 +690,8 @@ export const SystemHealthSchema = z
|
||||
.meta({ description: "Image cache statistics" }),
|
||||
backups: z
|
||||
.object({
|
||||
lastBackupAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Last backup timestamp (ISO 8601)"),
|
||||
lastBackupAgeHours: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("Hours since the last backup"),
|
||||
lastBackupAt: z.string().nullable().describe("Last backup timestamp (ISO 8601)"),
|
||||
lastBackupAgeHours: z.number().nullable().describe("Hours since the last backup"),
|
||||
backupCount: z.number().describe("Total backup files"),
|
||||
totalSizeBytes: z.number().describe("Total size of all backups"),
|
||||
})
|
||||
@@ -857,25 +699,18 @@ export const SystemHealthSchema = z
|
||||
environment: z
|
||||
.object({
|
||||
dataDir: z.string().describe("Configured data directory path"),
|
||||
dataDirWritable: z
|
||||
.boolean()
|
||||
.describe("Whether the data directory is writable"),
|
||||
dataDirWritable: z.boolean().describe("Whether the data directory is writable"),
|
||||
envVars: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().describe("Environment variable name"),
|
||||
value: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Current value (sensitive values are masked)"),
|
||||
value: z.string().nullable().describe("Current value (sensitive values are masked)"),
|
||||
}),
|
||||
)
|
||||
.describe("Relevant environment variable statuses"),
|
||||
})
|
||||
.meta({ description: "Server environment information" }),
|
||||
checkedAt: z
|
||||
.string()
|
||||
.describe("When this health check was performed (ISO 8601)"),
|
||||
checkedAt: z.string().describe("When this health check was performed (ISO 8601)"),
|
||||
})
|
||||
.meta({
|
||||
description:
|
||||
@@ -900,18 +735,11 @@ export const IntegrationSchema = z
|
||||
provider: z.string().describe("Provider name (plex, jellyfin, etc.)"),
|
||||
type: z
|
||||
.enum(["webhook", "list"])
|
||||
.describe(
|
||||
"Integration type: webhook (Plex/Jellyfin/Emby) or list (Sonarr/Radarr)",
|
||||
),
|
||||
.describe("Integration type: webhook (Plex/Jellyfin/Emby) or list (Sonarr/Radarr)"),
|
||||
token: z.string().describe("Webhook authentication token"),
|
||||
enabled: z.boolean().describe("Whether the integration is active"),
|
||||
lastEventAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Last received event timestamp (ISO 8601)"),
|
||||
createdAt: z
|
||||
.string()
|
||||
.describe("When the integration was created (ISO 8601)"),
|
||||
lastEventAt: z.string().nullable().describe("Last received event timestamp (ISO 8601)"),
|
||||
createdAt: z.string().describe("When the integration was created (ISO 8601)"),
|
||||
})
|
||||
.meta({ description: "A media server integration configuration" });
|
||||
|
||||
@@ -921,9 +749,7 @@ export const IntegrationEventSchema = z
|
||||
eventType: z.string().nullable().describe("Webhook event type"),
|
||||
mediaType: z.string().nullable().describe("Media type from the event"),
|
||||
mediaTitle: z.string().nullable().describe("Title from the event"),
|
||||
status: z
|
||||
.enum(["success", "ignored", "error"])
|
||||
.describe("Event processing outcome"),
|
||||
status: z.enum(["success", "ignored", "error"]).describe("Event processing outcome"),
|
||||
receivedAt: z.string().describe("When the event was received (ISO 8601)"),
|
||||
})
|
||||
.meta({ description: "A webhook or sync event from a media server" });
|
||||
@@ -953,9 +779,7 @@ export const BackupSchema = z
|
||||
createdAt: z.string().describe("When the backup was created (ISO 8601)"),
|
||||
source: z
|
||||
.enum(["manual", "scheduled", "pre-restore"])
|
||||
.describe(
|
||||
"How the backup was created: manual, scheduled, or automatic pre-restore",
|
||||
),
|
||||
.describe("How the backup was created: manual, scheduled, or automatic pre-restore"),
|
||||
})
|
||||
.meta({ description: "A database backup file" });
|
||||
|
||||
@@ -970,14 +794,10 @@ export const BackupCreateOutput = BackupSchema;
|
||||
export const BackupScheduleOutput = z
|
||||
.object({
|
||||
enabled: z.boolean().describe("Whether scheduled backups are enabled"),
|
||||
maxRetention: z
|
||||
.number()
|
||||
.describe("Maximum backups to keep (0 = unlimited)"),
|
||||
maxRetention: z.number().describe("Maximum backups to keep (0 = unlimited)"),
|
||||
frequency: backupFrequency,
|
||||
time: z.string().describe("Scheduled time (HH:MM, 24-hour format)"),
|
||||
dayOfWeek: z
|
||||
.number()
|
||||
.describe("Day of week for weekly backups (0 = Sunday)"),
|
||||
dayOfWeek: z.number().describe("Day of week for weekly backups (0 = Sunday)"),
|
||||
})
|
||||
.meta({ description: "Automated backup schedule configuration" });
|
||||
|
||||
@@ -989,30 +809,20 @@ export const RegistrationOutput = z
|
||||
|
||||
const UpdateCheckResultSchema = z
|
||||
.object({
|
||||
updateAvailable: z
|
||||
.boolean()
|
||||
.describe("Whether a newer version is available"),
|
||||
updateAvailable: z.boolean().describe("Whether a newer version is available"),
|
||||
currentVersion: z.string().describe("Currently running version"),
|
||||
latestVersion: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Latest available version, or null if check failed"),
|
||||
releaseUrl: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("URL to the latest release page"),
|
||||
lastCheckedAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("When the last check was performed (ISO 8601)"),
|
||||
releaseUrl: z.string().nullable().describe("URL to the latest release page"),
|
||||
lastCheckedAt: z.string().nullable().describe("When the last check was performed (ISO 8601)"),
|
||||
})
|
||||
.meta({ description: "Result of an update availability check" });
|
||||
|
||||
export const UpdateCheckOutput = z
|
||||
.object({
|
||||
enabled: z
|
||||
.boolean()
|
||||
.describe("Whether automatic update checks are enabled"),
|
||||
enabled: z.boolean().describe("Whether automatic update checks are enabled"),
|
||||
updateCheck: UpdateCheckResultSchema.nullable().describe(
|
||||
"Latest check result, or null if checks are disabled or never ran",
|
||||
),
|
||||
@@ -1025,12 +835,8 @@ export const TriggerJobOutput = z.object({
|
||||
|
||||
export const PurgeMetadataCacheOutput = z
|
||||
.object({
|
||||
deletedTitles: z
|
||||
.number()
|
||||
.describe("Number of un-enriched stub titles deleted"),
|
||||
deletedPersons: z
|
||||
.number()
|
||||
.describe("Number of orphaned person records deleted"),
|
||||
deletedTitles: z.number().describe("Number of un-enriched stub titles deleted"),
|
||||
deletedPersons: z.number().describe("Number of orphaned person records deleted"),
|
||||
})
|
||||
.meta({
|
||||
description: "Result of purging un-enriched metadata from the database",
|
||||
@@ -1038,9 +844,7 @@ export const PurgeMetadataCacheOutput = z
|
||||
|
||||
export const PurgeImageCacheOutput = z
|
||||
.object({
|
||||
deletedFiles: z
|
||||
.number()
|
||||
.describe("Number of image files deleted from disk"),
|
||||
deletedFiles: z.number().describe("Number of image files deleted from disk"),
|
||||
freedBytes: z.number().describe("Total bytes freed from disk"),
|
||||
})
|
||||
.meta({ description: "Result of purging the image cache from disk" });
|
||||
@@ -1050,9 +854,7 @@ export const PurgeImageCacheOutput = z
|
||||
export const QuickAddOutput = z
|
||||
.object({
|
||||
id: z.string().describe("Internal title ID"),
|
||||
alreadyAdded: z
|
||||
.boolean()
|
||||
.describe("True if the title was already in the user's library"),
|
||||
alreadyAdded: z.boolean().describe("True if the title was already in the user's library"),
|
||||
})
|
||||
.meta({
|
||||
description: "Result of a quick-add operation",
|
||||
@@ -1065,12 +867,8 @@ export const PublicInfoOutput = z
|
||||
instanceId: z.string().describe("Unique instance identifier"),
|
||||
tmdbConfigured: z.boolean().describe("Whether TMDB API is configured"),
|
||||
userCount: z.number().describe("Number of registered users"),
|
||||
registrationOpen: z
|
||||
.boolean()
|
||||
.describe("Whether new user registration is open"),
|
||||
posterUrls: z
|
||||
.array(z.string())
|
||||
.describe("Poster image URLs for the login screen collage"),
|
||||
registrationOpen: z.boolean().describe("Whether new user registration is open"),
|
||||
posterUrls: z.array(z.string()).describe("Poster image URLs for the login screen collage"),
|
||||
})
|
||||
.meta({
|
||||
description: "Public instance information shown on the login/setup screen",
|
||||
@@ -1083,12 +881,8 @@ export const AuthConfigOutput = z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Display name of the OIDC provider (e.g. Authelia, Keycloak)"),
|
||||
passwordLoginDisabled: z
|
||||
.boolean()
|
||||
.describe("Whether password-based login is disabled"),
|
||||
registrationOpen: z
|
||||
.boolean()
|
||||
.describe("Whether new user registration is open"),
|
||||
passwordLoginDisabled: z.boolean().describe("Whether password-based login is disabled"),
|
||||
registrationOpen: z.boolean().describe("Whether new user registration is open"),
|
||||
userCount: z.number().describe("Number of registered users"),
|
||||
})
|
||||
.meta({
|
||||
@@ -1106,11 +900,7 @@ export const ImportMovieSchema = z.object({
|
||||
imdbId: z.string().optional(),
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
watchedAt: z
|
||||
.string()
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe("ISO 8601 timestamp"),
|
||||
watchedAt: z.string().datetime({ offset: true }).optional().describe("ISO 8601 timestamp"),
|
||||
watchedOn: z.string().date().optional().describe("YYYY-MM-DD date-only"),
|
||||
});
|
||||
|
||||
@@ -1196,13 +986,7 @@ export const ParsePayloadInput = z.object({
|
||||
data: NormalizedImportSchema,
|
||||
});
|
||||
|
||||
export const ImportJobStatusEnum = z.enum([
|
||||
"pending",
|
||||
"running",
|
||||
"success",
|
||||
"error",
|
||||
"cancelled",
|
||||
]);
|
||||
export const ImportJobStatusEnum = z.enum(["pending", "running", "success", "error", "cancelled"]);
|
||||
|
||||
export const ImportJobSchema = z.object({
|
||||
id: z.string(),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"]
|
||||
}
|
||||
@@ -8,8 +8,9 @@
|
||||
"./config": "./src/config.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
||||
import { expo } from "@better-auth/expo";
|
||||
import {
|
||||
getUserCount,
|
||||
isRegistrationOpen,
|
||||
setSetting,
|
||||
} from "@sofa/core/settings";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { APIError, createAuthMiddleware } from "better-auth/api";
|
||||
import { type BetterAuthOptions, betterAuth } from "better-auth/minimal";
|
||||
import { admin, genericOAuth } from "better-auth/plugins";
|
||||
import {
|
||||
isOidcAutoRegisterEnabled,
|
||||
isOidcConfigured,
|
||||
isPasswordLoginDisabled,
|
||||
} from "./config";
|
||||
|
||||
import { getUserCount, isRegistrationOpen, setSetting } from "@sofa/core/settings";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { isOidcAutoRegisterEnabled, isOidcConfigured, isPasswordLoginDisabled } from "./config";
|
||||
|
||||
const authLog = createLogger("auth");
|
||||
|
||||
@@ -63,8 +57,7 @@ export const auth = betterAuth({
|
||||
pkce: true,
|
||||
disableImplicitSignUp: !isOidcAutoRegisterEnabled(),
|
||||
mapProfileToUser: (profile) => ({
|
||||
name:
|
||||
profile.name || profile.preferred_username || profile.email,
|
||||
name: profile.name || profile.preferred_username || profile.email,
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"]
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
|
||||
@@ -4,8 +4,7 @@ import path from "node:path";
|
||||
|
||||
export const DATA_DIR = path.resolve(process.env.DATA_DIR || "./data");
|
||||
|
||||
export const DATABASE_URL =
|
||||
process.env.DATABASE_URL || path.join(DATA_DIR, "sqlite.db");
|
||||
export const DATABASE_URL = process.env.DATABASE_URL || path.join(DATA_DIR, "sqlite.db");
|
||||
|
||||
export const CACHE_DIR = process.env.CACHE_DIR
|
||||
? path.join(process.env.CACHE_DIR, "images")
|
||||
@@ -17,8 +16,6 @@ export const AVATAR_DIR = path.join(DATA_DIR, "avatars");
|
||||
|
||||
// ─── TMDB ────────────────────────────────────────────────────
|
||||
|
||||
export const TMDB_API_BASE_URL =
|
||||
process.env.TMDB_API_BASE_URL || "https://api.themoviedb.org/3";
|
||||
export const TMDB_API_BASE_URL = process.env.TMDB_API_BASE_URL || "https://api.themoviedb.org/3";
|
||||
|
||||
export const TMDB_IMAGE_BASE_URL =
|
||||
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||
export const TMDB_IMAGE_BASE_URL = process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"]
|
||||
}
|
||||
@@ -26,8 +26,9 @@
|
||||
"./webhooks": "./src/webhooks.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
|
||||
@@ -41,19 +41,11 @@ export async function refreshAvailability(titleId: string) {
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.delete(availabilityOffers)
|
||||
.where(
|
||||
and(
|
||||
eq(availabilityOffers.titleId, titleId),
|
||||
eq(availabilityOffers.region, "US"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(availabilityOffers.titleId, titleId), eq(availabilityOffers.region, "US")))
|
||||
.run();
|
||||
|
||||
if (allOfferRows.length > 0) {
|
||||
tx.insert(availabilityOffers)
|
||||
.values(allOfferRows)
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
tx.insert(availabilityOffers).values(allOfferRows).onConflictDoNothing().run();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -62,9 +54,5 @@ export async function refreshAvailability(titleId: string) {
|
||||
}
|
||||
|
||||
export function getAvailability(titleId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
.where(eq(availabilityOffers.titleId, titleId))
|
||||
.all();
|
||||
return db.select().from(availabilityOffers).where(eq(availabilityOffers.titleId, titleId)).all();
|
||||
}
|
||||
+14
-33
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -16,10 +17,8 @@ function formatTimestamp(date: Date): string {
|
||||
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$/;
|
||||
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";
|
||||
@@ -104,10 +103,7 @@ function validateBackupDatabase(filePath: string): void {
|
||||
const integrityRows = testDb.query("PRAGMA integrity_check").all() as {
|
||||
integrity_check: string;
|
||||
}[];
|
||||
if (
|
||||
integrityRows.length === 0 ||
|
||||
integrityRows.some((row) => row.integrity_check !== "ok")
|
||||
) {
|
||||
if (integrityRows.length === 0 || integrityRows.some((row) => row.integrity_check !== "ok")) {
|
||||
throw new Error("Database integrity check failed");
|
||||
}
|
||||
|
||||
@@ -116,15 +112,13 @@ function validateBackupDatabase(filePath: string): void {
|
||||
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 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(", ")})`,
|
||||
);
|
||||
throw new Error(`Invalid backup: missing required tables (${missing.join(", ")})`);
|
||||
}
|
||||
} finally {
|
||||
testDb.close();
|
||||
@@ -134,9 +128,7 @@ function validateBackupDatabase(filePath: string): void {
|
||||
/** @internal */
|
||||
export function isValidBackupFilename(filename: string): boolean {
|
||||
const base = path.basename(filename);
|
||||
return (
|
||||
base === filename && !filename.includes("..") && isKnownBackup(filename)
|
||||
);
|
||||
return base === filename && !filename.includes("..") && isKnownBackup(filename);
|
||||
}
|
||||
|
||||
async function createBackupInternal(prefix: BackupPrefix): Promise<BackupInfo> {
|
||||
@@ -160,9 +152,7 @@ async function createBackupInternal(prefix: BackupPrefix): Promise<BackupInfo> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBackup(
|
||||
prefix: BackupPrefix = "sofa-manual",
|
||||
): Promise<BackupInfo> {
|
||||
export async function createBackup(prefix: BackupPrefix = "sofa-manual"): Promise<BackupInfo> {
|
||||
return withBackupLock(async () => createBackupInternal(prefix));
|
||||
}
|
||||
|
||||
@@ -182,9 +172,7 @@ export async function listBackups(): Promise<BackupInfo[]> {
|
||||
});
|
||||
}
|
||||
|
||||
return results.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
async function deleteBackupInternal(filename: string): Promise<void> {
|
||||
@@ -218,9 +206,7 @@ export async function readBackupFile(filename: string): Promise<Buffer | null> {
|
||||
return Buffer.from(await Bun.file(filePath).arrayBuffer());
|
||||
}
|
||||
|
||||
export async function restoreFromBackup(
|
||||
source: Buffer | string,
|
||||
): Promise<void> {
|
||||
export async function restoreFromBackup(source: Buffer | string): Promise<void> {
|
||||
await withBackupLock(async () => {
|
||||
await ensureBackupDir();
|
||||
|
||||
@@ -228,10 +214,7 @@ export async function restoreFromBackup(
|
||||
await mkdir(dbDir, { recursive: true });
|
||||
|
||||
const timestamp = formatTimestamp(new Date());
|
||||
const tempPath = path.join(
|
||||
dbDir,
|
||||
`.restore-temp-${timestamp}-${crypto.randomUUID()}.db`,
|
||||
);
|
||||
const tempPath = path.join(dbDir, `.restore-temp-${timestamp}-${crypto.randomUUID()}.db`);
|
||||
|
||||
try {
|
||||
if (typeof source === "string") {
|
||||
@@ -263,9 +246,7 @@ export async function restoreFromBackup(
|
||||
|
||||
export async function pruneBackups(maxKeep: number): Promise<void> {
|
||||
await withBackupLock(async () => {
|
||||
const backups = (await listBackups()).filter((b) =>
|
||||
SCHEDULED_PATTERN.test(b.filename),
|
||||
);
|
||||
const backups = (await listBackups()).filter((b) => SCHEDULED_PATTERN.test(b.filename));
|
||||
|
||||
if (maxKeep === 0 || backups.length <= maxKeep) return;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readdir, stat, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { CACHE_DIR } from "@sofa/config";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { inArray, isNull, notInArray } from "@sofa/db/helpers";
|
||||
@@ -40,9 +41,7 @@ export function purgeMetadataCache(): {
|
||||
.map((r) => r.titleId),
|
||||
);
|
||||
|
||||
const toDelete = shellTitles
|
||||
.map((t) => t.id)
|
||||
.filter((id) => !libraryTitleIds.has(id));
|
||||
const toDelete = shellTitles.map((t) => t.id).filter((id) => !libraryTitleIds.has(id));
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
log.info("All un-enriched titles are in user libraries, skipping");
|
||||
@@ -72,10 +71,7 @@ function purgeOrphanedPersons(): number {
|
||||
.select({ id: persons.id })
|
||||
.from(persons)
|
||||
.where(
|
||||
notInArray(
|
||||
persons.id,
|
||||
db.selectDistinct({ personId: titleCast.personId }).from(titleCast),
|
||||
),
|
||||
notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)),
|
||||
)
|
||||
.all();
|
||||
|
||||
@@ -93,13 +89,7 @@ function purgeOrphanedPersons(): number {
|
||||
}
|
||||
|
||||
/** Image cache subdirectories to scan */
|
||||
const IMAGE_CATEGORIES = [
|
||||
"posters",
|
||||
"backdrops",
|
||||
"stills",
|
||||
"logos",
|
||||
"profiles",
|
||||
] as const;
|
||||
const IMAGE_CATEGORIES = ["posters", "backdrops", "stills", "logos", "profiles"] as const;
|
||||
|
||||
/**
|
||||
* Delete all cached images from disk.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Vibrant } from "node-vibrant/node";
|
||||
|
||||
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 { loadImageBuffer } from "./image-cache";
|
||||
|
||||
const log = createLogger("colors");
|
||||
@@ -22,17 +24,12 @@ export async function extractAndStoreColors(
|
||||
sourceBuffer?: Buffer | null,
|
||||
): Promise<ColorPalette | null> {
|
||||
if (!posterPath) {
|
||||
db.update(titles)
|
||||
.set({ colorPalette: null })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
db.update(titles).set({ colorPalette: null }).where(eq(titles.id, titleId)).run();
|
||||
return null;
|
||||
}
|
||||
|
||||
const source =
|
||||
sourceBuffer === undefined
|
||||
? await loadImageBuffer(posterPath, "posters")
|
||||
: sourceBuffer;
|
||||
sourceBuffer === undefined ? await loadImageBuffer(posterPath, "posters") : sourceBuffer;
|
||||
if (!source) return null;
|
||||
|
||||
try {
|
||||
@@ -52,9 +49,7 @@ export async function extractAndStoreColors(
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
|
||||
log.debug(
|
||||
`Extracted colors for title ${titleId}: colors=${JSON.stringify(colors)}`,
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
import { generatePersonThumbHash } from "./thumbhash";
|
||||
|
||||
@@ -69,9 +70,7 @@ function batchUpsertPersons(people: PersonData[]): Map<number, string> {
|
||||
name: p.name,
|
||||
profilePath: p.profilePath,
|
||||
popularity: nextPopularity,
|
||||
profileThumbHash: pathChanged
|
||||
? null
|
||||
: existingPerson.profileThumbHash,
|
||||
profileThumbHash: pathChanged ? null : existingPerson.profileThumbHash,
|
||||
})
|
||||
.where(eq(persons.id, existingPerson.id))
|
||||
.run();
|
||||
@@ -94,9 +93,7 @@ function batchUpsertPersons(people: PersonData[]): Map<number, string> {
|
||||
});
|
||||
|
||||
// One fallback query for any concurrent inserts that conflicted
|
||||
const stillMissing = uniquePeople
|
||||
.filter((p) => !idMap.has(p.tmdbId))
|
||||
.map((p) => p.tmdbId);
|
||||
const stillMissing = uniquePeople.filter((p) => !idMap.has(p.tmdbId)).map((p) => p.tmdbId);
|
||||
if (stillMissing.length > 0) {
|
||||
const fallbacks = db
|
||||
.select({ id: persons.id, tmdbId: persons.tmdbId })
|
||||
|
||||
@@ -48,12 +48,7 @@ export function getWatchCount(
|
||||
const [row] = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(watchTable)
|
||||
.where(
|
||||
and(
|
||||
eq(watchTable.userId, userId),
|
||||
sql`${watchTable.watchedAt} >= ${timestamp}`,
|
||||
),
|
||||
)
|
||||
.where(and(eq(watchTable.userId, userId), sql`${watchTable.watchedAt} >= ${timestamp}`))
|
||||
.all();
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
@@ -121,22 +116,14 @@ export function getWatchHistory(
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
bucket:
|
||||
sql<string>`strftime(${fmt}, ${watchTable.watchedAt}, 'unixepoch', 'localtime')`.as(
|
||||
"bucket",
|
||||
),
|
||||
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')`,
|
||||
)
|
||||
.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]));
|
||||
@@ -194,9 +181,7 @@ export interface ContinueWatchingItem {
|
||||
watchedEpisodes: number;
|
||||
}
|
||||
|
||||
export function getContinueWatchingFeed(
|
||||
userId: string,
|
||||
): ContinueWatchingItem[] {
|
||||
export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[] {
|
||||
// Get in-progress TV shows
|
||||
const inProgress = db
|
||||
.select({
|
||||
@@ -204,12 +189,7 @@ export function getContinueWatchingFeed(
|
||||
updatedAt: userTitleStatus.updatedAt,
|
||||
})
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.status, "in_progress"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.status, "in_progress")))
|
||||
.all();
|
||||
|
||||
if (inProgress.length === 0) return [];
|
||||
@@ -359,8 +339,7 @@ export function getContinueWatchingFeed(
|
||||
return items;
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: days reserved for future date filtering
|
||||
export function getNewAvailableFeed(userId: string, days = 14) {
|
||||
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
|
||||
@@ -380,10 +359,7 @@ export function getNewAvailableFeed(userId: string, days = 14) {
|
||||
.from(titles)
|
||||
.innerJoin(
|
||||
userTitleStatus,
|
||||
and(
|
||||
eq(userTitleStatus.titleId, titles.id),
|
||||
eq(userTitleStatus.userId, userId),
|
||||
),
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.where(
|
||||
sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`,
|
||||
@@ -447,21 +423,14 @@ export function getRecommendationsFeed(userId: string) {
|
||||
const userCompletedOrRated = db
|
||||
.select({ titleId: userTitleStatus.titleId })
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.status, "completed"),
|
||||
),
|
||||
)
|
||||
.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`),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`))
|
||||
.all()
|
||||
.map((r) => r.titleId);
|
||||
|
||||
@@ -504,19 +473,13 @@ export function getRecommendationsFeed(userId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...recs.values()]
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 20);
|
||||
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 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);
|
||||
@@ -544,8 +507,7 @@ export function getRecommendationsForTitle(titleId: string) {
|
||||
tmdb_similar: 1,
|
||||
} as const;
|
||||
const orderedRecs = [...recs].sort(
|
||||
(a, b) =>
|
||||
a.rank - b.rank || sourcePriority[a.source] - sourcePriority[b.source],
|
||||
(a, b) => a.rank - b.rank || sourcePriority[a.source] - sourcePriority[b.source],
|
||||
);
|
||||
|
||||
const seenRecommendedTitleIds = new Set<string>();
|
||||
@@ -559,11 +521,7 @@ export function getRecommendationsForTitle(titleId: string) {
|
||||
|
||||
// Batch fetch all recommended titles (1 query)
|
||||
const recTitleIds = uniqueRecs.map((r) => r.recommendedTitleId);
|
||||
const recTitles = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(inArray(titles.id, recTitleIds))
|
||||
.all();
|
||||
const recTitles = db.select().from(titles).where(inArray(titles.id, recTitleIds)).all();
|
||||
const recTitleMap = new Map(recTitles.map((t) => [t.id, t]));
|
||||
|
||||
return uniqueRecs
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
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 { availabilityOffers, episodes, persons, seasons, titleCast, titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import {
|
||||
IMAGE_CATEGORY_SIZES,
|
||||
type ImageCategory,
|
||||
tmdbCdnImageUrl,
|
||||
} from "@sofa/tmdb/image";
|
||||
import { IMAGE_CATEGORY_SIZES, type ImageCategory, tmdbCdnImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
const log = createLogger("image-cache");
|
||||
|
||||
@@ -32,17 +22,11 @@ export async function ensureImageDirs() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function isImageCached(
|
||||
category: ImageCategory,
|
||||
filename: string,
|
||||
): Promise<boolean> {
|
||||
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 {
|
||||
export function getLocalImagePath(category: ImageCategory, filename: string): string {
|
||||
return path.join(CACHE_DIR, category, path.basename(filename));
|
||||
}
|
||||
|
||||
@@ -60,8 +44,7 @@ async function fetchRemoteImage(
|
||||
tmdbPath: string,
|
||||
category: ImageCategory,
|
||||
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||
const url =
|
||||
tmdbCdnImageUrl(tmdbPath, category) ?? `${TMDB_IMAGE_BASE_URL}${tmdbPath}`;
|
||||
const url = tmdbCdnImageUrl(tmdbPath, category) ?? `${TMDB_IMAGE_BASE_URL}${tmdbPath}`;
|
||||
|
||||
const res = await globalThis.fetch(url);
|
||||
if (!res.ok) {
|
||||
@@ -109,11 +92,7 @@ export async function fetchAndMaybeCache(
|
||||
log.debug(`Cache hit: ${category}/${filename}`);
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const contentType =
|
||||
ext === ".png"
|
||||
? "image/png"
|
||||
: ext === ".webp"
|
||||
? "image/webp"
|
||||
: "image/jpeg";
|
||||
ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg";
|
||||
return { buffer: cached, contentType };
|
||||
}
|
||||
|
||||
@@ -155,20 +134,13 @@ export async function cacheImagesForTitle(titleId: string) {
|
||||
|
||||
// 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.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();
|
||||
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" });
|
||||
if (s.posterPath) candidates.push({ imgPath: s.posterPath, category: "posters" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,20 +163,12 @@ export async function cacheImagesForTitle(titleId: string) {
|
||||
}
|
||||
|
||||
export async function cacheEpisodeStills(titleId: string) {
|
||||
const allSeasons = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
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 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,
|
||||
@@ -264,8 +228,7 @@ export async function cacheProfilePhotos(titleId: string) {
|
||||
for (const row of castRows) {
|
||||
if (row.profilePath) {
|
||||
const basename = path.basename(row.profilePath);
|
||||
if (!uniqueProfiles.has(basename))
|
||||
uniqueProfiles.set(basename, row.profilePath);
|
||||
if (!uniqueProfiles.has(basename)) uniqueProfiles.set(basename, row.profilePath);
|
||||
}
|
||||
}
|
||||
const checks = await Promise.all(
|
||||
|
||||
@@ -18,8 +18,4 @@ export {
|
||||
processImportJob,
|
||||
readImportJob,
|
||||
} from "./processor";
|
||||
export {
|
||||
type ExternalIds,
|
||||
resolveMovieTmdbId,
|
||||
resolveShowTmdbId,
|
||||
} from "./resolve";
|
||||
export { type ExternalIds, resolveMovieTmdbId, resolveShowTmdbId } from "./resolve";
|
||||
@@ -254,9 +254,7 @@ export function parseTraktPayload(data: {
|
||||
if (!entry?.title || item.rating == null) continue;
|
||||
const converted = convertRating10to5(item.rating);
|
||||
if (converted == null) {
|
||||
warnings.push(
|
||||
`Skipped invalid Trakt rating ${item.rating} for "${entry.title}"`,
|
||||
);
|
||||
warnings.push(`Skipped invalid Trakt rating ${item.rating} for "${entry.title}"`);
|
||||
continue;
|
||||
}
|
||||
ratings.push({
|
||||
@@ -345,11 +343,7 @@ export function parseSimklPayload(data: {
|
||||
year: item.year,
|
||||
type: "movie",
|
||||
});
|
||||
} else if (
|
||||
item.status === "completed" ||
|
||||
item.status === "watching" ||
|
||||
item.last_watched_at
|
||||
) {
|
||||
} else if (item.status === "completed" || item.status === "watching" || item.last_watched_at) {
|
||||
movies.push({
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
@@ -422,10 +416,7 @@ export function parseSimklPayload(data: {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!item.seasons?.length &&
|
||||
(item.status === "completed" || item.status === "watching")
|
||||
) {
|
||||
if (!item.seasons?.length && (item.status === "completed" || item.status === "watching")) {
|
||||
warnings.push(
|
||||
`"${item.title}" is marked ${item.status} but has no episode data — episode watches were not imported.`,
|
||||
);
|
||||
@@ -475,15 +466,9 @@ const LETTERBOXD_EXPECTED_FILES = [
|
||||
"ratings.csv",
|
||||
] as const;
|
||||
|
||||
const LETTERBOXD_IGNORED_FILES = new Set([
|
||||
"reviews.csv",
|
||||
"profile.csv",
|
||||
"comments.csv",
|
||||
]);
|
||||
const LETTERBOXD_IGNORED_FILES = new Set(["reviews.csv", "profile.csv", "comments.csv"]);
|
||||
|
||||
export async function parseLetterboxdExport(
|
||||
zipFile: Blob,
|
||||
): Promise<ParseResult> {
|
||||
export async function parseLetterboxdExport(zipFile: Blob): Promise<ParseResult> {
|
||||
const warnings: string[] = [];
|
||||
const movies: ImportMovie[] = [];
|
||||
const watchlist: ImportWatchlistItem[] = [];
|
||||
@@ -495,9 +480,7 @@ export async function parseLetterboxdExport(
|
||||
const buffer = Buffer.from(await zipFile.arrayBuffer());
|
||||
zip = new AdmZip(buffer);
|
||||
} catch {
|
||||
warnings.push(
|
||||
"Failed to read ZIP file. Ensure it is a valid Letterboxd export.",
|
||||
);
|
||||
warnings.push("Failed to read ZIP file. Ensure it is a valid Letterboxd export.");
|
||||
return {
|
||||
data: {
|
||||
source: "letterboxd",
|
||||
@@ -519,11 +502,7 @@ export async function parseLetterboxdExport(
|
||||
// Letterboxd exports may nest files in a subdirectory — use the basename
|
||||
const name = entry.entryName.split("/").pop() ?? entry.entryName;
|
||||
allFilenames.push(name);
|
||||
if (
|
||||
LETTERBOXD_EXPECTED_FILES.includes(
|
||||
name as (typeof LETTERBOXD_EXPECTED_FILES)[number],
|
||||
)
|
||||
) {
|
||||
if (LETTERBOXD_EXPECTED_FILES.includes(name as (typeof LETTERBOXD_EXPECTED_FILES)[number])) {
|
||||
entryMap.set(name, entry.getData().toString("utf-8"));
|
||||
}
|
||||
}
|
||||
@@ -539,11 +518,7 @@ export async function parseLetterboxdExport(
|
||||
|
||||
// Log ignored files
|
||||
for (const name of allFilenames) {
|
||||
if (
|
||||
LETTERBOXD_EXPECTED_FILES.includes(
|
||||
name as (typeof LETTERBOXD_EXPECTED_FILES)[number],
|
||||
)
|
||||
) {
|
||||
if (LETTERBOXD_EXPECTED_FILES.includes(name as (typeof LETTERBOXD_EXPECTED_FILES)[number])) {
|
||||
continue;
|
||||
}
|
||||
if (!LETTERBOXD_IGNORED_FILES.has(name)) {
|
||||
@@ -625,9 +600,7 @@ export async function parseLetterboxdExport(
|
||||
|
||||
const converted = convertLetterboxdRating(rawRating);
|
||||
if (converted == null) {
|
||||
warnings.push(
|
||||
`Skipped invalid Letterboxd rating ${rawRating} for "${title}"`,
|
||||
);
|
||||
warnings.push(`Skipped invalid Letterboxd rating ${rawRating} for "${title}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,19 +11,11 @@ import {
|
||||
userTitleStatus,
|
||||
} from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { getOrFetchTitleByTmdbId } from "../metadata";
|
||||
import {
|
||||
logEpisodeWatch,
|
||||
logMovieWatch,
|
||||
rateTitleStars,
|
||||
setTitleStatus,
|
||||
} from "../tracking";
|
||||
import type {
|
||||
ImportEpisode,
|
||||
ImportMovie,
|
||||
ImportRating,
|
||||
ImportWatchlistItem,
|
||||
} from "./parsers";
|
||||
import { logEpisodeWatch, logMovieWatch, rateTitleStars, setTitleStatus } from "../tracking";
|
||||
|
||||
import type { ImportEpisode, ImportMovie, ImportRating, ImportWatchlistItem } from "./parsers";
|
||||
import { resolveMovieTmdbId, resolveShowTmdbId } from "./resolve";
|
||||
|
||||
const log = createLogger("imports");
|
||||
@@ -60,12 +52,7 @@ function hasExistingMovieWatch(userId: string, titleId: string): boolean {
|
||||
const existing = db
|
||||
.select({ id: userMovieWatches.id })
|
||||
.from(userMovieWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userMovieWatches.userId, userId),
|
||||
eq(userMovieWatches.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userMovieWatches.userId, userId), eq(userMovieWatches.titleId, titleId)))
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
@@ -74,12 +61,7 @@ function hasExistingEpisodeWatch(userId: string, episodeId: string): boolean {
|
||||
const existing = db
|
||||
.select({ id: userEpisodeWatches.id })
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
eq(userEpisodeWatches.episodeId, episodeId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.episodeId, episodeId)))
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
@@ -88,12 +70,7 @@ function hasExistingWatchlistStatus(userId: string, titleId: string): boolean {
|
||||
const existing = db
|
||||
.select({ status: userTitleStatus.status })
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
@@ -102,9 +79,7 @@ function hasExistingRating(userId: string, titleId: string): boolean {
|
||||
const existing = db
|
||||
.select({ ratingStars: userRatings.ratingStars })
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
@@ -192,38 +167,24 @@ async function processEpisode(
|
||||
const season = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(
|
||||
and(
|
||||
eq(seasons.titleId, title.id),
|
||||
eq(seasons.seasonNumber, ep.seasonNumber),
|
||||
),
|
||||
)
|
||||
.where(and(eq(seasons.titleId, title.id), eq(seasons.seasonNumber, ep.seasonNumber)))
|
||||
.get();
|
||||
|
||||
if (!season) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Season ${ep.seasonNumber} not found for "${title.title}"`,
|
||||
);
|
||||
result.errors.push(`Season ${ep.seasonNumber} not found for "${title.title}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const episode = db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(
|
||||
and(
|
||||
eq(episodes.seasonId, season.id),
|
||||
eq(episodes.episodeNumber, ep.episodeNumber),
|
||||
),
|
||||
)
|
||||
.where(and(eq(episodes.seasonId, season.id), eq(episodes.episodeNumber, ep.episodeNumber)))
|
||||
.get();
|
||||
|
||||
if (!episode) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`S${ep.seasonNumber}E${ep.episodeNumber} not found for "${title.title}"`,
|
||||
);
|
||||
result.errors.push(`S${ep.seasonNumber}E${ep.episodeNumber} not found for "${title.title}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -247,8 +208,7 @@ async function processWatchlistItem(
|
||||
result: ImportResult,
|
||||
cache?: Map<string, number | null>,
|
||||
): Promise<void> {
|
||||
const resolveFn =
|
||||
item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
|
||||
const resolveFn = item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
|
||||
const tmdbId = await resolveFn(
|
||||
{
|
||||
tmdbId: item.tmdbId,
|
||||
@@ -271,9 +231,7 @@ async function processWatchlistItem(
|
||||
const title = await getOrFetchTitleByTmdbId(tmdbId, item.type);
|
||||
if (!title) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`,
|
||||
);
|
||||
result.errors.push(`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,8 +250,7 @@ async function processRating(
|
||||
result: ImportResult,
|
||||
cache?: Map<string, number | null>,
|
||||
): Promise<void> {
|
||||
const resolveFn =
|
||||
item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
|
||||
const resolveFn = item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
|
||||
const tmdbId = await resolveFn(
|
||||
{
|
||||
tmdbId: item.tmdbId,
|
||||
@@ -316,9 +273,7 @@ async function processRating(
|
||||
const title = await getOrFetchTitleByTmdbId(tmdbId, item.type);
|
||||
if (!title) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`,
|
||||
);
|
||||
result.errors.push(`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -390,11 +345,7 @@ export function readImportJob(jobId: string, userId?: string): ImportJob {
|
||||
// ─── Job Processor ───────────────────────────────────────────────────
|
||||
|
||||
export async function processImportJob(jobId: string): Promise<void> {
|
||||
const row = db
|
||||
.select()
|
||||
.from(importJobs)
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.get();
|
||||
const row = db.select().from(importJobs).where(eq(importJobs.id, jobId)).get();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Import job ${jobId} not found`);
|
||||
@@ -408,9 +359,7 @@ export async function processImportJob(jobId: string): Promise<void> {
|
||||
}
|
||||
const parsed = NormalizedImportSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Import job ${jobId} has malformed payload: ${parsed.error.message}`,
|
||||
);
|
||||
throw new Error(`Import job ${jobId} has malformed payload: ${parsed.error.message}`);
|
||||
}
|
||||
const data = parsed.data;
|
||||
const result: ImportResult = {
|
||||
@@ -466,9 +415,7 @@ export async function processImportJob(jobId: string): Promise<void> {
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.run();
|
||||
|
||||
log.info(
|
||||
`Starting ${data.source} import job ${jobId} for user ${row.userId}: ${total} items`,
|
||||
);
|
||||
log.info(`Starting ${data.source} import job ${jobId} for user ${row.userId}: ${total} items`);
|
||||
|
||||
// Shared resolution cache for the entire import job
|
||||
const resolveCache = new Map<string, number | null>();
|
||||
@@ -501,20 +448,10 @@ export async function processImportJob(jobId: string): Promise<void> {
|
||||
try {
|
||||
switch (item.type) {
|
||||
case "movie":
|
||||
await processMovie(
|
||||
row.userId,
|
||||
data.movies[item.index],
|
||||
result,
|
||||
resolveCache,
|
||||
);
|
||||
await processMovie(row.userId, data.movies[item.index], result, resolveCache);
|
||||
break;
|
||||
case "episode":
|
||||
await processEpisode(
|
||||
row.userId,
|
||||
data.episodes[item.index],
|
||||
result,
|
||||
resolveCache,
|
||||
);
|
||||
await processEpisode(row.userId, data.episodes[item.index], result, resolveCache);
|
||||
break;
|
||||
case "watchlist":
|
||||
await processWatchlistItem(
|
||||
@@ -525,26 +462,16 @@ export async function processImportJob(jobId: string): Promise<void> {
|
||||
);
|
||||
break;
|
||||
case "rating":
|
||||
await processRating(
|
||||
row.userId,
|
||||
data.ratings[item.index],
|
||||
result,
|
||||
resolveCache,
|
||||
);
|
||||
await processRating(row.userId, data.ratings[item.index], result, resolveCache);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Unexpected error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
result.errors.push(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// Update DB progress periodically
|
||||
if (
|
||||
i % progressInterval === progressInterval - 1 ||
|
||||
i === items.length - 1
|
||||
) {
|
||||
if (i % progressInterval === progressInterval - 1 || i === items.length - 1) {
|
||||
const currentItem =
|
||||
item.type === "movie"
|
||||
? data.movies[item.index]
|
||||
@@ -594,9 +521,7 @@ export async function processImportJob(jobId: string): Promise<void> {
|
||||
);
|
||||
} catch (err) {
|
||||
// Fatal error
|
||||
result.errors.push(
|
||||
`Fatal: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
result.errors.push(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
|
||||
db.update(importJobs)
|
||||
.set({
|
||||
status: "error",
|
||||
|
||||
@@ -91,9 +91,7 @@ export async function resolveMovieTmdbId(
|
||||
const results = searchResult.results ?? [];
|
||||
if (ids.year) {
|
||||
const match = results.find((r) => {
|
||||
const releaseYear = r.release_date
|
||||
? new Date(r.release_date).getFullYear()
|
||||
: null;
|
||||
const releaseYear = r.release_date ? new Date(r.release_date).getFullYear() : null;
|
||||
return releaseYear === ids.year;
|
||||
});
|
||||
if (match) {
|
||||
@@ -164,9 +162,7 @@ export async function resolveShowTmdbId(
|
||||
const results = searchResult.results ?? [];
|
||||
if (ids.year) {
|
||||
const match = results.find((r) => {
|
||||
const airYear = r.first_air_date
|
||||
? new Date(r.first_air_date).getFullYear()
|
||||
: null;
|
||||
const airYear = r.first_air_date ? new Date(r.first_air_date).getFullYear() : null;
|
||||
return airYear === ids.year;
|
||||
});
|
||||
if (match) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
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");
|
||||
|
||||
@@ -38,9 +39,7 @@ 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[];
|
||||
const parsed = param.split(",").filter((s) => statusSchema.safeParse(s).success) as Status[];
|
||||
return parsed.length > 0 ? parsed : ["watchlist"];
|
||||
}
|
||||
|
||||
|
||||
+43
-159
@@ -17,12 +17,7 @@ import {
|
||||
titles,
|
||||
} from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import type {
|
||||
TmdbGenre,
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
TmdbVideo,
|
||||
} from "@sofa/tmdb/client";
|
||||
import type { TmdbGenre, TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
|
||||
import {
|
||||
getMovieDetails,
|
||||
getRecommendations,
|
||||
@@ -32,6 +27,7 @@ import {
|
||||
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";
|
||||
@@ -59,11 +55,7 @@ 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"
|
||||
) {
|
||||
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();
|
||||
}
|
||||
@@ -72,9 +64,7 @@ function upsertTitle(values: typeof titles.$inferInsert, tmdbId: number) {
|
||||
}
|
||||
|
||||
function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
|
||||
const validGenres = tmdbGenres.filter(
|
||||
(g): g is TmdbGenre & { name: string } => !!g.name,
|
||||
);
|
||||
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) {
|
||||
@@ -85,10 +75,7 @@ function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
|
||||
}
|
||||
tx.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
|
||||
for (const g of validGenres) {
|
||||
tx.insert(titleGenres)
|
||||
.values({ titleId, genreId: g.id })
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
tx.insert(titleGenres).values({ titleId, genreId: g.id }).onConflictDoNothing().run();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -96,12 +83,7 @@ function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
|
||||
export function updateTitleWithArtInvalidation(
|
||||
title: Pick<
|
||||
typeof titles.$inferSelect,
|
||||
| "id"
|
||||
| "posterPath"
|
||||
| "backdropPath"
|
||||
| "posterThumbHash"
|
||||
| "backdropThumbHash"
|
||||
| "colorPalette"
|
||||
"id" | "posterPath" | "backdropPath" | "posterThumbHash" | "backdropThumbHash" | "colorPalette"
|
||||
>,
|
||||
values: Partial<typeof titles.$inferInsert>,
|
||||
) {
|
||||
@@ -135,9 +117,7 @@ export function updateTitleWithArtInvalidation(
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function extractMovieContentRating(
|
||||
movie: TmdbMovieDetails,
|
||||
): string | null {
|
||||
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 ?? []) {
|
||||
@@ -159,21 +139,15 @@ function fireAndForgetEnrichment(
|
||||
backdropPath: string | null | undefined,
|
||||
type: "movie" | "tv",
|
||||
) {
|
||||
refreshAvailability(titleId).catch((err) =>
|
||||
log.debug("Availability enrichment failed:", err),
|
||||
);
|
||||
refreshAvailability(titleId).catch((err) => log.debug("Availability enrichment failed:", err));
|
||||
refreshRecommendations(titleId).catch((err) =>
|
||||
log.debug("Recommendations enrichment failed:", err),
|
||||
);
|
||||
syncTitleArt(titleId, posterPath, backdropPath, type).catch((err) =>
|
||||
log.debug("Cache/thumbhash failed:", err),
|
||||
);
|
||||
refreshCredits(titleId).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
refreshTrailer(titleId).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
);
|
||||
refreshCredits(titleId).catch((err) => log.debug("Credits enrichment failed:", err));
|
||||
refreshTrailer(titleId).catch((err) => log.debug("Trailer enrichment failed:", err));
|
||||
}
|
||||
|
||||
type ImportResult = ReturnType<typeof _getOrFetchTitleByTmdbId>;
|
||||
@@ -181,16 +155,11 @@ type ImportResult = ReturnType<typeof _getOrFetchTitleByTmdbId>;
|
||||
/** In-flight import promises keyed by `${tmdbId}-${type}` — coalesces concurrent calls */
|
||||
const inflightImports = new Map<string, ImportResult>();
|
||||
|
||||
export function getOrFetchTitleByTmdbId(
|
||||
tmdbId: number,
|
||||
type: "movie" | "tv",
|
||||
): ImportResult {
|
||||
export function getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv"): ImportResult {
|
||||
const key = `${tmdbId}-${type}`;
|
||||
const inflight = inflightImports.get(key);
|
||||
if (inflight) {
|
||||
log.debug(
|
||||
`Import already in-flight for ${type} TMDB ${tmdbId}, coalescing`,
|
||||
);
|
||||
log.debug(`Import already in-flight for ${type} TMDB ${tmdbId}, coalescing`);
|
||||
return inflight;
|
||||
}
|
||||
|
||||
@@ -232,12 +201,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
lastFetchedAt: new Date(),
|
||||
});
|
||||
upsertGenres(existing.id, movie.genres ?? []);
|
||||
fireAndForgetEnrichment(
|
||||
existing.id,
|
||||
movie.poster_path,
|
||||
movie.backdrop_path,
|
||||
"movie",
|
||||
);
|
||||
fireAndForgetEnrichment(existing.id, movie.poster_path, movie.backdrop_path, "movie");
|
||||
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
|
||||
}
|
||||
|
||||
@@ -256,12 +220,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
});
|
||||
upsertGenres(existing.id, show.genres ?? []);
|
||||
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
||||
fireAndForgetEnrichment(
|
||||
existing.id,
|
||||
show.poster_path,
|
||||
show.backdrop_path,
|
||||
"tv",
|
||||
);
|
||||
fireAndForgetEnrichment(existing.id, show.poster_path, show.backdrop_path, "tv");
|
||||
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
|
||||
}
|
||||
|
||||
@@ -277,12 +236,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
const show = await getTvDetails(tmdbId);
|
||||
upsertGenres(existing.id, show.genres ?? []);
|
||||
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
||||
fireAndForgetEnrichment(
|
||||
existing.id,
|
||||
show.poster_path,
|
||||
show.backdrop_path,
|
||||
"tv",
|
||||
);
|
||||
fireAndForgetEnrichment(existing.id, show.poster_path, show.backdrop_path, "tv");
|
||||
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
|
||||
}
|
||||
}
|
||||
@@ -318,12 +272,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
);
|
||||
if (!row) return undefined;
|
||||
upsertGenres(row.id, movie.genres ?? []);
|
||||
fireAndForgetEnrichment(
|
||||
row.id,
|
||||
movie.poster_path,
|
||||
movie.backdrop_path,
|
||||
"movie",
|
||||
);
|
||||
fireAndForgetEnrichment(row.id, movie.poster_path, movie.backdrop_path, "movie");
|
||||
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
|
||||
return row;
|
||||
}
|
||||
@@ -417,26 +366,15 @@ export async function refreshTitle(titleId: string) {
|
||||
updated.backdropPath,
|
||||
updated.type as "movie" | "tv",
|
||||
).catch((err) => log.debug("Cache/thumbhash failed:", err));
|
||||
refreshTrailer(updated.id).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
);
|
||||
refreshCredits(updated.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
refreshTrailer(updated.id).catch((err) => log.debug("Trailer enrichment failed:", err));
|
||||
refreshCredits(updated.id).catch((err) => log.debug("Credits enrichment failed:", err));
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function refreshTvChildren(
|
||||
titleId: string,
|
||||
tmdbId: number,
|
||||
numberOfSeasons: number,
|
||||
) {
|
||||
export async function refreshTvChildren(titleId: string, tmdbId: number, numberOfSeasons: number) {
|
||||
// Fetch all seasons from TMDB concurrently (~40 req/s rate limit)
|
||||
const seasonNumbers = Array.from(
|
||||
{ length: numberOfSeasons },
|
||||
(_, i) => i + 1,
|
||||
);
|
||||
const seasonNumbers = Array.from({ length: numberOfSeasons }, (_, i) => i + 1);
|
||||
const fetched = await Promise.allSettled(
|
||||
seasonNumbers.map((sn) => getTvSeasonDetails(tmdbId, sn)),
|
||||
);
|
||||
@@ -447,10 +385,7 @@ export async function refreshTvChildren(
|
||||
const result = fetched[i];
|
||||
const sn = seasonNumbers[i];
|
||||
if (result.status === "rejected") {
|
||||
log.error(
|
||||
`Failed to fetch season ${sn} for TMDB ${tmdbId}:`,
|
||||
result.reason,
|
||||
);
|
||||
log.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}:`, result.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -535,13 +470,8 @@ export async function refreshTvChildren(
|
||||
// Clear stale hashes when image paths change during the upsert.
|
||||
// Full hash (re)generation is handled by syncTitleArt() after
|
||||
// cache warming, so we only need to null out stale values here.
|
||||
if (
|
||||
(existingSeason?.posterPath ?? null) !== (seasonData.poster_path ?? null)
|
||||
) {
|
||||
db.update(seasons)
|
||||
.set({ posterThumbHash: null })
|
||||
.where(eq(seasons.id, seasonRow.id))
|
||||
.run();
|
||||
if ((existingSeason?.posterPath ?? null) !== (seasonData.poster_path ?? null)) {
|
||||
db.update(seasons).set({ posterThumbHash: null }).where(eq(seasons.id, seasonRow.id)).run();
|
||||
}
|
||||
const seasonEps = db
|
||||
.select({
|
||||
@@ -556,10 +486,7 @@ export async function refreshTvChildren(
|
||||
for (const ep of seasonEps) {
|
||||
const oldStill = oldEpStills.get(ep.episodeNumber);
|
||||
if (oldStill !== ep.stillPath && ep.stillThumbHash) {
|
||||
db.update(episodes)
|
||||
.set({ stillThumbHash: null })
|
||||
.where(eq(episodes.id, ep.id))
|
||||
.run();
|
||||
db.update(episodes).set({ stillThumbHash: null }).where(eq(episodes.id, ep.id)).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,8 +564,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
tmdbId: item.result.id,
|
||||
type: item.type,
|
||||
title: item.result.title ?? item.result.name ?? "Unknown",
|
||||
originalTitle:
|
||||
item.result.original_title ?? item.result.original_name ?? null,
|
||||
originalTitle: item.result.original_title ?? item.result.original_name ?? null,
|
||||
overview: item.result.overview ?? null,
|
||||
releaseDate: item.result.release_date ?? null,
|
||||
firstAirDate: item.result.first_air_date ?? null,
|
||||
@@ -665,9 +591,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
.where(inArray(titles.tmdbId, tmdbIds))
|
||||
.all();
|
||||
const existingTitleMap = new Map(existingTitles.map((t) => [t.tmdbId, t]));
|
||||
const titleIdMap = new Map<number, string>(
|
||||
existingTitles.map((t) => [t.tmdbId, t.id]),
|
||||
);
|
||||
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) => {
|
||||
@@ -676,8 +600,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
const existingTitle = existingTitleMap.get(item.tmdbId);
|
||||
if (existingTitle) {
|
||||
const posterPathChanged = existingTitle.posterPath !== item.posterPath;
|
||||
const backdropPathChanged =
|
||||
existingTitle.backdropPath !== item.backdropPath;
|
||||
const backdropPathChanged = existingTitle.backdropPath !== item.backdropPath;
|
||||
|
||||
tx.update(titles)
|
||||
.set({
|
||||
@@ -734,9 +657,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
}
|
||||
|
||||
// One fallback query for any that conflicted
|
||||
const stillMissing = [...insertedTmdbIds].filter(
|
||||
(id) => !titleIdMap.has(id),
|
||||
);
|
||||
const stillMissing = [...insertedTmdbIds].filter((id) => !titleIdMap.has(id));
|
||||
if (stillMissing.length > 0) {
|
||||
const fallbacks = tx
|
||||
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||
@@ -781,9 +702,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
|
||||
// Fire-and-forget thumbhash generation for recommendation titles missing one
|
||||
const recTitleIds = [
|
||||
...new Set(
|
||||
allItems.map((i) => titleIdMap.get(i.result.id)).filter(Boolean),
|
||||
),
|
||||
...new Set(allItems.map((i) => titleIdMap.get(i.result.id)).filter(Boolean)),
|
||||
] as string[];
|
||||
if (recTitleIds.length > 0) {
|
||||
const needingHash = db
|
||||
@@ -888,10 +807,7 @@ export async function ensureTvHydrated(titleId: string): Promise<Season[]> {
|
||||
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
||||
result = fetchSeasonsFromDb(titleId);
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
`Failed to backfill missing seasons for title ${titleId}:`,
|
||||
err,
|
||||
);
|
||||
log.debug(`Failed to backfill missing seasons for title ${titleId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,9 +828,7 @@ async function ensureEnriched(
|
||||
|
||||
if (!existing.hasCast) {
|
||||
tasks.push(
|
||||
refreshCredits(titleId).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
),
|
||||
refreshCredits(titleId).catch((err) => log.debug("Credits enrichment failed:", err)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -961,26 +875,19 @@ async function ensureEnriched(
|
||||
|
||||
if (!title.trailerVideoKey) {
|
||||
tasks.push(
|
||||
refreshTrailer(titleId).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
),
|
||||
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}"`,
|
||||
);
|
||||
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[] {
|
||||
function readAvailability(titleId: string, titleName: string): AvailabilityOffer[] {
|
||||
return db
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
@@ -1053,8 +960,7 @@ export async function getOrFetchTitle(id: string): Promise<{
|
||||
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);
|
||||
if (availability.length === 0) availability = readAvailability(title.id, title.title);
|
||||
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
||||
}
|
||||
}
|
||||
@@ -1138,24 +1044,15 @@ export async function refreshTrailer(titleId: string) {
|
||||
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"}`,
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateMissingTvChildThumbHashes(titleId: string) {
|
||||
const titleSeasons = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
const titleSeasons = db.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
|
||||
|
||||
const hashTasks: Promise<unknown>[] = [];
|
||||
|
||||
@@ -1189,15 +1086,9 @@ async function generateMissingTvChildThumbHashes(titleId: string) {
|
||||
await Promise.all(hashTasks);
|
||||
}
|
||||
|
||||
export async function syncTvChildArt(
|
||||
titleId: string,
|
||||
options?: { warmCache?: boolean },
|
||||
) {
|
||||
export async function syncTvChildArt(titleId: string, options?: { warmCache?: boolean }) {
|
||||
if (options?.warmCache !== false && imageCacheEnabled()) {
|
||||
await Promise.all([
|
||||
cacheImagesForTitle(titleId),
|
||||
cacheEpisodeStills(titleId),
|
||||
]);
|
||||
await Promise.all([cacheImagesForTitle(titleId), cacheEpisodeStills(titleId)]);
|
||||
}
|
||||
|
||||
await generateMissingTvChildThumbHashes(titleId);
|
||||
@@ -1220,9 +1111,7 @@ async function syncTitleArt(
|
||||
}
|
||||
}
|
||||
|
||||
const posterBuffer = posterPath
|
||||
? await loadImageBuffer(posterPath, "posters")
|
||||
: undefined;
|
||||
const posterBuffer = posterPath ? await loadImageBuffer(posterPath, "posters") : undefined;
|
||||
|
||||
// Title poster colors + thumbhash, then backdrop thumbhash
|
||||
await Promise.all([
|
||||
@@ -1287,10 +1176,7 @@ export function ensureBrowseTitlesExist(
|
||||
.where(inArray(titles.tmdbId, tmdbIds))
|
||||
.all();
|
||||
|
||||
const result = new Map<
|
||||
string,
|
||||
{ id: string; posterThumbHash: string | null }
|
||||
>();
|
||||
const result = new Map<string, { id: string; posterThumbHash: string | null }>();
|
||||
for (const row of existing) {
|
||||
result.set(browseTitleKey(row.tmdbId, row.type), {
|
||||
id: row.id,
|
||||
@@ -1337,9 +1223,7 @@ export function ensureBrowseTitlesExist(
|
||||
// Fallback for any that conflicted (concurrent insert)
|
||||
const stillMissing = missingKeys.filter((key) => !result.has(key));
|
||||
if (stillMissing.length > 0) {
|
||||
const missingTmdbIds = stillMissing.map(
|
||||
(key) => unique.get(key)?.tmdbId ?? 0,
|
||||
);
|
||||
const missingTmdbIds = stillMissing.map((key) => unique.get(key)?.tmdbId ?? 0);
|
||||
const fallbacks = tx
|
||||
.select({
|
||||
id: titles.id,
|
||||
|
||||
+14
-44
@@ -5,16 +5,14 @@ import { personFilmography, persons, titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { generatePersonThumbHash } from "./thumbhash";
|
||||
|
||||
const log = createLogger("person");
|
||||
const FILMOGRAPHY_STALE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function ensureProfileThumbHash(
|
||||
person: Pick<
|
||||
typeof persons.$inferSelect,
|
||||
"id" | "profilePath" | "profileThumbHash"
|
||||
>,
|
||||
person: Pick<typeof persons.$inferSelect, "id" | "profilePath" | "profileThumbHash">,
|
||||
) {
|
||||
if (!person.profilePath) {
|
||||
if (person.profileThumbHash) {
|
||||
@@ -30,14 +28,8 @@ async function ensureProfileThumbHash(
|
||||
return (await generatePersonThumbHash(person.id, person.profilePath)) ?? null;
|
||||
}
|
||||
|
||||
export async function getOrFetchPerson(
|
||||
personId: string,
|
||||
): Promise<ResolvedPerson | null> {
|
||||
const person = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, personId))
|
||||
.get();
|
||||
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
|
||||
@@ -109,14 +101,8 @@ export async function getOrFetchPerson(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOrFetchPersonByTmdbId(
|
||||
tmdbId: number,
|
||||
): Promise<ResolvedPerson | null> {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.tmdbId, tmdbId))
|
||||
.get();
|
||||
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);
|
||||
@@ -144,8 +130,7 @@ export async function getOrFetchPersonByTmdbId(
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const person =
|
||||
row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
||||
const person = row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
||||
if (!person) return null;
|
||||
|
||||
const profileThumbHash = await ensureProfileThumbHash(person);
|
||||
@@ -207,9 +192,7 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
|
||||
}));
|
||||
}
|
||||
|
||||
async function syncPersonFilmography(
|
||||
person: Pick<typeof persons.$inferSelect, "id" | "tmdbId">,
|
||||
) {
|
||||
async function syncPersonFilmography(person: Pick<typeof persons.$inferSelect, "id" | "tmdbId">) {
|
||||
const credits = await getPersonCombinedCredits(person.tmdbId);
|
||||
|
||||
// Schema types combined credits as movie-only; TV entries also carry
|
||||
@@ -273,9 +256,7 @@ async function syncPersonFilmography(
|
||||
}
|
||||
});
|
||||
|
||||
const stillMissing = [...insertedTmdbIds].filter(
|
||||
(tmdbId) => !titleIdMap.has(tmdbId),
|
||||
);
|
||||
const stillMissing = [...insertedTmdbIds].filter((tmdbId) => !titleIdMap.has(tmdbId));
|
||||
if (stillMissing.length > 0) {
|
||||
const fallbacks = db
|
||||
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||
@@ -323,9 +304,7 @@ async function syncPersonFilmography(
|
||||
|
||||
const now = new Date();
|
||||
db.transaction((tx) => {
|
||||
tx.delete(personFilmography)
|
||||
.where(eq(personFilmography.personId, person.id))
|
||||
.run();
|
||||
tx.delete(personFilmography).where(eq(personFilmography.personId, person.id)).run();
|
||||
|
||||
for (const row of nextRows) {
|
||||
tx.insert(personFilmography).values(row).run();
|
||||
@@ -338,21 +317,14 @@ async function syncPersonFilmography(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchFullFilmography(
|
||||
personId: string,
|
||||
): Promise<PersonCredit[]> {
|
||||
const person = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, personId))
|
||||
.get();
|
||||
export async function fetchFullFilmography(personId: string): Promise<PersonCredit[]> {
|
||||
const person = db.select().from(persons).where(eq(persons.id, personId)).get();
|
||||
if (!person) return [];
|
||||
|
||||
const localFilmography = getLocalFilmography(personId);
|
||||
const isFresh =
|
||||
person.filmographyLastFetchedAt &&
|
||||
person.filmographyLastFetchedAt.getTime() >
|
||||
Date.now() - FILMOGRAPHY_STALE_MS;
|
||||
person.filmographyLastFetchedAt.getTime() > Date.now() - FILMOGRAPHY_STALE_MS;
|
||||
|
||||
if (isFresh) {
|
||||
return localFilmography;
|
||||
@@ -389,9 +361,7 @@ interface BrowsePersonInput {
|
||||
* Inserts shell persons (`lastFetchedAt = null`) for new tmdbIds.
|
||||
* Returns a map of tmdbId → internal UUID.
|
||||
*/
|
||||
export function ensureBrowsePersonsExist(
|
||||
items: BrowsePersonInput[],
|
||||
): Map<number, string> {
|
||||
export function ensureBrowsePersonsExist(items: BrowsePersonInput[]): Map<number, string> {
|
||||
if (items.length === 0) return new Map();
|
||||
|
||||
const unique = new Map<number, BrowsePersonInput>();
|
||||
|
||||
@@ -110,10 +110,7 @@ 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 {
|
||||
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));
|
||||
|
||||
@@ -3,11 +3,7 @@ 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();
|
||||
const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get();
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
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 { 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";
|
||||
@@ -186,18 +183,14 @@ function getJobsHealth(): SystemHealthData["jobs"] {
|
||||
}
|
||||
|
||||
const disabled =
|
||||
(jobName === "scheduledBackup" &&
|
||||
getSetting("scheduledBackups") !== "true") ||
|
||||
(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,
|
||||
lastRunAt: latest?.finishedAt?.toISOString() ?? latest?.startedAt?.toISOString() ?? null,
|
||||
lastDurationMs,
|
||||
lastStatus: (latest?.status as "running" | "success" | "error") ?? null,
|
||||
lastError: latest?.errorMessage ?? null,
|
||||
@@ -223,9 +216,7 @@ async function getImageCacheHealth(): Promise<SystemHealthData["imageCache"]> {
|
||||
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 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;
|
||||
@@ -246,9 +237,7 @@ async function getBackupsHealth(): Promise<SystemHealthData["backups"]> {
|
||||
const lastBackup = backups[0] ?? null;
|
||||
const lastBackupAt = lastBackup?.createdAt ?? null;
|
||||
const lastBackupAgeHours = lastBackupAt
|
||||
? Math.round(
|
||||
(Date.now() - new Date(lastBackupAt).getTime()) / (1000 * 60 * 60),
|
||||
)
|
||||
? Math.round((Date.now() - new Date(lastBackupAt).getTime()) / (1000 * 60 * 60))
|
||||
: null;
|
||||
|
||||
return {
|
||||
@@ -267,9 +256,7 @@ async function getBackupsHealth(): Promise<SystemHealthData["backups"]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getEnvironmentHealth(): Promise<
|
||||
SystemHealthData["environment"]
|
||||
> {
|
||||
async function getEnvironmentHealth(): Promise<SystemHealthData["environment"]> {
|
||||
const resolvedDataDir = path.resolve(DATA_DIR);
|
||||
let dataDirWritable = false;
|
||||
try {
|
||||
|
||||
@@ -2,17 +2,12 @@ import { db } from "@sofa/db/client";
|
||||
import { count } from "@sofa/db/helpers";
|
||||
import { titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { imageCacheEnabled } from "./image-cache";
|
||||
import {
|
||||
getInstanceId,
|
||||
getSetting,
|
||||
getUserCount,
|
||||
setSetting,
|
||||
} from "./settings";
|
||||
import { getInstanceId, getSetting, getUserCount, setSetting } from "./settings";
|
||||
|
||||
const APP_VERSION = process.env.APP_VERSION || "0.0.0";
|
||||
const PUBLIC_API_URL =
|
||||
process.env.PUBLIC_API_URL || "https://public-api.sofa.watch";
|
||||
const PUBLIC_API_URL = process.env.PUBLIC_API_URL || "https://public-api.sofa.watch";
|
||||
const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
const log = createLogger("telemetry");
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import path from "node:path";
|
||||
|
||||
import sharp from "sharp";
|
||||
import { rgbaToThumbHash } from "thumbhash";
|
||||
|
||||
import { db } from "@sofa/db/client";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes, persons, seasons, titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import type { ImageCategory } from "@sofa/tmdb/image";
|
||||
import sharp from "sharp";
|
||||
import { rgbaToThumbHash } from "thumbhash";
|
||||
|
||||
import { loadImageBuffer } from "./image-cache";
|
||||
|
||||
const log = createLogger("thumbhash");
|
||||
@@ -21,9 +24,7 @@ export async function generateThumbHash(
|
||||
): Promise<string | null> {
|
||||
const filename = path.basename(imagePath);
|
||||
const source =
|
||||
sourceBuffer === undefined
|
||||
? await loadImageBuffer(imagePath, category)
|
||||
: sourceBuffer;
|
||||
sourceBuffer === undefined ? await loadImageBuffer(imagePath, category) : sourceBuffer;
|
||||
if (!source) return null;
|
||||
|
||||
try {
|
||||
@@ -46,13 +47,8 @@ export async function generateTitlePosterThumbHash(
|
||||
posterPath: string | null,
|
||||
sourceBuffer?: Buffer | null,
|
||||
): Promise<string | null> {
|
||||
const hash = posterPath
|
||||
? await generateThumbHash(posterPath, "posters", sourceBuffer)
|
||||
: null;
|
||||
db.update(titles)
|
||||
.set({ posterThumbHash: hash })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
const hash = posterPath ? await generateThumbHash(posterPath, "posters", sourceBuffer) : null;
|
||||
db.update(titles).set({ posterThumbHash: hash }).where(eq(titles.id, titleId)).run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -60,13 +56,8 @@ export async function generateTitleBackdropThumbHash(
|
||||
titleId: string,
|
||||
backdropPath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = backdropPath
|
||||
? await generateThumbHash(backdropPath, "backdrops")
|
||||
: null;
|
||||
db.update(titles)
|
||||
.set({ backdropThumbHash: hash })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
const hash = backdropPath ? await generateThumbHash(backdropPath, "backdrops") : null;
|
||||
db.update(titles).set({ backdropThumbHash: hash }).where(eq(titles.id, titleId)).run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -74,13 +65,8 @@ export async function generateSeasonThumbHash(
|
||||
seasonId: string,
|
||||
posterPath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = posterPath
|
||||
? await generateThumbHash(posterPath, "posters")
|
||||
: null;
|
||||
db.update(seasons)
|
||||
.set({ posterThumbHash: hash })
|
||||
.where(eq(seasons.id, seasonId))
|
||||
.run();
|
||||
const hash = posterPath ? await generateThumbHash(posterPath, "posters") : null;
|
||||
db.update(seasons).set({ posterThumbHash: hash }).where(eq(seasons.id, seasonId)).run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -89,10 +75,7 @@ export async function generateEpisodeThumbHash(
|
||||
stillPath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = stillPath ? await generateThumbHash(stillPath, "stills") : null;
|
||||
db.update(episodes)
|
||||
.set({ stillThumbHash: hash })
|
||||
.where(eq(episodes.id, episodeId))
|
||||
.run();
|
||||
db.update(episodes).set({ stillThumbHash: hash }).where(eq(episodes.id, episodeId)).run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -100,12 +83,7 @@ export async function generatePersonThumbHash(
|
||||
personId: string,
|
||||
profilePath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = profilePath
|
||||
? await generateThumbHash(profilePath, "profiles")
|
||||
: null;
|
||||
db.update(persons)
|
||||
.set({ profileThumbHash: hash })
|
||||
.where(eq(persons.id, personId))
|
||||
.run();
|
||||
const hash = profilePath ? await generateThumbHash(profilePath, "profiles") : null;
|
||||
db.update(persons).set({ profileThumbHash: hash }).where(eq(persons.id, personId)).run();
|
||||
return hash;
|
||||
}
|
||||
+27
-126
@@ -14,8 +14,7 @@ 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",
|
||||
_source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||
addedAt?: Date,
|
||||
) {
|
||||
const now = addedAt ?? new Date();
|
||||
@@ -30,12 +29,7 @@ export function setTitleStatus(
|
||||
|
||||
export function removeTitleStatus(userId: string, titleId: string) {
|
||||
db.delete(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -46,20 +40,13 @@ export function logMovieWatch(
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
const now = watchedAt ?? new Date();
|
||||
db.insert(userMovieWatches)
|
||||
.values({ userId, titleId, watchedAt: now, source })
|
||||
.run();
|
||||
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),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
@@ -76,9 +63,7 @@ export function logEpisodeWatch(
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
const now = watchedAt ?? new Date();
|
||||
db.insert(userEpisodeWatches)
|
||||
.values({ userId, episodeId, watchedAt: now, source })
|
||||
.run();
|
||||
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
|
||||
@@ -94,12 +79,7 @@ export function logEpisodeWatch(
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
|
||||
if (!existing || existing.status === "watchlist") {
|
||||
@@ -123,25 +103,15 @@ export function logEpisodeWatchBatch(
|
||||
|
||||
// Batch INSERT all watch records
|
||||
for (const episodeId of episodeIds) {
|
||||
tx.insert(userEpisodeWatches)
|
||||
.values({ userId, episodeId, watchedAt: now, source })
|
||||
.run();
|
||||
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();
|
||||
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();
|
||||
const seasonRows = tx.select().from(seasons).where(inArray(seasons.id, seasonIds)).all();
|
||||
if (seasonRows.length === 0) return;
|
||||
|
||||
const { titleId } = seasonRows[0];
|
||||
@@ -150,12 +120,7 @@ export function logEpisodeWatchBatch(
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
|
||||
if (!existing || existing.status === "watchlist") {
|
||||
@@ -176,19 +141,11 @@ export function logEpisodeWatchBatch(
|
||||
}
|
||||
|
||||
// Check completion once (not per-episode)
|
||||
const allSeasons = tx
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
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 allEps = tx.select().from(episodes).where(inArray(episodes.seasonId, allSeasonIds)).all();
|
||||
const totalEpisodes = allEps.length;
|
||||
if (totalEpisodes === 0) return;
|
||||
|
||||
@@ -199,10 +156,7 @@ export function logEpisodeWatchBatch(
|
||||
})
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
inArray(userEpisodeWatches.episodeId, allEpIds),
|
||||
),
|
||||
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, allEpIds)),
|
||||
)
|
||||
.all();
|
||||
|
||||
@@ -291,12 +245,7 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
|
||||
})
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
inArray(userEpisodeWatches.episodeId, epIds),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, epIds)))
|
||||
.all();
|
||||
|
||||
if (watchCount.count >= totalEpisodes) {
|
||||
@@ -306,12 +255,7 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
|
||||
export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
db.delete(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
eq(userEpisodeWatches.episodeId, episodeId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.episodeId, episodeId)))
|
||||
.run();
|
||||
|
||||
// Find parent title and downgrade from completed to in_progress
|
||||
@@ -326,12 +270,7 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, row.titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, row.titleId)))
|
||||
.get();
|
||||
|
||||
if (existing?.status === "completed") {
|
||||
@@ -340,41 +279,25 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
}
|
||||
|
||||
export function unwatchSeason(userId: string, seasonId: string) {
|
||||
const seasonEps = db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, seasonId))
|
||||
.all();
|
||||
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),
|
||||
),
|
||||
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();
|
||||
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),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, season.titleId)))
|
||||
.get();
|
||||
|
||||
if (existing?.status === "completed") {
|
||||
@@ -391,9 +314,7 @@ export function rateTitleStars(
|
||||
const now = ratedAt ?? new Date();
|
||||
if (ratingStars === 0) {
|
||||
db.delete(userRatings)
|
||||
.where(
|
||||
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
@@ -418,20 +339,12 @@ export function getUserStatusesByTitleIds(
|
||||
status: userTitleStatus.status,
|
||||
})
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
inArray(userTitleStatus.titleId, titleIds),
|
||||
),
|
||||
)
|
||||
.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";
|
||||
result[row.titleId] = row.status as "watchlist" | "in_progress" | "completed";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -445,9 +358,7 @@ export function getEpisodeProgressByTitleIds(
|
||||
const rows = db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as(
|
||||
"totalEpisodes",
|
||||
),
|
||||
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",
|
||||
@@ -458,10 +369,7 @@ export function getEpisodeProgressByTitleIds(
|
||||
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
|
||||
.leftJoin(
|
||||
userEpisodeWatches,
|
||||
and(
|
||||
eq(userEpisodeWatches.episodeId, episodes.id),
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
),
|
||||
and(eq(userEpisodeWatches.episodeId, episodes.id), eq(userEpisodeWatches.userId, userId)),
|
||||
)
|
||||
.where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
|
||||
.groupBy(titles.id)
|
||||
@@ -483,20 +391,13 @@ export function getUserTitleInfo(userId: string, titleId: string) {
|
||||
const status = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.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)),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
|
||||
.get();
|
||||
|
||||
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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 PUBLIC_API_URL =
|
||||
process.env.PUBLIC_API_URL || "https://public-api.sofa.watch";
|
||||
const PUBLIC_API_URL = process.env.PUBLIC_API_URL || "https://public-api.sofa.watch";
|
||||
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
@@ -39,9 +39,7 @@ export function getCachedUpdateCheck(): UpdateCheckResult {
|
||||
const lastCheckedAt = getSetting("updateCheckLastCheckedAt");
|
||||
|
||||
return {
|
||||
updateAvailable: latestVersion
|
||||
? isNewerVersion(latestVersion, APP_VERSION)
|
||||
: false,
|
||||
updateAvailable: latestVersion ? isNewerVersion(latestVersion, APP_VERSION) : false,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion,
|
||||
releaseUrl,
|
||||
@@ -83,9 +81,7 @@ export async function performUpdateCheck(): Promise<UpdateCheckResult> {
|
||||
setSetting("updateCheckReleaseUrl", data.releaseUrl);
|
||||
setSetting("updateCheckLastCheckedAt", new Date().toISOString());
|
||||
|
||||
log.info(
|
||||
`Update check complete: current=${APP_VERSION}, latest=${version}`,
|
||||
);
|
||||
log.info(`Update check complete: current=${APP_VERSION}, latest=${version}`);
|
||||
|
||||
return {
|
||||
updateAvailable: isNewerVersion(version, APP_VERSION),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
userMovieWatches,
|
||||
} from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { resolveMovieTmdbId, resolveShowTmdbId } from "./imports/resolve";
|
||||
import { getOrFetchTitleByTmdbId } from "./metadata";
|
||||
import { logEpisodeWatch, logMovieWatch } from "./tracking";
|
||||
@@ -65,9 +66,7 @@ export function parsePlexPayload(formData: FormData): WebhookEvent | null {
|
||||
if (!isMovie && !isEpisode) return null;
|
||||
|
||||
// Extract external IDs from Guid array
|
||||
const guids = (metadata.Guid ?? metadata.guid) as
|
||||
| { id: string }[]
|
||||
| undefined;
|
||||
const guids = (metadata.Guid ?? metadata.guid) as { id: string }[] | undefined;
|
||||
let tmdbId: number | undefined;
|
||||
let imdbId: string | undefined;
|
||||
let tvdbId: string | undefined;
|
||||
@@ -90,15 +89,11 @@ export function parsePlexPayload(formData: FormData): WebhookEvent | null {
|
||||
tvdbId,
|
||||
seasonNumber: toOptionalInt(metadata.parentIndex),
|
||||
episodeNumber: toOptionalInt(metadata.index),
|
||||
showTitle: (metadata.grandparentTitle ?? metadata.parentTitle) as
|
||||
| string
|
||||
| undefined,
|
||||
showTitle: (metadata.grandparentTitle ?? metadata.parentTitle) as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJellyfinPayload(
|
||||
body: Record<string, unknown>,
|
||||
): WebhookEvent | null {
|
||||
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;
|
||||
@@ -123,9 +118,7 @@ export function parseJellyfinPayload(
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEmbyPayload(
|
||||
body: Record<string, unknown>,
|
||||
): WebhookEvent | null {
|
||||
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;
|
||||
@@ -262,15 +255,8 @@ export async function processWebhook(
|
||||
title: hasExternalId ? event.title : undefined,
|
||||
});
|
||||
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",
|
||||
);
|
||||
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" };
|
||||
}
|
||||
|
||||
@@ -282,12 +268,7 @@ export async function processWebhook(
|
||||
|
||||
if (isDuplicateMovieWatch(userId, title.id)) {
|
||||
log.debug(`Duplicate movie watch ignored: "${event.title}"`);
|
||||
logEvent(
|
||||
connectionId,
|
||||
event,
|
||||
"ignored",
|
||||
"Duplicate watch within 5 minutes",
|
||||
);
|
||||
logEvent(connectionId, event, "ignored", "Duplicate watch within 5 minutes");
|
||||
return { status: "ignored", message: "Duplicate watch" };
|
||||
}
|
||||
|
||||
@@ -315,21 +296,11 @@ export async function processWebhook(
|
||||
const season = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(
|
||||
and(
|
||||
eq(seasons.titleId, title.id),
|
||||
eq(seasons.seasonNumber, resolved.seasonNumber),
|
||||
),
|
||||
)
|
||||
.where(and(eq(seasons.titleId, title.id), eq(seasons.seasonNumber, resolved.seasonNumber)))
|
||||
.get();
|
||||
|
||||
if (!season) {
|
||||
logEvent(
|
||||
connectionId,
|
||||
event,
|
||||
"error",
|
||||
`Season ${resolved.seasonNumber} not found`,
|
||||
);
|
||||
logEvent(connectionId, event, "error", `Season ${resolved.seasonNumber} not found`);
|
||||
return {
|
||||
status: "error",
|
||||
message: `Season ${resolved.seasonNumber} not found`,
|
||||
@@ -340,10 +311,7 @@ export async function processWebhook(
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(
|
||||
and(
|
||||
eq(episodes.seasonId, season.id),
|
||||
eq(episodes.episodeNumber, resolved.episodeNumber),
|
||||
),
|
||||
and(eq(episodes.seasonId, season.id), eq(episodes.episodeNumber, resolved.episodeNumber)),
|
||||
)
|
||||
.get();
|
||||
|
||||
@@ -362,12 +330,7 @@ export async function processWebhook(
|
||||
|
||||
if (isDuplicateEpisodeWatch(userId, episode.id)) {
|
||||
log.debug(`Duplicate episode watch ignored: "${event.title}"`);
|
||||
logEvent(
|
||||
connectionId,
|
||||
event,
|
||||
"ignored",
|
||||
"Duplicate watch within 5 minutes",
|
||||
);
|
||||
logEvent(connectionId, event, "ignored", "Duplicate watch within 5 minutes");
|
||||
return { status: "ignored", message: "Duplicate watch" };
|
||||
}
|
||||
|
||||
@@ -379,12 +342,7 @@ export async function processWebhook(
|
||||
return { status: "success", message: `Logged watch for ${event.title}` };
|
||||
}
|
||||
|
||||
logEvent(
|
||||
connectionId,
|
||||
event,
|
||||
"ignored",
|
||||
`Unsupported media type: ${event.mediaType}`,
|
||||
);
|
||||
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";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
getBackupSource,
|
||||
isKnownBackup,
|
||||
isValidBackupFilename,
|
||||
} from "../src/backup";
|
||||
import { getBackupSource, isKnownBackup, isValidBackupFilename } from "../src/backup";
|
||||
|
||||
describe("getBackupSource", () => {
|
||||
test("detects manual backup", () => {
|
||||
@@ -12,21 +8,15 @@ describe("getBackupSource", () => {
|
||||
});
|
||||
|
||||
test("detects manual backup with milliseconds", () => {
|
||||
expect(getBackupSource("sofa-manual-2024-01-15-120000123.db")).toBe(
|
||||
"manual",
|
||||
);
|
||||
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",
|
||||
);
|
||||
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",
|
||||
);
|
||||
expect(getBackupSource("pre-restore-2024-06-01-030000.db")).toBe("pre-restore");
|
||||
});
|
||||
|
||||
test("defaults to manual for unknown patterns", () => {
|
||||
@@ -66,27 +56,19 @@ describe("isKnownBackup", () => {
|
||||
|
||||
describe("isValidBackupFilename", () => {
|
||||
test("accepts valid manual backup", () => {
|
||||
expect(isValidBackupFilename("sofa-manual-2024-01-15-120000.db")).toBe(
|
||||
true,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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);
|
||||
expect(isValidBackupFilename("subdir/sofa-manual-2024-01-15-120000.db")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects unknown filenames", () => {
|
||||
@@ -94,8 +76,6 @@ describe("isValidBackupFilename", () => {
|
||||
});
|
||||
|
||||
test("rejects filenames with double dots", () => {
|
||||
expect(isValidBackupFilename("sofa-manual-2024..01-15-120000.db")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isValidBackupFilename("sofa-manual-2024..01-15-120000.db")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { persons, titleCast } from "@sofa/db/schema";
|
||||
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
@@ -8,12 +9,7 @@ beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
function insertPerson(
|
||||
id: string,
|
||||
tmdbId: number,
|
||||
name: string,
|
||||
profilePath: string | null = null,
|
||||
) {
|
||||
function insertPerson(id: string, tmdbId: number, name: string, profilePath: string | null = null) {
|
||||
testDb.insert(persons).values({ id, tmdbId, name, profilePath }).run();
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
clearAllTables,
|
||||
insertAvailabilityOffer,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
insertTvShow,
|
||||
insertUser,
|
||||
} from "@sofa/db/test-utils";
|
||||
|
||||
import {
|
||||
getContinueWatchingFeed,
|
||||
getLibraryFeed,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
|
||||
import { loadImageBuffer } from "../src/image-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import AdmZip from "adm-zip";
|
||||
|
||||
import {
|
||||
type ParseResult,
|
||||
parseLetterboxdExport,
|
||||
@@ -747,9 +749,7 @@ describe("parseLetterboxdExport", () => {
|
||||
|
||||
expect(result.data.movies).toHaveLength(1);
|
||||
// Should have warnings for missing watched.csv, watchlist.csv, ratings.csv
|
||||
const missingWarnings = result.warnings.filter((w) =>
|
||||
w.includes("not found in export"),
|
||||
);
|
||||
const missingWarnings = result.warnings.filter((w) => w.includes("not found in export"));
|
||||
expect(missingWarnings).toHaveLength(3);
|
||||
expect(missingWarnings.some((w) => w.includes("watched.csv"))).toBe(true);
|
||||
expect(missingWarnings.some((w) => w.includes("watchlist.csv"))).toBe(true);
|
||||
@@ -779,8 +779,7 @@ describe("parseLetterboxdExport", () => {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date\n2024-01-15,Inception,2010,https://boxd.it/123,,false,,2024-01-14",
|
||||
"watched.csv":
|
||||
"Date,Name,Year,Letterboxd URI\n2024-01-10,Inception,2010,https://boxd.it/123",
|
||||
"watched.csv": "Date,Name,Year,Letterboxd URI\n2024-01-10,Inception,2010,https://boxd.it/123",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"ratings.csv": "Date,Name,Year,Letterboxd URI,Rating",
|
||||
});
|
||||
@@ -795,8 +794,7 @@ describe("parseLetterboxdExport", () => {
|
||||
describe("rating conversion (Letterboxd 0.5-5 to 1-5)", () => {
|
||||
async function rateLetterboxd(rating: number) {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"diary.csv": "Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"watched.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"ratings.csv": `Date,Name,Year,Letterboxd URI,Rating\n2024-01-15,Test,2020,https://boxd.it/1,${rating}`,
|
||||
@@ -857,21 +855,17 @@ describe("parseLetterboxdExport", () => {
|
||||
test("skips rating 0 with warning", async () => {
|
||||
const result = await rateLetterboxd(0);
|
||||
expect(result.data.ratings).toHaveLength(0);
|
||||
expect(
|
||||
result.warnings.some((w) =>
|
||||
w.includes("Skipped invalid Letterboxd rating"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.warnings.some((w) => w.includes("Skipped invalid Letterboxd rating"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("skips rating 6 with warning", async () => {
|
||||
const result = await rateLetterboxd(6);
|
||||
expect(result.data.ratings).toHaveLength(0);
|
||||
expect(
|
||||
result.warnings.some((w) =>
|
||||
w.includes("Skipped invalid Letterboxd rating"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.warnings.some((w) => w.includes("Skipped invalid Letterboxd rating"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -940,9 +934,7 @@ describe("parseLetterboxdExport", () => {
|
||||
const result = await parseLetterboxdExport(invalidBlob);
|
||||
|
||||
expect(result.data.movies).toHaveLength(0);
|
||||
expect(result.warnings.some((w) => w.includes("Failed to read ZIP"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.warnings.some((w) => w.includes("Failed to read ZIP"))).toBe(true);
|
||||
});
|
||||
|
||||
test("handles CSV files nested in a subdirectory", async () => {
|
||||
@@ -980,8 +972,7 @@ describe("parseLetterboxdExport", () => {
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date\n2024-01-15,Movie A,2020,https://boxd.it/1,,false,,2024-01-14\n2024-01-16,Movie B,2021,https://boxd.it/2,,false,,2024-01-15",
|
||||
"watched.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"watchlist.csv":
|
||||
"Date,Name,Year,Letterboxd URI\n2024-01-20,Movie C,2022,https://boxd.it/3",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI\n2024-01-20,Movie C,2022,https://boxd.it/3",
|
||||
"ratings.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating\n2024-01-15,Movie A,2020,https://boxd.it/1,4",
|
||||
});
|
||||
@@ -994,8 +985,7 @@ describe("parseLetterboxdExport", () => {
|
||||
|
||||
test("ratings.csv with missing rating column skips the row", async () => {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"diary.csv": "Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"watched.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"ratings.csv":
|
||||
@@ -1009,8 +999,7 @@ describe("parseLetterboxdExport", () => {
|
||||
|
||||
test("multiple movies in watched.csv are all parsed", async () => {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"diary.csv": "Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"watched.csv":
|
||||
"Date,Name,Year,Letterboxd URI\n2024-01-10,Movie A,2020,https://boxd.it/1\n2024-01-11,Movie B,2021,https://boxd.it/2\n2024-01-12,Movie C,2022,https://boxd.it/3",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI",
|
||||
@@ -1020,17 +1009,12 @@ describe("parseLetterboxdExport", () => {
|
||||
const result = await parseLetterboxdExport(zip);
|
||||
|
||||
expect(result.data.movies).toHaveLength(3);
|
||||
expect(result.data.movies.map((m) => m.title)).toEqual([
|
||||
"Movie A",
|
||||
"Movie B",
|
||||
"Movie C",
|
||||
]);
|
||||
expect(result.data.movies.map((m) => m.title)).toEqual(["Movie A", "Movie B", "Movie C"]);
|
||||
});
|
||||
|
||||
test("uses Title column as fallback when Name column is absent", async () => {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"diary.csv": "Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"watched.csv":
|
||||
"Date,Title,Year,Letterboxd URI\n2024-01-10,Fallback Title,2020,https://boxd.it/1",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI",
|
||||
@@ -1045,8 +1029,7 @@ describe("parseLetterboxdExport", () => {
|
||||
|
||||
test("watchlist items all have type movie", async () => {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"diary.csv": "Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"watched.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"watchlist.csv":
|
||||
"Date,Name,Year,Letterboxd URI\n2024-01-20,WL Item A,2024,https://boxd.it/1\n2024-01-21,WL Item B,2025,https://boxd.it/2",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import {
|
||||
importJobs,
|
||||
@@ -16,17 +17,14 @@ import {
|
||||
testDb,
|
||||
} from "@sofa/db/test-utils";
|
||||
import * as tmdbClient from "@sofa/tmdb/client";
|
||||
|
||||
import type { NormalizedImport } from "../src/imports/parsers";
|
||||
import { processImportJob, readImportJob } from "../src/imports/processor";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Insert a fully-fetched movie title (lastFetchedAt set so metadata won't re-fetch). */
|
||||
function insertMovieTitle(
|
||||
id: string,
|
||||
tmdbId: number,
|
||||
movieTitle = "Test Movie",
|
||||
) {
|
||||
function insertMovieTitle(id: string, tmdbId: number, movieTitle = "Test Movie") {
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({
|
||||
@@ -49,11 +47,7 @@ function insertTvShowWithFetchedAt(
|
||||
) {
|
||||
const result = insertTvShow(titleId, tmdbId, seasonCount, epsPerSeason);
|
||||
// Mark as fully fetched so getOrFetchTitleByTmdbId skips TMDB API calls
|
||||
testDb
|
||||
.update(titles)
|
||||
.set({ lastFetchedAt: new Date() })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
testDb.update(titles).set({ lastFetchedAt: new Date() }).where(eq(titles.id, titleId)).run();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -348,9 +342,7 @@ describe("processImportJob — state transitions", () => {
|
||||
});
|
||||
|
||||
test("readImportJob throws for non-existent job", () => {
|
||||
expect(() => readImportJob("non-existent-id")).toThrow(
|
||||
"Import job non-existent-id not found",
|
||||
);
|
||||
expect(() => readImportJob("non-existent-id")).toThrow("Import job non-existent-id not found");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
||||
|
||||
import * as tmdbClient from "@sofa/tmdb/client";
|
||||
|
||||
import { resolveMovieTmdbId, resolveShowTmdbId } from "../src/imports/resolve";
|
||||
|
||||
// The TMDB client functions (findByExternalId, searchMovies, searchTv) are
|
||||
@@ -56,25 +58,23 @@ describe("resolveMovieTmdbId", () => {
|
||||
|
||||
test("IMDB returns no movie, falls back to TVDB", async () => {
|
||||
let callIndex = 0;
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockImplementation(
|
||||
async () => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) {
|
||||
// IMDB lookup — no results
|
||||
return {
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never;
|
||||
}
|
||||
// TVDB lookup — has result
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockImplementation(async () => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) {
|
||||
// IMDB lookup — no results
|
||||
return {
|
||||
movie_results: [{ id: 789 }],
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
}
|
||||
// TVDB lookup — has result
|
||||
return {
|
||||
movie_results: [{ id: 789 }],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never;
|
||||
});
|
||||
|
||||
const result = await resolveMovieTmdbId({
|
||||
imdbId: "tt0000001",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
import {
|
||||
clearAllTables,
|
||||
insertIntegration,
|
||||
@@ -6,12 +7,8 @@ import {
|
||||
insertTitle,
|
||||
insertUser,
|
||||
} from "@sofa/db/test-utils";
|
||||
import {
|
||||
getRadarrList,
|
||||
getSonarrList,
|
||||
parseStatusParam,
|
||||
resolveListToken,
|
||||
} from "../src/lists";
|
||||
|
||||
import { getRadarrList, getSonarrList, parseStatusParam, resolveListToken } from "../src/lists";
|
||||
|
||||
// Mock getTvExternalIds for lazy resolution tests
|
||||
const mockGetTvExternalIds = mock(
|
||||
@@ -63,17 +60,11 @@ describe("parseStatusParam", () => {
|
||||
});
|
||||
|
||||
test("parses comma-separated statuses", () => {
|
||||
expect(parseStatusParam("watchlist,in_progress")).toEqual([
|
||||
"watchlist",
|
||||
"in_progress",
|
||||
]);
|
||||
expect(parseStatusParam("watchlist,in_progress")).toEqual(["watchlist", "in_progress"]);
|
||||
});
|
||||
|
||||
test("filters invalid statuses", () => {
|
||||
expect(parseStatusParam("watchlist,invalid,completed")).toEqual([
|
||||
"watchlist",
|
||||
"completed",
|
||||
]);
|
||||
expect(parseStatusParam("watchlist,invalid,completed")).toEqual(["watchlist", "completed"]);
|
||||
});
|
||||
|
||||
test("defaults to watchlist when all invalid", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes, seasons, titles } from "@sofa/db/schema";
|
||||
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
|
||||
@@ -52,10 +53,7 @@ mock.module("@sofa/tmdb/client", () => ({
|
||||
getVideos: async () => ({ results: [] }),
|
||||
}));
|
||||
|
||||
import {
|
||||
refreshTvChildren,
|
||||
updateTitleWithArtInvalidation,
|
||||
} from "../src/metadata";
|
||||
import { refreshTvChildren, updateTitleWithArtInvalidation } from "../src/metadata";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
@@ -104,16 +102,8 @@ describe("refreshTvChildren", () => {
|
||||
|
||||
await refreshTvChildren("tv-1", 10, 1);
|
||||
|
||||
const season = testDb
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.id, "season-1"))
|
||||
.get();
|
||||
const episode = testDb
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, "episode-1"))
|
||||
.get();
|
||||
const season = testDb.select().from(seasons).where(eq(seasons.id, "season-1")).get();
|
||||
const episode = testDb.select().from(episodes).where(eq(episodes.id, "episode-1")).get();
|
||||
|
||||
expect(season?.posterPath).toBe("/new-season.png");
|
||||
expect(season?.posterThumbHash).toBeNull();
|
||||
@@ -160,11 +150,7 @@ describe("refreshTvChildren", () => {
|
||||
|
||||
await refreshTvChildren("tv-1", 10, 1);
|
||||
|
||||
const episode = testDb
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, "episode-1"))
|
||||
.get();
|
||||
const episode = testDb.select().from(episodes).where(eq(episodes.id, "episode-1")).get();
|
||||
|
||||
expect(episode?.stillPath).toBeNull();
|
||||
expect(episode?.stillThumbHash).toBeNull();
|
||||
@@ -189,11 +175,7 @@ describe("updateTitleWithArtInvalidation", () => {
|
||||
})
|
||||
.run();
|
||||
|
||||
const existing = testDb
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, "movie-1"))
|
||||
.get();
|
||||
const existing = testDb.select().from(titles).where(eq(titles.id, "movie-1")).get();
|
||||
|
||||
expect(existing).not.toBeUndefined();
|
||||
if (!existing) {
|
||||
@@ -205,11 +187,7 @@ describe("updateTitleWithArtInvalidation", () => {
|
||||
backdropPath: "/new-backdrop.png",
|
||||
});
|
||||
|
||||
const title = testDb
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, "movie-1"))
|
||||
.get();
|
||||
const title = testDb.select().from(titles).where(eq(titles.id, "movie-1")).get();
|
||||
|
||||
expect(title?.posterPath).toBe("/new-poster.png");
|
||||
expect(title?.backdropPath).toBe("/new-backdrop.png");
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
TmdbVideo,
|
||||
} from "@sofa/tmdb/client";
|
||||
|
||||
import type { TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
|
||||
|
||||
import {
|
||||
extractMovieContentRating,
|
||||
extractTvContentRating,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { personFilmography, persons } from "@sofa/db/schema";
|
||||
import { clearAllTables, testDb } from "@sofa/db/test-utils";
|
||||
@@ -133,11 +126,7 @@ describe("getOrFetchPerson", () => {
|
||||
.run();
|
||||
|
||||
const person = await getOrFetchPerson("p1");
|
||||
const stored = testDb
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, "p1"))
|
||||
.get();
|
||||
const stored = testDb.select().from(persons).where(eq(persons.id, "p1")).get();
|
||||
|
||||
expect(person?.profileThumbHash).toBeString();
|
||||
expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
|
||||
@@ -157,11 +146,7 @@ describe("getOrFetchPerson", () => {
|
||||
.run();
|
||||
|
||||
const person = await getOrFetchPerson("p1");
|
||||
const stored = testDb
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, "p1"))
|
||||
.get();
|
||||
const stored = testDb.select().from(persons).where(eq(persons.id, "p1")).get();
|
||||
|
||||
expect(stored?.profilePath).toBe("/new-profile.png");
|
||||
expect(person?.profileThumbHash).toBeString();
|
||||
@@ -184,11 +169,7 @@ describe("fetchFullFilmography", () => {
|
||||
|
||||
const first = await fetchFullFilmography("p1");
|
||||
const second = await fetchFullFilmography("p1");
|
||||
const storedPerson = testDb
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, "p1"))
|
||||
.get();
|
||||
const storedPerson = testDb.select().from(persons).where(eq(persons.id, "p1")).get();
|
||||
const storedCredits = testDb
|
||||
.select()
|
||||
.from(personFilmography)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { personFilmography, persons } from "@sofa/db/schema";
|
||||
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
import { getLocalFilmography } from "../src/person";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -45,10 +47,7 @@ describe("getLocalFilmography", () => {
|
||||
|
||||
const filmography = getLocalFilmography("p1");
|
||||
expect(filmography).toHaveLength(2);
|
||||
expect(filmography.map((f) => f.title).sort()).toEqual([
|
||||
"Movie One",
|
||||
"Movie Two",
|
||||
]);
|
||||
expect(filmography.map((f) => f.title).sort()).toEqual(["Movie One", "Movie Two"]);
|
||||
});
|
||||
|
||||
test("includes crew roles", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, mock } from "bun:test";
|
||||
|
||||
import { applyMigrations, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
process.env.LOG_LEVEL ??= "error";
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { clearAllTables, insertUser } from "@sofa/db/test-utils";
|
||||
import {
|
||||
getSetting,
|
||||
getUserCount,
|
||||
isRegistrationOpen,
|
||||
setSetting,
|
||||
} from "../src/settings";
|
||||
|
||||
import { getSetting, getUserCount, isRegistrationOpen, setSetting } from "../src/settings";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes, seasons, titles } from "@sofa/db/schema";
|
||||
import { clearAllTables, testDb } from "@sofa/db/test-utils";
|
||||
@@ -52,14 +45,8 @@ describe("thumbhash generation", () => {
|
||||
});
|
||||
|
||||
test("clears a stale episode thumbhash when regeneration fails", async () => {
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({ id: "tv-1", tmdbId: 100, type: "tv", title: "Show" })
|
||||
.run();
|
||||
testDb
|
||||
.insert(seasons)
|
||||
.values({ id: "season-1", titleId: "tv-1", seasonNumber: 1 })
|
||||
.run();
|
||||
testDb.insert(titles).values({ id: "tv-1", tmdbId: 100, type: "tv", title: "Show" }).run();
|
||||
testDb.insert(seasons).values({ id: "season-1", titleId: "tv-1", seasonNumber: 1 }).run();
|
||||
testDb
|
||||
.insert(episodes)
|
||||
.values({
|
||||
@@ -74,11 +61,7 @@ describe("thumbhash generation", () => {
|
||||
nextBuffer = null;
|
||||
const hash = await generateEpisodeThumbHash("ep-1", "/new-still.png");
|
||||
|
||||
const episode = testDb
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, "ep-1"))
|
||||
.get();
|
||||
const episode = testDb.select().from(episodes).where(eq(episodes.id, "ep-1")).get();
|
||||
|
||||
expect(hash).toBeNull();
|
||||
expect(episode?.stillThumbHash).toBeNull();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { and, eq } from "@sofa/db/helpers";
|
||||
import {
|
||||
userEpisodeWatches,
|
||||
@@ -6,13 +7,8 @@ import {
|
||||
userRatings,
|
||||
userTitleStatus,
|
||||
} from "@sofa/db/schema";
|
||||
import {
|
||||
clearAllTables,
|
||||
insertTitle,
|
||||
insertTvShow,
|
||||
insertUser,
|
||||
testDb,
|
||||
} from "@sofa/db/test-utils";
|
||||
import { clearAllTables, insertTitle, insertTvShow, insertUser, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
import {
|
||||
getUserTitleInfo,
|
||||
logEpisodeWatch,
|
||||
@@ -41,12 +37,7 @@ describe("setTitleStatus", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("watchlist");
|
||||
});
|
||||
@@ -60,12 +51,7 @@ describe("setTitleStatus", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -79,12 +65,7 @@ describe("setTitleStatus", () => {
|
||||
const rows = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].status).toBe("completed");
|
||||
@@ -103,12 +84,7 @@ describe("removeTitleStatus", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
@@ -146,12 +122,7 @@ describe("logMovieWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -165,12 +136,7 @@ describe("logMovieWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -184,12 +150,7 @@ describe("logMovieWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -203,12 +164,7 @@ describe("logMovieWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -239,12 +195,7 @@ describe("logEpisodeWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -258,12 +209,7 @@ describe("logEpisodeWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -277,12 +223,7 @@ describe("logEpisodeWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -298,12 +239,7 @@ describe("logEpisodeWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -333,12 +269,7 @@ describe("logEpisodeWatchBatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -351,12 +282,7 @@ describe("logEpisodeWatchBatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -392,12 +318,7 @@ describe("markAllEpisodesWatched", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -460,12 +381,7 @@ describe("unwatchEpisode", () => {
|
||||
let row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
|
||||
@@ -475,12 +391,7 @@ describe("unwatchEpisode", () => {
|
||||
row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -495,12 +406,7 @@ describe("unwatchEpisode", () => {
|
||||
let row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
|
||||
@@ -509,12 +415,7 @@ describe("unwatchEpisode", () => {
|
||||
row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -550,12 +451,7 @@ describe("unwatchSeason", () => {
|
||||
let row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
|
||||
@@ -565,12 +461,7 @@ describe("unwatchSeason", () => {
|
||||
row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -587,12 +478,7 @@ describe("rateTitleStars", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(
|
||||
eq(userRatings.userId, "user-1"),
|
||||
eq(userRatings.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, "user-1"), eq(userRatings.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.ratingStars).toBe(4);
|
||||
});
|
||||
@@ -606,12 +492,7 @@ describe("rateTitleStars", () => {
|
||||
const rows = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(
|
||||
eq(userRatings.userId, "user-1"),
|
||||
eq(userRatings.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, "user-1"), eq(userRatings.titleId, "title-1")))
|
||||
.all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ratingStars).toBe(5);
|
||||
@@ -626,12 +507,7 @@ describe("rateTitleStars", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(
|
||||
eq(userRatings.userId, "user-1"),
|
||||
eq(userRatings.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, "user-1"), eq(userRatings.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { isNewerVersion } from "../src/update-check";
|
||||
|
||||
describe("isNewerVersion", () => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"ignorePatterns": ["node_modules", "dist", "build", "drizzle"]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
@@ -6,8 +7,6 @@ export default defineConfig({
|
||||
schema: "./src/schema.ts",
|
||||
out: "./drizzle",
|
||||
dbCredentials: {
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
path.join(process.env.DATA_DIR || "./data", "sqlite.db"),
|
||||
url: process.env.DATABASE_URL || path.join(process.env.DATA_DIR || "./data", "sqlite.db"),
|
||||
},
|
||||
});
|
||||
@@ -11,8 +11,9 @@
|
||||
"./test-utils": "./src/test-utils.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||
"check-types": "tsc --noEmit",
|
||||
"db:generate": "bun --bun drizzle-kit generate",
|
||||
"db:migrate": "bun --bun drizzle-kit migrate",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { DATABASE_URL } from "@sofa/config";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import type { Logger } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
import { DATABASE_URL } from "@sofa/config";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import * as schema from "./schema";
|
||||
|
||||
const log = createLogger("drizzle");
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import path from "node:path";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { db } from "./client";
|
||||
|
||||
const log = createLogger("db");
|
||||
|
||||
export function runMigrations(
|
||||
migrationsFolder = path.join(import.meta.dir, "../drizzle"),
|
||||
) {
|
||||
export function runMigrations(migrationsFolder = path.join(import.meta.dir, "../drizzle")) {
|
||||
log.info("Running database migrations...");
|
||||
migrate(db, { migrationsFolder });
|
||||
log.info("Database migrations complete");
|
||||
|
||||
+15
-64
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
index,
|
||||
int,
|
||||
real,
|
||||
sqliteTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { index, int, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Helper for UUID primary keys
|
||||
const uuidPk = () =>
|
||||
@@ -19,9 +12,7 @@ export const user = sqliteTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: int("emailVerified", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
emailVerified: int("emailVerified", { mode: "boolean" }).notNull().default(false),
|
||||
image: text("image"),
|
||||
role: text("role").default("user"),
|
||||
banned: int("banned", { mode: "boolean" }).default(false),
|
||||
@@ -130,11 +121,7 @@ export const titles = sqliteTable(
|
||||
index("titles_type_releaseDate").on(table.type, table.releaseDate),
|
||||
index("titles_type_firstAirDate").on(table.type, table.firstAirDate),
|
||||
index("titles_lastFetchedAt").on(table.lastFetchedAt),
|
||||
index("titles_type_status_lastFetchedAt").on(
|
||||
table.type,
|
||||
table.status,
|
||||
table.lastFetchedAt,
|
||||
),
|
||||
index("titles_type_status_lastFetchedAt").on(table.type, table.status, table.lastFetchedAt),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -153,12 +140,7 @@ export const seasons = sqliteTable(
|
||||
airDate: text("airDate"),
|
||||
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("seasons_titleId_seasonNumber").on(
|
||||
table.titleId,
|
||||
table.seasonNumber,
|
||||
),
|
||||
],
|
||||
(table) => [uniqueIndex("seasons_titleId_seasonNumber").on(table.titleId, table.seasonNumber)],
|
||||
);
|
||||
|
||||
export const episodes = sqliteTable(
|
||||
@@ -177,10 +159,7 @@ export const episodes = sqliteTable(
|
||||
runtimeMinutes: int("runtimeMinutes"),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("episodes_seasonId_episodeNumber").on(
|
||||
table.seasonId,
|
||||
table.episodeNumber,
|
||||
),
|
||||
uniqueIndex("episodes_seasonId_episodeNumber").on(table.seasonId, table.episodeNumber),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -200,10 +179,7 @@ export const userTitleStatus = sqliteTable(
|
||||
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("userTitleStatus_userId_titleId").on(
|
||||
table.userId,
|
||||
table.titleId,
|
||||
),
|
||||
uniqueIndex("userTitleStatus_userId_titleId").on(table.userId, table.titleId),
|
||||
index("userTitleStatus_userId_status").on(table.userId, table.status),
|
||||
],
|
||||
);
|
||||
@@ -226,10 +202,7 @@ export const userMovieWatches = sqliteTable(
|
||||
.default("manual"),
|
||||
},
|
||||
(table) => [
|
||||
index("userMovieWatches_userId_watchedAt").on(
|
||||
table.userId,
|
||||
table.watchedAt,
|
||||
),
|
||||
index("userMovieWatches_userId_watchedAt").on(table.userId, table.watchedAt),
|
||||
index("userMovieWatches_titleId").on(table.titleId),
|
||||
index("userMovieWatches_userId_titleId").on(table.userId, table.titleId),
|
||||
],
|
||||
@@ -253,15 +226,9 @@ export const userEpisodeWatches = sqliteTable(
|
||||
.default("manual"),
|
||||
},
|
||||
(table) => [
|
||||
index("userEpisodeWatches_userId_watchedAt").on(
|
||||
table.userId,
|
||||
table.watchedAt,
|
||||
),
|
||||
index("userEpisodeWatches_userId_watchedAt").on(table.userId, table.watchedAt),
|
||||
index("userEpisodeWatches_episodeId").on(table.episodeId),
|
||||
index("userEpisodeWatches_userId_episodeId").on(
|
||||
table.userId,
|
||||
table.episodeId,
|
||||
),
|
||||
index("userEpisodeWatches_userId_episodeId").on(table.userId, table.episodeId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -277,9 +244,7 @@ export const userRatings = sqliteTable(
|
||||
ratingStars: int("ratingStars").notNull(),
|
||||
ratedAt: int("ratedAt", { mode: "timestamp" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("userRatings_userId_titleId").on(table.userId, table.titleId),
|
||||
],
|
||||
(table) => [uniqueIndex("userRatings_userId_titleId").on(table.userId, table.titleId)],
|
||||
);
|
||||
|
||||
export const availabilityOffers = sqliteTable(
|
||||
@@ -385,10 +350,7 @@ export const titleCast = sqliteTable(
|
||||
table.department,
|
||||
table.character,
|
||||
),
|
||||
index("titleCast_titleId_displayOrder").on(
|
||||
table.titleId,
|
||||
table.displayOrder,
|
||||
),
|
||||
index("titleCast_titleId_displayOrder").on(table.titleId, table.displayOrder),
|
||||
index("titleCast_personId").on(table.personId),
|
||||
],
|
||||
);
|
||||
@@ -409,10 +371,7 @@ export const personFilmography = sqliteTable(
|
||||
displayOrder: int("displayOrder").notNull().default(0),
|
||||
},
|
||||
(table) => [
|
||||
index("personFilmography_personId_displayOrder").on(
|
||||
table.personId,
|
||||
table.displayOrder,
|
||||
),
|
||||
index("personFilmography_personId_displayOrder").on(table.personId, table.displayOrder),
|
||||
index("personFilmography_titleId").on(table.titleId),
|
||||
],
|
||||
);
|
||||
@@ -434,10 +393,7 @@ export const integrations = sqliteTable(
|
||||
lastEventAt: int("lastEventAt", { mode: "timestamp" }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("integrations_userId_provider").on(
|
||||
table.userId,
|
||||
table.provider,
|
||||
),
|
||||
uniqueIndex("integrations_userId_provider").on(table.userId, table.provider),
|
||||
uniqueIndex("integrations_token").on(table.token),
|
||||
],
|
||||
);
|
||||
@@ -459,10 +415,7 @@ export const integrationEvents = sqliteTable(
|
||||
receivedAt: int("receivedAt", { mode: "timestamp" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("integrationEvents_integrationId_receivedAt").on(
|
||||
table.integrationId,
|
||||
table.receivedAt,
|
||||
),
|
||||
index("integrationEvents_integrationId_receivedAt").on(table.integrationId, table.receivedAt),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -481,9 +434,7 @@ export const cronRuns = sqliteTable(
|
||||
durationMs: int("durationMs"),
|
||||
errorMessage: text("errorMessage"),
|
||||
},
|
||||
(table) => [
|
||||
index("cronRuns_jobName_startedAt").on(table.jobName, table.startedAt),
|
||||
],
|
||||
(table) => [index("cronRuns_jobName_startedAt").on(table.jobName, table.startedAt)],
|
||||
);
|
||||
|
||||
// ─── Import Jobs ────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
|
||||
import * as schema from "./schema";
|
||||
|
||||
const {
|
||||
@@ -28,9 +30,7 @@ export function applyMigrations() {
|
||||
|
||||
export function clearAllTables() {
|
||||
const tables = testClient
|
||||
.query(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__drizzle%'",
|
||||
)
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__drizzle%'")
|
||||
.all() as { name: string }[];
|
||||
testClient.run("PRAGMA foreign_keys = OFF");
|
||||
for (const { name } of tables) {
|
||||
@@ -79,20 +79,12 @@ export function insertTitle(
|
||||
return id;
|
||||
}
|
||||
|
||||
export function insertTvShow(
|
||||
titleId = "tv-1",
|
||||
tmdbId = 99999,
|
||||
seasonCount = 1,
|
||||
epsPerSeason = 3,
|
||||
) {
|
||||
export function insertTvShow(titleId = "tv-1", tmdbId = 99999, seasonCount = 1, epsPerSeason = 3) {
|
||||
insertTitle({ id: titleId, tmdbId, type: "tv", title: "Test Show" });
|
||||
const episodeIds: string[] = [];
|
||||
for (let s = 1; s <= seasonCount; s++) {
|
||||
const seasonId = `${titleId}-s${s}`;
|
||||
testDb
|
||||
.insert(seasons)
|
||||
.values({ id: seasonId, titleId, seasonNumber: s })
|
||||
.run();
|
||||
testDb.insert(seasons).values({ id: seasonId, titleId, seasonNumber: s }).run();
|
||||
for (let e = 1; e <= epsPerSeason; e++) {
|
||||
const epId = `${titleId}-s${s}e${e}`;
|
||||
testDb
|
||||
@@ -110,11 +102,7 @@ export function insertTvShow(
|
||||
return { titleId, episodeIds };
|
||||
}
|
||||
|
||||
export function insertMovieWatch(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
export function insertMovieWatch(userId: string, titleId: string, watchedAt?: Date) {
|
||||
testDb
|
||||
.insert(userMovieWatches)
|
||||
.values({
|
||||
@@ -126,11 +114,7 @@ export function insertMovieWatch(
|
||||
.run();
|
||||
}
|
||||
|
||||
export function insertEpisodeWatch(
|
||||
userId: string,
|
||||
episodeId: string,
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
export function insertEpisodeWatch(userId: string, episodeId: string, watchedAt?: Date) {
|
||||
testDb
|
||||
.insert(userEpisodeWatches)
|
||||
.values({
|
||||
@@ -147,22 +131,15 @@ export function insertStatus(
|
||||
titleId: string,
|
||||
status: "watchlist" | "in_progress" | "completed",
|
||||
) {
|
||||
const now = new Date();
|
||||
const timestamp = new Date();
|
||||
testDb
|
||||
.insert(userTitleStatus)
|
||||
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
|
||||
.values({ userId, titleId, status, addedAt: timestamp, updatedAt: timestamp })
|
||||
.run();
|
||||
}
|
||||
|
||||
export function insertRating(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
ratingStars: number,
|
||||
) {
|
||||
testDb
|
||||
.insert(userRatings)
|
||||
.values({ userId, titleId, ratingStars, ratedAt: new Date() })
|
||||
.run();
|
||||
export function insertRating(userId: string, titleId: string, ratingStars: number) {
|
||||
testDb.insert(userRatings).values({ userId, titleId, ratingStars, ratedAt: new Date() }).run();
|
||||
}
|
||||
|
||||
export function insertAvailabilityOffer(
|
||||
@@ -184,13 +161,8 @@ export function insertAvailabilityOffer(
|
||||
.run();
|
||||
}
|
||||
|
||||
export function insertIntegration(
|
||||
userId: string,
|
||||
provider: string,
|
||||
token = "test-token",
|
||||
) {
|
||||
const type =
|
||||
provider === "sonarr" || provider === "radarr" ? "list" : "webhook";
|
||||
export function insertIntegration(userId: string, provider: string, token = "test-token") {
|
||||
const type = provider === "sonarr" || provider === "radarr" ? "list" : "webhook";
|
||||
return testDb
|
||||
.insert(integrations)
|
||||
.values({
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"ignorePatterns": ["node_modules", "dist", "build", "src/po/*.ts"]
|
||||
}
|
||||
@@ -10,8 +10,9 @@
|
||||
"./test-utils": "./src/test-utils.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -9,14 +9,10 @@ function toDate(date: Date | string): Date {
|
||||
return typeof date === "string" ? new Date(date) : date;
|
||||
}
|
||||
|
||||
export function formatDate(
|
||||
date: Date | string,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): string {
|
||||
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
|
||||
// Date-only ISO strings (e.g. "1990-05-15") are parsed as UTC midnight.
|
||||
// Format in UTC so users west of UTC don't see the previous day.
|
||||
const useUTC =
|
||||
typeof date === "string" && isDateOnly(date) && !options?.timeZone;
|
||||
const useUTC = typeof date === "string" && isDateOnly(date) && !options?.timeZone;
|
||||
return new Intl.DateTimeFormat(i18n.locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
@@ -53,10 +49,7 @@ export function formatRelativeTime(date: Date | string): string {
|
||||
return rtf.format(sign * Math.round(absDay / 365), "year");
|
||||
}
|
||||
|
||||
export function formatNumber(
|
||||
n: number,
|
||||
options?: Intl.NumberFormatOptions,
|
||||
): string {
|
||||
export function formatNumber(n: number, options?: Intl.NumberFormatOptions): string {
|
||||
return new Intl.NumberFormat(i18n.locale, options).format(n);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { i18n, type Messages } from "@lingui/core";
|
||||
|
||||
import { messages as enMessages } from "./po/en";
|
||||
|
||||
export const SUPPORTED_LOCALES = ["en", "fr", "de", "es", "it", "pt"] as const;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { messages } from "./po/en";
|
||||
|
||||
i18n.load("en", messages);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"]
|
||||
}
|
||||
@@ -7,8 +7,9 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -31,10 +31,7 @@ export interface Logger {
|
||||
debug(msg: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
function wrapLevel(
|
||||
child: pino.Logger,
|
||||
lvl: "error" | "warn" | "info" | "debug",
|
||||
) {
|
||||
function wrapLevel(child: pino.Logger, lvl: "error" | "warn" | "info" | "debug") {
|
||||
return (msg: string, ...args: unknown[]) => {
|
||||
if (args.length === 0) {
|
||||
child[lvl](msg);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"ignorePatterns": ["node_modules", "dist", "build", "src/schema.d.ts"]
|
||||
}
|
||||
@@ -9,8 +9,9 @@
|
||||
"./config": "./src/config.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||
"check-types": "tsc --noEmit",
|
||||
"generate-schema": "bunx openapi-typescript https://developer.themoviedb.org/openapi/tmdb-api.json -o ./src/schema.d.ts"
|
||||
},
|
||||
|
||||
+38
-72
@@ -1,5 +1,7 @@
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import createClient, { type Middleware } from "openapi-fetch";
|
||||
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import type { operations, paths } from "./schema";
|
||||
|
||||
const log = createLogger("tmdb");
|
||||
@@ -17,9 +19,7 @@ type OpResponse<K extends keyof operations> = operations[K] extends {
|
||||
|
||||
// Search
|
||||
export type TmdbSearchResponse = OpResponse<"search-multi">;
|
||||
export type TmdbSearchResult = NonNullable<
|
||||
TmdbSearchResponse["results"]
|
||||
>[number];
|
||||
export type TmdbSearchResult = NonNullable<TmdbSearchResponse["results"]>[number];
|
||||
|
||||
// Movie details — augmented with append_to_response data
|
||||
export type TmdbMovieDetails = OpResponse<"movie-details"> & {
|
||||
@@ -60,9 +60,7 @@ export interface TmdbWatchProviderResponse {
|
||||
|
||||
// Recommendations — movie-recommendations is Record<string, never> in schema,
|
||||
// so we base on tv-series-recommendations and add movie fields TMDB returns.
|
||||
type TvRecResult = NonNullable<
|
||||
OpResponse<"tv-series-recommendations">["results"]
|
||||
>[number];
|
||||
type TvRecResult = NonNullable<OpResponse<"tv-series-recommendations">["results"]>[number];
|
||||
export type TmdbRecommendationResponse = Omit<
|
||||
OpResponse<"tv-series-recommendations">,
|
||||
"results"
|
||||
@@ -75,10 +73,7 @@ export type TmdbRecommendationResponse = Omit<
|
||||
};
|
||||
|
||||
// Find — schema types tv_results and tv_episode_results as unknown[]
|
||||
export type TmdbFindResult = Omit<
|
||||
OpResponse<"find-by-id">,
|
||||
"tv_results" | "tv_episode_results"
|
||||
> & {
|
||||
export type TmdbFindResult = Omit<OpResponse<"find-by-id">, "tv_results" | "tv_episode_results"> & {
|
||||
tv_results?: TmdbSearchResult[];
|
||||
tv_episode_results?: {
|
||||
id: number;
|
||||
@@ -90,20 +85,13 @@ export type TmdbFindResult = Omit<
|
||||
};
|
||||
|
||||
// Videos
|
||||
export type TmdbVideo = NonNullable<
|
||||
OpResponse<"movie-videos">["results"]
|
||||
>[number];
|
||||
export type TmdbVideo = NonNullable<OpResponse<"movie-videos">["results"]>[number];
|
||||
|
||||
// Genres
|
||||
export type TmdbGenre = NonNullable<
|
||||
OpResponse<"genre-movie-list">["genres"]
|
||||
>[number];
|
||||
export type TmdbGenre = NonNullable<OpResponse<"genre-movie-list">["genres"]>[number];
|
||||
|
||||
// Person — schema types deathday as unknown
|
||||
export type TmdbPersonDetails = Omit<
|
||||
OpResponse<"person-details">,
|
||||
"deathday"
|
||||
> & {
|
||||
export type TmdbPersonDetails = Omit<OpResponse<"person-details">, "deathday"> & {
|
||||
deathday?: string | null;
|
||||
};
|
||||
|
||||
@@ -156,9 +144,7 @@ const loggingMiddleware: Middleware = {
|
||||
const start = requestTimings.get(request);
|
||||
const elapsed = start ? Math.round(performance.now() - start) : 0;
|
||||
if (!response.ok) {
|
||||
log.warn(
|
||||
`${request.url} -> ${response.status} ${response.statusText} (${elapsed}ms)`,
|
||||
);
|
||||
log.warn(`${request.url} -> ${response.status} ${response.statusText} (${elapsed}ms)`);
|
||||
} else {
|
||||
log.debug(`${request.url} -> ${response.status} (${elapsed}ms)`);
|
||||
}
|
||||
@@ -229,16 +215,12 @@ export async function getTvExternalIds(tmdbId: number) {
|
||||
}
|
||||
|
||||
export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/tv/{series_id}/season/{season_number}",
|
||||
{
|
||||
params: {
|
||||
path: { series_id: tmdbId, season_number: seasonNumber },
|
||||
},
|
||||
const { data, error } = await client.GET("/3/tv/{series_id}/season/{season_number}", {
|
||||
params: {
|
||||
path: { series_id: tmdbId, season_number: seasonNumber },
|
||||
},
|
||||
);
|
||||
if (error)
|
||||
throw new Error(`TMDB API error: tv/${tmdbId}/season/${seasonNumber}`);
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/season/${seasonNumber}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -246,18 +228,15 @@ export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
|
||||
|
||||
export async function getWatchProviders(tmdbId: number, type: "movie" | "tv") {
|
||||
if (type === "movie") {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/movie/{movie_id}/watch/providers",
|
||||
{ params: { path: { movie_id: tmdbId } } },
|
||||
);
|
||||
if (error)
|
||||
throw new Error(`TMDB API error: movie/${tmdbId}/watch/providers`);
|
||||
const { data, error } = await client.GET("/3/movie/{movie_id}/watch/providers", {
|
||||
params: { path: { movie_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/watch/providers`);
|
||||
return data as TmdbWatchProviderResponse;
|
||||
}
|
||||
const { data, error } = await client.GET(
|
||||
"/3/tv/{series_id}/watch/providers",
|
||||
{ params: { path: { series_id: tmdbId } } },
|
||||
);
|
||||
const { data, error } = await client.GET("/3/tv/{series_id}/watch/providers", {
|
||||
params: { path: { series_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/watch/providers`);
|
||||
return data as TmdbWatchProviderResponse;
|
||||
}
|
||||
@@ -266,18 +245,15 @@ export async function getWatchProviders(tmdbId: number, type: "movie" | "tv") {
|
||||
|
||||
export async function getRecommendations(tmdbId: number, type: "movie" | "tv") {
|
||||
if (type === "movie") {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/movie/{movie_id}/recommendations",
|
||||
{ params: { path: { movie_id: tmdbId } } },
|
||||
);
|
||||
if (error)
|
||||
throw new Error(`TMDB API error: movie/${tmdbId}/recommendations`);
|
||||
const { data, error } = await client.GET("/3/movie/{movie_id}/recommendations", {
|
||||
params: { path: { movie_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/recommendations`);
|
||||
return data as TmdbRecommendationResponse;
|
||||
}
|
||||
const { data, error } = await client.GET(
|
||||
"/3/tv/{series_id}/recommendations",
|
||||
{ params: { path: { series_id: tmdbId } } },
|
||||
);
|
||||
const { data, error } = await client.GET("/3/tv/{series_id}/recommendations", {
|
||||
params: { path: { series_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/recommendations`);
|
||||
return data as TmdbRecommendationResponse;
|
||||
}
|
||||
@@ -309,10 +285,9 @@ export async function getTrending(
|
||||
const query = { page } as Record<string, unknown>;
|
||||
|
||||
if (mediaType === "movie") {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/trending/movie/{time_window}",
|
||||
{ params: { path: { time_window: timeWindow }, query } },
|
||||
);
|
||||
const { data, error } = await client.GET("/3/trending/movie/{time_window}", {
|
||||
params: { path: { time_window: timeWindow }, query },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: trending/movie");
|
||||
return data;
|
||||
}
|
||||
@@ -360,11 +335,7 @@ export async function getGenres(type: "movie" | "tv") {
|
||||
|
||||
// ─── Discover ───────────────────────────────────────────────────────
|
||||
|
||||
export async function discover(
|
||||
type: "movie" | "tv",
|
||||
params: Record<string, string>,
|
||||
page = 1,
|
||||
) {
|
||||
export async function discover(type: "movie" | "tv", params: Record<string, string>, page = 1) {
|
||||
// Discover accepts many dynamic filter params (with_genres, vote_count.gte, etc.)
|
||||
// that aren't individually typed in the schema, so widen to Record<string, unknown>.
|
||||
if (type === "movie") {
|
||||
@@ -400,10 +371,7 @@ export async function getVideos(tmdbId: number, type: "movie" | "tv") {
|
||||
|
||||
// ─── Find by External ID ───────────────────────────────────────────
|
||||
|
||||
export async function findByExternalId(
|
||||
externalId: string,
|
||||
source: "imdb_id" | "tvdb_id",
|
||||
) {
|
||||
export async function findByExternalId(externalId: string, source: "imdb_id" | "tvdb_id") {
|
||||
const { data, error } = await client.GET("/3/find/{external_id}", {
|
||||
params: {
|
||||
path: { external_id: externalId },
|
||||
@@ -425,10 +393,9 @@ export async function getMovieCredits(tmdbId: number) {
|
||||
}
|
||||
|
||||
export async function getTvAggregateCredits(tmdbId: number) {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/tv/{series_id}/aggregate_credits",
|
||||
{ params: { path: { series_id: tmdbId } } },
|
||||
);
|
||||
const { data, error } = await client.GET("/3/tv/{series_id}/aggregate_credits", {
|
||||
params: { path: { series_id: tmdbId } },
|
||||
});
|
||||
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/aggregate_credits`);
|
||||
return data;
|
||||
}
|
||||
@@ -447,8 +414,7 @@ export async function getPersonCombinedCredits(tmdbId: number) {
|
||||
// Schema incorrectly types person_id as string here (number everywhere else)
|
||||
{ params: { path: { person_id: String(tmdbId) } } },
|
||||
);
|
||||
if (error)
|
||||
throw new Error(`TMDB API error: person/${tmdbId}/combined_credits`);
|
||||
if (error) throw new Error(`TMDB API error: person/${tmdbId}/combined_credits`);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
export type ImageCategory =
|
||||
| "posters"
|
||||
| "backdrops"
|
||||
| "stills"
|
||||
| "logos"
|
||||
| "profiles";
|
||||
export type ImageCategory = "posters" | "backdrops" | "stills" | "logos" | "profiles";
|
||||
|
||||
const IMAGE_BASE_URL =
|
||||
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||
const IMAGE_BASE_URL = process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||
|
||||
export const IMAGE_CATEGORY_SIZES: Record<ImageCategory, string> = {
|
||||
posters: "w500",
|
||||
@@ -26,11 +20,7 @@ export function tmdbCdnImageUrl(
|
||||
return `${IMAGE_BASE_URL}/${size}${path}`;
|
||||
}
|
||||
|
||||
export function tmdbImageUrl(
|
||||
path: string | null,
|
||||
category: ImageCategory,
|
||||
sizeOverride?: string,
|
||||
) {
|
||||
export function tmdbImageUrl(path: string | null, category: ImageCategory, sizeOverride?: string) {
|
||||
if (!path) return null;
|
||||
|
||||
if (process.env.IMAGE_CACHE_ENABLED === "false") {
|
||||
|
||||
Reference in new issue
Block a user