mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
feat: add watch history import from Trakt, Simkl, and Letterboxd (#13)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { oc } from "@orpc/contract";
|
||||
import { eventIterator, oc } from "@orpc/contract";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AuthConfigOutput,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BackupsListOutput,
|
||||
BatchWatchInput,
|
||||
ContinueWatchingOutput,
|
||||
CreateImportJobInput,
|
||||
CreateIntegrationInput,
|
||||
DashboardRecommendationsOutput,
|
||||
DashboardStatsOutput,
|
||||
@@ -15,12 +16,17 @@ import {
|
||||
FilenameParam,
|
||||
GenresOutput,
|
||||
IdParam,
|
||||
ImportJobEvent,
|
||||
ImportJobSchema,
|
||||
ImportPreviewSchema,
|
||||
IntegrationOutput,
|
||||
IntegrationsListOutput,
|
||||
LibraryOutput,
|
||||
MediaTypeParam,
|
||||
PageParam,
|
||||
PaginatedInput,
|
||||
ParseFileInput,
|
||||
ParsePayloadInput,
|
||||
PersonDetailOutput,
|
||||
PopularOutput,
|
||||
ProviderParam,
|
||||
@@ -683,4 +689,77 @@ export const contract = {
|
||||
.input(z.void())
|
||||
.output(z.void()),
|
||||
},
|
||||
imports: {
|
||||
parseFile: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/imports/parse-file",
|
||||
tags: ["Imports"],
|
||||
summary: "Parse import file",
|
||||
description:
|
||||
"Upload and parse an export file from Trakt, Simkl, or Letterboxd. Returns a preview of items found without importing anything.",
|
||||
successDescription: "Preview of importable items with counts",
|
||||
})
|
||||
.input(ParseFileInput)
|
||||
.output(ImportPreviewSchema),
|
||||
parsePayload: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/imports/parse-payload",
|
||||
tags: ["Imports"],
|
||||
summary: "Preview normalized import data",
|
||||
description:
|
||||
"Accept pre-normalized import data from the OAuth proxy and return a preview with item counts. No parsing is needed — data is already in NormalizedImport format.",
|
||||
successDescription: "Preview of importable items with counts",
|
||||
})
|
||||
.input(ParsePayloadInput)
|
||||
.output(ImportPreviewSchema),
|
||||
createJob: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/imports/jobs",
|
||||
tags: ["Imports"],
|
||||
summary: "Create import job",
|
||||
description:
|
||||
"Create and start a background import job from previously parsed data.",
|
||||
successDescription: "Created import job",
|
||||
})
|
||||
.input(CreateImportJobInput)
|
||||
.output(ImportJobSchema),
|
||||
getJob: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/imports/jobs/{id}",
|
||||
tags: ["Imports"],
|
||||
summary: "Get import job status",
|
||||
description: "Get the current status and progress of an import job.",
|
||||
successDescription: "Current job state",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(ImportJobSchema),
|
||||
cancelJob: oc
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/imports/jobs/{id}/cancel",
|
||||
tags: ["Imports"],
|
||||
summary: "Cancel import job",
|
||||
description:
|
||||
"Cancel a pending or running import job. Already-imported items are not rolled back.",
|
||||
successDescription: "Updated job state",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(ImportJobSchema),
|
||||
jobEvents: oc
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/imports/jobs/{id}/events",
|
||||
tags: ["Imports"],
|
||||
summary: "Stream import job events",
|
||||
description:
|
||||
"SSE stream of import job progress events. Yields progress updates and a final complete event.",
|
||||
successDescription: "Stream of job progress events",
|
||||
})
|
||||
.input(IdParam)
|
||||
.output(eventIterator(ImportJobEvent)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -887,6 +887,7 @@ export const SystemStatusOutput = z
|
||||
tmdbConfigured: z
|
||||
.boolean()
|
||||
.describe("Whether a TMDB API token is configured"),
|
||||
publicApiUrl: z.string().describe("Base URL of the centralized public API"),
|
||||
})
|
||||
.meta({ description: "Quick TMDB configuration check" });
|
||||
|
||||
@@ -1095,6 +1096,142 @@ export const AuthConfigOutput = z
|
||||
description: "Authentication provider configuration",
|
||||
});
|
||||
|
||||
// ─── Imports ──────────────────────────────────────────────────
|
||||
|
||||
export const ImportSourceEnum = z
|
||||
.enum(["trakt", "simkl", "letterboxd"])
|
||||
.describe("External service to import from");
|
||||
|
||||
export const ImportMovieSchema = z.object({
|
||||
tmdbId: z.number().optional(),
|
||||
imdbId: z.string().optional(),
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
watchedAt: z
|
||||
.string()
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe("ISO 8601 timestamp"),
|
||||
watchedOn: z.string().date().optional().describe("YYYY-MM-DD date-only"),
|
||||
});
|
||||
|
||||
export const ImportEpisodeSchema = z.object({
|
||||
showTmdbId: z.number().optional(),
|
||||
imdbId: z.string().optional(),
|
||||
tvdbId: z.number().optional(),
|
||||
showTitle: z.string().optional(),
|
||||
year: z.number().optional(),
|
||||
seasonNumber: z.number().int().min(0),
|
||||
episodeNumber: z.number().int().min(1),
|
||||
watchedAt: z.string().datetime({ offset: true }).optional(),
|
||||
watchedOn: z.string().date().optional(),
|
||||
});
|
||||
|
||||
export const ImportWatchlistItemSchema = z.object({
|
||||
tmdbId: z.number().optional(),
|
||||
imdbId: z.string().optional(),
|
||||
tvdbId: z.number().optional(),
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
type: z.enum(["movie", "tv"]),
|
||||
});
|
||||
|
||||
export const ImportRatingSchema = z.object({
|
||||
tmdbId: z.number().optional(),
|
||||
imdbId: z.string().optional(),
|
||||
tvdbId: z.number().optional(),
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
type: z.enum(["movie", "tv"]),
|
||||
rating: z.number().int().min(1).max(5).describe("Sofa 1-5 star rating"),
|
||||
ratedAt: z.string().datetime({ offset: true }).optional(),
|
||||
ratedOn: z.string().date().optional(),
|
||||
});
|
||||
|
||||
export const NormalizedImportSchema = z.object({
|
||||
source: ImportSourceEnum,
|
||||
movies: z.array(ImportMovieSchema).max(50_000),
|
||||
episodes: z.array(ImportEpisodeSchema).max(50_000),
|
||||
watchlist: z.array(ImportWatchlistItemSchema).max(50_000),
|
||||
ratings: z.array(ImportRatingSchema).max(50_000),
|
||||
});
|
||||
|
||||
export const ImportOptionsSchema = z.object({
|
||||
importWatches: z.boolean().describe("Import movie and episode watch history"),
|
||||
importWatchlist: z.boolean().describe("Import watchlist items"),
|
||||
importRatings: z.boolean().describe("Import ratings"),
|
||||
});
|
||||
|
||||
export const ImportResultSchema = z.object({
|
||||
imported: z.number().describe("Items successfully imported"),
|
||||
skipped: z.number().describe("Items skipped (already exist)"),
|
||||
failed: z.number().describe("Items that failed to import"),
|
||||
errors: z.array(z.string()).describe("Error messages for failed items"),
|
||||
warnings: z.array(z.string()).describe("Non-fatal warnings"),
|
||||
});
|
||||
|
||||
export const ImportPreviewSchema = z.object({
|
||||
data: NormalizedImportSchema,
|
||||
warnings: z.array(z.string()),
|
||||
stats: z.object({
|
||||
movies: z.number(),
|
||||
episodes: z.number(),
|
||||
watchlist: z.number(),
|
||||
ratings: z.number(),
|
||||
}),
|
||||
diagnostics: z
|
||||
.object({
|
||||
unresolved: z.number(),
|
||||
unsupported: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
blockingErrors: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ParseFileInput = z.object({
|
||||
source: ImportSourceEnum,
|
||||
file: z.file().max(100 * 1024 * 1024, "File too large (max 100 MB)"),
|
||||
});
|
||||
|
||||
export const ParsePayloadInput = z.object({
|
||||
data: NormalizedImportSchema,
|
||||
});
|
||||
|
||||
export const ImportJobStatusEnum = z.enum([
|
||||
"pending",
|
||||
"running",
|
||||
"success",
|
||||
"error",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export const ImportJobSchema = z.object({
|
||||
id: z.string(),
|
||||
source: ImportSourceEnum,
|
||||
status: ImportJobStatusEnum,
|
||||
totalItems: z.number(),
|
||||
processedItems: z.number(),
|
||||
importedCount: z.number(),
|
||||
skippedCount: z.number(),
|
||||
failedCount: z.number(),
|
||||
currentMessage: z.string().nullable(),
|
||||
errors: z.array(z.string()),
|
||||
warnings: z.array(z.string()),
|
||||
createdAt: z.string(),
|
||||
startedAt: z.string().nullable(),
|
||||
finishedAt: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const CreateImportJobInput = z.object({
|
||||
data: NormalizedImportSchema,
|
||||
options: ImportOptionsSchema,
|
||||
});
|
||||
|
||||
export const ImportJobEvent = z.object({
|
||||
type: z.enum(["progress", "complete", "timeout"]),
|
||||
job: ImportJobSchema,
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Inferred types — use these instead of hand-written interfaces
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1116,4 +1253,6 @@ export type Season = z.infer<typeof SeasonSchema>;
|
||||
export type SystemHealthData = z.infer<typeof SystemHealthSchema>;
|
||||
export type PaginationInfo = z.infer<typeof PaginationMeta>;
|
||||
export type TimePeriod = z.infer<typeof WatchHistoryInput>["period"];
|
||||
export type ImportJob = z.infer<typeof ImportJobSchema>;
|
||||
export type NormalizedImport = z.infer<typeof NormalizedImportSchema>;
|
||||
export type UpdateCheckResult = z.infer<typeof UpdateCheckResultSchema>;
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"./credits": "./src/credits.ts",
|
||||
"./discovery": "./src/discovery.ts",
|
||||
"./image-cache": "./src/image-cache.ts",
|
||||
"./imports": "./src/imports/index.ts",
|
||||
"./imports/parsers": "./src/imports/parsers.ts",
|
||||
"./lists": "./src/lists.ts",
|
||||
"./metadata": "./src/metadata.ts",
|
||||
"./person": "./src/person.ts",
|
||||
@@ -35,6 +37,7 @@
|
||||
"@sofa/db": "workspace:*",
|
||||
"@sofa/logger": "workspace:*",
|
||||
"@sofa/tmdb": "workspace:*",
|
||||
"adm-zip": "0.5.16",
|
||||
"date-fns": "catalog:",
|
||||
"node-vibrant": "4.0.4",
|
||||
"sharp": "0.34.5",
|
||||
@@ -42,6 +45,7 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "0.5.8",
|
||||
"@types/bun": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
countUnresolved,
|
||||
type ImportEpisode,
|
||||
type ImportMovie,
|
||||
type ImportRating,
|
||||
type ImportSource,
|
||||
type ImportWatchlistItem,
|
||||
type NormalizedImport,
|
||||
type ParseDiagnostics,
|
||||
type ParseResult,
|
||||
parseLetterboxdExport,
|
||||
parseSimklPayload,
|
||||
parseTraktPayload,
|
||||
} from "./parsers";
|
||||
export {
|
||||
type ImportOptions,
|
||||
type ImportResult,
|
||||
processImportJob,
|
||||
readImportJob,
|
||||
} from "./processor";
|
||||
export {
|
||||
type ExternalIds,
|
||||
resolveMovieTmdbId,
|
||||
resolveShowTmdbId,
|
||||
} from "./resolve";
|
||||
@@ -0,0 +1,656 @@
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
const log = createLogger("imports");
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ImportMovie {
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
title: string;
|
||||
year?: number;
|
||||
watchedAt?: string;
|
||||
watchedOn?: string;
|
||||
}
|
||||
|
||||
export interface ImportEpisode {
|
||||
showTmdbId?: number;
|
||||
imdbId?: string;
|
||||
tvdbId?: number;
|
||||
showTitle?: string;
|
||||
year?: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
watchedAt?: string;
|
||||
watchedOn?: string;
|
||||
}
|
||||
|
||||
export interface ImportWatchlistItem {
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
tvdbId?: number;
|
||||
title: string;
|
||||
year?: number;
|
||||
type: "movie" | "tv";
|
||||
}
|
||||
|
||||
export interface ImportRating {
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
tvdbId?: number;
|
||||
title: string;
|
||||
year?: number;
|
||||
type: "movie" | "tv";
|
||||
rating: number; // 1-5 (Sofa scale)
|
||||
ratedAt?: string;
|
||||
ratedOn?: string;
|
||||
}
|
||||
|
||||
export type ImportSource = "trakt" | "simkl" | "letterboxd";
|
||||
|
||||
export interface NormalizedImport {
|
||||
source: ImportSource;
|
||||
movies: ImportMovie[];
|
||||
episodes: ImportEpisode[];
|
||||
watchlist: ImportWatchlistItem[];
|
||||
ratings: ImportRating[];
|
||||
}
|
||||
|
||||
export interface ParseDiagnostics {
|
||||
unresolved: number;
|
||||
unsupported: number;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
data: NormalizedImport;
|
||||
warnings: string[];
|
||||
diagnostics?: ParseDiagnostics;
|
||||
}
|
||||
|
||||
// ─── Diagnostics ────────────────────────────────────────────────────
|
||||
|
||||
/** Count items that have no external IDs and will need title-based resolution. */
|
||||
export function countUnresolved(data: NormalizedImport): number {
|
||||
let count = 0;
|
||||
for (const m of data.movies) {
|
||||
if (!m.tmdbId && !m.imdbId) count++;
|
||||
}
|
||||
for (const e of data.episodes) {
|
||||
if (!e.showTmdbId && !e.imdbId && !e.tvdbId) count++;
|
||||
}
|
||||
for (const w of data.watchlist) {
|
||||
if (!w.tmdbId && !w.imdbId && !w.tvdbId) count++;
|
||||
}
|
||||
for (const r of data.ratings) {
|
||||
if (!r.tmdbId && !r.imdbId && !r.tvdbId) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ─── Rating Conversion ──────────────────────────────────────────────
|
||||
|
||||
/** Convert a 1-10 rating to Sofa's 1-5 integer scale. */
|
||||
function convertRating10to5(rating: number): number | null {
|
||||
if (rating < 1 || rating > 10) return null;
|
||||
return Math.max(1, Math.min(5, Math.round(rating / 2)));
|
||||
}
|
||||
|
||||
/** Convert Letterboxd's 0.5-5 half-star rating to Sofa's 1-5 integer scale. */
|
||||
function convertLetterboxdRating(rating: number): number | null {
|
||||
if (rating < 0.5 || rating > 5) return null;
|
||||
return Math.max(1, Math.min(5, Math.round(rating)));
|
||||
}
|
||||
|
||||
// ─── CSV Parsing ────────────────────────────────────────────────────
|
||||
|
||||
/** Simple CSV parser that handles quoted fields with commas. */
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (i + 1 < line.length && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"') {
|
||||
inQuotes = true;
|
||||
} else if (char === ",") {
|
||||
fields.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
fields.push(current.trim());
|
||||
return fields;
|
||||
}
|
||||
|
||||
function parseCsv(text: string): Record<string, string>[] {
|
||||
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const headers = parseCsvLine(lines[0]);
|
||||
const rows: Record<string, string>[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = parseCsvLine(lines[i]);
|
||||
const row: Record<string, string> = {};
|
||||
for (let j = 0; j < headers.length; j++) {
|
||||
row[headers[j]] = values[j] ?? "";
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Trakt Parser ───────────────────────────────────────────────────
|
||||
|
||||
interface TraktIds {
|
||||
trakt?: number;
|
||||
slug?: string;
|
||||
imdb?: string;
|
||||
tmdb?: number;
|
||||
tvdb?: number;
|
||||
}
|
||||
|
||||
interface TraktHistoryMovie {
|
||||
watched_at?: string;
|
||||
movie?: { title?: string; year?: number; ids?: TraktIds };
|
||||
}
|
||||
|
||||
interface TraktHistoryEpisode {
|
||||
watched_at?: string;
|
||||
show?: { title?: string; year?: number; ids?: TraktIds };
|
||||
episode?: {
|
||||
season?: number;
|
||||
number?: number;
|
||||
title?: string;
|
||||
ids?: TraktIds;
|
||||
};
|
||||
}
|
||||
|
||||
interface TraktWatchlistItem {
|
||||
type?: "movie" | "show";
|
||||
movie?: { title?: string; year?: number; ids?: TraktIds };
|
||||
show?: { title?: string; year?: number; ids?: TraktIds };
|
||||
}
|
||||
|
||||
interface TraktRatingItem {
|
||||
type?: "movie" | "show";
|
||||
rating?: number;
|
||||
rated_at?: string;
|
||||
movie?: { title?: string; year?: number; ids?: TraktIds };
|
||||
show?: { title?: string; year?: number; ids?: TraktIds };
|
||||
}
|
||||
|
||||
export function parseTraktPayload(data: {
|
||||
history?: { movies?: TraktHistoryMovie[]; shows?: TraktHistoryEpisode[] };
|
||||
watchlist?: TraktWatchlistItem[];
|
||||
ratings?: TraktRatingItem[];
|
||||
}): ParseResult {
|
||||
const warnings: string[] = [];
|
||||
const movies: ImportMovie[] = [];
|
||||
const episodes: ImportEpisode[] = [];
|
||||
const watchlist: ImportWatchlistItem[] = [];
|
||||
const ratings: ImportRating[] = [];
|
||||
|
||||
// Movie watch history
|
||||
for (const item of data.history?.movies ?? []) {
|
||||
const movie = item.movie;
|
||||
if (!movie?.title) continue;
|
||||
movies.push({
|
||||
tmdbId: movie.ids?.tmdb,
|
||||
imdbId: movie.ids?.imdb,
|
||||
title: movie.title,
|
||||
year: movie.year,
|
||||
watchedAt: item.watched_at,
|
||||
});
|
||||
}
|
||||
|
||||
// Episode watch history
|
||||
for (const item of data.history?.shows ?? []) {
|
||||
const show = item.show;
|
||||
const ep = item.episode;
|
||||
if (!show?.title || ep?.season == null || ep?.number == null) continue;
|
||||
episodes.push({
|
||||
showTmdbId: show.ids?.tmdb,
|
||||
imdbId: show.ids?.imdb,
|
||||
tvdbId: show.ids?.tvdb,
|
||||
showTitle: show.title,
|
||||
year: show.year,
|
||||
seasonNumber: ep.season,
|
||||
episodeNumber: ep.number,
|
||||
watchedAt: item.watched_at,
|
||||
});
|
||||
}
|
||||
|
||||
// Watchlist
|
||||
for (const item of data.watchlist ?? []) {
|
||||
const entry = item.type === "movie" ? item.movie : item.show;
|
||||
if (!entry?.title) continue;
|
||||
watchlist.push({
|
||||
tmdbId: entry.ids?.tmdb,
|
||||
imdbId: entry.ids?.imdb,
|
||||
tvdbId: entry.ids?.tvdb,
|
||||
title: entry.title,
|
||||
year: entry.year,
|
||||
type: item.type === "show" ? "tv" : "movie",
|
||||
});
|
||||
}
|
||||
|
||||
// Ratings
|
||||
for (const item of data.ratings ?? []) {
|
||||
const entry = item.type === "movie" ? item.movie : item.show;
|
||||
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}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
ratings.push({
|
||||
tmdbId: entry.ids?.tmdb,
|
||||
imdbId: entry.ids?.imdb,
|
||||
tvdbId: entry.ids?.tvdb,
|
||||
title: entry.title,
|
||||
year: entry.year,
|
||||
type: item.type === "show" ? "tv" : "movie",
|
||||
rating: converted,
|
||||
ratedAt: item.rated_at,
|
||||
});
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Parsed Trakt data: ${movies.length} movies, ${episodes.length} episodes, ${watchlist.length} watchlist, ${ratings.length} ratings`,
|
||||
);
|
||||
|
||||
const normalized: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies,
|
||||
episodes,
|
||||
watchlist,
|
||||
ratings,
|
||||
};
|
||||
|
||||
return {
|
||||
data: normalized,
|
||||
warnings,
|
||||
diagnostics: { unresolved: countUnresolved(normalized), unsupported: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Simkl Parser ───────────────────────────────────────────────────
|
||||
|
||||
interface SimklIds {
|
||||
simkl?: number;
|
||||
imdb?: string;
|
||||
tmdb?: string | number;
|
||||
tvdb?: string | number;
|
||||
mal?: string | number;
|
||||
}
|
||||
|
||||
interface SimklItem {
|
||||
title?: string;
|
||||
year?: number;
|
||||
type?: string; // "movie", "show", "anime"
|
||||
ids?: SimklIds;
|
||||
status?: string; // "completed", "watching", "plantowatch", "dropped", "hold"
|
||||
user_rating?: number;
|
||||
last_watched_at?: string;
|
||||
watched_episodes_count?: number;
|
||||
total_episodes_count?: number;
|
||||
seasons?: {
|
||||
number?: number;
|
||||
episodes?: { number?: number; watched_at?: string }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export function parseSimklPayload(data: {
|
||||
movies?: SimklItem[];
|
||||
shows?: SimklItem[];
|
||||
anime?: SimklItem[];
|
||||
}): ParseResult {
|
||||
const warnings: string[] = [];
|
||||
const movies: ImportMovie[] = [];
|
||||
const episodes: ImportEpisode[] = [];
|
||||
const watchlist: ImportWatchlistItem[] = [];
|
||||
const ratings: ImportRating[] = [];
|
||||
|
||||
// Movies
|
||||
for (const item of data.movies ?? []) {
|
||||
if (!item.title) continue;
|
||||
const tmdbId =
|
||||
typeof item.ids?.tmdb === "number"
|
||||
? item.ids.tmdb
|
||||
: item.ids?.tmdb
|
||||
? Number(item.ids.tmdb)
|
||||
: undefined;
|
||||
|
||||
if (item.status === "plantowatch") {
|
||||
watchlist.push({
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
type: "movie",
|
||||
});
|
||||
} else if (
|
||||
item.status === "completed" ||
|
||||
item.status === "watching" ||
|
||||
item.last_watched_at
|
||||
) {
|
||||
movies.push({
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
watchedAt: item.last_watched_at,
|
||||
});
|
||||
}
|
||||
|
||||
if (item.user_rating != null) {
|
||||
const converted = convertRating10to5(item.user_rating);
|
||||
if (converted != null) {
|
||||
ratings.push({
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
type: "movie",
|
||||
rating: converted,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shows + Anime (both map to TV type)
|
||||
const allShows = [...(data.shows ?? []), ...(data.anime ?? [])];
|
||||
for (const item of allShows) {
|
||||
if (!item.title) continue;
|
||||
const tmdbId =
|
||||
typeof item.ids?.tmdb === "number"
|
||||
? item.ids.tmdb
|
||||
: item.ids?.tmdb
|
||||
? Number(item.ids.tmdb)
|
||||
: undefined;
|
||||
const tvdbId =
|
||||
typeof item.ids?.tvdb === "number"
|
||||
? item.ids.tvdb
|
||||
: item.ids?.tvdb
|
||||
? Number(item.ids.tvdb)
|
||||
: undefined;
|
||||
|
||||
if (item.status === "plantowatch") {
|
||||
watchlist.push({
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
tvdbId: tvdbId ?? undefined,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
type: "tv",
|
||||
});
|
||||
}
|
||||
|
||||
// Extract individual episodes from seasons data
|
||||
if (item.seasons) {
|
||||
for (const season of item.seasons) {
|
||||
if (season.number == null) continue;
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if (ep.number == null) continue;
|
||||
episodes.push({
|
||||
showTmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
tvdbId: tvdbId ?? undefined,
|
||||
showTitle: item.title,
|
||||
year: item.year,
|
||||
seasonNumber: season.number,
|
||||
episodeNumber: ep.number,
|
||||
watchedAt: ep.watched_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (item.user_rating != null) {
|
||||
const converted = convertRating10to5(item.user_rating);
|
||||
if (converted != null) {
|
||||
ratings.push({
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
imdbId: item.ids?.imdb,
|
||||
tvdbId: tvdbId ?? undefined,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
type: "tv",
|
||||
rating: converted,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Parsed Simkl data: ${movies.length} movies, ${episodes.length} episodes, ${watchlist.length} watchlist, ${ratings.length} ratings`,
|
||||
);
|
||||
|
||||
const normalized: NormalizedImport = {
|
||||
source: "simkl",
|
||||
movies,
|
||||
episodes,
|
||||
watchlist,
|
||||
ratings,
|
||||
};
|
||||
|
||||
return {
|
||||
data: normalized,
|
||||
warnings,
|
||||
diagnostics: { unresolved: countUnresolved(normalized), unsupported: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Letterboxd Parser ──────────────────────────────────────────────
|
||||
|
||||
const LETTERBOXD_EXPECTED_FILES = [
|
||||
"diary.csv",
|
||||
"watched.csv",
|
||||
"watchlist.csv",
|
||||
"ratings.csv",
|
||||
] as const;
|
||||
|
||||
const LETTERBOXD_IGNORED_FILES = new Set([
|
||||
"reviews.csv",
|
||||
"profile.csv",
|
||||
"comments.csv",
|
||||
]);
|
||||
|
||||
export async function parseLetterboxdExport(
|
||||
zipFile: Blob,
|
||||
): Promise<ParseResult> {
|
||||
const warnings: string[] = [];
|
||||
const movies: ImportMovie[] = [];
|
||||
const watchlist: ImportWatchlistItem[] = [];
|
||||
const ratings: ImportRating[] = [];
|
||||
|
||||
const AdmZip = (await import("adm-zip")).default;
|
||||
let zip: InstanceType<typeof AdmZip>;
|
||||
try {
|
||||
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.",
|
||||
);
|
||||
return {
|
||||
data: {
|
||||
source: "letterboxd",
|
||||
movies,
|
||||
episodes: [],
|
||||
watchlist,
|
||||
ratings,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = zip.getEntries();
|
||||
const entryMap = new Map<string, string>();
|
||||
const allFilenames: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
// 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],
|
||||
)
|
||||
) {
|
||||
entryMap.set(name, entry.getData().toString("utf-8"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check which expected files exist
|
||||
for (const expected of LETTERBOXD_EXPECTED_FILES) {
|
||||
if (!entryMap.has(expected)) {
|
||||
warnings.push(
|
||||
`${expected} not found in export — the corresponding data will not be imported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Log ignored files
|
||||
for (const name of allFilenames) {
|
||||
if (
|
||||
LETTERBOXD_EXPECTED_FILES.includes(
|
||||
name as (typeof LETTERBOXD_EXPECTED_FILES)[number],
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!LETTERBOXD_IGNORED_FILES.has(name)) {
|
||||
log.debug(`Ignoring unknown file in Letterboxd export: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readCsv(filename: string): Record<string, string>[] {
|
||||
const text = entryMap.get(filename);
|
||||
if (!text) return [];
|
||||
return parseCsv(text);
|
||||
}
|
||||
|
||||
// Track which movies we've already seen (for dedup between diary and watched.csv)
|
||||
const seenMovies = new Set<string>();
|
||||
|
||||
// Parse diary.csv (watch history with dates and optional ratings)
|
||||
for (const row of readCsv("diary.csv")) {
|
||||
const title = row.Name || row.Title;
|
||||
const yearStr = row.Year;
|
||||
if (!title) continue;
|
||||
|
||||
const year = yearStr ? Number.parseInt(yearStr, 10) : undefined;
|
||||
const watchedOn = (row["Watched Date"] || row.WatchedDate) ?? "";
|
||||
// Include date in key to preserve rewatches of the same movie
|
||||
const key = `${title}::${year ?? ""}::${watchedOn}`;
|
||||
if (seenMovies.has(key)) continue;
|
||||
seenMovies.add(key);
|
||||
// Also mark title+year as seen to dedup against watched.csv
|
||||
seenMovies.add(`${title}::${year ?? ""}`);
|
||||
|
||||
movies.push({
|
||||
title,
|
||||
year: year && !Number.isNaN(year) ? year : undefined,
|
||||
watchedOn: row["Watched Date"] || row.WatchedDate || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse watched.csv (films marked watched without specific dates)
|
||||
for (const row of readCsv("watched.csv")) {
|
||||
const title = row.Name || row.Title;
|
||||
const yearStr = row.Year;
|
||||
if (!title) continue;
|
||||
|
||||
const year = yearStr ? Number.parseInt(yearStr, 10) : undefined;
|
||||
const key = `${title}::${year ?? ""}`;
|
||||
if (seenMovies.has(key)) continue;
|
||||
seenMovies.add(key);
|
||||
|
||||
movies.push({
|
||||
title,
|
||||
year: year && !Number.isNaN(year) ? year : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse watchlist.csv
|
||||
for (const row of readCsv("watchlist.csv")) {
|
||||
const title = row.Name || row.Title;
|
||||
const yearStr = row.Year;
|
||||
if (!title) continue;
|
||||
|
||||
const year = yearStr ? Number.parseInt(yearStr, 10) : undefined;
|
||||
watchlist.push({
|
||||
title,
|
||||
year: year && !Number.isNaN(year) ? year : undefined,
|
||||
type: "movie",
|
||||
});
|
||||
}
|
||||
|
||||
// Parse ratings.csv
|
||||
for (const row of readCsv("ratings.csv")) {
|
||||
const title = row.Name || row.Title;
|
||||
const yearStr = row.Year;
|
||||
const ratingStr = row.Rating;
|
||||
if (!title || !ratingStr) continue;
|
||||
|
||||
const rawRating = Number.parseFloat(ratingStr);
|
||||
if (Number.isNaN(rawRating)) continue;
|
||||
|
||||
const converted = convertLetterboxdRating(rawRating);
|
||||
if (converted == null) {
|
||||
warnings.push(
|
||||
`Skipped invalid Letterboxd rating ${rawRating} for "${title}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const year = yearStr ? Number.parseInt(yearStr, 10) : undefined;
|
||||
ratings.push({
|
||||
title,
|
||||
year: year && !Number.isNaN(year) ? year : undefined,
|
||||
type: "movie",
|
||||
rating: converted,
|
||||
ratedOn: row.Date || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Parsed Letterboxd export: ${movies.length} movies, ${watchlist.length} watchlist, ${ratings.length} ratings`,
|
||||
);
|
||||
|
||||
// Letterboxd has no IDs — all items need title-based resolution
|
||||
const unresolved = movies.length + watchlist.length + ratings.length;
|
||||
|
||||
return {
|
||||
data: { source: "letterboxd", movies, episodes: [], watchlist, ratings },
|
||||
warnings,
|
||||
diagnostics: { unresolved, unsupported: 0 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
import { type ImportJob, NormalizedImportSchema } from "@sofa/api/schemas";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { and, eq } from "@sofa/db/helpers";
|
||||
import {
|
||||
episodes,
|
||||
importJobs,
|
||||
seasons,
|
||||
userEpisodeWatches,
|
||||
userMovieWatches,
|
||||
userRatings,
|
||||
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 { resolveMovieTmdbId, resolveShowTmdbId } from "./resolve";
|
||||
|
||||
const log = createLogger("imports");
|
||||
|
||||
function safeParseJsonArray(value: string | null): string[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ImportOptions {
|
||||
importWatches: boolean;
|
||||
importWatchlist: boolean;
|
||||
importRatings: boolean;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ─── Deduplication ──────────────────────────────────────────────────
|
||||
|
||||
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),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
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)),
|
||||
)
|
||||
.get();
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
// ─── Item Processors ────────────────────────────────────────────────
|
||||
|
||||
async function processMovie(
|
||||
userId: string,
|
||||
movie: ImportMovie,
|
||||
result: ImportResult,
|
||||
cache?: Map<string, number | null>,
|
||||
): Promise<void> {
|
||||
const tmdbId = await resolveMovieTmdbId(
|
||||
{
|
||||
tmdbId: movie.tmdbId,
|
||||
imdbId: movie.imdbId,
|
||||
title: movie.title,
|
||||
year: movie.year,
|
||||
},
|
||||
cache,
|
||||
);
|
||||
|
||||
if (!tmdbId) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Could not resolve movie: "${movie.title}"${movie.year ? ` (${movie.year})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = await getOrFetchTitleByTmdbId(tmdbId, "movie");
|
||||
if (!title) {
|
||||
result.failed++;
|
||||
result.errors.push(`Failed to fetch metadata for movie TMDB ${tmdbId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasExistingMovieWatch(userId, title.id)) {
|
||||
result.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
const watchedAt = movie.watchedAt
|
||||
? new Date(movie.watchedAt)
|
||||
: movie.watchedOn
|
||||
? new Date(movie.watchedOn)
|
||||
: undefined;
|
||||
logMovieWatch(userId, title.id, "import", watchedAt);
|
||||
result.imported++;
|
||||
}
|
||||
|
||||
async function processEpisode(
|
||||
userId: string,
|
||||
ep: ImportEpisode,
|
||||
result: ImportResult,
|
||||
cache?: Map<string, number | null>,
|
||||
): Promise<void> {
|
||||
const showTmdbId = await resolveShowTmdbId(
|
||||
{
|
||||
tmdbId: ep.showTmdbId,
|
||||
imdbId: ep.imdbId,
|
||||
tvdbId: ep.tvdbId,
|
||||
title: ep.showTitle,
|
||||
year: ep.year,
|
||||
},
|
||||
cache,
|
||||
);
|
||||
|
||||
if (!showTmdbId) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Could not resolve show: "${ep.showTitle ?? "unknown"}" S${ep.seasonNumber}E${ep.episodeNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = await getOrFetchTitleByTmdbId(showTmdbId, "tv");
|
||||
if (!title) {
|
||||
result.failed++;
|
||||
result.errors.push(`Failed to fetch metadata for show TMDB ${showTmdbId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the specific episode in our DB
|
||||
const season = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.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}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const episode = db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.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}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasExistingEpisodeWatch(userId, episode.id)) {
|
||||
result.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
const watchedAt = ep.watchedAt
|
||||
? new Date(ep.watchedAt)
|
||||
: ep.watchedOn
|
||||
? new Date(ep.watchedOn)
|
||||
: undefined;
|
||||
logEpisodeWatch(userId, episode.id, "import", watchedAt);
|
||||
result.imported++;
|
||||
}
|
||||
|
||||
async function processWatchlistItem(
|
||||
userId: string,
|
||||
item: ImportWatchlistItem,
|
||||
result: ImportResult,
|
||||
cache?: Map<string, number | null>,
|
||||
): Promise<void> {
|
||||
const resolveFn =
|
||||
item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
|
||||
const tmdbId = await resolveFn(
|
||||
{
|
||||
tmdbId: item.tmdbId,
|
||||
imdbId: item.imdbId,
|
||||
tvdbId: item.tvdbId,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
},
|
||||
cache,
|
||||
);
|
||||
|
||||
if (!tmdbId) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Could not resolve watchlist item: "${item.title}"${item.year ? ` (${item.year})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = await getOrFetchTitleByTmdbId(tmdbId, item.type);
|
||||
if (!title) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasExistingWatchlistStatus(userId, title.id)) {
|
||||
result.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
setTitleStatus(userId, title.id, "watchlist", "import");
|
||||
result.imported++;
|
||||
}
|
||||
|
||||
async function processRating(
|
||||
userId: string,
|
||||
item: ImportRating,
|
||||
result: ImportResult,
|
||||
cache?: Map<string, number | null>,
|
||||
): Promise<void> {
|
||||
const resolveFn =
|
||||
item.type === "movie" ? resolveMovieTmdbId : resolveShowTmdbId;
|
||||
const tmdbId = await resolveFn(
|
||||
{
|
||||
tmdbId: item.tmdbId,
|
||||
imdbId: item.imdbId,
|
||||
tvdbId: item.tvdbId,
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
},
|
||||
cache,
|
||||
);
|
||||
|
||||
if (!tmdbId) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Could not resolve rating item: "${item.title}"${item.year ? ` (${item.year})` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = await getOrFetchTitleByTmdbId(tmdbId, item.type);
|
||||
if (!title) {
|
||||
result.failed++;
|
||||
result.errors.push(
|
||||
`Failed to fetch metadata for ${item.type} TMDB ${tmdbId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasExistingRating(userId, title.id)) {
|
||||
result.skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
const ratedAt = item.ratedAt
|
||||
? new Date(item.ratedAt)
|
||||
: item.ratedOn
|
||||
? new Date(item.ratedOn)
|
||||
: undefined;
|
||||
rateTitleStars(userId, title.id, item.rating, ratedAt);
|
||||
result.imported++;
|
||||
}
|
||||
|
||||
// ─── Read Job Helper ─────────────────────────────────────────────────
|
||||
|
||||
export function readImportJob(jobId: string, userId?: string): ImportJob {
|
||||
const row = db
|
||||
.select({
|
||||
id: importJobs.id,
|
||||
userId: importJobs.userId,
|
||||
source: importJobs.source,
|
||||
status: importJobs.status,
|
||||
totalItems: importJobs.totalItems,
|
||||
processedItems: importJobs.processedItems,
|
||||
importedCount: importJobs.importedCount,
|
||||
skippedCount: importJobs.skippedCount,
|
||||
failedCount: importJobs.failedCount,
|
||||
currentMessage: importJobs.currentMessage,
|
||||
errors: importJobs.errors,
|
||||
warnings: importJobs.warnings,
|
||||
createdAt: importJobs.createdAt,
|
||||
startedAt: importJobs.startedAt,
|
||||
finishedAt: importJobs.finishedAt,
|
||||
})
|
||||
.from(importJobs)
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Import job ${jobId} not found`);
|
||||
}
|
||||
|
||||
if (userId && row.userId !== userId) {
|
||||
throw new Error("Not authorized");
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
source: row.source as ImportJob["source"],
|
||||
status: row.status as ImportJob["status"],
|
||||
totalItems: row.totalItems,
|
||||
processedItems: row.processedItems,
|
||||
importedCount: row.importedCount,
|
||||
skippedCount: row.skippedCount,
|
||||
failedCount: row.failedCount,
|
||||
currentMessage: row.currentMessage,
|
||||
errors: safeParseJsonArray(row.errors),
|
||||
warnings: safeParseJsonArray(row.warnings),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
startedAt: row.startedAt?.toISOString() ?? null,
|
||||
finishedAt: row.finishedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Job Processor ───────────────────────────────────────────────────
|
||||
|
||||
export async function processImportJob(jobId: string): Promise<void> {
|
||||
const row = db
|
||||
.select()
|
||||
.from(importJobs)
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Import job ${jobId} not found`);
|
||||
}
|
||||
|
||||
let rawPayload: unknown;
|
||||
try {
|
||||
rawPayload = JSON.parse(row.payload);
|
||||
} catch {
|
||||
throw new Error(`Import job ${jobId} has invalid JSON payload`);
|
||||
}
|
||||
const parsed = NormalizedImportSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Import job ${jobId} has malformed payload: ${parsed.error.message}`,
|
||||
);
|
||||
}
|
||||
const data = parsed.data;
|
||||
const result: ImportResult = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Build item list based on options
|
||||
const items: { type: string; index: number }[] = [];
|
||||
|
||||
if (row.importWatches) {
|
||||
for (let i = 0; i < data.movies.length; i++) {
|
||||
items.push({ type: "movie", index: i });
|
||||
}
|
||||
for (let i = 0; i < data.episodes.length; i++) {
|
||||
items.push({ type: "episode", index: i });
|
||||
}
|
||||
}
|
||||
if (row.importWatchlist) {
|
||||
for (let i = 0; i < data.watchlist.length; i++) {
|
||||
items.push({ type: "watchlist", index: i });
|
||||
}
|
||||
}
|
||||
if (row.importRatings) {
|
||||
for (let i = 0; i < data.ratings.length; i++) {
|
||||
items.push({ type: "rating", index: i });
|
||||
}
|
||||
}
|
||||
|
||||
const total = items.length;
|
||||
|
||||
if (total === 0) {
|
||||
result.warnings.push("No items to import with the selected options.");
|
||||
db.update(importJobs)
|
||||
.set({
|
||||
status: "success",
|
||||
finishedAt: new Date(),
|
||||
totalItems: 0,
|
||||
warnings: JSON.stringify(result.warnings),
|
||||
})
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set status to running + totalItems atomically
|
||||
db.update(importJobs)
|
||||
.set({ status: "running", startedAt: new Date(), totalItems: total })
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.run();
|
||||
|
||||
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>();
|
||||
|
||||
const progressInterval = 4;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
// Check for cancellation periodically
|
||||
if (i % progressInterval === 0) {
|
||||
const currentStatus = db
|
||||
.select({ status: importJobs.status })
|
||||
.from(importJobs)
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.get();
|
||||
if (currentStatus?.status === "cancelled") {
|
||||
db.update(importJobs)
|
||||
.set({
|
||||
finishedAt: new Date(),
|
||||
errors: JSON.stringify(result.errors),
|
||||
warnings: JSON.stringify(result.warnings),
|
||||
currentMessage: "Import cancelled",
|
||||
})
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.run();
|
||||
log.info(`Import job ${jobId} cancelled by user`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const item = items[i];
|
||||
try {
|
||||
switch (item.type) {
|
||||
case "movie":
|
||||
await processMovie(
|
||||
row.userId,
|
||||
data.movies[item.index],
|
||||
result,
|
||||
resolveCache,
|
||||
);
|
||||
break;
|
||||
case "episode":
|
||||
await processEpisode(
|
||||
row.userId,
|
||||
data.episodes[item.index],
|
||||
result,
|
||||
resolveCache,
|
||||
);
|
||||
break;
|
||||
case "watchlist":
|
||||
await processWatchlistItem(
|
||||
row.userId,
|
||||
data.watchlist[item.index],
|
||||
result,
|
||||
resolveCache,
|
||||
);
|
||||
break;
|
||||
case "rating":
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Update DB progress periodically
|
||||
if (
|
||||
i % progressInterval === progressInterval - 1 ||
|
||||
i === items.length - 1
|
||||
) {
|
||||
const currentItem =
|
||||
item.type === "movie"
|
||||
? data.movies[item.index]
|
||||
: item.type === "episode"
|
||||
? data.episodes[item.index]
|
||||
: item.type === "watchlist"
|
||||
? data.watchlist[item.index]
|
||||
: data.ratings[item.index];
|
||||
const label =
|
||||
"title" in currentItem
|
||||
? currentItem.title
|
||||
: "showTitle" in currentItem
|
||||
? (currentItem.showTitle ?? "Unknown")
|
||||
: "Unknown";
|
||||
|
||||
db.update(importJobs)
|
||||
.set({
|
||||
processedItems: i + 1,
|
||||
importedCount: result.imported,
|
||||
skippedCount: result.skipped,
|
||||
failedCount: result.failed,
|
||||
currentMessage: label,
|
||||
})
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// Success
|
||||
db.update(importJobs)
|
||||
.set({
|
||||
status: "success",
|
||||
finishedAt: new Date(),
|
||||
processedItems: total,
|
||||
importedCount: result.imported,
|
||||
skippedCount: result.skipped,
|
||||
failedCount: result.failed,
|
||||
errors: JSON.stringify(result.errors),
|
||||
warnings: JSON.stringify(result.warnings),
|
||||
currentMessage: "Import complete",
|
||||
})
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.run();
|
||||
|
||||
log.info(
|
||||
`Import job ${jobId} complete: ${result.imported} imported, ${result.skipped} skipped, ${result.failed} failed`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Fatal error
|
||||
result.errors.push(
|
||||
`Fatal: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
db.update(importJobs)
|
||||
.set({
|
||||
status: "error",
|
||||
finishedAt: new Date(),
|
||||
errors: JSON.stringify(result.errors),
|
||||
warnings: JSON.stringify(result.warnings),
|
||||
currentMessage: "Import failed",
|
||||
})
|
||||
.where(eq(importJobs.id, jobId))
|
||||
.run();
|
||||
|
||||
log.error(`Import job ${jobId} failed:`, err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { findByExternalId, searchMovies, searchTv } from "@sofa/tmdb/client";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExternalIds {
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
tvdbId?: number | string;
|
||||
title?: string;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
// ─── Rate Limiter ───────────────────────────────────────────────────
|
||||
|
||||
class RateLimiter {
|
||||
private tokens: number;
|
||||
private lastRefill: number;
|
||||
|
||||
constructor(
|
||||
private maxTokens: number,
|
||||
private refillMs: number,
|
||||
) {
|
||||
this.tokens = maxTokens;
|
||||
this.lastRefill = Date.now();
|
||||
}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
const maxAttempts = 100;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
this.refill();
|
||||
if (this.tokens > 0) {
|
||||
this.tokens--;
|
||||
return;
|
||||
}
|
||||
const waitMs = this.refillMs - (Date.now() - this.lastRefill);
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(waitMs, 50)));
|
||||
}
|
||||
throw new Error("Rate limiter timed out after too many attempts");
|
||||
}
|
||||
|
||||
private refill() {
|
||||
const now = Date.now();
|
||||
if (now - this.lastRefill >= this.refillMs) {
|
||||
this.tokens = this.maxTokens;
|
||||
this.lastRefill = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** TMDB allows 40 req/s — use 35 to leave headroom */
|
||||
const tmdbLimiter = new RateLimiter(35, 1_000);
|
||||
|
||||
// ─── Movie Resolution ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a movie's TMDB ID from various external IDs.
|
||||
* Fallback chain: direct TMDB ID → IMDB → TVDB → title+year search.
|
||||
*/
|
||||
export async function resolveMovieTmdbId(
|
||||
ids: ExternalIds,
|
||||
cache?: Map<string, number | null>,
|
||||
): Promise<number | null> {
|
||||
if (ids.tmdbId) return ids.tmdbId;
|
||||
|
||||
const key = `movie:${ids.imdbId ?? ""}:${ids.tvdbId ?? ""}:${ids.title ?? ""}:${ids.year ?? ""}`;
|
||||
if (cache?.has(key)) return cache.get(key) ?? null;
|
||||
|
||||
if (ids.imdbId) {
|
||||
await tmdbLimiter.acquire();
|
||||
const result = await findByExternalId(ids.imdbId, "imdb_id");
|
||||
const movie = result.movie_results?.[0];
|
||||
if (movie) {
|
||||
cache?.set(key, movie.id);
|
||||
return movie.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.tvdbId) {
|
||||
await tmdbLimiter.acquire();
|
||||
const result = await findByExternalId(String(ids.tvdbId), "tvdb_id");
|
||||
const movie = result.movie_results?.[0];
|
||||
if (movie) {
|
||||
cache?.set(key, movie.id);
|
||||
return movie.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.title) {
|
||||
await tmdbLimiter.acquire();
|
||||
const searchResult = await searchMovies(ids.title);
|
||||
const results = searchResult.results ?? [];
|
||||
if (ids.year) {
|
||||
const match = results.find((r) => {
|
||||
const releaseYear = r.release_date
|
||||
? new Date(r.release_date).getFullYear()
|
||||
: null;
|
||||
return releaseYear === ids.year;
|
||||
});
|
||||
if (match) {
|
||||
cache?.set(key, match.id);
|
||||
return match.id;
|
||||
}
|
||||
// Year specified but no match — don't fall back to an arbitrary result
|
||||
} else if (results[0]) {
|
||||
cache?.set(key, results[0].id);
|
||||
return results[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
cache?.set(key, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Show Resolution ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a TV show's TMDB ID from various external IDs.
|
||||
* Fallback chain: direct TMDB ID → IMDB → TVDB → title search.
|
||||
* Handles both show-level and episode-level external IDs.
|
||||
*/
|
||||
export async function resolveShowTmdbId(
|
||||
ids: ExternalIds,
|
||||
cache?: Map<string, number | null>,
|
||||
): Promise<number | null> {
|
||||
if (ids.tmdbId) return ids.tmdbId;
|
||||
|
||||
const key = `tv:${ids.imdbId ?? ""}:${ids.tvdbId ?? ""}:${ids.title ?? ""}:${ids.year ?? ""}`;
|
||||
if (cache?.has(key)) return cache.get(key) ?? null;
|
||||
|
||||
if (ids.imdbId) {
|
||||
await tmdbLimiter.acquire();
|
||||
const result = await findByExternalId(ids.imdbId, "imdb_id");
|
||||
// IMDB ID might reference an episode — extract the show_id
|
||||
const ep = result.tv_episode_results?.[0];
|
||||
if (ep) {
|
||||
cache?.set(key, ep.show_id);
|
||||
return ep.show_id;
|
||||
}
|
||||
const show = result.tv_results?.[0];
|
||||
if (show) {
|
||||
cache?.set(key, show.id);
|
||||
return show.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.tvdbId) {
|
||||
await tmdbLimiter.acquire();
|
||||
const result = await findByExternalId(String(ids.tvdbId), "tvdb_id");
|
||||
const ep = result.tv_episode_results?.[0];
|
||||
if (ep) {
|
||||
cache?.set(key, ep.show_id);
|
||||
return ep.show_id;
|
||||
}
|
||||
const show = result.tv_results?.[0];
|
||||
if (show) {
|
||||
cache?.set(key, show.id);
|
||||
return show.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.title) {
|
||||
await tmdbLimiter.acquire();
|
||||
const searchResult = await searchTv(ids.title);
|
||||
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;
|
||||
return airYear === ids.year;
|
||||
});
|
||||
if (match) {
|
||||
cache?.set(key, match.id);
|
||||
return match.id;
|
||||
}
|
||||
// Year specified but no match — don't fall back to an arbitrary result
|
||||
} else if (results[0]) {
|
||||
cache?.set(key, results[0].id);
|
||||
return results[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
cache?.set(key, null);
|
||||
return null;
|
||||
}
|
||||
@@ -16,8 +16,9 @@ export function setTitleStatus(
|
||||
status: "watchlist" | "in_progress" | "completed",
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: kept for API consistency with callers
|
||||
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||
addedAt?: Date,
|
||||
) {
|
||||
const now = new Date();
|
||||
const now = addedAt ?? new Date();
|
||||
db.insert(userTitleStatus)
|
||||
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
@@ -42,8 +43,9 @@ export function logMovieWatch(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
const now = new Date();
|
||||
const now = watchedAt ?? new Date();
|
||||
db.insert(userMovieWatches)
|
||||
.values({ userId, titleId, watchedAt: now, source })
|
||||
.run();
|
||||
@@ -71,8 +73,9 @@ export function logEpisodeWatch(
|
||||
userId: string,
|
||||
episodeId: string,
|
||||
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
const now = new Date();
|
||||
const now = watchedAt ?? new Date();
|
||||
db.insert(userEpisodeWatches)
|
||||
.values({ userId, episodeId, watchedAt: now, source })
|
||||
.run();
|
||||
@@ -111,11 +114,12 @@ export function logEpisodeWatchBatch(
|
||||
userId: string,
|
||||
episodeIds: string[],
|
||||
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
if (episodeIds.length === 0) return;
|
||||
|
||||
db.transaction((tx) => {
|
||||
const now = new Date();
|
||||
const now = watchedAt ?? new Date();
|
||||
|
||||
// Batch INSERT all watch records
|
||||
for (const episodeId of episodeIds) {
|
||||
@@ -382,8 +386,9 @@ export function rateTitleStars(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
ratingStars: number,
|
||||
ratedAt?: Date,
|
||||
) {
|
||||
const now = new Date();
|
||||
const now = ratedAt ?? new Date();
|
||||
if (ratingStars === 0) {
|
||||
db.delete(userRatings)
|
||||
.where(
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
userMovieWatches,
|
||||
} from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { findByExternalId, searchTv } from "@sofa/tmdb/client";
|
||||
import { resolveMovieTmdbId, resolveShowTmdbId } from "./imports/resolve";
|
||||
import { getOrFetchTitleByTmdbId } from "./metadata";
|
||||
import { logEpisodeWatch, logMovieWatch } from "./tracking";
|
||||
|
||||
@@ -157,24 +157,6 @@ export function parseEmbyPayload(
|
||||
|
||||
// ─── Title Resolution ───────────────────────────────────────────────
|
||||
|
||||
async function resolveMovieTmdbId(event: WebhookEvent): Promise<number | null> {
|
||||
if (event.tmdbId) return event.tmdbId;
|
||||
|
||||
if (event.imdbId) {
|
||||
const result = await findByExternalId(event.imdbId, "imdb_id");
|
||||
const movie = result.movie_results?.[0];
|
||||
if (movie) return movie.id;
|
||||
}
|
||||
|
||||
if (event.tvdbId) {
|
||||
const result = await findByExternalId(event.tvdbId, "tvdb_id");
|
||||
const movie = result.movie_results?.[0];
|
||||
if (movie) return movie.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveEpisode(event: WebhookEvent): Promise<{
|
||||
showTmdbId: number;
|
||||
seasonNumber: number;
|
||||
@@ -183,48 +165,15 @@ async function resolveEpisode(event: WebhookEvent): Promise<{
|
||||
const { seasonNumber, episodeNumber } = event;
|
||||
if (seasonNumber == null || episodeNumber == null) return null;
|
||||
|
||||
// Strategy 1: Use IMDB ID to find the episode and get show_id
|
||||
if (event.imdbId) {
|
||||
const result = await findByExternalId(event.imdbId, "imdb_id");
|
||||
const ep = result.tv_episode_results?.[0];
|
||||
if (ep) {
|
||||
return { showTmdbId: ep.show_id, seasonNumber, episodeNumber };
|
||||
}
|
||||
// IMDB ID might reference the show itself
|
||||
const show = result.tv_results?.[0];
|
||||
if (show) {
|
||||
return { showTmdbId: show.id, seasonNumber, episodeNumber };
|
||||
}
|
||||
}
|
||||
const showTmdbId = await resolveShowTmdbId({
|
||||
tmdbId: event.tmdbId,
|
||||
imdbId: event.imdbId,
|
||||
tvdbId: event.tvdbId,
|
||||
title: event.showTitle,
|
||||
});
|
||||
|
||||
// Strategy 2: Use TVDB ID
|
||||
if (event.tvdbId) {
|
||||
const result = await findByExternalId(event.tvdbId, "tvdb_id");
|
||||
const ep = result.tv_episode_results?.[0];
|
||||
if (ep) {
|
||||
return { showTmdbId: ep.show_id, seasonNumber, episodeNumber };
|
||||
}
|
||||
const show = result.tv_results?.[0];
|
||||
if (show) {
|
||||
return { showTmdbId: show.id, seasonNumber, episodeNumber };
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Use TMDB ID directly if it's the show ID
|
||||
if (event.tmdbId) {
|
||||
return { showTmdbId: event.tmdbId, seasonNumber, episodeNumber };
|
||||
}
|
||||
|
||||
// Strategy 4: Search by show title
|
||||
if (event.showTitle) {
|
||||
const searchResult = await searchTv(event.showTitle);
|
||||
const tvMatch = searchResult.results?.[0];
|
||||
if (tvMatch) {
|
||||
return { showTmdbId: tvMatch.id, seasonNumber, episodeNumber };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
if (!showTmdbId) return null;
|
||||
return { showTmdbId, seasonNumber, episodeNumber };
|
||||
}
|
||||
|
||||
// ─── Deduplication ──────────────────────────────────────────────────
|
||||
@@ -303,7 +252,15 @@ export async function processWebhook(
|
||||
log.info(`Received ${provider} webhook: ${event.mediaType} "${event.title}"`);
|
||||
try {
|
||||
if (event.mediaType === "movie") {
|
||||
const tmdbId = await resolveMovieTmdbId(event);
|
||||
const hasExternalId = event.tmdbId || event.imdbId || event.tvdbId;
|
||||
const tmdbId = await resolveMovieTmdbId({
|
||||
tmdbId: event.tmdbId,
|
||||
imdbId: event.imdbId,
|
||||
tvdbId: event.tvdbId,
|
||||
// Only use title fallback when at least one external ID is present;
|
||||
// title-only resolution without year is too unreliable for webhooks
|
||||
title: hasExternalId ? event.title : undefined,
|
||||
});
|
||||
if (!tmdbId) {
|
||||
log.warn(
|
||||
`Could not resolve TMDB ID for movie "${event.title}" from ${provider}`,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,390 @@
|
||||
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import {
|
||||
importJobs,
|
||||
titles,
|
||||
userEpisodeWatches,
|
||||
userMovieWatches,
|
||||
userRatings,
|
||||
userTitleStatus,
|
||||
} from "@sofa/db/schema";
|
||||
import {
|
||||
clearAllTables,
|
||||
insertMovieWatch,
|
||||
insertTvShow,
|
||||
insertUser,
|
||||
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",
|
||||
) {
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({
|
||||
id,
|
||||
tmdbId,
|
||||
type: "movie",
|
||||
title: movieTitle,
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Insert a fully-fetched TV title with seasons/episodes (lastFetchedAt set). */
|
||||
function insertTvShowWithFetchedAt(
|
||||
titleId: string,
|
||||
tmdbId: number,
|
||||
seasonCount = 1,
|
||||
epsPerSeason = 3,
|
||||
) {
|
||||
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();
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Create an import job row in the DB and return its ID. */
|
||||
function createJob(
|
||||
userId: string,
|
||||
payload: NormalizedImport,
|
||||
options: {
|
||||
importWatches?: boolean;
|
||||
importWatchlist?: boolean;
|
||||
importRatings?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
const id = `job-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
testDb
|
||||
.insert(importJobs)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
source: payload.source,
|
||||
status: "pending",
|
||||
payload: JSON.stringify(payload),
|
||||
importWatches: options.importWatches ?? true,
|
||||
importWatchlist: options.importWatchlist ?? true,
|
||||
importRatings: options.importRatings ?? true,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
// ── Movie Import ────────────────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — movies", () => {
|
||||
test("imports a movie with direct tmdbId", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-1", 550, "Fight Club");
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies: [
|
||||
{
|
||||
tmdbId: 550,
|
||||
title: "Fight Club",
|
||||
year: 1999,
|
||||
watchedAt: "2024-06-15T20:00:00Z",
|
||||
},
|
||||
],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload);
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.importedCount).toBe(1);
|
||||
expect(job.skippedCount).toBe(0);
|
||||
expect(job.failedCount).toBe(0);
|
||||
|
||||
// Verify watch record created
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userMovieWatches)
|
||||
.where(eq(userMovieWatches.userId, userId))
|
||||
.all();
|
||||
expect(watches).toHaveLength(1);
|
||||
expect(watches[0].titleId).toBe("movie-1");
|
||||
});
|
||||
|
||||
test("deduplicates existing movie watches", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-1", 550, "Fight Club");
|
||||
insertMovieWatch(userId, "movie-1");
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies: [{ tmdbId: 550, title: "Fight Club" }],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload);
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.importedCount).toBe(0);
|
||||
expect(job.skippedCount).toBe(1);
|
||||
|
||||
// Should still be just the original watch
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userMovieWatches)
|
||||
.where(eq(userMovieWatches.userId, userId))
|
||||
.all();
|
||||
expect(watches).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Episode Import ──────────────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — episodes", () => {
|
||||
test("imports episodes for a pre-seeded TV show", async () => {
|
||||
const userId = insertUser();
|
||||
insertTvShowWithFetchedAt("tv-1", 1399, 1, 3);
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies: [],
|
||||
episodes: [
|
||||
{
|
||||
showTmdbId: 1399,
|
||||
showTitle: "Test Show",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
watchedAt: "2024-01-10T20:00:00Z",
|
||||
},
|
||||
{
|
||||
showTmdbId: 1399,
|
||||
showTitle: "Test Show",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
watchedAt: "2024-01-11T20:00:00Z",
|
||||
},
|
||||
],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload);
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.importedCount).toBe(2);
|
||||
expect(job.failedCount).toBe(0);
|
||||
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, userId))
|
||||
.all();
|
||||
expect(watches).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Watchlist Import ────────────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — watchlist", () => {
|
||||
test("sets title status to watchlist", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-wl", 999, "Watchlist Movie");
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "simkl",
|
||||
movies: [],
|
||||
episodes: [],
|
||||
watchlist: [{ tmdbId: 999, title: "Watchlist Movie", type: "movie" }],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload, {
|
||||
importWatches: false,
|
||||
importWatchlist: true,
|
||||
importRatings: false,
|
||||
});
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.importedCount).toBe(1);
|
||||
|
||||
const statusRow = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(eq(userTitleStatus.userId, userId))
|
||||
.all();
|
||||
expect(statusRow).toHaveLength(1);
|
||||
expect(statusRow[0].status).toBe("watchlist");
|
||||
expect(statusRow[0].titleId).toBe("movie-wl");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rating Import ───────────────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — ratings", () => {
|
||||
test("stores rating correctly", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-r", 888, "Rated Movie");
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies: [],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [
|
||||
{
|
||||
tmdbId: 888,
|
||||
title: "Rated Movie",
|
||||
type: "movie",
|
||||
rating: 4,
|
||||
ratedAt: "2024-03-01T12:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload, {
|
||||
importWatches: false,
|
||||
importWatchlist: false,
|
||||
importRatings: true,
|
||||
});
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.importedCount).toBe(1);
|
||||
|
||||
const ratingRows = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(eq(userRatings.userId, userId))
|
||||
.all();
|
||||
expect(ratingRows).toHaveLength(1);
|
||||
expect(ratingRows[0].ratingStars).toBe(4);
|
||||
expect(ratingRows[0].titleId).toBe("movie-r");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Job State Transitions ───────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — state transitions", () => {
|
||||
test("job starts as pending, ends as success", async () => {
|
||||
const userId = insertUser();
|
||||
insertMovieTitle("movie-st", 111, "State Test");
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "letterboxd",
|
||||
movies: [{ tmdbId: 111, title: "State Test" }],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
const jobId = createJob(userId, payload);
|
||||
|
||||
// Before processing
|
||||
const before = readImportJob(jobId);
|
||||
expect(before.status).toBe("pending");
|
||||
expect(before.startedAt).toBeNull();
|
||||
expect(before.finishedAt).toBeNull();
|
||||
|
||||
await processImportJob(jobId);
|
||||
|
||||
// After processing
|
||||
const after = readImportJob(jobId);
|
||||
expect(after.status).toBe("success");
|
||||
expect(after.startedAt).not.toBeNull();
|
||||
expect(after.finishedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
test("empty import with no matching options succeeds with warning", async () => {
|
||||
const userId = insertUser();
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "trakt",
|
||||
movies: [{ tmdbId: 111, title: "A Movie" }],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
// Disable all import options — movies exist but importWatches is false
|
||||
const jobId = createJob(userId, payload, {
|
||||
importWatches: false,
|
||||
importWatchlist: false,
|
||||
importRatings: false,
|
||||
});
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.warnings.length).toBeGreaterThan(0);
|
||||
expect(job.warnings[0]).toContain("No items to import");
|
||||
});
|
||||
|
||||
test("readImportJob throws for non-existent job", () => {
|
||||
expect(() => readImportJob("non-existent-id")).toThrow(
|
||||
"Import job non-existent-id not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Failed Resolution ───────────────────────────────────────────────
|
||||
|
||||
describe("processImportJob — failed resolution", () => {
|
||||
test("records failure when movie cannot be resolved", async () => {
|
||||
const userId = insertUser();
|
||||
|
||||
const payload: NormalizedImport = {
|
||||
source: "letterboxd",
|
||||
movies: [{ title: "Completely Unknown Film ZZZZZ" }],
|
||||
episodes: [],
|
||||
watchlist: [],
|
||||
ratings: [],
|
||||
};
|
||||
|
||||
// Mock TMDB search to return empty results (no network call)
|
||||
const searchSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [],
|
||||
} as never);
|
||||
|
||||
try {
|
||||
const jobId = createJob(userId, payload);
|
||||
await processImportJob(jobId);
|
||||
|
||||
const job = readImportJob(jobId);
|
||||
expect(job.status).toBe("success");
|
||||
expect(job.failedCount).toBe(1);
|
||||
expect(job.importedCount).toBe(0);
|
||||
expect(job.errors.length).toBeGreaterThan(0);
|
||||
expect(job.errors[0]).toContain("Could not resolve movie");
|
||||
} finally {
|
||||
searchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
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
|
||||
// called by the resolve functions. We spy on them directly rather than on
|
||||
// globalThis.fetch because openapi-fetch captures the fetch reference at
|
||||
// module-init time, making globalThis.fetch mocking ineffective.
|
||||
|
||||
let findSpy: ReturnType<typeof spyOn>;
|
||||
let searchMoviesSpy: ReturnType<typeof spyOn>;
|
||||
let searchTvSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
afterEach(() => {
|
||||
findSpy?.mockRestore();
|
||||
searchMoviesSpy?.mockRestore();
|
||||
searchTvSpy?.mockRestore();
|
||||
});
|
||||
|
||||
// ── resolveMovieTmdbId ──────────────────────────────────────────────
|
||||
|
||||
describe("resolveMovieTmdbId", () => {
|
||||
test("returns tmdbId directly when provided", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId");
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies");
|
||||
|
||||
const result = await resolveMovieTmdbId({ tmdbId: 123 });
|
||||
expect(result).toBe(123);
|
||||
expect(findSpy).not.toHaveBeenCalled();
|
||||
expect(searchMoviesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("resolves via IMDB ID lookup", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [{ id: 456 }],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({ imdbId: "tt1234567" });
|
||||
expect(result).toBe(456);
|
||||
expect(findSpy).toHaveBeenCalledWith("tt1234567", "imdb_id");
|
||||
});
|
||||
|
||||
test("falls back to TVDB lookup when no IMDB ID", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [{ id: 789 }],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({ tvdbId: 99887 });
|
||||
expect(result).toBe(789);
|
||||
expect(findSpy).toHaveBeenCalledWith("99887", "tvdb_id");
|
||||
});
|
||||
|
||||
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
|
||||
return {
|
||||
movie_results: [{ id: 789 }],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await resolveMovieTmdbId({
|
||||
imdbId: "tt0000001",
|
||||
tvdbId: 99887,
|
||||
});
|
||||
expect(result).toBe(789);
|
||||
expect(findSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("falls back to title search when no IDs available", async () => {
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [{ id: 321, title: "Inception", release_date: "2010-07-16" }],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({ title: "Inception" });
|
||||
expect(result).toBe(321);
|
||||
expect(searchMoviesSpy).toHaveBeenCalledWith("Inception");
|
||||
});
|
||||
|
||||
test("title search with year prefers matching year", async () => {
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [
|
||||
{ id: 100, title: "Dune", release_date: "1984-12-14" },
|
||||
{ id: 200, title: "Dune", release_date: "2021-10-22" },
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({ title: "Dune", year: 2021 });
|
||||
expect(result).toBe(200);
|
||||
});
|
||||
|
||||
test("title search without year match returns null", async () => {
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [
|
||||
{ id: 100, title: "Dune", release_date: "1984-12-14" },
|
||||
{ id: 200, title: "Dune", release_date: "2021-10-22" },
|
||||
],
|
||||
} as never);
|
||||
|
||||
// year=1999 doesn't match any result — don't fall back to an arbitrary result
|
||||
const result = await resolveMovieTmdbId({ title: "Dune", year: 1999 });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when all methods fail", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveMovieTmdbId({
|
||||
imdbId: "tt0000000",
|
||||
title: "NonexistentFilm",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when no identifiers at all", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId");
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies");
|
||||
|
||||
const result = await resolveMovieTmdbId({});
|
||||
expect(result).toBeNull();
|
||||
expect(findSpy).not.toHaveBeenCalled();
|
||||
expect(searchMoviesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cache prevents duplicate lookups", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [{ id: 555 }],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const cache = new Map<string, number | null>();
|
||||
|
||||
const first = await resolveMovieTmdbId({ imdbId: "tt9999999" }, cache);
|
||||
expect(first).toBe(555);
|
||||
expect(findSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = await resolveMovieTmdbId({ imdbId: "tt9999999" }, cache);
|
||||
expect(second).toBe(555);
|
||||
// No additional calls — cache hit
|
||||
expect(findSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("cache stores null for unresolvable items", async () => {
|
||||
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
|
||||
results: [],
|
||||
} as never);
|
||||
|
||||
const cache = new Map<string, number | null>();
|
||||
|
||||
const first = await resolveMovieTmdbId({ title: "Nothing" }, cache);
|
||||
expect(first).toBeNull();
|
||||
|
||||
// Cache should contain a null entry
|
||||
expect(cache.size).toBe(1);
|
||||
const cachedValue = [...cache.values()][0];
|
||||
expect(cachedValue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveShowTmdbId ───────────────────────────────────────────────
|
||||
|
||||
describe("resolveShowTmdbId", () => {
|
||||
test("returns tmdbId directly when provided", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId");
|
||||
|
||||
const result = await resolveShowTmdbId({ tmdbId: 42 });
|
||||
expect(result).toBe(42);
|
||||
expect(findSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("resolves via IMDB ID — show-level result", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [{ id: 600, name: "Breaking Bad" }],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ imdbId: "tt5555555" });
|
||||
expect(result).toBe(600);
|
||||
});
|
||||
|
||||
test("resolves via IMDB ID — episode-level result extracts show_id", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [
|
||||
{
|
||||
id: 9001,
|
||||
episode_number: 3,
|
||||
name: "Fly",
|
||||
season_number: 3,
|
||||
show_id: 700,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ imdbId: "tt7777777" });
|
||||
expect(result).toBe(700);
|
||||
});
|
||||
|
||||
test("falls back to TVDB lookup", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [{ id: 800 }],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ tvdbId: 12345 });
|
||||
expect(result).toBe(800);
|
||||
expect(findSpy).toHaveBeenCalledWith("12345", "tvdb_id");
|
||||
});
|
||||
|
||||
test("TVDB lookup extracts show_id from episode result", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [
|
||||
{
|
||||
id: 1,
|
||||
episode_number: 1,
|
||||
name: "Pilot",
|
||||
season_number: 1,
|
||||
show_id: 850,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ tvdbId: 54321 });
|
||||
expect(result).toBe(850);
|
||||
});
|
||||
|
||||
test("falls back to title search", async () => {
|
||||
searchTvSpy = spyOn(tmdbClient, "searchTv").mockResolvedValue({
|
||||
results: [{ id: 900, name: "The Office" }],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({ title: "The Office" });
|
||||
expect(result).toBe(900);
|
||||
expect(searchTvSpy).toHaveBeenCalledWith("The Office");
|
||||
});
|
||||
|
||||
test("returns null when all methods fail", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
searchTvSpy = spyOn(tmdbClient, "searchTv").mockResolvedValue({
|
||||
results: [],
|
||||
} as never);
|
||||
|
||||
const result = await resolveShowTmdbId({
|
||||
imdbId: "tt0000000",
|
||||
title: "Nothing",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("cache prevents duplicate show lookups", async () => {
|
||||
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
|
||||
movie_results: [],
|
||||
tv_results: [{ id: 950 }],
|
||||
tv_episode_results: [],
|
||||
} as never);
|
||||
|
||||
const cache = new Map<string, number | null>();
|
||||
|
||||
const first = await resolveShowTmdbId({ imdbId: "tt8888888" }, cache);
|
||||
expect(first).toBe(950);
|
||||
expect(findSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = await resolveShowTmdbId({ imdbId: "tt8888888" }, cache);
|
||||
expect(second).toBe(950);
|
||||
expect(findSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE `importJobs` (
|
||||
`id` text PRIMARY KEY,
|
||||
`userId` text NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`payload` text NOT NULL,
|
||||
`importWatches` integer NOT NULL,
|
||||
`importWatchlist` integer NOT NULL,
|
||||
`importRatings` integer NOT NULL,
|
||||
`totalItems` integer DEFAULT 0 NOT NULL,
|
||||
`processedItems` integer DEFAULT 0 NOT NULL,
|
||||
`importedCount` integer DEFAULT 0 NOT NULL,
|
||||
`skippedCount` integer DEFAULT 0 NOT NULL,
|
||||
`failedCount` integer DEFAULT 0 NOT NULL,
|
||||
`currentMessage` text,
|
||||
`errors` text,
|
||||
`warnings` text,
|
||||
`createdAt` integer NOT NULL,
|
||||
`startedAt` integer,
|
||||
`finishedAt` integer,
|
||||
CONSTRAINT `fk_importJobs_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `importJobs_userId_createdAt` ON `importJobs` (`userId`,`createdAt`);--> statement-breakpoint
|
||||
CREATE INDEX `importJobs_status` ON `importJobs` (`status`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -486,6 +486,43 @@ export const cronRuns = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Import Jobs ────────────────────────────────────────────────────
|
||||
|
||||
export const importJobs = sqliteTable(
|
||||
"importJobs",
|
||||
{
|
||||
id: uuidPk(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
source: text("source", {
|
||||
enum: ["trakt", "simkl", "letterboxd"],
|
||||
}).notNull(),
|
||||
status: text("status", {
|
||||
enum: ["pending", "running", "success", "error", "cancelled"],
|
||||
}).notNull(),
|
||||
payload: text("payload").notNull(),
|
||||
importWatches: int("importWatches", { mode: "boolean" }).notNull(),
|
||||
importWatchlist: int("importWatchlist", { mode: "boolean" }).notNull(),
|
||||
importRatings: int("importRatings", { mode: "boolean" }).notNull(),
|
||||
totalItems: int("totalItems").notNull().default(0),
|
||||
processedItems: int("processedItems").notNull().default(0),
|
||||
importedCount: int("importedCount").notNull().default(0),
|
||||
skippedCount: int("skippedCount").notNull().default(0),
|
||||
failedCount: int("failedCount").notNull().default(0),
|
||||
currentMessage: text("currentMessage"),
|
||||
errors: text("errors"),
|
||||
warnings: text("warnings"),
|
||||
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
|
||||
startedAt: int("startedAt", { mode: "timestamp" }),
|
||||
finishedAt: int("finishedAt", { mode: "timestamp" }),
|
||||
},
|
||||
(table) => [
|
||||
index("importJobs_userId_createdAt").on(table.userId, table.createdAt),
|
||||
index("importJobs_status").on(table.status),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── App Settings ───────────────────────────────────────────────────
|
||||
|
||||
export const appSettings = sqliteTable("appSettings", {
|
||||
|
||||
Reference in New Issue
Block a user