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:
jakeandClaude Opus 4.6 committed 2026-03-18 13:26:50 -04:00
1 parent a80c8c9a0c
commit 2b0c683b7b
359 files changed
+3894 -7208

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "../../node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.json"]
}
+3 -2
View File
@@ -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"
},
+3 -15
View File
@@ -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
View File
@@ -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;
+4 -14
View File
@@ -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.
+6 -11
View File
@@ -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);
+3 -6
View File
@@ -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 })
+16 -58
View File
@@ -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
+14 -51
View File
@@ -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(
+1 -5
View File
@@ -18,8 +18,4 @@ export {
processImportJob,
readImportJob,
} from "./processor";
export {
type ExternalIds,
resolveMovieTmdbId,
resolveShowTmdbId,
} from "./resolve";
export { type ExternalIds, resolveMovieTmdbId, resolveShowTmdbId } from "./resolve";
+9 -36
View File
@@ -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;
}
+25 -100
View File
@@ -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",
+2 -6
View File
@@ -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) {
+3 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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>();
+1 -4
View File
@@ -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));
+1 -5
View File
@@ -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;
}
+8 -21
View File
@@ -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 {
+3 -8
View File
@@ -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");
+15 -37
View File
@@ -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
View File
@@ -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)
+4 -8
View File
@@ -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),
+13 -55
View File
@@ -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";
+9 -29
View File
@@ -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);
});
});
+2 -6
View File
@@ -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;
}
+2
View File
@@ -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
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { loadImageBuffer } from "../src/image-cache";
beforeEach(() => {
+18 -35
View File
@@ -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",
+5 -13
View File
@@ -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");
});
});
+15 -15
View File
@@ -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",
+5 -14
View File
@@ -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", () => {
+7 -29
View File
@@ -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");
+3 -5
View File
@@ -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,
+5 -24
View File
@@ -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)
+3 -4
View File
@@ -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
View File
@@ -1,4 +1,5 @@
import { afterEach, mock } from "bun:test";
import { applyMigrations, testDb } from "@sofa/db/test-utils";
process.env.LOG_LEVEL ??= "error";
+3 -6
View File
@@ -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();
+5 -22
View File
@@ -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();
+27 -151
View File
@@ -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
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { isNewerVersion } from "../src/update-check";
describe("isNewerVersion", () => {