feat: add watch history import from Trakt, Simkl, and Letterboxd (#13)

This commit is contained in:
2026-03-17 11:02:58 -04:00
committed by GitHub
parent 25736f684a
commit 710c43a01f
41 changed files with 10230 additions and 106 deletions
+80 -1
View File
@@ -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)),
},
};
+139
View File
@@ -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>;
+4
View File
@@ -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:"
}
+25
View File
@@ -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";
+656
View File
@@ -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 },
};
}
+613
View File
@@ -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);
}
}
+185
View File
@@ -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;
}
+10 -5
View File
@@ -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(
+18 -61
View File
@@ -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
+390
View File
@@ -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();
}
});
});
+301
View File
@@ -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
+37
View File
@@ -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", {