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 {
|
interface TraktWatchlistItem {
|
||||||
type?: "movie" | "show";
|
type?: "movie" | "show" | "season" | "episode";
|
||||||
movie?: { title?: string; year?: number; ids?: TraktIds };
|
movie?: { title?: string; year?: number; ids?: TraktIds };
|
||||||
show?: { title?: string; year?: number; ids?: TraktIds };
|
show?: { title?: string; year?: number; ids?: TraktIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TraktRatingItem {
|
interface TraktRatingItem {
|
||||||
type?: "movie" | "show";
|
type?: "movie" | "show" | "season" | "episode";
|
||||||
rating?: number;
|
rating?: number;
|
||||||
rated_at?: string;
|
rated_at?: string;
|
||||||
movie?: { title?: string; year?: number; ids?: TraktIds };
|
movie?: { title?: string; year?: number; ids?: TraktIds };
|
||||||
@@ -237,7 +237,16 @@ export function parseTraktPayload(data: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watchlist
|
// Watchlist
|
||||||
|
// Sofa has no season/episode watchlist: those items mean "the user wants this show".
|
||||||
for (const item of data.watchlist ?? []) {
|
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;
|
const entry = item.type === "movie" ? item.movie : item.show;
|
||||||
if (!entry?.title) continue;
|
if (!entry?.title) continue;
|
||||||
watchlist.push({
|
watchlist.push({
|
||||||
@@ -246,12 +255,18 @@ export function parseTraktPayload(data: {
|
|||||||
tvdbId: entry.ids?.tvdb,
|
tvdbId: entry.ids?.tvdb,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
year: entry.year,
|
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 ?? []) {
|
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;
|
const entry = item.type === "movie" ? item.movie : item.show;
|
||||||
if (!entry?.title || item.rating == null) continue;
|
if (!entry?.title || item.rating == null) continue;
|
||||||
const converted = convertRating10to5(item.rating);
|
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(
|
log.info(
|
||||||
`Parsed Trakt data: ${movies.length} movies, ${episodes.length} episodes, ${watchlist.length} watchlist, ${ratings.length} ratings`,
|
`Parsed Trakt data: ${movies.length} movies, ${episodes.length} episodes, ${watchlist.length} watchlist, ${ratings.length} ratings`,
|
||||||
);
|
);
|
||||||
@@ -286,7 +307,7 @@ export function parseTraktPayload(data: {
|
|||||||
return {
|
return {
|
||||||
data: normalized,
|
data: normalized,
|
||||||
warnings,
|
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 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);
|
const currentStatus = getTitleStatusValue(userId, title.id);
|
||||||
|
|
||||||
if (currentStatus && STATUS_RANK[currentStatus] >= STATUS_RANK[targetStatus]) {
|
if (currentStatus && STATUS_RANK[currentStatus] >= STATUS_RANK[targetStatus]) {
|
||||||
|
|||||||
@@ -348,6 +348,34 @@ describe("parseTraktPayload", () => {
|
|||||||
expect(result.data.watchlist[0].type).toBe("tv");
|
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", () => {
|
test("maps show rating type to tv", () => {
|
||||||
const result = parseTraktPayload({
|
const result = parseTraktPayload({
|
||||||
ratings: [
|
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 { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -12,8 +16,11 @@ import {
|
|||||||
clearAllTables,
|
clearAllTables,
|
||||||
eq,
|
eq,
|
||||||
insertMovieWatch,
|
insertMovieWatch,
|
||||||
|
insertStatus,
|
||||||
|
insertTitle,
|
||||||
insertTvShow,
|
insertTvShow,
|
||||||
insertUser,
|
insertUser,
|
||||||
|
testClient,
|
||||||
testDb,
|
testDb,
|
||||||
} from "@sofa/test/db";
|
} from "@sofa/test/db";
|
||||||
import * as tmdbClient from "@sofa/tmdb/client";
|
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 ───────────────────────────────────────────────────
|
// ── Rating Import ───────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("processImportJob — ratings", () => {
|
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