fix(imports): map Trakt season/episode items and normalize imported statuses

Trakt season/episode watchlist items now add the parent show instead of an
unrelated movie sharing the show's TMDB id; season/episode ratings are skipped
and reported as unsupported. The import processor normalizes statuses per
title type (TV never 'completed', movies never 'in_progress') and a data
migration repairs rows already stored wrongly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 15:22:11 -04:00
1 parent 2529b132ef
commit c8c1bf801b
6 files changed
+3583 -6

No files matched your search

+26 -5
View File
@@ -182,13 +182,13 @@ interface TraktHistoryEpisode {
}
interface TraktWatchlistItem {
type?: "movie" | "show";
type?: "movie" | "show" | "season" | "episode";
movie?: { title?: string; year?: number; ids?: TraktIds };
show?: { title?: string; year?: number; ids?: TraktIds };
}
interface TraktRatingItem {
type?: "movie" | "show";
type?: "movie" | "show" | "season" | "episode";
rating?: number;
rated_at?: string;
movie?: { title?: string; year?: number; ids?: TraktIds };
@@ -237,7 +237,16 @@ export function parseTraktPayload(data: {
}
// Watchlist
// Sofa has no season/episode watchlist: those items mean "the user wants this show".
for (const item of data.watchlist ?? []) {
if (
item.type !== "movie" &&
item.type !== "show" &&
item.type !== "season" &&
item.type !== "episode"
) {
continue;
}
const entry = item.type === "movie" ? item.movie : item.show;
if (!entry?.title) continue;
watchlist.push({
@@ -246,12 +255,18 @@ export function parseTraktPayload(data: {
tvdbId: entry.ids?.tvdb,
title: entry.title,
year: entry.year,
type: item.type === "show" ? "tv" : "movie",
type: item.type === "movie" ? "movie" : "tv",
});
}
// Ratings
// Ratings (Sofa only rates movies and shows; season/episode ratings are unsupported)
let unsupported = 0;
for (const item of data.ratings ?? []) {
if (item.type === "season" || item.type === "episode") {
unsupported++;
continue;
}
if (item.type !== "movie" && item.type !== "show") continue;
const entry = item.type === "movie" ? item.movie : item.show;
if (!entry?.title || item.rating == null) continue;
const converted = convertRating10to5(item.rating);
@@ -271,6 +286,12 @@ export function parseTraktPayload(data: {
});
}
if (unsupported > 0) {
warnings.push(
`Skipped ${unsupported} Trakt season/episode ratings (Sofa only rates movies and shows)`,
);
}
log.info(
`Parsed Trakt data: ${movies.length} movies, ${episodes.length} episodes, ${watchlist.length} watchlist, ${ratings.length} ratings`,
);
@@ -286,7 +307,7 @@ export function parseTraktPayload(data: {
return {
data: normalized,
warnings,
diagnostics: { unresolved: countUnresolved(normalized), unsupported: 0 },
diagnostics: { unresolved: countUnresolved(normalized), unsupported },
};
}
+9 -1
View File
@@ -190,7 +190,15 @@ async function processWatchlistItem(
}
const STATUS_RANK = { watchlist: 0, in_progress: 1, completed: 2 } as const;
const targetStatus = item.status ?? "watchlist";
// Stored-status invariants (see migration sour_harry_osborn): TV never stores
// 'completed' (derived from episode progress) and movies never store 'in_progress'.
const requested = item.status ?? "watchlist";
const targetStatus =
title.type === "tv" && requested === "completed"
? "in_progress"
: title.type === "movie" && requested === "in_progress"
? "watchlist"
: requested;
const currentStatus = getTitleStatusValue(userId, title.id);
if (currentStatus && STATUS_RANK[currentStatus] >= STATUS_RANK[targetStatus]) {
+28
View File
@@ -348,6 +348,34 @@ describe("parseTraktPayload", () => {
expect(result.data.watchlist[0].type).toBe("tv");
});
test("maps season and episode watchlist items to their show", () => {
const result = parseTraktPayload({
watchlist: [
{ type: "season", show: { title: "Show S", ids: { tmdb: 10 } } },
{ type: "episode", show: { title: "Show E", ids: { tmdb: 11 } } },
],
});
expect(result.data.watchlist).toHaveLength(2);
expect(result.data.watchlist.map((w) => w.type)).toEqual(["tv", "tv"]);
expect(result.data.watchlist.map((w) => w.tmdbId)).toEqual([10, 11]);
});
test("skips season and episode ratings as unsupported", () => {
const result = parseTraktPayload({
ratings: [
{ type: "episode", rating: 8, show: { title: "Show", ids: { tmdb: 12 } } },
{ type: "season", rating: 6, show: { title: "Show", ids: { tmdb: 12 } } },
{ type: "movie", rating: 10, movie: { title: "M", ids: { tmdb: 13 } } },
],
});
expect(result.data.ratings).toHaveLength(1);
expect(result.data.ratings[0].type).toBe("movie");
expect(result.diagnostics?.unsupported).toBe(2);
expect(result.warnings.some((w) => w.includes("2 Trakt season/episode ratings"))).toBe(true);
});
test("maps show rating type to tv", () => {
const result = parseTraktPayload({
ratings: [
@@ -1,3 +1,7 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
@@ -12,8 +16,11 @@ import {
clearAllTables,
eq,
insertMovieWatch,
insertStatus,
insertTitle,
insertTvShow,
insertUser,
testClient,
testDb,
} from "@sofa/test/db";
import * as tmdbClient from "@sofa/tmdb/client";
@@ -239,6 +246,94 @@ describe("processImportJob — watchlist", () => {
});
});
// ── Status Normalization ────────────────────────────────────────────
describe("processImportJob — status normalization", () => {
const watchlistOnly = {
importWatches: false,
importWatchlist: true,
importRatings: false,
};
test("stores TV 'completed' as 'in_progress'", async () => {
const userId = insertUser();
insertTvShowWithFetchedAt("tv-c", 500);
const payload: NormalizedImport = {
source: "simkl",
movies: [],
episodes: [],
watchlist: [{ tmdbId: 500, title: "Test Show", type: "tv", status: "completed" }],
ratings: [],
};
const jobId = createJob(userId, payload, watchlistOnly);
await processImportJob(jobId);
const rows = testDb
.select()
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
expect(rows).toHaveLength(1);
expect(rows[0].titleId).toBe("tv-c");
expect(rows[0].status).toBe("in_progress");
});
test("stores movie 'in_progress' as 'watchlist'", async () => {
const userId = insertUser();
insertMovieTitle("m-ip", 501);
const payload: NormalizedImport = {
source: "simkl",
movies: [],
episodes: [],
watchlist: [{ tmdbId: 501, title: "Test Movie", type: "movie", status: "in_progress" }],
ratings: [],
};
const jobId = createJob(userId, payload, watchlistOnly);
await processImportJob(jobId);
const rows = testDb
.select()
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
expect(rows).toHaveLength(1);
expect(rows[0].titleId).toBe("m-ip");
expect(rows[0].status).toBe("watchlist");
});
});
// ── Migration: normalize_imported_statuses ──────────────────────────
describe("normalize_imported_statuses migration", () => {
test("repairs TV 'completed' and movie 'in_progress' rows", () => {
const dir = fileURLToPath(new URL("../../db/drizzle", import.meta.url));
const folder = readdirSync(dir).find((d) => d.endsWith("_normalize_imported_statuses"));
expect(folder).toBeDefined();
const userId = insertUser();
insertTitle({ id: "mig-tv", tmdbId: 7001, type: "tv", title: "Mig Show" });
insertTitle({ id: "mig-watched", tmdbId: 7002, type: "movie", title: "Mig Watched" });
insertTitle({ id: "mig-unwatched", tmdbId: 7003, type: "movie", title: "Mig Unwatched" });
insertStatus(userId, "mig-tv", "completed");
insertStatus(userId, "mig-watched", "in_progress");
insertStatus(userId, "mig-unwatched", "in_progress");
insertMovieWatch(userId, "mig-watched");
testClient.exec(readFileSync(join(dir, folder!, "migration.sql"), "utf8"));
const statusOf = (titleId: string) =>
testDb.select().from(userTitleStatus).where(eq(userTitleStatus.titleId, titleId)).get()
?.status;
expect(statusOf("mig-tv")).toBe("in_progress");
expect(statusOf("mig-watched")).toBe("completed");
expect(statusOf("mig-unwatched")).toBe("watchlist");
});
});
// ── Rating Import ───────────────────────────────────────────────────
describe("processImportJob — ratings", () => {
@@ -0,0 +1,19 @@
-- Re-apply stored-status invariants for rows written by imports before they were
-- normalized at import time. TV never stores 'completed'.
UPDATE userTitleStatus SET status = 'in_progress'
WHERE status = 'completed'
AND titleId IN (SELECT id FROM titles WHERE type = 'tv');
--> statement-breakpoint
-- Movies never store 'in_progress'. Movies with a logged watch are 'completed'.
UPDATE userTitleStatus SET status = 'completed'
WHERE status = 'in_progress'
AND titleId IN (SELECT id FROM titles WHERE type = 'movie')
AND EXISTS (
SELECT 1 FROM userMovieWatches w
WHERE w.userId = userTitleStatus.userId AND w.titleId = userTitleStatus.titleId
);
--> statement-breakpoint
-- Remaining movie 'in_progress' rows have no watch: they are watchlist items.
UPDATE userTitleStatus SET status = 'watchlist'
WHERE status = 'in_progress'
AND titleId IN (SELECT id FROM titles WHERE type = 'movie');
File diff suppressed because it is too large. Load diff