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
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);
});
});