mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 01:05:42 -04:00
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:
6 files changed
+3583
-6
No files matched your search
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user