mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
refactor: replace Biome with oxlint + oxfmt
Migrate the entire monorepo from Biome 2.4.7 to oxlint 1.56.0 (linter) and oxfmt 0.41.0 (formatter) for faster lint/format and broader rule coverage. - Add `.oxlintrc.json` with React, TypeScript, unicorn, import plugins and correctness/suspicious categories - Add `.oxfmtrc.json` with 2-space indent, import sorting, and Tailwind class sorting (all 30+ custom className attributes migrated) - Add `docs/.oxlintrc.json` and `docs/.oxfmtrc.json` with Next.js plugin - Update all 12 workspace package.json scripts: `oxlint`, `oxfmt`, `oxfmt --check` - Add `format:check` turbo task and CI step - Update VS Code settings/extensions to use `oxc.oxc-vscode` - Update CI path triggers from `biome.json` to new config files - Remove all `biome-ignore` comments and fix shadowed variables - Delete `biome.json` and `docs/biome.json` - Reformat entire codebase with oxfmt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
359 files changed
+3894
-7208
No files matched your search
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"]
|
||||
}
|
||||
@@ -26,8 +26,9 @@
|
||||
"./webhooks": "./src/webhooks.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
|
||||
@@ -41,19 +41,11 @@ export async function refreshAvailability(titleId: string) {
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.delete(availabilityOffers)
|
||||
.where(
|
||||
and(
|
||||
eq(availabilityOffers.titleId, titleId),
|
||||
eq(availabilityOffers.region, "US"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(availabilityOffers.titleId, titleId), eq(availabilityOffers.region, "US")))
|
||||
.run();
|
||||
|
||||
if (allOfferRows.length > 0) {
|
||||
tx.insert(availabilityOffers)
|
||||
.values(allOfferRows)
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
tx.insert(availabilityOffers).values(allOfferRows).onConflictDoNothing().run();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -62,9 +54,5 @@ export async function refreshAvailability(titleId: string) {
|
||||
}
|
||||
|
||||
export function getAvailability(titleId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
.where(eq(availabilityOffers.titleId, titleId))
|
||||
.all();
|
||||
return db.select().from(availabilityOffers).where(eq(availabilityOffers.titleId, titleId)).all();
|
||||
}
|
||||
+14
-33
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
|
||||
import { renameSync, unlinkSync } from "node:fs";
|
||||
import { mkdir, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { BACKUP_DIR, DATABASE_URL } from "@sofa/config";
|
||||
import { closeDatabase, db } from "@sofa/db/client";
|
||||
import { sql } from "@sofa/db/helpers";
|
||||
@@ -16,10 +17,8 @@ function formatTimestamp(date: Date): string {
|
||||
const log = createLogger("backup");
|
||||
|
||||
const MANUAL_PATTERN = /^sofa-manual-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
|
||||
const SCHEDULED_PATTERN =
|
||||
/^sofa-scheduled-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
|
||||
const PRE_RESTORE_PATTERN =
|
||||
/^pre-restore-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
|
||||
const SCHEDULED_PATTERN = /^sofa-scheduled-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
|
||||
const PRE_RESTORE_PATTERN = /^pre-restore-\d{4}-\d{2}-\d{2}-\d{6}(?:\d{3})?\.db$/;
|
||||
|
||||
export type BackupSource = "manual" | "scheduled" | "pre-restore";
|
||||
type BackupPrefix = "sofa-manual" | "sofa-scheduled" | "pre-restore";
|
||||
@@ -104,10 +103,7 @@ function validateBackupDatabase(filePath: string): void {
|
||||
const integrityRows = testDb.query("PRAGMA integrity_check").all() as {
|
||||
integrity_check: string;
|
||||
}[];
|
||||
if (
|
||||
integrityRows.length === 0 ||
|
||||
integrityRows.some((row) => row.integrity_check !== "ok")
|
||||
) {
|
||||
if (integrityRows.length === 0 || integrityRows.some((row) => row.integrity_check !== "ok")) {
|
||||
throw new Error("Database integrity check failed");
|
||||
}
|
||||
|
||||
@@ -116,15 +112,13 @@ function validateBackupDatabase(filePath: string): void {
|
||||
throw new Error("Database foreign key check failed");
|
||||
}
|
||||
|
||||
const tableRows = testDb
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all() as { name: string }[];
|
||||
const tableRows = testDb.query("SELECT name FROM sqlite_master WHERE type='table'").all() as {
|
||||
name: string;
|
||||
}[];
|
||||
const tableSet = new Set(tableRows.map((row) => row.name));
|
||||
const missing = REQUIRED_TABLES.filter((table) => !tableSet.has(table));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid backup: missing required tables (${missing.join(", ")})`,
|
||||
);
|
||||
throw new Error(`Invalid backup: missing required tables (${missing.join(", ")})`);
|
||||
}
|
||||
} finally {
|
||||
testDb.close();
|
||||
@@ -134,9 +128,7 @@ function validateBackupDatabase(filePath: string): void {
|
||||
/** @internal */
|
||||
export function isValidBackupFilename(filename: string): boolean {
|
||||
const base = path.basename(filename);
|
||||
return (
|
||||
base === filename && !filename.includes("..") && isKnownBackup(filename)
|
||||
);
|
||||
return base === filename && !filename.includes("..") && isKnownBackup(filename);
|
||||
}
|
||||
|
||||
async function createBackupInternal(prefix: BackupPrefix): Promise<BackupInfo> {
|
||||
@@ -160,9 +152,7 @@ async function createBackupInternal(prefix: BackupPrefix): Promise<BackupInfo> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBackup(
|
||||
prefix: BackupPrefix = "sofa-manual",
|
||||
): Promise<BackupInfo> {
|
||||
export async function createBackup(prefix: BackupPrefix = "sofa-manual"): Promise<BackupInfo> {
|
||||
return withBackupLock(async () => createBackupInternal(prefix));
|
||||
}
|
||||
|
||||
@@ -182,9 +172,7 @@ export async function listBackups(): Promise<BackupInfo[]> {
|
||||
});
|
||||
}
|
||||
|
||||
return results.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
async function deleteBackupInternal(filename: string): Promise<void> {
|
||||
@@ -218,9 +206,7 @@ export async function readBackupFile(filename: string): Promise<Buffer | null> {
|
||||
return Buffer.from(await Bun.file(filePath).arrayBuffer());
|
||||
}
|
||||
|
||||
export async function restoreFromBackup(
|
||||
source: Buffer | string,
|
||||
): Promise<void> {
|
||||
export async function restoreFromBackup(source: Buffer | string): Promise<void> {
|
||||
await withBackupLock(async () => {
|
||||
await ensureBackupDir();
|
||||
|
||||
@@ -228,10 +214,7 @@ export async function restoreFromBackup(
|
||||
await mkdir(dbDir, { recursive: true });
|
||||
|
||||
const timestamp = formatTimestamp(new Date());
|
||||
const tempPath = path.join(
|
||||
dbDir,
|
||||
`.restore-temp-${timestamp}-${crypto.randomUUID()}.db`,
|
||||
);
|
||||
const tempPath = path.join(dbDir, `.restore-temp-${timestamp}-${crypto.randomUUID()}.db`);
|
||||
|
||||
try {
|
||||
if (typeof source === "string") {
|
||||
@@ -263,9 +246,7 @@ export async function restoreFromBackup(
|
||||
|
||||
export async function pruneBackups(maxKeep: number): Promise<void> {
|
||||
await withBackupLock(async () => {
|
||||
const backups = (await listBackups()).filter((b) =>
|
||||
SCHEDULED_PATTERN.test(b.filename),
|
||||
);
|
||||
const backups = (await listBackups()).filter((b) => SCHEDULED_PATTERN.test(b.filename));
|
||||
|
||||
if (maxKeep === 0 || backups.length <= maxKeep) return;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readdir, stat, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { CACHE_DIR } from "@sofa/config";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { inArray, isNull, notInArray } from "@sofa/db/helpers";
|
||||
@@ -40,9 +41,7 @@ export function purgeMetadataCache(): {
|
||||
.map((r) => r.titleId),
|
||||
);
|
||||
|
||||
const toDelete = shellTitles
|
||||
.map((t) => t.id)
|
||||
.filter((id) => !libraryTitleIds.has(id));
|
||||
const toDelete = shellTitles.map((t) => t.id).filter((id) => !libraryTitleIds.has(id));
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
log.info("All un-enriched titles are in user libraries, skipping");
|
||||
@@ -72,10 +71,7 @@ function purgeOrphanedPersons(): number {
|
||||
.select({ id: persons.id })
|
||||
.from(persons)
|
||||
.where(
|
||||
notInArray(
|
||||
persons.id,
|
||||
db.selectDistinct({ personId: titleCast.personId }).from(titleCast),
|
||||
),
|
||||
notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)),
|
||||
)
|
||||
.all();
|
||||
|
||||
@@ -93,13 +89,7 @@ function purgeOrphanedPersons(): number {
|
||||
}
|
||||
|
||||
/** Image cache subdirectories to scan */
|
||||
const IMAGE_CATEGORIES = [
|
||||
"posters",
|
||||
"backdrops",
|
||||
"stills",
|
||||
"logos",
|
||||
"profiles",
|
||||
] as const;
|
||||
const IMAGE_CATEGORIES = ["posters", "backdrops", "stills", "logos", "profiles"] as const;
|
||||
|
||||
/**
|
||||
* Delete all cached images from disk.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Vibrant } from "node-vibrant/node";
|
||||
|
||||
import { db } from "@sofa/db/client";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { Vibrant } from "node-vibrant/node";
|
||||
|
||||
import { loadImageBuffer } from "./image-cache";
|
||||
|
||||
const log = createLogger("colors");
|
||||
@@ -22,17 +24,12 @@ export async function extractAndStoreColors(
|
||||
sourceBuffer?: Buffer | null,
|
||||
): Promise<ColorPalette | null> {
|
||||
if (!posterPath) {
|
||||
db.update(titles)
|
||||
.set({ colorPalette: null })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
db.update(titles).set({ colorPalette: null }).where(eq(titles.id, titleId)).run();
|
||||
return null;
|
||||
}
|
||||
|
||||
const source =
|
||||
sourceBuffer === undefined
|
||||
? await loadImageBuffer(posterPath, "posters")
|
||||
: sourceBuffer;
|
||||
sourceBuffer === undefined ? await loadImageBuffer(posterPath, "posters") : sourceBuffer;
|
||||
if (!source) return null;
|
||||
|
||||
try {
|
||||
@@ -52,9 +49,7 @@ export async function extractAndStoreColors(
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
|
||||
log.debug(
|
||||
`Extracted colors for title ${titleId}: colors=${JSON.stringify(colors)}`,
|
||||
);
|
||||
log.debug(`Extracted colors for title ${titleId}: colors=${JSON.stringify(colors)}`);
|
||||
return colors;
|
||||
} catch (err) {
|
||||
log.error(`Failed to extract colors for title ${titleId}:`, err);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { persons, titleCast, titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { getMovieCredits, getTvAggregateCredits } from "@sofa/tmdb/client";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache";
|
||||
import { generatePersonThumbHash } from "./thumbhash";
|
||||
|
||||
@@ -69,9 +70,7 @@ function batchUpsertPersons(people: PersonData[]): Map<number, string> {
|
||||
name: p.name,
|
||||
profilePath: p.profilePath,
|
||||
popularity: nextPopularity,
|
||||
profileThumbHash: pathChanged
|
||||
? null
|
||||
: existingPerson.profileThumbHash,
|
||||
profileThumbHash: pathChanged ? null : existingPerson.profileThumbHash,
|
||||
})
|
||||
.where(eq(persons.id, existingPerson.id))
|
||||
.run();
|
||||
@@ -94,9 +93,7 @@ function batchUpsertPersons(people: PersonData[]): Map<number, string> {
|
||||
});
|
||||
|
||||
// One fallback query for any concurrent inserts that conflicted
|
||||
const stillMissing = uniquePeople
|
||||
.filter((p) => !idMap.has(p.tmdbId))
|
||||
.map((p) => p.tmdbId);
|
||||
const stillMissing = uniquePeople.filter((p) => !idMap.has(p.tmdbId)).map((p) => p.tmdbId);
|
||||
if (stillMissing.length > 0) {
|
||||
const fallbacks = db
|
||||
.select({ id: persons.id, tmdbId: persons.tmdbId })
|
||||
|
||||
@@ -48,12 +48,7 @@ export function getWatchCount(
|
||||
const [row] = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(watchTable)
|
||||
.where(
|
||||
and(
|
||||
eq(watchTable.userId, userId),
|
||||
sql`${watchTable.watchedAt} >= ${timestamp}`,
|
||||
),
|
||||
)
|
||||
.where(and(eq(watchTable.userId, userId), sql`${watchTable.watchedAt} >= ${timestamp}`))
|
||||
.all();
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
@@ -121,22 +116,14 @@ export function getWatchHistory(
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
bucket:
|
||||
sql<string>`strftime(${fmt}, ${watchTable.watchedAt}, 'unixepoch', 'localtime')`.as(
|
||||
"bucket",
|
||||
),
|
||||
bucket: sql<string>`strftime(${fmt}, ${watchTable.watchedAt}, 'unixepoch', 'localtime')`.as(
|
||||
"bucket",
|
||||
),
|
||||
count: sql<number>`count(*)`.as("cnt"),
|
||||
})
|
||||
.from(watchTable)
|
||||
.where(
|
||||
and(
|
||||
eq(watchTable.userId, userId),
|
||||
sql`${watchTable.watchedAt} >= ${startTs}`,
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
sql`strftime(${fmt}, ${watchTable.watchedAt}, 'unixepoch', 'localtime')`,
|
||||
)
|
||||
.where(and(eq(watchTable.userId, userId), sql`${watchTable.watchedAt} >= ${startTs}`))
|
||||
.groupBy(sql`strftime(${fmt}, ${watchTable.watchedAt}, 'unixepoch', 'localtime')`)
|
||||
.all();
|
||||
|
||||
const countMap = new Map(rows.map((r) => [r.bucket, r.count]));
|
||||
@@ -194,9 +181,7 @@ export interface ContinueWatchingItem {
|
||||
watchedEpisodes: number;
|
||||
}
|
||||
|
||||
export function getContinueWatchingFeed(
|
||||
userId: string,
|
||||
): ContinueWatchingItem[] {
|
||||
export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[] {
|
||||
// Get in-progress TV shows
|
||||
const inProgress = db
|
||||
.select({
|
||||
@@ -204,12 +189,7 @@ export function getContinueWatchingFeed(
|
||||
updatedAt: userTitleStatus.updatedAt,
|
||||
})
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.status, "in_progress"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.status, "in_progress")))
|
||||
.all();
|
||||
|
||||
if (inProgress.length === 0) return [];
|
||||
@@ -359,8 +339,7 @@ export function getContinueWatchingFeed(
|
||||
return items;
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: days reserved for future date filtering
|
||||
export function getNewAvailableFeed(userId: string, days = 14) {
|
||||
export function getNewAvailableFeed(userId: string, _days = 14) {
|
||||
// Get titles the user has in any status that have availability offers
|
||||
// and recent release/air dates
|
||||
const results = db
|
||||
@@ -380,10 +359,7 @@ export function getNewAvailableFeed(userId: string, days = 14) {
|
||||
.from(titles)
|
||||
.innerJoin(
|
||||
userTitleStatus,
|
||||
and(
|
||||
eq(userTitleStatus.titleId, titles.id),
|
||||
eq(userTitleStatus.userId, userId),
|
||||
),
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.where(
|
||||
sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`,
|
||||
@@ -447,21 +423,14 @@ export function getRecommendationsFeed(userId: string) {
|
||||
const userCompletedOrRated = db
|
||||
.select({ titleId: userTitleStatus.titleId })
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.status, "completed"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.status, "completed")))
|
||||
.all()
|
||||
.map((r) => r.titleId);
|
||||
|
||||
const ratedIds = db
|
||||
.select({ titleId: userRatings.titleId })
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`))
|
||||
.all()
|
||||
.map((r) => r.titleId);
|
||||
|
||||
@@ -504,19 +473,13 @@ export function getRecommendationsFeed(userId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...recs.values()]
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 20);
|
||||
const sorted = [...recs.values()].sort((a, b) => b.score - a.score).slice(0, 20);
|
||||
|
||||
if (sorted.length === 0) return [];
|
||||
|
||||
// Batch fetch all recommended titles (1 query)
|
||||
const recTitleIds = sorted.map((r) => r.titleId);
|
||||
const recTitles = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(inArray(titles.id, recTitleIds))
|
||||
.all();
|
||||
const recTitles = db.select().from(titles).where(inArray(titles.id, recTitleIds)).all();
|
||||
const recTitleMap = new Map(recTitles.map((t) => [t.id, t]));
|
||||
|
||||
return sorted.map((r) => recTitleMap.get(r.titleId)).filter(Boolean);
|
||||
@@ -544,8 +507,7 @@ export function getRecommendationsForTitle(titleId: string) {
|
||||
tmdb_similar: 1,
|
||||
} as const;
|
||||
const orderedRecs = [...recs].sort(
|
||||
(a, b) =>
|
||||
a.rank - b.rank || sourcePriority[a.source] - sourcePriority[b.source],
|
||||
(a, b) => a.rank - b.rank || sourcePriority[a.source] - sourcePriority[b.source],
|
||||
);
|
||||
|
||||
const seenRecommendedTitleIds = new Set<string>();
|
||||
@@ -559,11 +521,7 @@ export function getRecommendationsForTitle(titleId: string) {
|
||||
|
||||
// Batch fetch all recommended titles (1 query)
|
||||
const recTitleIds = uniqueRecs.map((r) => r.recommendedTitleId);
|
||||
const recTitles = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(inArray(titles.id, recTitleIds))
|
||||
.all();
|
||||
const recTitles = db.select().from(titles).where(inArray(titles.id, recTitleIds)).all();
|
||||
const recTitleMap = new Map(recTitles.map((t) => [t.id, t]));
|
||||
|
||||
return uniqueRecs
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { CACHE_DIR, TMDB_IMAGE_BASE_URL } from "@sofa/config";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { eq, inArray } from "@sofa/db/helpers";
|
||||
import {
|
||||
availabilityOffers,
|
||||
episodes,
|
||||
persons,
|
||||
seasons,
|
||||
titleCast,
|
||||
titles,
|
||||
} from "@sofa/db/schema";
|
||||
import { availabilityOffers, episodes, persons, seasons, titleCast, titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import {
|
||||
IMAGE_CATEGORY_SIZES,
|
||||
type ImageCategory,
|
||||
tmdbCdnImageUrl,
|
||||
} from "@sofa/tmdb/image";
|
||||
import { IMAGE_CATEGORY_SIZES, type ImageCategory, tmdbCdnImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
const log = createLogger("image-cache");
|
||||
|
||||
@@ -32,17 +22,11 @@ export async function ensureImageDirs() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function isImageCached(
|
||||
category: ImageCategory,
|
||||
filename: string,
|
||||
): Promise<boolean> {
|
||||
export async function isImageCached(category: ImageCategory, filename: string): Promise<boolean> {
|
||||
return Bun.file(getLocalImagePath(category, filename)).exists();
|
||||
}
|
||||
|
||||
export function getLocalImagePath(
|
||||
category: ImageCategory,
|
||||
filename: string,
|
||||
): string {
|
||||
export function getLocalImagePath(category: ImageCategory, filename: string): string {
|
||||
return path.join(CACHE_DIR, category, path.basename(filename));
|
||||
}
|
||||
|
||||
@@ -60,8 +44,7 @@ async function fetchRemoteImage(
|
||||
tmdbPath: string,
|
||||
category: ImageCategory,
|
||||
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||
const url =
|
||||
tmdbCdnImageUrl(tmdbPath, category) ?? `${TMDB_IMAGE_BASE_URL}${tmdbPath}`;
|
||||
const url = tmdbCdnImageUrl(tmdbPath, category) ?? `${TMDB_IMAGE_BASE_URL}${tmdbPath}`;
|
||||
|
||||
const res = await globalThis.fetch(url);
|
||||
if (!res.ok) {
|
||||
@@ -109,11 +92,7 @@ export async function fetchAndMaybeCache(
|
||||
log.debug(`Cache hit: ${category}/${filename}`);
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const contentType =
|
||||
ext === ".png"
|
||||
? "image/png"
|
||||
: ext === ".webp"
|
||||
? "image/webp"
|
||||
: "image/jpeg";
|
||||
ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg";
|
||||
return { buffer: cached, contentType };
|
||||
}
|
||||
|
||||
@@ -155,20 +134,13 @@ export async function cacheImagesForTitle(titleId: string) {
|
||||
|
||||
// Collect all candidate images, then check cache in parallel
|
||||
const candidates: { imgPath: string; category: ImageCategory }[] = [];
|
||||
if (title.posterPath)
|
||||
candidates.push({ imgPath: title.posterPath, category: "posters" });
|
||||
if (title.backdropPath)
|
||||
candidates.push({ imgPath: title.backdropPath, category: "backdrops" });
|
||||
if (title.posterPath) candidates.push({ imgPath: title.posterPath, category: "posters" });
|
||||
if (title.backdropPath) candidates.push({ imgPath: title.backdropPath, category: "backdrops" });
|
||||
|
||||
if (title.type === "tv") {
|
||||
const allSeasons = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
const allSeasons = db.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
|
||||
for (const s of allSeasons) {
|
||||
if (s.posterPath)
|
||||
candidates.push({ imgPath: s.posterPath, category: "posters" });
|
||||
if (s.posterPath) candidates.push({ imgPath: s.posterPath, category: "posters" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,20 +163,12 @@ export async function cacheImagesForTitle(titleId: string) {
|
||||
}
|
||||
|
||||
export async function cacheEpisodeStills(titleId: string) {
|
||||
const allSeasons = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
const allSeasons = db.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
|
||||
|
||||
const seasonIds = allSeasons.map((s) => s.id);
|
||||
if (seasonIds.length === 0) return;
|
||||
|
||||
const allEps = db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(inArray(episodes.seasonId, seasonIds))
|
||||
.all();
|
||||
const allEps = db.select().from(episodes).where(inArray(episodes.seasonId, seasonIds)).all();
|
||||
|
||||
const epsWithStills = allEps.filter(
|
||||
(ep): ep is typeof ep & { stillPath: string } => ep.stillPath != null,
|
||||
@@ -264,8 +228,7 @@ export async function cacheProfilePhotos(titleId: string) {
|
||||
for (const row of castRows) {
|
||||
if (row.profilePath) {
|
||||
const basename = path.basename(row.profilePath);
|
||||
if (!uniqueProfiles.has(basename))
|
||||
uniqueProfiles.set(basename, row.profilePath);
|
||||
if (!uniqueProfiles.has(basename)) uniqueProfiles.set(basename, row.profilePath);
|
||||
}
|
||||
}
|
||||
const checks = await Promise.all(
|
||||
|
||||
@@ -18,8 +18,4 @@ export {
|
||||
processImportJob,
|
||||
readImportJob,
|
||||
} from "./processor";
|
||||
export {
|
||||
type ExternalIds,
|
||||
resolveMovieTmdbId,
|
||||
resolveShowTmdbId,
|
||||
} from "./resolve";
|
||||
export { type ExternalIds, resolveMovieTmdbId, resolveShowTmdbId } from "./resolve";
|
||||
@@ -254,9 +254,7 @@ export function parseTraktPayload(data: {
|
||||
if (!entry?.title || item.rating == null) continue;
|
||||
const converted = convertRating10to5(item.rating);
|
||||
if (converted == null) {
|
||||
warnings.push(
|
||||
`Skipped invalid Trakt rating ${item.rating} for "${entry.title}"`,
|
||||
);
|
||||
warnings.push(`Skipped invalid Trakt rating ${item.rating} for "${entry.title}"`);
|
||||
continue;
|
||||
}
|
||||
ratings.push({
|
||||
@@ -345,11 +343,7 @@ export function parseSimklPayload(data: {
|
||||
year: item.year,
|
||||
type: "movie",
|
||||
});
|
||||
} else if (
|
||||
item.status === "completed" ||
|
||||
item.status === "watching" ||
|
||||
item.last_watched_at
|
||||
) {
|
||||
} else if (item.status === "completed" || item.status === "watching" || item.last_watched_at) {
|
||||
movies.push({
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
@@ -422,10 +416,7 @@ export function parseSimklPayload(data: {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!item.seasons?.length &&
|
||||
(item.status === "completed" || item.status === "watching")
|
||||
) {
|
||||
if (!item.seasons?.length && (item.status === "completed" || item.status === "watching")) {
|
||||
warnings.push(
|
||||
`"${item.title}" is marked ${item.status} but has no episode data — episode watches were not imported.`,
|
||||
);
|
||||
@@ -475,15 +466,9 @@ const LETTERBOXD_EXPECTED_FILES = [
|
||||
"ratings.csv",
|
||||
] as const;
|
||||
|
||||
const LETTERBOXD_IGNORED_FILES = new Set([
|
||||
"reviews.csv",
|
||||
"profile.csv",
|
||||
"comments.csv",
|
||||
]);
|
||||
const LETTERBOXD_IGNORED_FILES = new Set(["reviews.csv", "profile.csv", "comments.csv"]);
|
||||
|
||||
export async function parseLetterboxdExport(
|
||||
zipFile: Blob,
|
||||
): Promise<ParseResult> {
|
||||
export async function parseLetterboxdExport(zipFile: Blob): Promise<ParseResult> {
|
||||
const warnings: string[] = [];
|
||||
const movies: ImportMovie[] = [];
|
||||
const watchlist: ImportWatchlistItem[] = [];
|
||||
@@ -495,9 +480,7 @@ export async function parseLetterboxdExport(
|
||||
const buffer = Buffer.from(await zipFile.arrayBuffer());
|
||||
zip = new AdmZip(buffer);
|
||||
} catch {
|
||||
warnings.push(
|
||||
"Failed to read ZIP file. Ensure it is a valid Letterboxd export.",
|
||||
);
|
||||
warnings.push("Failed to read ZIP file. Ensure it is a valid Letterboxd export.");
|
||||
return {
|
||||
data: {
|
||||
source: "letterboxd",
|
||||
@@ -519,11 +502,7 @@ export async function parseLetterboxdExport(
|
||||
// Letterboxd exports may nest files in a subdirectory — use the basename
|
||||
const name = entry.entryName.split("/").pop() ?? entry.entryName;
|
||||
allFilenames.push(name);
|
||||
if (
|
||||
LETTERBOXD_EXPECTED_FILES.includes(
|
||||
name as (typeof LETTERBOXD_EXPECTED_FILES)[number],
|
||||
)
|
||||
) {
|
||||
if (LETTERBOXD_EXPECTED_FILES.includes(name as (typeof LETTERBOXD_EXPECTED_FILES)[number])) {
|
||||
entryMap.set(name, entry.getData().toString("utf-8"));
|
||||
}
|
||||
}
|
||||
@@ -539,11 +518,7 @@ export async function parseLetterboxdExport(
|
||||
|
||||
// Log ignored files
|
||||
for (const name of allFilenames) {
|
||||
if (
|
||||
LETTERBOXD_EXPECTED_FILES.includes(
|
||||
name as (typeof LETTERBOXD_EXPECTED_FILES)[number],
|
||||
)
|
||||
) {
|
||||
if (LETTERBOXD_EXPECTED_FILES.includes(name as (typeof LETTERBOXD_EXPECTED_FILES)[number])) {
|
||||
continue;
|
||||
}
|
||||
if (!LETTERBOXD_IGNORED_FILES.has(name)) {
|
||||
@@ -625,9 +600,7 @@ export async function parseLetterboxdExport(
|
||||
|
||||
const converted = convertLetterboxdRating(rawRating);
|
||||
if (converted == null) {
|
||||
warnings.push(
|
||||
`Skipped invalid Letterboxd rating ${rawRating} for "${title}"`,
|
||||
);
|
||||
warnings.push(`Skipped invalid Letterboxd rating ${rawRating} for "${title}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,19 +11,11 @@ import {
|
||||
userTitleStatus,
|
||||
} from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { getOrFetchTitleByTmdbId } from "../metadata";
|
||||
import {
|
||||
logEpisodeWatch,
|
||||
logMovieWatch,
|
||||
rateTitleStars,
|
||||
setTitleStatus,
|
||||
} from "../tracking";
|
||||
import type {
|
||||
ImportEpisode,
|
||||
ImportMovie,
|
||||
ImportRating,
|
||||
ImportWatchlistItem,
|
||||
} from "./parsers";
|
||||
import { logEpisodeWatch, logMovieWatch, rateTitleStars, setTitleStatus } from "../tracking";
|
||||
|
||||
import type { ImportEpisode, ImportMovie, ImportRating, ImportWatchlistItem } from "./parsers";
|
||||
import { resolveMovieTmdbId, resolveShowTmdbId } from "./resolve";
|
||||
|
||||
const log = createLogger("imports");
|
||||
@@ -60,12 +52,7 @@ function hasExistingMovieWatch(userId: string, titleId: string): boolean {
|
||||
const existing = db
|
||||
.select({ id: userMovieWatches.id })
|
||||
.from(userMovieWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userMovieWatches.userId, userId),
|
||||
eq(userMovieWatches.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userMovieWatches.userId, userId), eq(userMovieWatches.titleId, titleId)))
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
@@ -74,12 +61,7 @@ function hasExistingEpisodeWatch(userId: string, episodeId: string): boolean {
|
||||
const existing = db
|
||||
.select({ id: userEpisodeWatches.id })
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
eq(userEpisodeWatches.episodeId, episodeId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.episodeId, episodeId)))
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
@@ -88,12 +70,7 @@ function hasExistingWatchlistStatus(userId: string, titleId: string): boolean {
|
||||
const existing = db
|
||||
.select({ status: userTitleStatus.status })
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
@@ -102,9 +79,7 @@ function hasExistingRating(userId: string, titleId: string): boolean {
|
||||
const existing = db
|
||||
.select({ ratingStars: userRatings.ratingStars })
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
@@ -192,38 +167,24 @@ async function processEpisode(
|
||||
const season = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(
|
||||
and(
|
||||
eq(seasons.titleId, title.id),
|
||||
eq(seasons.seasonNumber, ep.seasonNumber),
|
||||
),
|
||||
)
|
||||
.where(and(eq(seasons.titleId, title.id), eq(seasons.seasonNumber, ep.seasonNumber)))
|
||||
.get();
|
||||
|
||||
if (!season) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Season ${ep.seasonNumber} not found for "${title.title}"`,
|
||||
);
|
||||
result.errors.push(`Season ${ep.seasonNumber} not found for "${title.title}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const episode = db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(
|
||||
and(
|
||||
eq(episodes.seasonId, season.id),
|
||||
eq(episodes.episodeNumber, ep.episodeNumber),
|
||||
),
|
||||
)
|
||||
.where(and(eq(episodes.seasonId, season.id), eq(episodes.episodeNumber, ep.episodeNumber)))
|
||||
.get();
|
||||
|
||||
if (!episode) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`S${ep.seasonNumber}E${ep.episodeNumber} not found for "${title.title}"`,
|
||||
);
|
||||
result.errors.push(`S${ep.seasonNumber}E${ep.episodeNumber} not found for "${title.title}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -247,8 +208,7 @@ async function processWatchlistItem(
|
||||
result: ImportResult,
|
||||
cache?: Map<string, number | null>,
|
||||
): Promise<void> {
|
||||
const resolveFn =
|
||||
item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
|
||||
const resolveFn = item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
|
||||
const tmdbId = await resolveFn(
|
||||
{
|
||||
tmdbId: item.tmdbId,
|
||||
@@ -271,9 +231,7 @@ async function processWatchlistItem(
|
||||
const title = await getOrFetchTitleByTmdbId(tmdbId, item.type);
|
||||
if (!title) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`,
|
||||
);
|
||||
result.errors.push(`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,8 +250,7 @@ async function processRating(
|
||||
result: ImportResult,
|
||||
cache?: Map<string, number | null>,
|
||||
): Promise<void> {
|
||||
const resolveFn =
|
||||
item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
|
||||
const resolveFn = item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
|
||||
const tmdbId = await resolveFn(
|
||||
{
|
||||
tmdbId: item.tmdbId,
|
||||
@@ -316,9 +273,7 @@ async function processRating(
|
||||
const title = await getOrFetchTitleByTmdbId(tmdbId, item.type);
|
||||
if (!title) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`,
|
||||
);
|
||||
result.errors.push(`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -390,11 +345,7 @@ export function readImportJob(jobId: string, userId?: string): ImportJob {
|
||||
// ─── Job Processor ───────────────────────────────────────────────────
|
||||
|
||||
export async function processImportJob(jobId: string): Promise<void> {
|
||||
const row = db
|
||||
.select()
|
||||
.from(importJobs)
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.get();
|
||||
const row = db.select().from(importJobs).where(eq(importJobs.id, jobId)).get();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Import job ${jobId} not found`);
|
||||
@@ -408,9 +359,7 @@ export async function processImportJob(jobId: string): Promise<void> {
|
||||
}
|
||||
const parsed = NormalizedImportSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Import job ${jobId} has malformed payload: ${parsed.error.message}`,
|
||||
);
|
||||
throw new Error(`Import job ${jobId} has malformed payload: ${parsed.error.message}`);
|
||||
}
|
||||
const data = parsed.data;
|
||||
const result: ImportResult = {
|
||||
@@ -466,9 +415,7 @@ export async function processImportJob(jobId: string): Promise<void> {
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.run();
|
||||
|
||||
log.info(
|
||||
`Starting ${data.source} import job ${jobId} for user ${row.userId}: ${total} items`,
|
||||
);
|
||||
log.info(`Starting ${data.source} import job ${jobId} for user ${row.userId}: ${total} items`);
|
||||
|
||||
// Shared resolution cache for the entire import job
|
||||
const resolveCache = new Map<string, number | null>();
|
||||
@@ -501,20 +448,10 @@ export async function processImportJob(jobId: string): Promise<void> {
|
||||
try {
|
||||
switch (item.type) {
|
||||
case "movie":
|
||||
await processMovie(
|
||||
row.userId,
|
||||
data.movies[item.index],
|
||||
result,
|
||||
resolveCache,
|
||||
);
|
||||
await processMovie(row.userId, data.movies[item.index], result, resolveCache);
|
||||
break;
|
||||
case "episode":
|
||||
await processEpisode(
|
||||
row.userId,
|
||||
data.episodes[item.index],
|
||||
result,
|
||||
resolveCache,
|
||||
);
|
||||
await processEpisode(row.userId, data.episodes[item.index], result, resolveCache);
|
||||
break;
|
||||
case "watchlist":
|
||||
await processWatchlistItem(
|
||||
@@ -525,26 +462,16 @@ export async function processImportJob(jobId: string): Promise<void> {
|
||||
);
|
||||
break;
|
||||
case "rating":
|
||||
await processRating(
|
||||
row.userId,
|
||||
data.ratings[item.index],
|
||||
result,
|
||||
resolveCache,
|
||||
);
|
||||
await processRating(row.userId, data.ratings[item.index], result, resolveCache);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Unexpected error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
result.errors.push(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// Update DB progress periodically
|
||||
if (
|
||||
i % progressInterval === progressInterval - 1 ||
|
||||
i === items.length - 1
|
||||
) {
|
||||
if (i % progressInterval === progressInterval - 1 || i === items.length - 1) {
|
||||
const currentItem =
|
||||
item.type === "movie"
|
||||
? data.movies[item.index]
|
||||
@@ -594,9 +521,7 @@ export async function processImportJob(jobId: string): Promise<void> {
|
||||
);
|
||||
} catch (err) {
|
||||
// Fatal error
|
||||
result.errors.push(
|
||||
`Fatal: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
result.errors.push(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
|
||||
db.update(importJobs)
|
||||
.set({
|
||||
status: "error",
|
||||
|
||||
@@ -91,9 +91,7 @@ export async function resolveMovieTmdbId(
|
||||
const results = searchResult.results ?? [];
|
||||
if (ids.year) {
|
||||
const match = results.find((r) => {
|
||||
const releaseYear = r.release_date
|
||||
? new Date(r.release_date).getFullYear()
|
||||
: null;
|
||||
const releaseYear = r.release_date ? new Date(r.release_date).getFullYear() : null;
|
||||
return releaseYear === ids.year;
|
||||
});
|
||||
if (match) {
|
||||
@@ -164,9 +162,7 @@ export async function resolveShowTmdbId(
|
||||
const results = searchResult.results ?? [];
|
||||
if (ids.year) {
|
||||
const match = results.find((r) => {
|
||||
const airYear = r.first_air_date
|
||||
? new Date(r.first_air_date).getFullYear()
|
||||
: null;
|
||||
const airYear = r.first_air_date ? new Date(r.first_air_date).getFullYear() : null;
|
||||
return airYear === ids.year;
|
||||
});
|
||||
if (match) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@sofa/db/client";
|
||||
import { and, eq, inArray } from "@sofa/db/helpers";
|
||||
import { integrations, titles, userTitleStatus } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { getTvExternalIds } from "@sofa/tmdb/client";
|
||||
import { z } from "zod";
|
||||
|
||||
const log = createLogger("lists");
|
||||
|
||||
@@ -38,9 +39,7 @@ type Status = z.infer<typeof statusSchema>;
|
||||
/** Parse a comma-separated status query param into validated statuses. */
|
||||
export function parseStatusParam(param: string | null): Status[] {
|
||||
if (!param) return ["watchlist"];
|
||||
const parsed = param
|
||||
.split(",")
|
||||
.filter((s) => statusSchema.safeParse(s).success) as Status[];
|
||||
const parsed = param.split(",").filter((s) => statusSchema.safeParse(s).success) as Status[];
|
||||
return parsed.length > 0 ? parsed : ["watchlist"];
|
||||
}
|
||||
|
||||
|
||||
+43
-159
@@ -17,12 +17,7 @@ import {
|
||||
titles,
|
||||
} from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import type {
|
||||
TmdbGenre,
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
TmdbVideo,
|
||||
} from "@sofa/tmdb/client";
|
||||
import type { TmdbGenre, TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
|
||||
import {
|
||||
getMovieDetails,
|
||||
getRecommendations,
|
||||
@@ -32,6 +27,7 @@ import {
|
||||
getVideos,
|
||||
} from "@sofa/tmdb/client";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { refreshAvailability } from "./availability";
|
||||
import { extractAndStoreColors, parseColorPalette } from "./colors";
|
||||
import { getCastForTitle, refreshCredits } from "./credits";
|
||||
@@ -59,11 +55,7 @@ function upsertTitle(values: typeof titles.$inferInsert, tmdbId: number) {
|
||||
try {
|
||||
return db.insert(titles).values(values).returning().get();
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
"code" in err &&
|
||||
err.code === "SQLITE_CONSTRAINT_UNIQUE"
|
||||
) {
|
||||
if (err instanceof Error && "code" in err && err.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||
log.debug(`TMDB ${tmdbId} was inserted concurrently, returning existing`);
|
||||
return db.select().from(titles).where(eq(titles.tmdbId, tmdbId)).get();
|
||||
}
|
||||
@@ -72,9 +64,7 @@ function upsertTitle(values: typeof titles.$inferInsert, tmdbId: number) {
|
||||
}
|
||||
|
||||
function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
|
||||
const validGenres = tmdbGenres.filter(
|
||||
(g): g is TmdbGenre & { name: string } => !!g.name,
|
||||
);
|
||||
const validGenres = tmdbGenres.filter((g): g is TmdbGenre & { name: string } => !!g.name);
|
||||
if (validGenres.length === 0) return;
|
||||
db.transaction((tx) => {
|
||||
for (const g of validGenres) {
|
||||
@@ -85,10 +75,7 @@ function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
|
||||
}
|
||||
tx.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
|
||||
for (const g of validGenres) {
|
||||
tx.insert(titleGenres)
|
||||
.values({ titleId, genreId: g.id })
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
tx.insert(titleGenres).values({ titleId, genreId: g.id }).onConflictDoNothing().run();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -96,12 +83,7 @@ function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
|
||||
export function updateTitleWithArtInvalidation(
|
||||
title: Pick<
|
||||
typeof titles.$inferSelect,
|
||||
| "id"
|
||||
| "posterPath"
|
||||
| "backdropPath"
|
||||
| "posterThumbHash"
|
||||
| "backdropThumbHash"
|
||||
| "colorPalette"
|
||||
"id" | "posterPath" | "backdropPath" | "posterThumbHash" | "backdropThumbHash" | "colorPalette"
|
||||
>,
|
||||
values: Partial<typeof titles.$inferInsert>,
|
||||
) {
|
||||
@@ -135,9 +117,7 @@ export function updateTitleWithArtInvalidation(
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function extractMovieContentRating(
|
||||
movie: TmdbMovieDetails,
|
||||
): string | null {
|
||||
export function extractMovieContentRating(movie: TmdbMovieDetails): string | null {
|
||||
const us = movie.release_dates?.results?.find((r) => r.iso_3166_1 === "US");
|
||||
if (!us) return null;
|
||||
for (const rd of us.release_dates ?? []) {
|
||||
@@ -159,21 +139,15 @@ function fireAndForgetEnrichment(
|
||||
backdropPath: string | null | undefined,
|
||||
type: "movie" | "tv",
|
||||
) {
|
||||
refreshAvailability(titleId).catch((err) =>
|
||||
log.debug("Availability enrichment failed:", err),
|
||||
);
|
||||
refreshAvailability(titleId).catch((err) => log.debug("Availability enrichment failed:", err));
|
||||
refreshRecommendations(titleId).catch((err) =>
|
||||
log.debug("Recommendations enrichment failed:", err),
|
||||
);
|
||||
syncTitleArt(titleId, posterPath, backdropPath, type).catch((err) =>
|
||||
log.debug("Cache/thumbhash failed:", err),
|
||||
);
|
||||
refreshCredits(titleId).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
refreshTrailer(titleId).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
);
|
||||
refreshCredits(titleId).catch((err) => log.debug("Credits enrichment failed:", err));
|
||||
refreshTrailer(titleId).catch((err) => log.debug("Trailer enrichment failed:", err));
|
||||
}
|
||||
|
||||
type ImportResult = ReturnType<typeof _getOrFetchTitleByTmdbId>;
|
||||
@@ -181,16 +155,11 @@ type ImportResult = ReturnType<typeof _getOrFetchTitleByTmdbId>;
|
||||
/** In-flight import promises keyed by `${tmdbId}-${type}` — coalesces concurrent calls */
|
||||
const inflightImports = new Map<string, ImportResult>();
|
||||
|
||||
export function getOrFetchTitleByTmdbId(
|
||||
tmdbId: number,
|
||||
type: "movie" | "tv",
|
||||
): ImportResult {
|
||||
export function getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv"): ImportResult {
|
||||
const key = `${tmdbId}-${type}`;
|
||||
const inflight = inflightImports.get(key);
|
||||
if (inflight) {
|
||||
log.debug(
|
||||
`Import already in-flight for ${type} TMDB ${tmdbId}, coalescing`,
|
||||
);
|
||||
log.debug(`Import already in-flight for ${type} TMDB ${tmdbId}, coalescing`);
|
||||
return inflight;
|
||||
}
|
||||
|
||||
@@ -232,12 +201,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
lastFetchedAt: new Date(),
|
||||
});
|
||||
upsertGenres(existing.id, movie.genres ?? []);
|
||||
fireAndForgetEnrichment(
|
||||
existing.id,
|
||||
movie.poster_path,
|
||||
movie.backdrop_path,
|
||||
"movie",
|
||||
);
|
||||
fireAndForgetEnrichment(existing.id, movie.poster_path, movie.backdrop_path, "movie");
|
||||
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
|
||||
}
|
||||
|
||||
@@ -256,12 +220,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
});
|
||||
upsertGenres(existing.id, show.genres ?? []);
|
||||
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
||||
fireAndForgetEnrichment(
|
||||
existing.id,
|
||||
show.poster_path,
|
||||
show.backdrop_path,
|
||||
"tv",
|
||||
);
|
||||
fireAndForgetEnrichment(existing.id, show.poster_path, show.backdrop_path, "tv");
|
||||
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
|
||||
}
|
||||
|
||||
@@ -277,12 +236,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
const show = await getTvDetails(tmdbId);
|
||||
upsertGenres(existing.id, show.genres ?? []);
|
||||
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
||||
fireAndForgetEnrichment(
|
||||
existing.id,
|
||||
show.poster_path,
|
||||
show.backdrop_path,
|
||||
"tv",
|
||||
);
|
||||
fireAndForgetEnrichment(existing.id, show.poster_path, show.backdrop_path, "tv");
|
||||
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
|
||||
}
|
||||
}
|
||||
@@ -318,12 +272,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
);
|
||||
if (!row) return undefined;
|
||||
upsertGenres(row.id, movie.genres ?? []);
|
||||
fireAndForgetEnrichment(
|
||||
row.id,
|
||||
movie.poster_path,
|
||||
movie.backdrop_path,
|
||||
"movie",
|
||||
);
|
||||
fireAndForgetEnrichment(row.id, movie.poster_path, movie.backdrop_path, "movie");
|
||||
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
|
||||
return row;
|
||||
}
|
||||
@@ -417,26 +366,15 @@ export async function refreshTitle(titleId: string) {
|
||||
updated.backdropPath,
|
||||
updated.type as "movie" | "tv",
|
||||
).catch((err) => log.debug("Cache/thumbhash failed:", err));
|
||||
refreshTrailer(updated.id).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
);
|
||||
refreshCredits(updated.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
refreshTrailer(updated.id).catch((err) => log.debug("Trailer enrichment failed:", err));
|
||||
refreshCredits(updated.id).catch((err) => log.debug("Credits enrichment failed:", err));
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function refreshTvChildren(
|
||||
titleId: string,
|
||||
tmdbId: number,
|
||||
numberOfSeasons: number,
|
||||
) {
|
||||
export async function refreshTvChildren(titleId: string, tmdbId: number, numberOfSeasons: number) {
|
||||
// Fetch all seasons from TMDB concurrently (~40 req/s rate limit)
|
||||
const seasonNumbers = Array.from(
|
||||
{ length: numberOfSeasons },
|
||||
(_, i) => i + 1,
|
||||
);
|
||||
const seasonNumbers = Array.from({ length: numberOfSeasons }, (_, i) => i + 1);
|
||||
const fetched = await Promise.allSettled(
|
||||
seasonNumbers.map((sn) => getTvSeasonDetails(tmdbId, sn)),
|
||||
);
|
||||
@@ -447,10 +385,7 @@ export async function refreshTvChildren(
|
||||
const result = fetched[i];
|
||||
const sn = seasonNumbers[i];
|
||||
if (result.status === "rejected") {
|
||||
log.error(
|
||||
`Failed to fetch season ${sn} for TMDB ${tmdbId}:`,
|
||||
result.reason,
|
||||
);
|
||||
log.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}:`, result.reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -535,13 +470,8 @@ export async function refreshTvChildren(
|
||||
// Clear stale hashes when image paths change during the upsert.
|
||||
// Full hash (re)generation is handled by syncTitleArt() after
|
||||
// cache warming, so we only need to null out stale values here.
|
||||
if (
|
||||
(existingSeason?.posterPath ?? null) !== (seasonData.poster_path ?? null)
|
||||
) {
|
||||
db.update(seasons)
|
||||
.set({ posterThumbHash: null })
|
||||
.where(eq(seasons.id, seasonRow.id))
|
||||
.run();
|
||||
if ((existingSeason?.posterPath ?? null) !== (seasonData.poster_path ?? null)) {
|
||||
db.update(seasons).set({ posterThumbHash: null }).where(eq(seasons.id, seasonRow.id)).run();
|
||||
}
|
||||
const seasonEps = db
|
||||
.select({
|
||||
@@ -556,10 +486,7 @@ export async function refreshTvChildren(
|
||||
for (const ep of seasonEps) {
|
||||
const oldStill = oldEpStills.get(ep.episodeNumber);
|
||||
if (oldStill !== ep.stillPath && ep.stillThumbHash) {
|
||||
db.update(episodes)
|
||||
.set({ stillThumbHash: null })
|
||||
.where(eq(episodes.id, ep.id))
|
||||
.run();
|
||||
db.update(episodes).set({ stillThumbHash: null }).where(eq(episodes.id, ep.id)).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -637,8 +564,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
tmdbId: item.result.id,
|
||||
type: item.type,
|
||||
title: item.result.title ?? item.result.name ?? "Unknown",
|
||||
originalTitle:
|
||||
item.result.original_title ?? item.result.original_name ?? null,
|
||||
originalTitle: item.result.original_title ?? item.result.original_name ?? null,
|
||||
overview: item.result.overview ?? null,
|
||||
releaseDate: item.result.release_date ?? null,
|
||||
firstAirDate: item.result.first_air_date ?? null,
|
||||
@@ -665,9 +591,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
.where(inArray(titles.tmdbId, tmdbIds))
|
||||
.all();
|
||||
const existingTitleMap = new Map(existingTitles.map((t) => [t.tmdbId, t]));
|
||||
const titleIdMap = new Map<number, string>(
|
||||
existingTitles.map((t) => [t.tmdbId, t.id]),
|
||||
);
|
||||
const titleIdMap = new Map<number, string>(existingTitles.map((t) => [t.tmdbId, t.id]));
|
||||
|
||||
// Insert missing titles + upsert recommendations in a single transaction
|
||||
db.transaction((tx) => {
|
||||
@@ -676,8 +600,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
const existingTitle = existingTitleMap.get(item.tmdbId);
|
||||
if (existingTitle) {
|
||||
const posterPathChanged = existingTitle.posterPath !== item.posterPath;
|
||||
const backdropPathChanged =
|
||||
existingTitle.backdropPath !== item.backdropPath;
|
||||
const backdropPathChanged = existingTitle.backdropPath !== item.backdropPath;
|
||||
|
||||
tx.update(titles)
|
||||
.set({
|
||||
@@ -734,9 +657,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
}
|
||||
|
||||
// One fallback query for any that conflicted
|
||||
const stillMissing = [...insertedTmdbIds].filter(
|
||||
(id) => !titleIdMap.has(id),
|
||||
);
|
||||
const stillMissing = [...insertedTmdbIds].filter((id) => !titleIdMap.has(id));
|
||||
if (stillMissing.length > 0) {
|
||||
const fallbacks = tx
|
||||
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||
@@ -781,9 +702,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
|
||||
// Fire-and-forget thumbhash generation for recommendation titles missing one
|
||||
const recTitleIds = [
|
||||
...new Set(
|
||||
allItems.map((i) => titleIdMap.get(i.result.id)).filter(Boolean),
|
||||
),
|
||||
...new Set(allItems.map((i) => titleIdMap.get(i.result.id)).filter(Boolean)),
|
||||
] as string[];
|
||||
if (recTitleIds.length > 0) {
|
||||
const needingHash = db
|
||||
@@ -888,10 +807,7 @@ export async function ensureTvHydrated(titleId: string): Promise<Season[]> {
|
||||
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
||||
result = fetchSeasonsFromDb(titleId);
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
`Failed to backfill missing seasons for title ${titleId}:`,
|
||||
err,
|
||||
);
|
||||
log.debug(`Failed to backfill missing seasons for title ${titleId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,9 +828,7 @@ async function ensureEnriched(
|
||||
|
||||
if (!existing.hasCast) {
|
||||
tasks.push(
|
||||
refreshCredits(titleId).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
),
|
||||
refreshCredits(titleId).catch((err) => log.debug("Credits enrichment failed:", err)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -961,26 +875,19 @@ async function ensureEnriched(
|
||||
|
||||
if (!title.trailerVideoKey) {
|
||||
tasks.push(
|
||||
refreshTrailer(titleId).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
),
|
||||
refreshTrailer(titleId).catch((err) => log.debug("Trailer enrichment failed:", err)),
|
||||
);
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
log.debug(
|
||||
`Backfilling ${tasks.length} enrichment task(s) for "${title.title}"`,
|
||||
);
|
||||
log.debug(`Backfilling ${tasks.length} enrichment task(s) for "${title.title}"`);
|
||||
await Promise.all(tasks);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readAvailability(
|
||||
titleId: string,
|
||||
titleName: string,
|
||||
): AvailabilityOffer[] {
|
||||
function readAvailability(titleId: string, titleName: string): AvailabilityOffer[] {
|
||||
return db
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
@@ -1053,8 +960,7 @@ export async function getOrFetchTitle(id: string): Promise<{
|
||||
if (enriched) {
|
||||
// Re-read only what was missing
|
||||
if (cast.length === 0) cast = getCastForTitle(id);
|
||||
if (availability.length === 0)
|
||||
availability = readAvailability(title.id, title.title);
|
||||
if (availability.length === 0) availability = readAvailability(title.id, title.title);
|
||||
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
||||
}
|
||||
}
|
||||
@@ -1138,24 +1044,15 @@ export async function refreshTrailer(titleId: string) {
|
||||
try {
|
||||
const response = await getVideos(title.tmdbId, title.type);
|
||||
const key = pickBestTrailer(response.results ?? []);
|
||||
db.update(titles)
|
||||
.set({ trailerVideoKey: key })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
log.debug(
|
||||
`Trailer for "${title.title}": ${key ? `YouTube ${key}` : "none found"}`,
|
||||
);
|
||||
db.update(titles).set({ trailerVideoKey: key }).where(eq(titles.id, titleId)).run();
|
||||
log.debug(`Trailer for "${title.title}": ${key ? `YouTube ${key}` : "none found"}`);
|
||||
} catch (err) {
|
||||
log.debug(`Failed to fetch trailer for title ${titleId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateMissingTvChildThumbHashes(titleId: string) {
|
||||
const titleSeasons = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
const titleSeasons = db.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
|
||||
|
||||
const hashTasks: Promise<unknown>[] = [];
|
||||
|
||||
@@ -1189,15 +1086,9 @@ async function generateMissingTvChildThumbHashes(titleId: string) {
|
||||
await Promise.all(hashTasks);
|
||||
}
|
||||
|
||||
export async function syncTvChildArt(
|
||||
titleId: string,
|
||||
options?: { warmCache?: boolean },
|
||||
) {
|
||||
export async function syncTvChildArt(titleId: string, options?: { warmCache?: boolean }) {
|
||||
if (options?.warmCache !== false && imageCacheEnabled()) {
|
||||
await Promise.all([
|
||||
cacheImagesForTitle(titleId),
|
||||
cacheEpisodeStills(titleId),
|
||||
]);
|
||||
await Promise.all([cacheImagesForTitle(titleId), cacheEpisodeStills(titleId)]);
|
||||
}
|
||||
|
||||
await generateMissingTvChildThumbHashes(titleId);
|
||||
@@ -1220,9 +1111,7 @@ async function syncTitleArt(
|
||||
}
|
||||
}
|
||||
|
||||
const posterBuffer = posterPath
|
||||
? await loadImageBuffer(posterPath, "posters")
|
||||
: undefined;
|
||||
const posterBuffer = posterPath ? await loadImageBuffer(posterPath, "posters") : undefined;
|
||||
|
||||
// Title poster colors + thumbhash, then backdrop thumbhash
|
||||
await Promise.all([
|
||||
@@ -1287,10 +1176,7 @@ export function ensureBrowseTitlesExist(
|
||||
.where(inArray(titles.tmdbId, tmdbIds))
|
||||
.all();
|
||||
|
||||
const result = new Map<
|
||||
string,
|
||||
{ id: string; posterThumbHash: string | null }
|
||||
>();
|
||||
const result = new Map<string, { id: string; posterThumbHash: string | null }>();
|
||||
for (const row of existing) {
|
||||
result.set(browseTitleKey(row.tmdbId, row.type), {
|
||||
id: row.id,
|
||||
@@ -1337,9 +1223,7 @@ export function ensureBrowseTitlesExist(
|
||||
// Fallback for any that conflicted (concurrent insert)
|
||||
const stillMissing = missingKeys.filter((key) => !result.has(key));
|
||||
if (stillMissing.length > 0) {
|
||||
const missingTmdbIds = stillMissing.map(
|
||||
(key) => unique.get(key)?.tmdbId ?? 0,
|
||||
);
|
||||
const missingTmdbIds = stillMissing.map((key) => unique.get(key)?.tmdbId ?? 0);
|
||||
const fallbacks = tx
|
||||
.select({
|
||||
id: titles.id,
|
||||
|
||||
+14
-44
@@ -5,16 +5,14 @@ import { personFilmography, persons, titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { generatePersonThumbHash } from "./thumbhash";
|
||||
|
||||
const log = createLogger("person");
|
||||
const FILMOGRAPHY_STALE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function ensureProfileThumbHash(
|
||||
person: Pick<
|
||||
typeof persons.$inferSelect,
|
||||
"id" | "profilePath" | "profileThumbHash"
|
||||
>,
|
||||
person: Pick<typeof persons.$inferSelect, "id" | "profilePath" | "profileThumbHash">,
|
||||
) {
|
||||
if (!person.profilePath) {
|
||||
if (person.profileThumbHash) {
|
||||
@@ -30,14 +28,8 @@ async function ensureProfileThumbHash(
|
||||
return (await generatePersonThumbHash(person.id, person.profilePath)) ?? null;
|
||||
}
|
||||
|
||||
export async function getOrFetchPerson(
|
||||
personId: string,
|
||||
): Promise<ResolvedPerson | null> {
|
||||
const person = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, personId))
|
||||
.get();
|
||||
export async function getOrFetchPerson(personId: string): Promise<ResolvedPerson | null> {
|
||||
const person = db.select().from(persons).where(eq(persons.id, personId)).get();
|
||||
if (!person) return null;
|
||||
|
||||
// Shell record — lazily hydrate from TMDB
|
||||
@@ -109,14 +101,8 @@ export async function getOrFetchPerson(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOrFetchPersonByTmdbId(
|
||||
tmdbId: number,
|
||||
): Promise<ResolvedPerson | null> {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.tmdbId, tmdbId))
|
||||
.get();
|
||||
export async function getOrFetchPersonByTmdbId(tmdbId: number): Promise<ResolvedPerson | null> {
|
||||
const existing = db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
||||
|
||||
if (existing) {
|
||||
return getOrFetchPerson(existing.id);
|
||||
@@ -144,8 +130,7 @@ export async function getOrFetchPersonByTmdbId(
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const person =
|
||||
row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
||||
const person = row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
||||
if (!person) return null;
|
||||
|
||||
const profileThumbHash = await ensureProfileThumbHash(person);
|
||||
@@ -207,9 +192,7 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
|
||||
}));
|
||||
}
|
||||
|
||||
async function syncPersonFilmography(
|
||||
person: Pick<typeof persons.$inferSelect, "id" | "tmdbId">,
|
||||
) {
|
||||
async function syncPersonFilmography(person: Pick<typeof persons.$inferSelect, "id" | "tmdbId">) {
|
||||
const credits = await getPersonCombinedCredits(person.tmdbId);
|
||||
|
||||
// Schema types combined credits as movie-only; TV entries also carry
|
||||
@@ -273,9 +256,7 @@ async function syncPersonFilmography(
|
||||
}
|
||||
});
|
||||
|
||||
const stillMissing = [...insertedTmdbIds].filter(
|
||||
(tmdbId) => !titleIdMap.has(tmdbId),
|
||||
);
|
||||
const stillMissing = [...insertedTmdbIds].filter((tmdbId) => !titleIdMap.has(tmdbId));
|
||||
if (stillMissing.length > 0) {
|
||||
const fallbacks = db
|
||||
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||
@@ -323,9 +304,7 @@ async function syncPersonFilmography(
|
||||
|
||||
const now = new Date();
|
||||
db.transaction((tx) => {
|
||||
tx.delete(personFilmography)
|
||||
.where(eq(personFilmography.personId, person.id))
|
||||
.run();
|
||||
tx.delete(personFilmography).where(eq(personFilmography.personId, person.id)).run();
|
||||
|
||||
for (const row of nextRows) {
|
||||
tx.insert(personFilmography).values(row).run();
|
||||
@@ -338,21 +317,14 @@ async function syncPersonFilmography(
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchFullFilmography(
|
||||
personId: string,
|
||||
): Promise<PersonCredit[]> {
|
||||
const person = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, personId))
|
||||
.get();
|
||||
export async function fetchFullFilmography(personId: string): Promise<PersonCredit[]> {
|
||||
const person = db.select().from(persons).where(eq(persons.id, personId)).get();
|
||||
if (!person) return [];
|
||||
|
||||
const localFilmography = getLocalFilmography(personId);
|
||||
const isFresh =
|
||||
person.filmographyLastFetchedAt &&
|
||||
person.filmographyLastFetchedAt.getTime() >
|
||||
Date.now() - FILMOGRAPHY_STALE_MS;
|
||||
person.filmographyLastFetchedAt.getTime() > Date.now() - FILMOGRAPHY_STALE_MS;
|
||||
|
||||
if (isFresh) {
|
||||
return localFilmography;
|
||||
@@ -389,9 +361,7 @@ interface BrowsePersonInput {
|
||||
* Inserts shell persons (`lastFetchedAt = null`) for new tmdbIds.
|
||||
* Returns a map of tmdbId → internal UUID.
|
||||
*/
|
||||
export function ensureBrowsePersonsExist(
|
||||
items: BrowsePersonInput[],
|
||||
): Map<number, string> {
|
||||
export function ensureBrowsePersonsExist(items: BrowsePersonInput[]): Map<number, string> {
|
||||
if (items.length === 0) return new Map();
|
||||
|
||||
const unique = new Map<number, BrowsePersonInput>();
|
||||
|
||||
@@ -110,10 +110,7 @@ const providerRegistry: ReadonlyMap<number, ProviderConfig> = new Map(
|
||||
Object.entries(providers).map(([id, config]) => [Number(id), config]),
|
||||
);
|
||||
|
||||
export function generateProviderUrl(
|
||||
providerId: number,
|
||||
titleName: string,
|
||||
): string | null {
|
||||
export function generateProviderUrl(providerId: number, titleName: string): string | null {
|
||||
const config = providerRegistry.get(providerId);
|
||||
if (!config) return null;
|
||||
return config.searchUrl.replace("{title}", encodeURIComponent(titleName));
|
||||
|
||||
@@ -3,11 +3,7 @@ import { count, eq } from "@sofa/db/helpers";
|
||||
import { appSettings, user } from "@sofa/db/schema";
|
||||
|
||||
export function getSetting(key: string): string | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(eq(appSettings.key, key))
|
||||
.get();
|
||||
const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get();
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { access, constants, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
CACHE_DIR,
|
||||
DATA_DIR,
|
||||
DATABASE_URL,
|
||||
TMDB_API_BASE_URL,
|
||||
} from "@sofa/config";
|
||||
|
||||
import { CACHE_DIR, DATA_DIR, DATABASE_URL, TMDB_API_BASE_URL } from "@sofa/config";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { count, desc, eq } from "@sofa/db/helpers";
|
||||
import { cronRuns, episodes, titles, user } from "@sofa/db/schema";
|
||||
|
||||
import { listBackups } from "./backup";
|
||||
import { imageCacheEnabled } from "./image-cache";
|
||||
import { getSetting } from "./settings";
|
||||
@@ -186,18 +183,14 @@ function getJobsHealth(): SystemHealthData["jobs"] {
|
||||
}
|
||||
|
||||
const disabled =
|
||||
(jobName === "scheduledBackup" &&
|
||||
getSetting("scheduledBackups") !== "true") ||
|
||||
(jobName === "scheduledBackup" && getSetting("scheduledBackups") !== "true") ||
|
||||
(jobName === "updateCheck" && !isUpdateCheckEnabled());
|
||||
|
||||
return {
|
||||
jobName,
|
||||
cronPattern: disabled ? null : (schedule?.pattern ?? null),
|
||||
nextRunAt: disabled ? null : (schedule?.nextRunAt ?? null),
|
||||
lastRunAt:
|
||||
latest?.finishedAt?.toISOString() ??
|
||||
latest?.startedAt?.toISOString() ??
|
||||
null,
|
||||
lastRunAt: latest?.finishedAt?.toISOString() ?? latest?.startedAt?.toISOString() ?? null,
|
||||
lastDurationMs,
|
||||
lastStatus: (latest?.status as "running" | "success" | "error") ?? null,
|
||||
lastError: latest?.errorMessage ?? null,
|
||||
@@ -223,9 +216,7 @@ async function getImageCacheHealth(): Promise<SystemHealthData["imageCache"]> {
|
||||
const dir = path.join(CACHE_DIR, category);
|
||||
try {
|
||||
const files = await readdir(dir);
|
||||
const sizes = await Promise.all(
|
||||
files.map((file) => Bun.file(path.join(dir, file)).size),
|
||||
);
|
||||
const sizes = await Promise.all(files.map((file) => Bun.file(path.join(dir, file)).size));
|
||||
const sizeBytes = sizes.reduce((sum, s) => sum + s, 0);
|
||||
categories[category] = { count: files.length, sizeBytes };
|
||||
totalSizeBytes += sizeBytes;
|
||||
@@ -246,9 +237,7 @@ async function getBackupsHealth(): Promise<SystemHealthData["backups"]> {
|
||||
const lastBackup = backups[0] ?? null;
|
||||
const lastBackupAt = lastBackup?.createdAt ?? null;
|
||||
const lastBackupAgeHours = lastBackupAt
|
||||
? Math.round(
|
||||
(Date.now() - new Date(lastBackupAt).getTime()) / (1000 * 60 * 60),
|
||||
)
|
||||
? Math.round((Date.now() - new Date(lastBackupAt).getTime()) / (1000 * 60 * 60))
|
||||
: null;
|
||||
|
||||
return {
|
||||
@@ -267,9 +256,7 @@ async function getBackupsHealth(): Promise<SystemHealthData["backups"]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getEnvironmentHealth(): Promise<
|
||||
SystemHealthData["environment"]
|
||||
> {
|
||||
async function getEnvironmentHealth(): Promise<SystemHealthData["environment"]> {
|
||||
const resolvedDataDir = path.resolve(DATA_DIR);
|
||||
let dataDirWritable = false;
|
||||
try {
|
||||
|
||||
@@ -2,17 +2,12 @@ import { db } from "@sofa/db/client";
|
||||
import { count } from "@sofa/db/helpers";
|
||||
import { titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { imageCacheEnabled } from "./image-cache";
|
||||
import {
|
||||
getInstanceId,
|
||||
getSetting,
|
||||
getUserCount,
|
||||
setSetting,
|
||||
} from "./settings";
|
||||
import { getInstanceId, getSetting, getUserCount, setSetting } from "./settings";
|
||||
|
||||
const APP_VERSION = process.env.APP_VERSION || "0.0.0";
|
||||
const PUBLIC_API_URL =
|
||||
process.env.PUBLIC_API_URL || "https://public-api.sofa.watch";
|
||||
const PUBLIC_API_URL = process.env.PUBLIC_API_URL || "https://public-api.sofa.watch";
|
||||
const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
const log = createLogger("telemetry");
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import path from "node:path";
|
||||
|
||||
import sharp from "sharp";
|
||||
import { rgbaToThumbHash } from "thumbhash";
|
||||
|
||||
import { db } from "@sofa/db/client";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes, persons, seasons, titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import type { ImageCategory } from "@sofa/tmdb/image";
|
||||
import sharp from "sharp";
|
||||
import { rgbaToThumbHash } from "thumbhash";
|
||||
|
||||
import { loadImageBuffer } from "./image-cache";
|
||||
|
||||
const log = createLogger("thumbhash");
|
||||
@@ -21,9 +24,7 @@ export async function generateThumbHash(
|
||||
): Promise<string | null> {
|
||||
const filename = path.basename(imagePath);
|
||||
const source =
|
||||
sourceBuffer === undefined
|
||||
? await loadImageBuffer(imagePath, category)
|
||||
: sourceBuffer;
|
||||
sourceBuffer === undefined ? await loadImageBuffer(imagePath, category) : sourceBuffer;
|
||||
if (!source) return null;
|
||||
|
||||
try {
|
||||
@@ -46,13 +47,8 @@ export async function generateTitlePosterThumbHash(
|
||||
posterPath: string | null,
|
||||
sourceBuffer?: Buffer | null,
|
||||
): Promise<string | null> {
|
||||
const hash = posterPath
|
||||
? await generateThumbHash(posterPath, "posters", sourceBuffer)
|
||||
: null;
|
||||
db.update(titles)
|
||||
.set({ posterThumbHash: hash })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
const hash = posterPath ? await generateThumbHash(posterPath, "posters", sourceBuffer) : null;
|
||||
db.update(titles).set({ posterThumbHash: hash }).where(eq(titles.id, titleId)).run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -60,13 +56,8 @@ export async function generateTitleBackdropThumbHash(
|
||||
titleId: string,
|
||||
backdropPath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = backdropPath
|
||||
? await generateThumbHash(backdropPath, "backdrops")
|
||||
: null;
|
||||
db.update(titles)
|
||||
.set({ backdropThumbHash: hash })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
const hash = backdropPath ? await generateThumbHash(backdropPath, "backdrops") : null;
|
||||
db.update(titles).set({ backdropThumbHash: hash }).where(eq(titles.id, titleId)).run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -74,13 +65,8 @@ export async function generateSeasonThumbHash(
|
||||
seasonId: string,
|
||||
posterPath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = posterPath
|
||||
? await generateThumbHash(posterPath, "posters")
|
||||
: null;
|
||||
db.update(seasons)
|
||||
.set({ posterThumbHash: hash })
|
||||
.where(eq(seasons.id, seasonId))
|
||||
.run();
|
||||
const hash = posterPath ? await generateThumbHash(posterPath, "posters") : null;
|
||||
db.update(seasons).set({ posterThumbHash: hash }).where(eq(seasons.id, seasonId)).run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -89,10 +75,7 @@ export async function generateEpisodeThumbHash(
|
||||
stillPath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = stillPath ? await generateThumbHash(stillPath, "stills") : null;
|
||||
db.update(episodes)
|
||||
.set({ stillThumbHash: hash })
|
||||
.where(eq(episodes.id, episodeId))
|
||||
.run();
|
||||
db.update(episodes).set({ stillThumbHash: hash }).where(eq(episodes.id, episodeId)).run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -100,12 +83,7 @@ export async function generatePersonThumbHash(
|
||||
personId: string,
|
||||
profilePath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = profilePath
|
||||
? await generateThumbHash(profilePath, "profiles")
|
||||
: null;
|
||||
db.update(persons)
|
||||
.set({ profileThumbHash: hash })
|
||||
.where(eq(persons.id, personId))
|
||||
.run();
|
||||
const hash = profilePath ? await generateThumbHash(profilePath, "profiles") : null;
|
||||
db.update(persons).set({ profileThumbHash: hash }).where(eq(persons.id, personId)).run();
|
||||
return hash;
|
||||
}
|
||||
+27
-126
@@ -14,8 +14,7 @@ export function setTitleStatus(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
status: "watchlist" | "in_progress" | "completed",
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers
|
||||
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||
_source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||
addedAt?: Date,
|
||||
) {
|
||||
const now = addedAt ?? new Date();
|
||||
@@ -30,12 +29,7 @@ export function setTitleStatus(
|
||||
|
||||
export function removeTitleStatus(userId: string, titleId: string) {
|
||||
db.delete(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -46,20 +40,13 @@ export function logMovieWatch(
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
const now = watchedAt ?? new Date();
|
||||
db.insert(userMovieWatches)
|
||||
.values({ userId, titleId, watchedAt: now, source })
|
||||
.run();
|
||||
db.insert(userMovieWatches).values({ userId, titleId, watchedAt: now, source }).run();
|
||||
|
||||
// Auto-set status to completed
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
@@ -76,9 +63,7 @@ export function logEpisodeWatch(
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
const now = watchedAt ?? new Date();
|
||||
db.insert(userEpisodeWatches)
|
||||
.values({ userId, episodeId, watchedAt: now, source })
|
||||
.run();
|
||||
db.insert(userEpisodeWatches).values({ userId, episodeId, watchedAt: now, source }).run();
|
||||
|
||||
// Find the title for this episode (single JOIN instead of 2 queries)
|
||||
const row = db
|
||||
@@ -94,12 +79,7 @@ export function logEpisodeWatch(
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
|
||||
if (!existing || existing.status === "watchlist") {
|
||||
@@ -123,25 +103,15 @@ export function logEpisodeWatchBatch(
|
||||
|
||||
// Batch INSERT all watch records
|
||||
for (const episodeId of episodeIds) {
|
||||
tx.insert(userEpisodeWatches)
|
||||
.values({ userId, episodeId, watchedAt: now, source })
|
||||
.run();
|
||||
tx.insert(userEpisodeWatches).values({ userId, episodeId, watchedAt: now, source }).run();
|
||||
}
|
||||
|
||||
// Resolve episode → season → title hierarchy with batch queries
|
||||
const eps = tx
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(inArray(episodes.id, episodeIds))
|
||||
.all();
|
||||
const eps = tx.select().from(episodes).where(inArray(episodes.id, episodeIds)).all();
|
||||
if (eps.length === 0) return;
|
||||
|
||||
const seasonIds = [...new Set(eps.map((e) => e.seasonId))];
|
||||
const seasonRows = tx
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(inArray(seasons.id, seasonIds))
|
||||
.all();
|
||||
const seasonRows = tx.select().from(seasons).where(inArray(seasons.id, seasonIds)).all();
|
||||
if (seasonRows.length === 0) return;
|
||||
|
||||
const { titleId } = seasonRows[0];
|
||||
@@ -150,12 +120,7 @@ export function logEpisodeWatchBatch(
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
|
||||
if (!existing || existing.status === "watchlist") {
|
||||
@@ -176,19 +141,11 @@ export function logEpisodeWatchBatch(
|
||||
}
|
||||
|
||||
// Check completion once (not per-episode)
|
||||
const allSeasons = tx
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
const allSeasons = tx.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
|
||||
if (allSeasons.length === 0) return;
|
||||
|
||||
const allSeasonIds = allSeasons.map((s) => s.id);
|
||||
const allEps = tx
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(inArray(episodes.seasonId, allSeasonIds))
|
||||
.all();
|
||||
const allEps = tx.select().from(episodes).where(inArray(episodes.seasonId, allSeasonIds)).all();
|
||||
const totalEpisodes = allEps.length;
|
||||
if (totalEpisodes === 0) return;
|
||||
|
||||
@@ -199,10 +156,7 @@ export function logEpisodeWatchBatch(
|
||||
})
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
inArray(userEpisodeWatches.episodeId, allEpIds),
|
||||
),
|
||||
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, allEpIds)),
|
||||
)
|
||||
.all();
|
||||
|
||||
@@ -291,12 +245,7 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
|
||||
})
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
inArray(userEpisodeWatches.episodeId, epIds),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, epIds)))
|
||||
.all();
|
||||
|
||||
if (watchCount.count >= totalEpisodes) {
|
||||
@@ -306,12 +255,7 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
|
||||
export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
db.delete(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
eq(userEpisodeWatches.episodeId, episodeId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.episodeId, episodeId)))
|
||||
.run();
|
||||
|
||||
// Find parent title and downgrade from completed to in_progress
|
||||
@@ -326,12 +270,7 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, row.titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, row.titleId)))
|
||||
.get();
|
||||
|
||||
if (existing?.status === "completed") {
|
||||
@@ -340,41 +279,25 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
}
|
||||
|
||||
export function unwatchSeason(userId: string, seasonId: string) {
|
||||
const seasonEps = db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, seasonId))
|
||||
.all();
|
||||
const seasonEps = db.select().from(episodes).where(eq(episodes.seasonId, seasonId)).all();
|
||||
|
||||
const epIds = seasonEps.map((ep) => ep.id);
|
||||
if (epIds.length > 0) {
|
||||
db.delete(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
inArray(userEpisodeWatches.episodeId, epIds),
|
||||
),
|
||||
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, epIds)),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
// Find parent title and downgrade from completed to in_progress
|
||||
const season = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.id, seasonId))
|
||||
.get();
|
||||
const season = db.select().from(seasons).where(eq(seasons.id, seasonId)).get();
|
||||
if (!season) return;
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, season.titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, season.titleId)))
|
||||
.get();
|
||||
|
||||
if (existing?.status === "completed") {
|
||||
@@ -391,9 +314,7 @@ export function rateTitleStars(
|
||||
const now = ratedAt ?? new Date();
|
||||
if (ratingStars === 0) {
|
||||
db.delete(userRatings)
|
||||
.where(
|
||||
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
@@ -418,20 +339,12 @@ export function getUserStatusesByTitleIds(
|
||||
status: userTitleStatus.status,
|
||||
})
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
inArray(userTitleStatus.titleId, titleIds),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), inArray(userTitleStatus.titleId, titleIds)))
|
||||
.all();
|
||||
|
||||
const result: Record<string, "watchlist" | "in_progress" | "completed"> = {};
|
||||
for (const row of rows) {
|
||||
result[row.titleId] = row.status as
|
||||
| "watchlist"
|
||||
| "in_progress"
|
||||
| "completed";
|
||||
result[row.titleId] = row.status as "watchlist" | "in_progress" | "completed";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -445,9 +358,7 @@ export function getEpisodeProgressByTitleIds(
|
||||
const rows = db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as(
|
||||
"totalEpisodes",
|
||||
),
|
||||
totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as("totalEpisodes"),
|
||||
watchedEpisodes:
|
||||
sql<number>`count(distinct case when ${userEpisodeWatches.id} is not null then ${episodes.id} end)`.as(
|
||||
"watchedEpisodes",
|
||||
@@ -458,10 +369,7 @@ export function getEpisodeProgressByTitleIds(
|
||||
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
|
||||
.leftJoin(
|
||||
userEpisodeWatches,
|
||||
and(
|
||||
eq(userEpisodeWatches.episodeId, episodes.id),
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
),
|
||||
and(eq(userEpisodeWatches.episodeId, episodes.id), eq(userEpisodeWatches.userId, userId)),
|
||||
)
|
||||
.where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
|
||||
.groupBy(titles.id)
|
||||
@@ -483,20 +391,13 @@ export function getUserTitleInfo(userId: string, titleId: string) {
|
||||
const status = db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
|
||||
const rating = db
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
|
||||
.get();
|
||||
|
||||
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { getSetting, setSetting } from "./settings";
|
||||
|
||||
const APP_VERSION = process.env.APP_VERSION || "0.0.0";
|
||||
|
||||
const log = createLogger("update-check");
|
||||
|
||||
const PUBLIC_API_URL =
|
||||
process.env.PUBLIC_API_URL || "https://public-api.sofa.watch";
|
||||
const PUBLIC_API_URL = process.env.PUBLIC_API_URL || "https://public-api.sofa.watch";
|
||||
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
@@ -39,9 +39,7 @@ export function getCachedUpdateCheck(): UpdateCheckResult {
|
||||
const lastCheckedAt = getSetting("updateCheckLastCheckedAt");
|
||||
|
||||
return {
|
||||
updateAvailable: latestVersion
|
||||
? isNewerVersion(latestVersion, APP_VERSION)
|
||||
: false,
|
||||
updateAvailable: latestVersion ? isNewerVersion(latestVersion, APP_VERSION) : false,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion,
|
||||
releaseUrl,
|
||||
@@ -83,9 +81,7 @@ export async function performUpdateCheck(): Promise<UpdateCheckResult> {
|
||||
setSetting("updateCheckReleaseUrl", data.releaseUrl);
|
||||
setSetting("updateCheckLastCheckedAt", new Date().toISOString());
|
||||
|
||||
log.info(
|
||||
`Update check complete: current=${APP_VERSION}, latest=${version}`,
|
||||
);
|
||||
log.info(`Update check complete: current=${APP_VERSION}, latest=${version}`);
|
||||
|
||||
return {
|
||||
updateAvailable: isNewerVersion(version, APP_VERSION),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
userMovieWatches,
|
||||
} from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { resolveMovieTmdbId, resolveShowTmdbId } from "./imports/resolve";
|
||||
import { getOrFetchTitleByTmdbId } from "./metadata";
|
||||
import { logEpisodeWatch, logMovieWatch } from "./tracking";
|
||||
@@ -65,9 +66,7 @@ export function parsePlexPayload(formData: FormData): WebhookEvent | null {
|
||||
if (!isMovie && !isEpisode) return null;
|
||||
|
||||
// Extract external IDs from Guid array
|
||||
const guids = (metadata.Guid ?? metadata.guid) as
|
||||
| { id: string }[]
|
||||
| undefined;
|
||||
const guids = (metadata.Guid ?? metadata.guid) as { id: string }[] | undefined;
|
||||
let tmdbId: number | undefined;
|
||||
let imdbId: string | undefined;
|
||||
let tvdbId: string | undefined;
|
||||
@@ -90,15 +89,11 @@ export function parsePlexPayload(formData: FormData): WebhookEvent | null {
|
||||
tvdbId,
|
||||
seasonNumber: toOptionalInt(metadata.parentIndex),
|
||||
episodeNumber: toOptionalInt(metadata.index),
|
||||
showTitle: (metadata.grandparentTitle ?? metadata.parentTitle) as
|
||||
| string
|
||||
| undefined,
|
||||
showTitle: (metadata.grandparentTitle ?? metadata.parentTitle) as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJellyfinPayload(
|
||||
body: Record<string, unknown>,
|
||||
): WebhookEvent | null {
|
||||
export function parseJellyfinPayload(body: Record<string, unknown>): WebhookEvent | null {
|
||||
const notifType = body.NotificationType as string | undefined;
|
||||
if (notifType !== "PlaybackStop") return null;
|
||||
if (body.PlayedToCompletion !== true) return null;
|
||||
@@ -123,9 +118,7 @@ export function parseJellyfinPayload(
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEmbyPayload(
|
||||
body: Record<string, unknown>,
|
||||
): WebhookEvent | null {
|
||||
export function parseEmbyPayload(body: Record<string, unknown>): WebhookEvent | null {
|
||||
// Emby sends "playback.stop" or "PlaybackStop" depending on webhook plugin version
|
||||
const event = body.Event as string | undefined;
|
||||
if (event !== "playback.stop" && event !== "PlaybackStop") return null;
|
||||
@@ -262,15 +255,8 @@ export async function processWebhook(
|
||||
title: hasExternalId ? event.title : undefined,
|
||||
});
|
||||
if (!tmdbId) {
|
||||
log.warn(
|
||||
`Could not resolve TMDB ID for movie "${event.title}" from ${provider}`,
|
||||
);
|
||||
logEvent(
|
||||
connectionId,
|
||||
event,
|
||||
"error",
|
||||
"Could not resolve TMDB ID for movie",
|
||||
);
|
||||
log.warn(`Could not resolve TMDB ID for movie "${event.title}" from ${provider}`);
|
||||
logEvent(connectionId, event, "error", "Could not resolve TMDB ID for movie");
|
||||
return { status: "error", message: "Could not resolve TMDB ID" };
|
||||
}
|
||||
|
||||
@@ -282,12 +268,7 @@ export async function processWebhook(
|
||||
|
||||
if (isDuplicateMovieWatch(userId, title.id)) {
|
||||
log.debug(`Duplicate movie watch ignored: "${event.title}"`);
|
||||
logEvent(
|
||||
connectionId,
|
||||
event,
|
||||
"ignored",
|
||||
"Duplicate watch within 5 minutes",
|
||||
);
|
||||
logEvent(connectionId, event, "ignored", "Duplicate watch within 5 minutes");
|
||||
return { status: "ignored", message: "Duplicate watch" };
|
||||
}
|
||||
|
||||
@@ -315,21 +296,11 @@ export async function processWebhook(
|
||||
const season = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(
|
||||
and(
|
||||
eq(seasons.titleId, title.id),
|
||||
eq(seasons.seasonNumber, resolved.seasonNumber),
|
||||
),
|
||||
)
|
||||
.where(and(eq(seasons.titleId, title.id), eq(seasons.seasonNumber, resolved.seasonNumber)))
|
||||
.get();
|
||||
|
||||
if (!season) {
|
||||
logEvent(
|
||||
connectionId,
|
||||
event,
|
||||
"error",
|
||||
`Season ${resolved.seasonNumber} not found`,
|
||||
);
|
||||
logEvent(connectionId, event, "error", `Season ${resolved.seasonNumber} not found`);
|
||||
return {
|
||||
status: "error",
|
||||
message: `Season ${resolved.seasonNumber} not found`,
|
||||
@@ -340,10 +311,7 @@ export async function processWebhook(
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(
|
||||
and(
|
||||
eq(episodes.seasonId, season.id),
|
||||
eq(episodes.episodeNumber, resolved.episodeNumber),
|
||||
),
|
||||
and(eq(episodes.seasonId, season.id), eq(episodes.episodeNumber, resolved.episodeNumber)),
|
||||
)
|
||||
.get();
|
||||
|
||||
@@ -362,12 +330,7 @@ export async function processWebhook(
|
||||
|
||||
if (isDuplicateEpisodeWatch(userId, episode.id)) {
|
||||
log.debug(`Duplicate episode watch ignored: "${event.title}"`);
|
||||
logEvent(
|
||||
connectionId,
|
||||
event,
|
||||
"ignored",
|
||||
"Duplicate watch within 5 minutes",
|
||||
);
|
||||
logEvent(connectionId, event, "ignored", "Duplicate watch within 5 minutes");
|
||||
return { status: "ignored", message: "Duplicate watch" };
|
||||
}
|
||||
|
||||
@@ -379,12 +342,7 @@ export async function processWebhook(
|
||||
return { status: "success", message: `Logged watch for ${event.title}` };
|
||||
}
|
||||
|
||||
logEvent(
|
||||
connectionId,
|
||||
event,
|
||||
"ignored",
|
||||
`Unsupported media type: ${event.mediaType}`,
|
||||
);
|
||||
logEvent(connectionId, event, "ignored", `Unsupported media type: ${event.mediaType}`);
|
||||
return { status: "ignored", message: "Unsupported media type" };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
getBackupSource,
|
||||
isKnownBackup,
|
||||
isValidBackupFilename,
|
||||
} from "../src/backup";
|
||||
import { getBackupSource, isKnownBackup, isValidBackupFilename } from "../src/backup";
|
||||
|
||||
describe("getBackupSource", () => {
|
||||
test("detects manual backup", () => {
|
||||
@@ -12,21 +8,15 @@ describe("getBackupSource", () => {
|
||||
});
|
||||
|
||||
test("detects manual backup with milliseconds", () => {
|
||||
expect(getBackupSource("sofa-manual-2024-01-15-120000123.db")).toBe(
|
||||
"manual",
|
||||
);
|
||||
expect(getBackupSource("sofa-manual-2024-01-15-120000123.db")).toBe("manual");
|
||||
});
|
||||
|
||||
test("detects scheduled backup", () => {
|
||||
expect(getBackupSource("sofa-scheduled-2024-06-01-030000.db")).toBe(
|
||||
"scheduled",
|
||||
);
|
||||
expect(getBackupSource("sofa-scheduled-2024-06-01-030000.db")).toBe("scheduled");
|
||||
});
|
||||
|
||||
test("detects pre-restore backup", () => {
|
||||
expect(getBackupSource("pre-restore-2024-06-01-030000.db")).toBe(
|
||||
"pre-restore",
|
||||
);
|
||||
expect(getBackupSource("pre-restore-2024-06-01-030000.db")).toBe("pre-restore");
|
||||
});
|
||||
|
||||
test("defaults to manual for unknown patterns", () => {
|
||||
@@ -66,27 +56,19 @@ describe("isKnownBackup", () => {
|
||||
|
||||
describe("isValidBackupFilename", () => {
|
||||
test("accepts valid manual backup", () => {
|
||||
expect(isValidBackupFilename("sofa-manual-2024-01-15-120000.db")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isValidBackupFilename("sofa-manual-2024-01-15-120000.db")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts valid scheduled backup", () => {
|
||||
expect(isValidBackupFilename("sofa-scheduled-2024-06-01-030000.db")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isValidBackupFilename("sofa-scheduled-2024-06-01-030000.db")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects path traversal", () => {
|
||||
expect(isValidBackupFilename("../sofa-manual-2024-01-15-120000.db")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isValidBackupFilename("../sofa-manual-2024-01-15-120000.db")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects directory separators", () => {
|
||||
expect(
|
||||
isValidBackupFilename("subdir/sofa-manual-2024-01-15-120000.db"),
|
||||
).toBe(false);
|
||||
expect(isValidBackupFilename("subdir/sofa-manual-2024-01-15-120000.db")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects unknown filenames", () => {
|
||||
@@ -94,8 +76,6 @@ describe("isValidBackupFilename", () => {
|
||||
});
|
||||
|
||||
test("rejects filenames with double dots", () => {
|
||||
expect(isValidBackupFilename("sofa-manual-2024..01-15-120000.db")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isValidBackupFilename("sofa-manual-2024..01-15-120000.db")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { persons, titleCast } from "@sofa/db/schema";
|
||||
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
@@ -8,12 +9,7 @@ beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
function insertPerson(
|
||||
id: string,
|
||||
tmdbId: number,
|
||||
name: string,
|
||||
profilePath: string | null = null,
|
||||
) {
|
||||
function insertPerson(id: string, tmdbId: number, name: string, profilePath: string | null = null) {
|
||||
testDb.insert(persons).values({ id, tmdbId, name, profilePath }).run();
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
clearAllTables,
|
||||
insertAvailabilityOffer,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
insertTvShow,
|
||||
insertUser,
|
||||
} from "@sofa/db/test-utils";
|
||||
|
||||
import {
|
||||
getContinueWatchingFeed,
|
||||
getLibraryFeed,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
|
||||
import { loadImageBuffer } from "../src/image-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import AdmZip from "adm-zip";
|
||||
|
||||
import {
|
||||
type ParseResult,
|
||||
parseLetterboxdExport,
|
||||
@@ -747,9 +749,7 @@ describe("parseLetterboxdExport", () => {
|
||||
|
||||
expect(result.data.movies).toHaveLength(1);
|
||||
// Should have warnings for missing watched.csv, watchlist.csv, ratings.csv
|
||||
const missingWarnings = result.warnings.filter((w) =>
|
||||
w.includes("not found in export"),
|
||||
);
|
||||
const missingWarnings = result.warnings.filter((w) => w.includes("not found in export"));
|
||||
expect(missingWarnings).toHaveLength(3);
|
||||
expect(missingWarnings.some((w) => w.includes("watched.csv"))).toBe(true);
|
||||
expect(missingWarnings.some((w) => w.includes("watchlist.csv"))).toBe(true);
|
||||
@@ -779,8 +779,7 @@ describe("parseLetterboxdExport", () => {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date\n2024-01-15,Inception,2010,https://boxd.it/123,,false,,2024-01-14",
|
||||
"watched.csv":
|
||||
"Date,Name,Year,Letterboxd URI\n2024-01-10,Inception,2010,https://boxd.it/123",
|
||||
"watched.csv": "Date,Name,Year,Letterboxd URI\n2024-01-10,Inception,2010,https://boxd.it/123",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"ratings.csv": "Date,Name,Year,Letterboxd URI,Rating",
|
||||
});
|
||||
@@ -795,8 +794,7 @@ describe("parseLetterboxdExport", () => {
|
||||
describe("rating conversion (Letterboxd 0.5-5 to 1-5)", () => {
|
||||
async function rateLetterboxd(rating: number) {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"diary.csv": "Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"watched.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"ratings.csv": `Date,Name,Year,Letterboxd URI,Rating\n2024-01-15,Test,2020,https://boxd.it/1,${rating}`,
|
||||
@@ -857,21 +855,17 @@ describe("parseLetterboxdExport", () => {
|
||||
test("skips rating 0 with warning", async () => {
|
||||
const result = await rateLetterboxd(0);
|
||||
expect(result.data.ratings).toHaveLength(0);
|
||||
expect(
|
||||
result.warnings.some((w) =>
|
||||
w.includes("Skipped invalid Letterboxd rating"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.warnings.some((w) => w.includes("Skipped invalid Letterboxd rating"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("skips rating 6 with warning", async () => {
|
||||
const result = await rateLetterboxd(6);
|
||||
expect(result.data.ratings).toHaveLength(0);
|
||||
expect(
|
||||
result.warnings.some((w) =>
|
||||
w.includes("Skipped invalid Letterboxd rating"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.warnings.some((w) => w.includes("Skipped invalid Letterboxd rating"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -940,9 +934,7 @@ describe("parseLetterboxdExport", () => {
|
||||
const result = await parseLetterboxdExport(invalidBlob);
|
||||
|
||||
expect(result.data.movies).toHaveLength(0);
|
||||
expect(result.warnings.some((w) => w.includes("Failed to read ZIP"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.warnings.some((w) => w.includes("Failed to read ZIP"))).toBe(true);
|
||||
});
|
||||
|
||||
test("handles CSV files nested in a subdirectory", async () => {
|
||||
@@ -980,8 +972,7 @@ describe("parseLetterboxdExport", () => {
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date\n2024-01-15,Movie A,2020,https://boxd.it/1,,false,,2024-01-14\n2024-01-16,Movie B,2021,https://boxd.it/2,,false,,2024-01-15",
|
||||
"watched.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"watchlist.csv":
|
||||
"Date,Name,Year,Letterboxd URI\n2024-01-20,Movie C,2022,https://boxd.it/3",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI\n2024-01-20,Movie C,2022,https://boxd.it/3",
|
||||
"ratings.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating\n2024-01-15,Movie A,2020,https://boxd.it/1,4",
|
||||
});
|
||||
@@ -994,8 +985,7 @@ describe("parseLetterboxdExport", () => {
|
||||
|
||||
test("ratings.csv with missing rating column skips the row", async () => {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"diary.csv": "Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"watched.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"ratings.csv":
|
||||
@@ -1009,8 +999,7 @@ describe("parseLetterboxdExport", () => {
|
||||
|
||||
test("multiple movies in watched.csv are all parsed", async () => {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"diary.csv": "Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"watched.csv":
|
||||
"Date,Name,Year,Letterboxd URI\n2024-01-10,Movie A,2020,https://boxd.it/1\n2024-01-11,Movie B,2021,https://boxd.it/2\n2024-01-12,Movie C,2022,https://boxd.it/3",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI",
|
||||
@@ -1020,17 +1009,12 @@ describe("parseLetterboxdExport", () => {
|
||||
const result = await parseLetterboxdExport(zip);
|
||||
|
||||
expect(result.data.movies).toHaveLength(3);
|
||||
expect(result.data.movies.map((m) => m.title)).toEqual([
|
||||
"Movie A",
|
||||
"Movie B",
|
||||
"Movie C",
|
||||
]);
|
||||
expect(result.data.movies.map((m) => m.title)).toEqual(["Movie A", "Movie B", "Movie C"]);
|
||||
});
|
||||
|
||||
test("uses Title column as fallback when Name column is absent", async () => {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"diary.csv": "Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"watched.csv":
|
||||
"Date,Title,Year,Letterboxd URI\n2024-01-10,Fallback Title,2020,https://boxd.it/1",
|
||||
"watchlist.csv": "Date,Name,Year,Letterboxd URI",
|
||||
@@ -1045,8 +1029,7 @@ describe("parseLetterboxdExport", () => {
|
||||
|
||||
test("watchlist items all have type movie", async () => {
|
||||
const zip = createLetterboxdZip({
|
||||
"diary.csv":
|
||||
"Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"diary.csv": "Date,Name,Year,Letterboxd URI,Rating,Rewatch,Tags,Watched Date",
|
||||
"watched.csv": "Date,Name,Year,Letterboxd URI",
|
||||
"watchlist.csv":
|
||||
"Date,Name,Year,Letterboxd URI\n2024-01-20,WL Item A,2024,https://boxd.it/1\n2024-01-21,WL Item B,2025,https://boxd.it/2",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import {
|
||||
importJobs,
|
||||
@@ -16,17 +17,14 @@ import {
|
||||
testDb,
|
||||
} from "@sofa/db/test-utils";
|
||||
import * as tmdbClient from "@sofa/tmdb/client";
|
||||
|
||||
import type { NormalizedImport } from "../src/imports/parsers";
|
||||
import { processImportJob, readImportJob } from "../src/imports/processor";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Insert a fully-fetched movie title (lastFetchedAt set so metadata won't re-fetch). */
|
||||
function insertMovieTitle(
|
||||
id: string,
|
||||
tmdbId: number,
|
||||
movieTitle = "Test Movie",
|
||||
) {
|
||||
function insertMovieTitle(id: string, tmdbId: number, movieTitle = "Test Movie") {
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({
|
||||
@@ -49,11 +47,7 @@ function insertTvShowWithFetchedAt(
|
||||
) {
|
||||
const result = insertTvShow(titleId, tmdbId, seasonCount, epsPerSeason);
|
||||
// Mark as fully fetched so getOrFetchTitleByTmdbId skips TMDB API calls
|
||||
testDb
|
||||
.update(titles)
|
||||
.set({ lastFetchedAt: new Date() })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
testDb.update(titles).set({ lastFetchedAt: new Date() }).where(eq(titles.id, titleId)).run();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -348,9 +342,7 @@ describe("processImportJob — state transitions", () => {
|
||||
});
|
||||
|
||||
test("readImportJob throws for non-existent job", () => {
|
||||
expect(() => readImportJob("non-existent-id")).toThrow(
|
||||
"Import job non-existent-id not found",
|
||||
);
|
||||
expect(() => readImportJob("non-existent-id")).toThrow("Import job non-existent-id not found");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
||||
|
||||
import * as tmdbClient from "@sofa/tmdb/client";
|
||||
|
||||
import { resolveMovieTmdbId, resolveShowTmdbId } from "../src/imports/resolve";
|
||||
|
||||
// The TMDB client functions (findByExternalId, searchMovies, searchTv) are
|
||||
@@ -56,25 +58,23 @@ describe("resolveMovieTmdbId", () => {
|
||||
|
||||
test("IMDB returns no movie, falls back to TVDB", async () => {
|
||||
let callIndex = 0;
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockImplementation(
|
||||
async () => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) {
|
||||
// IMDB lookup — no results
|
||||
return {
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never;
|
||||
}
|
||||
// TVDB lookup — has result
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockImplementation(async () => {
|
||||
callIndex++;
|
||||
if (callIndex === 1) {
|
||||
// IMDB lookup — no results
|
||||
return {
|
||||
movie_results: [{ id: 789 }],
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
}
|
||||
// TVDB lookup — has result
|
||||
return {
|
||||
movie_results: [{ id: 789 }],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never;
|
||||
});
|
||||
|
||||
const result = await resolveMovieTmdbId({
|
||||
imdbId: "tt0000001",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
import {
|
||||
clearAllTables,
|
||||
insertIntegration,
|
||||
@@ -6,12 +7,8 @@ import {
|
||||
insertTitle,
|
||||
insertUser,
|
||||
} from "@sofa/db/test-utils";
|
||||
import {
|
||||
getRadarrList,
|
||||
getSonarrList,
|
||||
parseStatusParam,
|
||||
resolveListToken,
|
||||
} from "../src/lists";
|
||||
|
||||
import { getRadarrList, getSonarrList, parseStatusParam, resolveListToken } from "../src/lists";
|
||||
|
||||
// Mock getTvExternalIds for lazy resolution tests
|
||||
const mockGetTvExternalIds = mock(
|
||||
@@ -63,17 +60,11 @@ describe("parseStatusParam", () => {
|
||||
});
|
||||
|
||||
test("parses comma-separated statuses", () => {
|
||||
expect(parseStatusParam("watchlist,in_progress")).toEqual([
|
||||
"watchlist",
|
||||
"in_progress",
|
||||
]);
|
||||
expect(parseStatusParam("watchlist,in_progress")).toEqual(["watchlist", "in_progress"]);
|
||||
});
|
||||
|
||||
test("filters invalid statuses", () => {
|
||||
expect(parseStatusParam("watchlist,invalid,completed")).toEqual([
|
||||
"watchlist",
|
||||
"completed",
|
||||
]);
|
||||
expect(parseStatusParam("watchlist,invalid,completed")).toEqual(["watchlist", "completed"]);
|
||||
});
|
||||
|
||||
test("defaults to watchlist when all invalid", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes, seasons, titles } from "@sofa/db/schema";
|
||||
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
|
||||
@@ -52,10 +53,7 @@ mock.module("@sofa/tmdb/client", () => ({
|
||||
getVideos: async () => ({ results: [] }),
|
||||
}));
|
||||
|
||||
import {
|
||||
refreshTvChildren,
|
||||
updateTitleWithArtInvalidation,
|
||||
} from "../src/metadata";
|
||||
import { refreshTvChildren, updateTitleWithArtInvalidation } from "../src/metadata";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
@@ -104,16 +102,8 @@ describe("refreshTvChildren", () => {
|
||||
|
||||
await refreshTvChildren("tv-1", 10, 1);
|
||||
|
||||
const season = testDb
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.id, "season-1"))
|
||||
.get();
|
||||
const episode = testDb
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, "episode-1"))
|
||||
.get();
|
||||
const season = testDb.select().from(seasons).where(eq(seasons.id, "season-1")).get();
|
||||
const episode = testDb.select().from(episodes).where(eq(episodes.id, "episode-1")).get();
|
||||
|
||||
expect(season?.posterPath).toBe("/new-season.png");
|
||||
expect(season?.posterThumbHash).toBeNull();
|
||||
@@ -160,11 +150,7 @@ describe("refreshTvChildren", () => {
|
||||
|
||||
await refreshTvChildren("tv-1", 10, 1);
|
||||
|
||||
const episode = testDb
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, "episode-1"))
|
||||
.get();
|
||||
const episode = testDb.select().from(episodes).where(eq(episodes.id, "episode-1")).get();
|
||||
|
||||
expect(episode?.stillPath).toBeNull();
|
||||
expect(episode?.stillThumbHash).toBeNull();
|
||||
@@ -189,11 +175,7 @@ describe("updateTitleWithArtInvalidation", () => {
|
||||
})
|
||||
.run();
|
||||
|
||||
const existing = testDb
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, "movie-1"))
|
||||
.get();
|
||||
const existing = testDb.select().from(titles).where(eq(titles.id, "movie-1")).get();
|
||||
|
||||
expect(existing).not.toBeUndefined();
|
||||
if (!existing) {
|
||||
@@ -205,11 +187,7 @@ describe("updateTitleWithArtInvalidation", () => {
|
||||
backdropPath: "/new-backdrop.png",
|
||||
});
|
||||
|
||||
const title = testDb
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, "movie-1"))
|
||||
.get();
|
||||
const title = testDb.select().from(titles).where(eq(titles.id, "movie-1")).get();
|
||||
|
||||
expect(title?.posterPath).toBe("/new-poster.png");
|
||||
expect(title?.backdropPath).toBe("/new-backdrop.png");
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
TmdbVideo,
|
||||
} from "@sofa/tmdb/client";
|
||||
|
||||
import type { TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
|
||||
|
||||
import {
|
||||
extractMovieContentRating,
|
||||
extractTvContentRating,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { personFilmography, persons } from "@sofa/db/schema";
|
||||
import { clearAllTables, testDb } from "@sofa/db/test-utils";
|
||||
@@ -133,11 +126,7 @@ describe("getOrFetchPerson", () => {
|
||||
.run();
|
||||
|
||||
const person = await getOrFetchPerson("p1");
|
||||
const stored = testDb
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, "p1"))
|
||||
.get();
|
||||
const stored = testDb.select().from(persons).where(eq(persons.id, "p1")).get();
|
||||
|
||||
expect(person?.profileThumbHash).toBeString();
|
||||
expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
|
||||
@@ -157,11 +146,7 @@ describe("getOrFetchPerson", () => {
|
||||
.run();
|
||||
|
||||
const person = await getOrFetchPerson("p1");
|
||||
const stored = testDb
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, "p1"))
|
||||
.get();
|
||||
const stored = testDb.select().from(persons).where(eq(persons.id, "p1")).get();
|
||||
|
||||
expect(stored?.profilePath).toBe("/new-profile.png");
|
||||
expect(person?.profileThumbHash).toBeString();
|
||||
@@ -184,11 +169,7 @@ describe("fetchFullFilmography", () => {
|
||||
|
||||
const first = await fetchFullFilmography("p1");
|
||||
const second = await fetchFullFilmography("p1");
|
||||
const storedPerson = testDb
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, "p1"))
|
||||
.get();
|
||||
const storedPerson = testDb.select().from(persons).where(eq(persons.id, "p1")).get();
|
||||
const storedCredits = testDb
|
||||
.select()
|
||||
.from(personFilmography)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { personFilmography, persons } from "@sofa/db/schema";
|
||||
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
import { getLocalFilmography } from "../src/person";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -45,10 +47,7 @@ describe("getLocalFilmography", () => {
|
||||
|
||||
const filmography = getLocalFilmography("p1");
|
||||
expect(filmography).toHaveLength(2);
|
||||
expect(filmography.map((f) => f.title).sort()).toEqual([
|
||||
"Movie One",
|
||||
"Movie Two",
|
||||
]);
|
||||
expect(filmography.map((f) => f.title).sort()).toEqual(["Movie One", "Movie Two"]);
|
||||
});
|
||||
|
||||
test("includes crew roles", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, mock } from "bun:test";
|
||||
|
||||
import { applyMigrations, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
process.env.LOG_LEVEL ??= "error";
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { clearAllTables, insertUser } from "@sofa/db/test-utils";
|
||||
import {
|
||||
getSetting,
|
||||
getUserCount,
|
||||
isRegistrationOpen,
|
||||
setSetting,
|
||||
} from "../src/settings";
|
||||
|
||||
import { getSetting, getUserCount, isRegistrationOpen, setSetting } from "../src/settings";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes, seasons, titles } from "@sofa/db/schema";
|
||||
import { clearAllTables, testDb } from "@sofa/db/test-utils";
|
||||
@@ -52,14 +45,8 @@ describe("thumbhash generation", () => {
|
||||
});
|
||||
|
||||
test("clears a stale episode thumbhash when regeneration fails", async () => {
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({ id: "tv-1", tmdbId: 100, type: "tv", title: "Show" })
|
||||
.run();
|
||||
testDb
|
||||
.insert(seasons)
|
||||
.values({ id: "season-1", titleId: "tv-1", seasonNumber: 1 })
|
||||
.run();
|
||||
testDb.insert(titles).values({ id: "tv-1", tmdbId: 100, type: "tv", title: "Show" }).run();
|
||||
testDb.insert(seasons).values({ id: "season-1", titleId: "tv-1", seasonNumber: 1 }).run();
|
||||
testDb
|
||||
.insert(episodes)
|
||||
.values({
|
||||
@@ -74,11 +61,7 @@ describe("thumbhash generation", () => {
|
||||
nextBuffer = null;
|
||||
const hash = await generateEpisodeThumbHash("ep-1", "/new-still.png");
|
||||
|
||||
const episode = testDb
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, "ep-1"))
|
||||
.get();
|
||||
const episode = testDb.select().from(episodes).where(eq(episodes.id, "ep-1")).get();
|
||||
|
||||
expect(hash).toBeNull();
|
||||
expect(episode?.stillThumbHash).toBeNull();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { and, eq } from "@sofa/db/helpers";
|
||||
import {
|
||||
userEpisodeWatches,
|
||||
@@ -6,13 +7,8 @@ import {
|
||||
userRatings,
|
||||
userTitleStatus,
|
||||
} from "@sofa/db/schema";
|
||||
import {
|
||||
clearAllTables,
|
||||
insertTitle,
|
||||
insertTvShow,
|
||||
insertUser,
|
||||
testDb,
|
||||
} from "@sofa/db/test-utils";
|
||||
import { clearAllTables, insertTitle, insertTvShow, insertUser, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
import {
|
||||
getUserTitleInfo,
|
||||
logEpisodeWatch,
|
||||
@@ -41,12 +37,7 @@ describe("setTitleStatus", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("watchlist");
|
||||
});
|
||||
@@ -60,12 +51,7 @@ describe("setTitleStatus", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -79,12 +65,7 @@ describe("setTitleStatus", () => {
|
||||
const rows = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].status).toBe("completed");
|
||||
@@ -103,12 +84,7 @@ describe("removeTitleStatus", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
@@ -146,12 +122,7 @@ describe("logMovieWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -165,12 +136,7 @@ describe("logMovieWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -184,12 +150,7 @@ describe("logMovieWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -203,12 +164,7 @@ describe("logMovieWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -239,12 +195,7 @@ describe("logEpisodeWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -258,12 +209,7 @@ describe("logEpisodeWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -277,12 +223,7 @@ describe("logEpisodeWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -298,12 +239,7 @@ describe("logEpisodeWatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -333,12 +269,7 @@ describe("logEpisodeWatchBatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -351,12 +282,7 @@ describe("logEpisodeWatchBatch", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -392,12 +318,7 @@ describe("markAllEpisodesWatched", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
@@ -460,12 +381,7 @@ describe("unwatchEpisode", () => {
|
||||
let row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
|
||||
@@ -475,12 +391,7 @@ describe("unwatchEpisode", () => {
|
||||
row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -495,12 +406,7 @@ describe("unwatchEpisode", () => {
|
||||
let row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
|
||||
@@ -509,12 +415,7 @@ describe("unwatchEpisode", () => {
|
||||
row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -550,12 +451,7 @@ describe("unwatchSeason", () => {
|
||||
let row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
|
||||
@@ -565,12 +461,7 @@ describe("unwatchSeason", () => {
|
||||
row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userTitleStatus.userId, "user-1"), eq(userTitleStatus.titleId, titleId)))
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
@@ -587,12 +478,7 @@ describe("rateTitleStars", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(
|
||||
eq(userRatings.userId, "user-1"),
|
||||
eq(userRatings.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, "user-1"), eq(userRatings.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row?.ratingStars).toBe(4);
|
||||
});
|
||||
@@ -606,12 +492,7 @@ describe("rateTitleStars", () => {
|
||||
const rows = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(
|
||||
eq(userRatings.userId, "user-1"),
|
||||
eq(userRatings.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, "user-1"), eq(userRatings.titleId, "title-1")))
|
||||
.all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ratingStars).toBe(5);
|
||||
@@ -626,12 +507,7 @@ describe("rateTitleStars", () => {
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(
|
||||
eq(userRatings.userId, "user-1"),
|
||||
eq(userRatings.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.where(and(eq(userRatings.userId, "user-1"), eq(userRatings.titleId, "title-1")))
|
||||
.get();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { isNewerVersion } from "../src/update-check";
|
||||
|
||||
describe("isNewerVersion", () => {
|
||||
|
||||
Reference in new issue
Block a user