From d0f8204a619d7727a21cb16c2513bf12d290a5f1 Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 20 Aug 2026 19:13:48 +0200 Subject: [PATCH 1/9] fix(import): coerce json ids to numbers Third-party exports send numeric ids as strings ("tmdb_id": "67324"). The JSON parser passed them straight through, so the sync payload shipped a string where Trakt expects an integer and the item silently matched nothing. --- .../import/parsers/TraktJsonParser.spec.ts | 21 ++++++ .../import/parsers/TraktJsonParser.ts | 31 ++++----- .../import/parsers/utils/toImportIds.spec.ts | 66 +++++++++++++++++++ .../import/parsers/utils/toImportIds.ts | 33 ++++++++++ 4 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 projects/client/src/lib/sections/settings/import/parsers/utils/toImportIds.spec.ts create mode 100644 projects/client/src/lib/sections/settings/import/parsers/utils/toImportIds.ts diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts index eef071ef83..5fdfe57787 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts @@ -256,6 +256,27 @@ describe('TraktJsonParser', () => { }); }); + it('coerces string numeric ids to numbers', async () => { + mockParseJsonFile.mockResolvedValue([ + { + tmdb_id: '67324', + tvdb_id: '79126', + trakt_id: '42', + watched_at: '2026-08-14T10:47:49.000Z', + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('history.json')]); + + expect(result).toHaveLength(1); + expect(result[0]?.ids).toEqual({ + trakt: 42, + imdb: undefined, + tmdb: 67324, + tvdb: 79126, + }); + }); + it('parses a watchlist entry via is_watchlisted field', async () => { mockParseJsonFile.mockResolvedValue([ { diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts index 793a7859c7..55261771d2 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts @@ -7,13 +7,14 @@ import type { import type { FileParser } from './ParserInterface.ts'; import { isValidItem } from './utils/isValidItem.ts'; import { parseJsonFile } from './utils/parseJsonFile.ts'; +import { toImportIds } from './utils/toImportIds.ts'; import { toImportISOString } from './utils/toImportISOString.ts'; type TraktJsonIds = { - trakt?: number; + trakt?: string | number; imdb?: string; - tmdb?: number; - tvdb?: number; + tmdb?: string | number; + tvdb?: string | number; }; type TraktJsonEntry = { @@ -37,9 +38,9 @@ type TraktJsonEntry = { id?: TraktJsonIds; // Flat format with *_id fields at root level (e.g. third-party exports) imdb_id?: string; - tvdb_id?: number; - tmdb_id?: number; - trakt_id?: number; + tvdb_id?: string | number; + tmdb_id?: string | number; + trakt_id?: string | number; title?: string; year?: number; created_at?: string; @@ -99,12 +100,7 @@ function parseFlatEntry(entry: TraktJsonEntry): UniversalImportItem | null { return { action, type: 'movie', - ids: { - trakt: ids.trakt, - imdb: ids.imdb, - tmdb: ids.tmdb, - tvdb: ids.tvdb, - }, + ids: toImportIds(ids), title: entry.title, year: entry.year, watched_at: toWatchedAt( @@ -135,12 +131,12 @@ function parseMultiIdFlatEntry( return { action, type: 'movie', - ids: { + ids: toImportIds({ trakt: entry.trakt_id, imdb: entry.imdb_id, tmdb: entry.tmdb_id, tvdb: entry.tvdb_id, - }, + }), title: entry.title, year: entry.year, watched_at: toWatchedAt( @@ -168,12 +164,7 @@ function parseTraktJsonEntry( return { action, type, - ids: { - trakt: ids.trakt, - imdb: ids.imdb, - tmdb: ids.tmdb, - tvdb: ids.tvdb, - }, + ids: toImportIds(ids), title: media?.title, year: media?.year, watched_at: toWatchedAt(entry.watched_at), diff --git a/projects/client/src/lib/sections/settings/import/parsers/utils/toImportIds.spec.ts b/projects/client/src/lib/sections/settings/import/parsers/utils/toImportIds.spec.ts new file mode 100644 index 0000000000..39818122ea --- /dev/null +++ b/projects/client/src/lib/sections/settings/import/parsers/utils/toImportIds.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { toImportIds } from './toImportIds.ts'; + +describe('util: toImportIds', () => { + it('should keep numeric ids as numbers', () => { + expect(toImportIds({ trakt: 1, tmdb: 1438, tvdb: 79126 })).toEqual({ + trakt: 1, + imdb: undefined, + tmdb: 1438, + tvdb: 79126, + }); + }); + + it('should coerce string numeric ids to numbers', () => { + expect(toImportIds({ trakt: '1', tmdb: '67324', tvdb: '79126' })).toEqual({ + trakt: 1, + imdb: undefined, + tmdb: 67324, + tvdb: 79126, + }); + }); + + it('should trim whitespace around string numeric ids', () => { + expect(toImportIds({ tmdb: ' 1438 ' }).tmdb).toBe(1438); + }); + + it('should keep imdb ids as strings', () => { + expect(toImportIds({ imdb: 'tt0306414' }).imdb).toBe('tt0306414'); + }); + + it('should trim whitespace around imdb ids', () => { + expect(toImportIds({ imdb: ' tt0306414 ' }).imdb).toBe('tt0306414'); + }); + + it('should drop empty and whitespace-only imdb ids', () => { + expect(toImportIds({ imdb: '' }).imdb).toBeUndefined(); + expect(toImportIds({ imdb: ' ' }).imdb).toBeUndefined(); + }); + + it('should drop non-numeric values for numeric ids', () => { + expect(toImportIds({ tmdb: 'tt0306414' }).tmdb).toBeUndefined(); + expect(toImportIds({ tmdb: '' }).tmdb).toBeUndefined(); + }); + + it('should drop zero and negative ids', () => { + expect(toImportIds({ tmdb: 0, tvdb: '0', trakt: -1 })).toEqual({ + trakt: undefined, + imdb: undefined, + tmdb: undefined, + tvdb: undefined, + }); + }); + + it('should drop non-integer numeric ids', () => { + expect(toImportIds({ tmdb: 14.38 }).tmdb).toBeUndefined(); + }); + + it('should drop nullish ids', () => { + expect(toImportIds({ trakt: null, imdb: undefined, tmdb: null })).toEqual({ + trakt: undefined, + imdb: undefined, + tmdb: undefined, + tvdb: undefined, + }); + }); +}); diff --git a/projects/client/src/lib/sections/settings/import/parsers/utils/toImportIds.ts b/projects/client/src/lib/sections/settings/import/parsers/utils/toImportIds.ts new file mode 100644 index 0000000000..ee2c0f04bc --- /dev/null +++ b/projects/client/src/lib/sections/settings/import/parsers/utils/toImportIds.ts @@ -0,0 +1,33 @@ +import type { ImportIds } from '../../ImportTypes.ts'; + +type RawId = string | number | null | undefined; + +type RawImportIds = { + trakt?: RawId; + imdb?: RawId; + tmdb?: RawId; + tvdb?: RawId; +}; + +function toNumericId(value: RawId): number | undefined { + const parsed = typeof value === 'number' + ? value + : Number.parseInt(String(value ?? '').trim(), 10); + + if (!Number.isInteger(parsed) || parsed <= 0) return undefined; + return parsed; +} + +function toTextId(value: RawId): string | undefined { + if (typeof value !== 'string') return undefined; + return value.trim() || undefined; +} + +export function toImportIds(ids: RawImportIds): ImportIds { + return { + trakt: toNumericId(ids.trakt), + imdb: toTextId(ids.imdb), + tmdb: toNumericId(ids.tmdb), + tvdb: toNumericId(ids.tvdb), + }; +} From 9f114d824c0e259a8fa8fe5ab7996e27f1f08229 Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 20 Aug 2026 19:18:16 +0200 Subject: [PATCH 2/9] fix(import): respect the type field on flat json entries Both flat entry shapes hardcoded a movie type, so a documented "type": "show" or "episode" was silently discarded and the item went out as a movie lookup against a TMDB id from the show namespace. Only the nested movie/show/episode wrapper ever reached toType. --- .../import/parsers/TraktJsonParser.spec.ts | 76 +++++++++++++++++++ .../import/parsers/TraktJsonParser.ts | 10 ++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts index 5fdfe57787..60f40f12f5 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts @@ -256,6 +256,82 @@ describe('TraktJsonParser', () => { }); }); + it('respects an explicit show type', async () => { + mockParseJsonFile.mockResolvedValue([ + { + tmdb_id: '67324', + type: 'show', + watched_at: '2026-08-14T10:47:49.000Z', + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('history.json')]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + action: 'history', + type: 'show', + ids: { tmdb: 67324 }, + }); + }); + + it('respects an explicit episode type', async () => { + mockParseJsonFile.mockResolvedValue([ + { + tmdb_id: '66452', + type: 'episode', + watched_at: '2026-08-15T08:20:22.419Z', + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('history.json')]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + action: 'history', + type: 'episode', + ids: { tmdb: 66452 }, + }); + }); + + it('treats series as an alias for show', async () => { + mockParseJsonFile.mockResolvedValue([ + { imdb_id: 'tt0306414', type: 'series' }, + ]); + + const result = await TraktJsonParser.parse([makeFile('history.json')]); + + expect(result[0]).toMatchObject({ type: 'show' }); + }); + + it('defaults to movie when no type is given', async () => { + mockParseJsonFile.mockResolvedValue([ + { imdb_id: 'tt1374992', watched_at: '2024-01-19T19:14:56Z' }, + ]); + + const result = await TraktJsonParser.parse([makeFile('history.json')]); + + expect(result[0]).toMatchObject({ type: 'movie' }); + }); + + it('respects an explicit type in the nested id format', async () => { + mockParseJsonFile.mockResolvedValue([ + { + id: { tmdb: 1438 }, + type: 'show', + watched_at: '2026-08-15T08:20:22.419Z', + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('history.json')]); + + expect(result[0]).toMatchObject({ + action: 'history', + type: 'show', + ids: { tmdb: 1438 }, + }); + }); + it('coerces string numeric ids to numbers', async () => { mockParseJsonFile.mockResolvedValue([ { diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts index 55261771d2..32a4234d8b 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts @@ -79,6 +79,10 @@ function toType(value: string): ImportType { return 'movie'; } +function resolveType(entry: TraktJsonEntry): ImportType { + return entry.type ? toType(entry.type) : inferType(entry); +} + function toWatchedAt(value?: string): string | undefined { if (value === 'unknown') return 'unknown'; return toImportISOString(value); @@ -99,7 +103,7 @@ function parseFlatEntry(entry: TraktJsonEntry): UniversalImportItem | null { return { action, - type: 'movie', + type: resolveType(entry), ids: toImportIds(ids), title: entry.title, year: entry.year, @@ -130,7 +134,7 @@ function parseMultiIdFlatEntry( return { action, - type: 'movie', + type: resolveType(entry), ids: toImportIds({ trakt: entry.trakt_id, imdb: entry.imdb_id, @@ -153,7 +157,7 @@ function parseTraktJsonEntry( if (isFlatEntry(entry)) return parseFlatEntry(entry); if (isMultiIdFlatEntry(entry)) return parseMultiIdFlatEntry(entry); - const type = entry.type ? toType(entry.type) : inferType(entry); + const type = resolveType(entry); const action = inferAction(entry); const media = type === 'episode' ? entry.show : (entry.movie ?? entry.show); From 28ca976e4c47da92e484eba7b32f44fbc96af85a Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 20 Aug 2026 19:24:57 +0200 Subject: [PATCH 3/9] fix(import): resolve shows and episodes by tmdb id Only movies had tmdb in their id priority, so a show or episode carrying nothing but a tmdb id resolved to nothing and was dropped from the payload. Both are supported server side: find_by_multi resolves a show by trakt/imdb/tmdb/tvdb and an episode by trakt/tmdb/tvdb/imdb. Inserted ahead of trakt only, so anything that already resolved keeps resolving to the same id. --- .../settings/import/engine/pickIds.spec.ts | 26 ++++++++++++++++++- .../settings/import/engine/pickIds.ts | 4 +-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts b/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts index ef681cb428..246dd90a84 100644 --- a/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts @@ -30,6 +30,18 @@ describe('pickIds', () => { it('should fall back to tvdb when imdb is absent', () => { expect(pickIds({ tvdb: 123 }, SHOW_IDS)).toEqual({ tvdb: 123 }); }); + + it('should prefer tvdb over tmdb', () => { + expect(pickIds({ tvdb: 123, tmdb: 456 }, SHOW_IDS)).toEqual({ + tvdb: 123, + }); + }); + + it('should fall back to tmdb when imdb and tvdb are absent', () => { + expect(pickIds({ tmdb: 67324, trakt: 6 }, SHOW_IDS)).toEqual({ + tmdb: 67324, + }); + }); }); describe('with EPISODE_IDS priority', () => { @@ -39,7 +51,19 @@ describe('pickIds', () => { }); }); - it('should fall back to trakt when tvdb is absent', () => { + it('should prefer tvdb over tmdb', () => { + expect(pickIds({ tvdb: 77, tmdb: 66452 }, EPISODE_IDS)).toEqual({ + tvdb: 77, + }); + }); + + it('should fall back to tmdb when tvdb is absent', () => { + expect(pickIds({ tmdb: 66452, trakt: 88 }, EPISODE_IDS)).toEqual({ + tmdb: 66452, + }); + }); + + it('should fall back to trakt when tvdb and tmdb are absent', () => { expect(pickIds({ trakt: 88 }, EPISODE_IDS)).toEqual({ trakt: 88 }); }); diff --git a/projects/client/src/lib/sections/settings/import/engine/pickIds.ts b/projects/client/src/lib/sections/settings/import/engine/pickIds.ts index 7c42d43663..85cc15a7d3 100644 --- a/projects/client/src/lib/sections/settings/import/engine/pickIds.ts +++ b/projects/client/src/lib/sections/settings/import/engine/pickIds.ts @@ -3,8 +3,8 @@ import type { ImportIds } from '../ImportTypes.ts'; export type IdPriority = ReadonlyArray; export const MOVIE_IDS: IdPriority = ['imdb', 'tmdb', 'trakt']; -export const SHOW_IDS: IdPriority = ['imdb', 'tvdb', 'trakt']; -export const EPISODE_IDS: IdPriority = ['tvdb', 'trakt']; +export const SHOW_IDS: IdPriority = ['imdb', 'tvdb', 'tmdb', 'trakt']; +export const EPISODE_IDS: IdPriority = ['tvdb', 'tmdb', 'trakt']; export function pickIds( ids: ImportIds, From 848f18289578aad30ccab91246c879d426d5ea7e Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 20 Aug 2026 19:35:52 +0200 Subject: [PATCH 4/9] fix(import): emit one item per action on a json entry A single entry can carry a watch, a rating and a watchlist date at once, as the documented example does. inferAction only ever returned one of them, so an entry with both watched_at and a rating was counted and imported as a rating alone and the watch was dropped. The CSV parser already fans these out; JSON now matches. --- .../import/parsers/TraktJsonParser.spec.ts | 101 ++++++++++++++++ .../import/parsers/TraktJsonParser.ts | 108 +++++++++++------- 2 files changed, 166 insertions(+), 43 deletions(-) diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts index 60f40f12f5..828d8d3721 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts @@ -427,6 +427,107 @@ describe('TraktJsonParser', () => { }); }); + describe('parse – multiple actions per entry', () => { + it('fans out an entry carrying both a watch and a rating', async () => { + mockParseJsonFile.mockResolvedValue([ + { + tmdb_id: 1438, + type: 'show', + watched_at: '2026-08-11T07:22:04.000Z', + rating: 8, + rated_at: '2026-08-11T07:22:03.000Z', + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('history.json')]); + + expect(result).toHaveLength(2); + expect(result).toEqual([ + expect.objectContaining({ + action: 'history', + type: 'show', + watched_at: '2026-08-11T07:22:04.000Z', + }), + expect.objectContaining({ + action: 'ratings', + type: 'show', + rating: 8, + }), + ]); + }); + + it('fans out the documented history, watchlist and rating example', async () => { + mockParseJsonFile.mockResolvedValue([ + { + imdb_id: 'tt0068646', + type: 'movie', + watched_at: '2024-10-25T20:00:00Z', + watchlisted_at: '2024-10-01T10:00:00Z', + rating: 6, + rated_at: '2024-10-26T21:00:00Z', + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('export.json')]); + + expect(result.map((item) => item.action)).toEqual([ + 'history', + 'ratings', + 'watchlist', + ]); + }); + + it('keeps a single item when the action is explicit', async () => { + mockParseJsonFile.mockResolvedValue([ + { + imdb_id: 'tt0068646', + action: 'ratings', + watched_at: '2024-10-25T20:00:00Z', + rating: 6, + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('ratings.json')]); + + expect(result).toHaveLength(1); + expect(result[0]?.action).toBe('ratings'); + }); + + it('does not infer a watch from created_at alongside another action', async () => { + mockParseJsonFile.mockResolvedValue([ + { + imdb_id: 'tt0068646', + is_watchlisted: true, + created_at: '2024-10-01T10:00:00Z', + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('watchlist.json')]); + + expect(result).toHaveLength(1); + expect(result[0]?.action).toBe('watchlist'); + expect(result[0]?.watched_at).toBeUndefined(); + }); + + it('emits one item per action for a nested entry', async () => { + mockParseJsonFile.mockResolvedValue([ + { + watched_at: '2026-08-15T08:20:22.419Z', + rating: 9, + show: { title: 'The Wire', year: 2002, ids: { imdb: 'tt0306414' } }, + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('export.json')]); + + expect(result.map((item) => item.action)).toEqual([ + 'history', + 'ratings', + ]); + expect(result.every((item) => item.type === 'show')).toBe(true); + }); + }); + describe('parse – ZIP export', () => { function setupZip(files: Record) { const encoder = new TextEncoder(); diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts index 32a4234d8b..4522f91278 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts @@ -17,6 +17,11 @@ type TraktJsonIds = { tvdb?: string | number; }; +type ImportItemBase = Omit< + UniversalImportItem, + 'action' | 'watched_at' | 'rating' | 'rated_at' +>; + type TraktJsonEntry = { type?: string; action?: string; @@ -53,6 +58,13 @@ function inferType(entry: TraktJsonEntry): ImportType { return 'movie'; } +function isWatchlisted(entry: TraktJsonEntry): boolean { + return entry.listed_at !== undefined || + entry.watchlisted_at !== undefined || + entry.is_watchlisted === true || + entry.is_watchlisted === 'true'; +} + function inferAction(entry: TraktJsonEntry): ImportAction { if (entry.action) { const normalized = entry.action.toLowerCase(); @@ -63,12 +75,7 @@ function inferAction(entry: TraktJsonEntry): ImportAction { if (entry.rated_at !== undefined || entry.rating !== undefined) { return 'ratings'; } - if ( - entry.listed_at !== undefined || - entry.watchlisted_at !== undefined || - entry.is_watchlisted === true || - entry.is_watchlisted === 'true' - ) return 'watchlist'; + if (isWatchlisted(entry)) return 'watchlist'; return 'history'; } @@ -97,21 +104,12 @@ function isFlatEntry(entry: TraktJsonEntry): boolean { ); } -function parseFlatEntry(entry: TraktJsonEntry): UniversalImportItem | null { - const ids = entry.id ?? {}; - const action = inferAction(entry); - +function toFlatBase(entry: TraktJsonEntry): ImportItemBase { return { - action, type: resolveType(entry), - ids: toImportIds(ids), + ids: toImportIds(entry.id ?? {}), title: entry.title, year: entry.year, - watched_at: toWatchedAt( - entry.watched_at ?? entry.date_watched ?? entry.created_at, - ), - rating: entry.rating, - rated_at: toImportISOString(entry.rated_at), }; } @@ -127,13 +125,8 @@ function isMultiIdFlatEntry(entry: TraktJsonEntry): boolean { ); } -function parseMultiIdFlatEntry( - entry: TraktJsonEntry, -): UniversalImportItem | null { - const action = inferAction(entry); - +function toMultiIdFlatBase(entry: TraktJsonEntry): ImportItemBase { return { - action, type: resolveType(entry), ids: toImportIds({ trakt: entry.trakt_id, @@ -143,46 +136,75 @@ function parseMultiIdFlatEntry( }), title: entry.title, year: entry.year, - watched_at: toWatchedAt( - entry.watched_at ?? entry.date_watched ?? entry.created_at, - ), - rating: entry.rating, - rated_at: toImportISOString(entry.rated_at), }; } -function parseTraktJsonEntry( - entry: TraktJsonEntry, -): UniversalImportItem | null { - if (isFlatEntry(entry)) return parseFlatEntry(entry); - if (isMultiIdFlatEntry(entry)) return parseMultiIdFlatEntry(entry); - +function toNestedBase(entry: TraktJsonEntry): ImportItemBase { const type = resolveType(entry); - const action = inferAction(entry); - const media = type === 'episode' ? entry.show : (entry.movie ?? entry.show); const episodeData = type === 'episode' ? entry.episode : undefined; - const ids: TraktJsonIds = episodeData?.ids ?? media?.ids ?? {}; return { - action, type, ids: toImportIds(ids), title: media?.title, year: media?.year, - watched_at: toWatchedAt(entry.watched_at), - rating: entry.rating, - rated_at: toImportISOString(entry.rated_at), season: episodeData?.season, episode: episodeData?.number, }; } +function toEntryBase(entry: TraktJsonEntry): ImportItemBase { + if (isFlatEntry(entry)) return toFlatBase(entry); + if (isMultiIdFlatEntry(entry)) return toMultiIdFlatBase(entry); + return toNestedBase(entry); +} + +function parseTraktJsonEntry(entry: TraktJsonEntry): UniversalImportItem[] { + const base = toEntryBase(entry); + const watchedAt = toWatchedAt(entry.watched_at ?? entry.date_watched); + const ratedAt = toImportISOString(entry.rated_at); + + if (entry.action) { + return [{ + ...base, + action: inferAction(entry), + watched_at: watchedAt ?? toWatchedAt(entry.created_at), + rating: entry.rating, + rated_at: ratedAt, + }]; + } + + const items: UniversalImportItem[] = [ + ...watchedAt + ? [{ ...base, action: 'history' as const, watched_at: watchedAt }] + : [], + ...entry.rating != null + ? [{ + ...base, + action: 'ratings' as const, + rating: entry.rating, + rated_at: ratedAt, + }] + : [], + ...isWatchlisted(entry) ? [{ ...base, action: 'watchlist' as const }] : [], + ]; + + if (items.length > 0) return items; + + return [{ + ...base, + action: inferAction(entry), + watched_at: toWatchedAt(entry.created_at), + rating: entry.rating, + rated_at: ratedAt, + }]; +} + function parseEntries(entries: TraktJsonEntry[]): UniversalImportItem[] { return entries - .map(parseTraktJsonEntry) - .filter((item): item is UniversalImportItem => item !== null) + .flatMap(parseTraktJsonEntry) .filter(isValidItem); } From 1eab546a6fcb4af7fbe32609fa49fad2ad23aaf3 Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 20 Aug 2026 19:41:07 +0200 Subject: [PATCH 5/9] feat(import): support season entries The guide has always listed season as a valid type, but toType had no branch for it so a season entry parsed as a movie, and none of the payload builders had a seasons bucket. Seasons resolve by trakt, tmdb or tvdb id server side, never imdb, hence the SEASON_IDS priority. --- .../sections/settings/import/ImportTypes.ts | 2 +- .../import/engine/buildHistoryPayload.spec.ts | 41 +++++++++++ .../import/engine/buildHistoryPayload.ts | 23 +++++- .../import/engine/buildRatingsPayload.spec.ts | 29 ++++++++ .../import/engine/buildRatingsPayload.ts | 22 +++++- .../engine/buildWatchlistPayload.spec.ts | 23 ++++++ .../import/engine/buildWatchlistPayload.ts | 17 ++++- .../settings/import/engine/pickIds.spec.ts | 26 ++++++- .../settings/import/engine/pickIds.ts | 1 + .../settings/import/parsers/TraktCsvParser.ts | 1 + .../import/parsers/TraktJsonParser.spec.ts | 70 +++++++++++++++++++ .../import/parsers/TraktJsonParser.ts | 12 +++- 12 files changed, 257 insertions(+), 10 deletions(-) diff --git a/projects/client/src/lib/sections/settings/import/ImportTypes.ts b/projects/client/src/lib/sections/settings/import/ImportTypes.ts index 23e7eeec70..feb5f9025c 100644 --- a/projects/client/src/lib/sections/settings/import/ImportTypes.ts +++ b/projects/client/src/lib/sections/settings/import/ImportTypes.ts @@ -22,7 +22,7 @@ export type EpisodeMatchMode = 'id' | 'positional'; export const DEFAULT_EPISODE_MATCH_MODE: EpisodeMatchMode = 'id'; -export type ImportType = 'movie' | 'show' | 'episode'; +export type ImportType = 'movie' | 'show' | 'season' | 'episode'; export type ImportStatus = | 'idle' diff --git a/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.spec.ts b/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.spec.ts index 88b9973f62..033a654d02 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.spec.ts @@ -302,4 +302,45 @@ describe('buildHistoryPayload', () => { expect(result.shows).toHaveLength(0); }); }); + + describe('seasons', () => { + it('should map a season item into the seasons bucket', () => { + const result = buildHistoryPayload([{ + action: 'history', + type: 'season', + ids: { tvdb: 12345 }, + watched_at: '2026-08-14T10:47:49.000Z', + }]); + + expect(result.seasons).toEqual([{ + ids: { tvdb: 12345 }, + watched_at: '2026-08-14T10:47:49.000Z', + }]); + expect(result.shows).toEqual([]); + expect(result.movies).toEqual([]); + }); + + it('should resolve a season by tmdb id', () => { + const result = buildHistoryPayload([{ + action: 'history', + type: 'season', + ids: { tmdb: 67324 }, + }]); + + expect(result.seasons).toEqual([{ + ids: { tmdb: 67324 }, + watched_at: undefined, + }]); + }); + + it('should drop a season carrying only an imdb id', () => { + const result = buildHistoryPayload([{ + action: 'history', + type: 'season', + ids: { imdb: 'tt0306414' }, + }]); + + expect(result.seasons).toEqual([]); + }); + }); }); diff --git a/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.ts b/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.ts index 6ebc93ed11..ebf0905ed2 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.ts @@ -4,10 +4,17 @@ import { type EpisodeMatchMode, type UniversalImportItem, } from '../ImportTypes.ts'; -import { EPISODE_IDS, MOVIE_IDS, pickIds, SHOW_IDS } from './pickIds.ts'; +import { + EPISODE_IDS, + MOVIE_IDS, + pickIds, + SEASON_IDS, + SHOW_IDS, +} from './pickIds.ts'; type HistoryMovie = NonNullable[number]; type HistoryShow = NonNullable[number]; +type HistorySeason = NonNullable[number]; type HistoryEpisode = NonNullable[number]; // Movies never fall back to {title, year}: server-side text matching @@ -30,6 +37,14 @@ function toHistoryShow( return null; } +function toHistorySeason( + { ids, watched_at }: UniversalImportItem, +): HistorySeason | null { + const resolvedIds = pickIds(ids, SEASON_IDS); + if (resolvedIds) return { ids: resolvedIds as never, watched_at }; + return null; +} + // Prefer the episode's own id (tvdb/trakt) over positional resolution: the // export's episode id is the exact identity of what was watched and survives // season/episode renumbering divergence between TVDB and Trakt. Positional @@ -132,5 +147,9 @@ export function buildHistoryPayload( ), ]; - return { movies, shows, episodes }; + const seasons = items + .filter((item) => item.type === 'season') + .flatMap((item) => toHistorySeason(item) ?? []); + + return { movies, shows, seasons, episodes }; } diff --git a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts index a82be88a03..16d9f87be2 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts @@ -149,4 +149,33 @@ describe('buildRatingsPayload', () => { expect(result.movies).toHaveLength(0); expect(result.shows).toHaveLength(0); }); + + describe('seasons', () => { + it('should map a rated season into the seasons bucket', () => { + const result = buildRatingsPayload([{ + action: 'ratings', + type: 'season', + ids: { tvdb: 12345 }, + rating: 8, + rated_at: '2026-08-11T07:22:03.000Z', + }]); + + expect(result.seasons).toEqual([{ + rating: 8, + ids: { tvdb: 12345 }, + rated_at: '2026-08-11T07:22:03.000Z', + }]); + expect(result.shows).toEqual([]); + }); + + it('should drop a season with no rating', () => { + const result = buildRatingsPayload([{ + action: 'ratings', + type: 'season', + ids: { tvdb: 12345 }, + }]); + + expect(result.seasons).toEqual([]); + }); + }); }); diff --git a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts index 60adad429e..2cfb604a79 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts @@ -1,9 +1,10 @@ import type { RatingsSyncRequest } from '@trakt/api'; import type { UniversalImportItem } from '../ImportTypes.ts'; -import { MOVIE_IDS, pickIds, SHOW_IDS } from './pickIds.ts'; +import { MOVIE_IDS, pickIds, SEASON_IDS, SHOW_IDS } from './pickIds.ts'; type RatingsMovie = NonNullable[number]; type RatingsShow = NonNullable[number]; +type RatingsSeason = NonNullable[number]; function clampRating(rating: number): number { return Math.min(10, Math.max(1, Math.round(rating))); @@ -35,6 +36,19 @@ function toRatingsShow( }; } +function toRatingsSeason( + { ids, rating, rated_at }: UniversalImportItem, +): RatingsSeason | null { + if (rating == null) return null; + const resolvedIds = pickIds(ids, SEASON_IDS); + if (!resolvedIds) return null; + return { + rating: clampRating(rating), + ids: resolvedIds as never, + ...(rated_at ? { rated_at } : {}), + }; +} + export function buildRatingsPayload( items: UniversalImportItem[], ): RatingsSyncRequest { @@ -46,5 +60,9 @@ export function buildRatingsPayload( .filter((item) => item.type === 'show') .flatMap((item) => toRatingsShow(item) ?? []); - return { movies, shows }; + const seasons = items + .filter((item) => item.type === 'season') + .flatMap((item) => toRatingsSeason(item) ?? []); + + return { movies, shows, seasons }; } diff --git a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts index 68c3b6e49e..0eed142253 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts @@ -81,4 +81,27 @@ describe('buildWatchlistPayload', () => { expect(result.movies).toHaveLength(0); expect(result.shows).toHaveLength(0); }); + + describe('seasons', () => { + it('should map a season item into the seasons bucket', () => { + const result = buildWatchlistPayload([{ + action: 'watchlist', + type: 'season', + ids: { tvdb: 12345 }, + }]); + + expect(result.seasons).toEqual([{ ids: { tvdb: 12345 } }]); + expect(result.shows).toEqual([]); + }); + + it('should drop a season carrying only an imdb id', () => { + const result = buildWatchlistPayload([{ + action: 'watchlist', + type: 'season', + ids: { imdb: 'tt0306414' }, + }]); + + expect(result.seasons).toEqual([]); + }); + }); }); diff --git a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts index 6710507fef..6c6351ae21 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts @@ -1,9 +1,10 @@ import type { WatchlistRequest } from '@trakt/api'; import type { UniversalImportItem } from '../ImportTypes.ts'; -import { MOVIE_IDS, pickIds, SHOW_IDS } from './pickIds.ts'; +import { MOVIE_IDS, pickIds, SEASON_IDS, SHOW_IDS } from './pickIds.ts'; type WatchlistMovie = NonNullable[number]; type WatchlistShow = NonNullable[number]; +type WatchlistSeason = NonNullable[number]; // Movies never fall back to {title, year}: server-side text matching // is too fuzzy and mismatches pollute the watchlist. Unresolved movies @@ -25,6 +26,14 @@ function toWatchlistShow( return null; } +function toWatchlistSeason( + { ids }: UniversalImportItem, +): WatchlistSeason | null { + const resolvedIds = pickIds(ids, SEASON_IDS); + if (resolvedIds) return { ids: resolvedIds as never }; + return null; +} + export function buildWatchlistPayload( items: UniversalImportItem[], ): WatchlistRequest { @@ -36,5 +45,9 @@ export function buildWatchlistPayload( .filter((item) => item.type === 'show') .flatMap((item) => toWatchlistShow(item) ?? []); - return { movies, shows }; + const seasons = items + .filter((item) => item.type === 'season') + .flatMap((item) => toWatchlistSeason(item) ?? []); + + return { movies, shows, seasons }; } diff --git a/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts b/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts index 246dd90a84..71583841df 100644 --- a/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { EPISODE_IDS, MOVIE_IDS, pickIds, SHOW_IDS } from './pickIds.ts'; +import { + EPISODE_IDS, + MOVIE_IDS, + pickIds, + SEASON_IDS, + SHOW_IDS, +} from './pickIds.ts'; describe('pickIds', () => { describe('with MOVIE_IDS priority', () => { @@ -44,6 +50,24 @@ describe('pickIds', () => { }); }); + describe('with SEASON_IDS priority', () => { + it('should return tvdb first when available', () => { + expect(pickIds({ tvdb: 12, tmdb: 34, trakt: 56 }, SEASON_IDS)).toEqual({ + tvdb: 12, + }); + }); + + it('should fall back to tmdb when tvdb is absent', () => { + expect(pickIds({ tmdb: 34, trakt: 56 }, SEASON_IDS)).toEqual({ + tmdb: 34, + }); + }); + + it('should return null when only imdb is present (not in season priority)', () => { + expect(pickIds({ imdb: 'tt0000000' }, SEASON_IDS)).toBeNull(); + }); + }); + describe('with EPISODE_IDS priority', () => { it('should return tvdb first when available', () => { expect(pickIds({ tvdb: 77, trakt: 88 }, EPISODE_IDS)).toEqual({ diff --git a/projects/client/src/lib/sections/settings/import/engine/pickIds.ts b/projects/client/src/lib/sections/settings/import/engine/pickIds.ts index 85cc15a7d3..30009f5970 100644 --- a/projects/client/src/lib/sections/settings/import/engine/pickIds.ts +++ b/projects/client/src/lib/sections/settings/import/engine/pickIds.ts @@ -4,6 +4,7 @@ export type IdPriority = ReadonlyArray; export const MOVIE_IDS: IdPriority = ['imdb', 'tmdb', 'trakt']; export const SHOW_IDS: IdPriority = ['imdb', 'tvdb', 'tmdb', 'trakt']; +export const SEASON_IDS: IdPriority = ['tvdb', 'tmdb', 'trakt']; export const EPISODE_IDS: IdPriority = ['tvdb', 'tmdb', 'trakt']; export function pickIds( diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktCsvParser.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktCsvParser.ts index 8f223457a9..d25cccaaa2 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktCsvParser.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktCsvParser.ts @@ -65,6 +65,7 @@ function toType(value?: string): ImportType { normalized === 'show' || normalized === 'series' || normalized === 'tv series' ) return 'show'; + if (normalized === 'season') return 'season'; if (normalized === 'episode') return 'episode'; return 'movie'; } diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts index 828d8d3721..b4eecb488b 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts @@ -275,6 +275,25 @@ describe('TraktJsonParser', () => { }); }); + it('respects an explicit season type', async () => { + mockParseJsonFile.mockResolvedValue([ + { + tvdb_id: '12345', + type: 'season', + watched_at: '2026-08-14T10:47:49.000Z', + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('history.json')]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + action: 'history', + type: 'season', + ids: { tvdb: 12345 }, + }); + }); + it('respects an explicit episode type', async () => { mockParseJsonFile.mockResolvedValue([ { @@ -427,6 +446,57 @@ describe('TraktJsonParser', () => { }); }); + describe('parse – nested season entries', () => { + it('uses the season ids, not the parent show ids', async () => { + mockParseJsonFile.mockResolvedValue([ + { + rated_at: '2025-09-29T21:35:25.000Z', + rating: 7, + type: 'season', + season: { + number: 1, + ids: { trakt: 279654, tvdb: 1967072, tmdb: 219370 }, + }, + show: { + title: 'Marvel Zombies', + year: 2025, + ids: { trakt: 191189, tvdb: 412428, imdb: 'tt16027014' }, + }, + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('ratings.json')]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + action: 'ratings', + type: 'season', + ids: { trakt: 279654, tvdb: 1967072, tmdb: 219370 }, + title: 'Marvel Zombies', + year: 2025, + season: 1, + rating: 7, + }); + }); + + it('infers the season type from a nested season object', async () => { + mockParseJsonFile.mockResolvedValue([ + { + rating: 9, + season: { number: 4, ids: { trakt: 432076 } }, + show: { title: 'The Bear', year: 2022, ids: { trakt: 1 } }, + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('ratings.json')]); + + expect(result[0]).toMatchObject({ + type: 'season', + ids: { trakt: 432076 }, + }); + }); + }); + describe('parse – multiple actions per entry', () => { it('fans out an entry carrying both a watch and a rating', async () => { mockParseJsonFile.mockResolvedValue([ diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts index 4522f91278..79a5e433ab 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts @@ -34,6 +34,10 @@ type TraktJsonEntry = { rated_at?: string; movie?: { title?: string; year?: number; ids?: TraktJsonIds }; show?: { title?: string; year?: number; ids?: TraktJsonIds }; + season?: { + number?: number; + ids?: TraktJsonIds; + }; episode?: { season?: number; number?: number; @@ -54,6 +58,7 @@ type TraktJsonEntry = { function inferType(entry: TraktJsonEntry): ImportType { if (entry.episode) return 'episode'; + if (entry.season) return 'season'; if (entry.show && !entry.movie) return 'show'; return 'movie'; } @@ -82,6 +87,7 @@ function inferAction(entry: TraktJsonEntry): ImportAction { function toType(value: string): ImportType { const normalized = value.toLowerCase(); if (normalized === 'show' || normalized === 'series') return 'show'; + if (normalized === 'season') return 'season'; if (normalized === 'episode') return 'episode'; return 'movie'; } @@ -143,14 +149,16 @@ function toNestedBase(entry: TraktJsonEntry): ImportItemBase { const type = resolveType(entry); const media = type === 'episode' ? entry.show : (entry.movie ?? entry.show); const episodeData = type === 'episode' ? entry.episode : undefined; - const ids: TraktJsonIds = episodeData?.ids ?? media?.ids ?? {}; + const seasonData = type === 'season' ? entry.season : undefined; + const ids: TraktJsonIds = episodeData?.ids ?? seasonData?.ids ?? media?.ids ?? + {}; return { type, ids: toImportIds(ids), title: media?.title, year: media?.year, - season: episodeData?.season, + season: episodeData?.season ?? seasonData?.number, episode: episodeData?.number, }; } From 86f21a969fbb8b076fc2eaff25c0c2c875509179 Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 20 Aug 2026 19:42:49 +0200 Subject: [PATCH 6/9] fix(import): add episodes to the watchlist payload buildWatchlistPayload only ever emitted movies and shows, so a watchlisted episode was dropped without a trace. It also backs syncLists, so episodes were missing from imported custom lists too. --- .../engine/buildWatchlistPayload.spec.ts | 33 +++++++++++++++++++ .../import/engine/buildWatchlistPayload.ts | 23 +++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts index 0eed142253..73d429563b 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts @@ -104,4 +104,37 @@ describe('buildWatchlistPayload', () => { expect(result.seasons).toEqual([]); }); }); + + describe('episodes', () => { + it('should map an episode item into the episodes bucket', () => { + const result = buildWatchlistPayload([{ + action: 'watchlist', + type: 'episode', + ids: { tvdb: 7654321 }, + }]); + + expect(result.episodes).toEqual([{ ids: { tvdb: 7654321 } }]); + expect(result.shows).toEqual([]); + }); + + it('should resolve an episode by tmdb id', () => { + const result = buildWatchlistPayload([{ + action: 'watchlist', + type: 'episode', + ids: { tmdb: 66452 }, + }]); + + expect(result.episodes).toEqual([{ ids: { tmdb: 66452 } }]); + }); + + it('should drop an episode carrying only an imdb id', () => { + const result = buildWatchlistPayload([{ + action: 'watchlist', + type: 'episode', + ids: { imdb: 'tt0306414' }, + }]); + + expect(result.episodes).toEqual([]); + }); + }); }); diff --git a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts index 6c6351ae21..525f2bf36a 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts @@ -1,10 +1,17 @@ import type { WatchlistRequest } from '@trakt/api'; import type { UniversalImportItem } from '../ImportTypes.ts'; -import { MOVIE_IDS, pickIds, SEASON_IDS, SHOW_IDS } from './pickIds.ts'; +import { + EPISODE_IDS, + MOVIE_IDS, + pickIds, + SEASON_IDS, + SHOW_IDS, +} from './pickIds.ts'; type WatchlistMovie = NonNullable[number]; type WatchlistShow = NonNullable[number]; type WatchlistSeason = NonNullable[number]; +type WatchlistEpisode = NonNullable[number]; // Movies never fall back to {title, year}: server-side text matching // is too fuzzy and mismatches pollute the watchlist. Unresolved movies @@ -34,6 +41,14 @@ function toWatchlistSeason( return null; } +function toWatchlistEpisode( + { ids }: UniversalImportItem, +): WatchlistEpisode | null { + const resolvedIds = pickIds(ids, EPISODE_IDS); + if (resolvedIds) return { ids: resolvedIds as never }; + return null; +} + export function buildWatchlistPayload( items: UniversalImportItem[], ): WatchlistRequest { @@ -49,5 +64,9 @@ export function buildWatchlistPayload( .filter((item) => item.type === 'season') .flatMap((item) => toWatchlistSeason(item) ?? []); - return { movies, shows, seasons }; + const episodes = items + .filter((item) => item.type === 'episode') + .flatMap((item) => toWatchlistEpisode(item) ?? []); + + return { movies, shows, seasons, episodes }; } From 6c887aa61303c1fed141b318f964fb6553a07434 Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 20 Aug 2026 19:46:29 +0200 Subject: [PATCH 7/9] fix(import): add episodes to the ratings payload buildRatingsPayload only emitted movies and shows, so an episode rating was dropped even though the guide documents rating for every type and the sync endpoint takes an episodes bucket. --- .../import/engine/buildRatingsPayload.spec.ts | 29 +++++++++++++++++++ .../import/engine/buildRatingsPayload.ts | 28 ++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts index 16d9f87be2..e486dd1c08 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts @@ -178,4 +178,33 @@ describe('buildRatingsPayload', () => { expect(result.seasons).toEqual([]); }); }); + + describe('episodes', () => { + it('should map a rated episode into the episodes bucket', () => { + const result = buildRatingsPayload([{ + action: 'ratings', + type: 'episode', + ids: { tvdb: 7654321 }, + rating: 9, + rated_at: '2026-08-11T07:22:03.000Z', + }]); + + expect(result.episodes).toEqual([{ + rating: 9, + ids: { tvdb: 7654321 }, + rated_at: '2026-08-11T07:22:03.000Z', + }]); + expect(result.shows).toEqual([]); + }); + + it('should drop an episode with no rating', () => { + const result = buildRatingsPayload([{ + action: 'ratings', + type: 'episode', + ids: { tvdb: 7654321 }, + }]); + + expect(result.episodes).toEqual([]); + }); + }); }); diff --git a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts index 2cfb604a79..496a1fcfa3 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts @@ -1,10 +1,17 @@ import type { RatingsSyncRequest } from '@trakt/api'; import type { UniversalImportItem } from '../ImportTypes.ts'; -import { MOVIE_IDS, pickIds, SEASON_IDS, SHOW_IDS } from './pickIds.ts'; +import { + EPISODE_IDS, + MOVIE_IDS, + pickIds, + SEASON_IDS, + SHOW_IDS, +} from './pickIds.ts'; type RatingsMovie = NonNullable[number]; type RatingsShow = NonNullable[number]; type RatingsSeason = NonNullable[number]; +type RatingsEpisode = NonNullable[number]; function clampRating(rating: number): number { return Math.min(10, Math.max(1, Math.round(rating))); @@ -49,6 +56,19 @@ function toRatingsSeason( }; } +function toRatingsEpisode( + { ids, rating, rated_at }: UniversalImportItem, +): RatingsEpisode | null { + if (rating == null) return null; + const resolvedIds = pickIds(ids, EPISODE_IDS); + if (!resolvedIds) return null; + return { + rating: clampRating(rating), + ids: resolvedIds as never, + ...(rated_at ? { rated_at } : {}), + }; +} + export function buildRatingsPayload( items: UniversalImportItem[], ): RatingsSyncRequest { @@ -64,5 +84,9 @@ export function buildRatingsPayload( .filter((item) => item.type === 'season') .flatMap((item) => toRatingsSeason(item) ?? []); - return { movies, shows, seasons }; + const episodes = items + .filter((item) => item.type === 'episode') + .flatMap((item) => toRatingsEpisode(item) ?? []); + + return { movies, shows, seasons, episodes }; } From 0106c19531b33d4822867e82deec72dce98192af Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 20 Aug 2026 19:51:39 +0200 Subject: [PATCH 8/9] fix(import): resolve episodes by imdb id An episode carrying only an imdb id had nowhere to go, so it was pushed into the shows bucket under that id. No show carries an episode's imdb id, so the entry either vanished or, worse, marked a whole show watched. Episodes do resolve by imdb server side, so route them as episodes and drop the punt. Fixes IMDb tvEpisode rows, which only ever carry an episode imdb id. --- .../sections/settings/import/ImportTypes.ts | 4 +- .../import/engine/buildHistoryPayload.spec.ts | 59 +++++++++- .../import/engine/buildHistoryPayload.ts | 109 ++++++++---------- .../import/engine/buildRatingsPayload.ts | 94 +++++---------- .../engine/buildWatchlistPayload.spec.ts | 5 +- .../import/engine/buildWatchlistPayload.ts | 90 ++++++--------- .../settings/import/engine/pickIds.spec.ts | 38 ++++-- .../settings/import/engine/pickIds.ts | 21 +++- .../import/parsers/utils/isValidItem.spec.ts | 68 +++++++++++ .../import/parsers/utils/isValidItem.ts | 37 ++++-- .../sections/settings/import/syncToTrakt.ts | 8 +- 11 files changed, 322 insertions(+), 211 deletions(-) create mode 100644 projects/client/src/lib/sections/settings/import/parsers/utils/isValidItem.spec.ts diff --git a/projects/client/src/lib/sections/settings/import/ImportTypes.ts b/projects/client/src/lib/sections/settings/import/ImportTypes.ts index feb5f9025c..8943c5b346 100644 --- a/projects/client/src/lib/sections/settings/import/ImportTypes.ts +++ b/projects/client/src/lib/sections/settings/import/ImportTypes.ts @@ -14,10 +14,10 @@ export type ImportAction = 'history' | 'watchlist' | 'ratings' | 'list'; export type ImportActionSelection = Record; // How watched episodes are matched to Trakt on import: -// - 'id' (default, recommended): the episode's own TVDB id - exact, survives +// - 'id' (default, recommended): the episode's own id - exact, survives // season/episode renumbering. // - 'positional': show + season/episode number - a fallback for episodes whose -// TVDB id Trakt doesn't have, at the cost of numbering-divergence mismatches. +// id Trakt doesn't have, at the cost of numbering-divergence mismatches. export type EpisodeMatchMode = 'id' | 'positional'; export const DEFAULT_EPISODE_MATCH_MODE: EpisodeMatchMode = 'id'; diff --git a/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.spec.ts b/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.spec.ts index 033a654d02..a7705d296e 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.spec.ts @@ -271,7 +271,7 @@ describe('buildHistoryPayload', () => { expect(result.shows).toHaveLength(0); }); - it('should fall back to show via imdb when no episode ids resolve', () => { + it('should resolve an episode carrying only an imdb id as an episode', () => { const item: UniversalImportItem = { action: 'history', type: 'episode', @@ -281,11 +281,11 @@ describe('buildHistoryPayload', () => { const result = buildHistoryPayload([item]); - expect(result.episodes).toHaveLength(0); - expect(result.shows).toEqual([{ + expect(result.episodes).toEqual([{ ids: { imdb: 'tt9999999' }, watched_at, }]); + expect(result.shows).toEqual([]); }); it('should skip an episode with no usable ids', () => { @@ -343,4 +343,57 @@ describe('buildHistoryPayload', () => { expect(result.seasons).toEqual([]); }); }); + + describe('episodes carrying positional numbers', () => { + it('should not treat a tmdb id as an episode id when season and episode are present', () => { + const result = buildHistoryPayload([{ + action: 'history', + type: 'episode', + ids: { tmdb: 1396 }, + season: 1, + episode: 1, + watched_at, + }]); + + expect(result.episodes).toEqual([]); + expect(result.shows).toEqual([]); + }); + + it('should not treat an imdb id as an episode id when season and episode are present', () => { + const result = buildHistoryPayload([{ + action: 'history', + type: 'episode', + ids: { imdb: 'tt0903747' }, + season: 3, + episode: 7, + watched_at, + }]); + + expect(result.episodes).toEqual([]); + }); + + it('should still resolve a tmdb episode id when no positional numbers are given', () => { + const result = buildHistoryPayload([{ + action: 'history', + type: 'episode', + ids: { tmdb: 66452 }, + watched_at, + }]); + + expect(result.episodes).toEqual([{ ids: { tmdb: 66452 }, watched_at }]); + }); + + it('should keep resolving a tvdb episode id alongside positional numbers', () => { + const result = buildHistoryPayload([{ + action: 'history', + type: 'episode', + ids: { tvdb: 4133781 }, + season: 1, + episode: 1, + watched_at, + }]); + + expect(result.episodes).toEqual([{ ids: { tvdb: 4133781 }, watched_at }]); + }); + }); }); diff --git a/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.ts b/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.ts index ebf0905ed2..43f4e16ae5 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.ts @@ -2,55 +2,50 @@ import type { HistoryAddRequest } from '@trakt/api'; import { DEFAULT_EPISODE_MATCH_MODE, type EpisodeMatchMode, + type ImportType, type UniversalImportItem, } from '../ImportTypes.ts'; import { - EPISODE_IDS, + type IdPriority, MOVIE_IDS, pickIds, + type ResolvedIds, SEASON_IDS, SHOW_IDS, + toEpisodeIdPriority, } from './pickIds.ts'; -type HistoryMovie = NonNullable[number]; -type HistoryShow = NonNullable[number]; -type HistorySeason = NonNullable[number]; -type HistoryEpisode = NonNullable[number]; +type HistoryEntry = + | { ids: ResolvedIds; watched_at?: string } + | { title: string; year: number; watched_at?: string }; -// Movies never fall back to {title, year}: server-side text matching -// is too fuzzy and mismatches pollute history. Unresolved movies are -// dropped instead (resolveMovieIds runs before this). -function toHistoryMovie( +function toHistoryEntry( { ids, watched_at }: UniversalImportItem, -): HistoryMovie | null { - const resolvedIds = pickIds(ids, MOVIE_IDS); - if (resolvedIds) return { ids: resolvedIds as never, watched_at }; + priority: IdPriority, +): HistoryEntry | null { + const resolvedIds = pickIds(ids, priority); + if (resolvedIds) return { ids: resolvedIds, watched_at }; return null; } -function toHistoryShow( - { ids, title, year, watched_at }: UniversalImportItem, -): HistoryShow | null { - const resolvedIds = pickIds(ids, SHOW_IDS); - if (resolvedIds) return { ids: resolvedIds as never, watched_at }; - if (title && year) return { title, year, watched_at }; - return null; -} +// Only shows fall back to {title, year}: server-side text matching is too fuzzy +// for movies and mismatches pollute history, so unresolved movies are dropped +// instead (resolveMovieIds runs before this). +function toHistoryShow(item: UniversalImportItem): HistoryEntry | null { + const entry = toHistoryEntry(item, SHOW_IDS); + if (entry) return entry; -function toHistorySeason( - { ids, watched_at }: UniversalImportItem, -): HistorySeason | null { - const resolvedIds = pickIds(ids, SEASON_IDS); - if (resolvedIds) return { ids: resolvedIds as never, watched_at }; + const { title, year, watched_at } = item; + if (title && year) return { title, year, watched_at }; return null; } -// Prefer the episode's own id (tvdb/trakt) over positional resolution: the -// export's episode id is the exact identity of what was watched and survives -// season/episode renumbering divergence between TVDB and Trakt. Positional -// (show id + season/number) is the fallback for episodes carrying no own id. +// Prefer the episode's own id over positional resolution: the export's episode +// id is the exact identity of what was watched and survives season/episode +// renumbering divergence between TVDB and Trakt. Positional (show id + +// season/number) is the fallback for episodes carrying no own id. function hasEpisodeId(item: UniversalImportItem): boolean { - return pickIds(item.ids, EPISODE_IDS) != null; + return pickIds(item.ids, toEpisodeIdPriority(item)) != null; } function isPositional( @@ -63,6 +58,10 @@ function isPositional( type PositionalEpisode = { number: number; watched_at?: string }; type ShowIds = { tvdb?: number; imdb?: string }; type ShowGroup = { ids: ShowIds; seasons: Map }; +type PositionalShow = { + ids: ShowIds; + seasons: Array<{ number: number; episodes: PositionalEpisode[] }>; +}; function toShowIds(item: UniversalImportItem): ShowIds { return { @@ -80,7 +79,7 @@ function toPositionalShows( items: ReadonlyArray< UniversalImportItem & { season: number; episode: number } >, -): HistoryShow[] { +): PositionalShow[] { const byShow = items.reduce((shows, item) => { const key = item.showTvdb != null ? `tvdb:${item.showTvdb}` @@ -101,11 +100,11 @@ function toPositionalShows( return [...byShow.values()].map(({ ids, seasons }) => ({ ids, seasons: [...seasons].map(([number, episodes]) => ({ number, episodes })), - })) as unknown as HistoryShow[]; + })); } export function buildHistoryPayload( - items: UniversalImportItem[], + items: ReadonlyArray, episodeMatch: EpisodeMatchMode = DEFAULT_EPISODE_MATCH_MODE, ): HistoryAddRequest { const episodeItems = items.filter((item) => item.type === 'episode'); @@ -122,34 +121,24 @@ export function buildHistoryPayload( const idEpisodes = episodeItems.filter((item) => !positionalSet.has(item) && hasEpisodeId(item) ); - const leftoverEpisodes = episodeItems.filter((item) => - !positionalSet.has(item) && !hasEpisodeId(item) - ); - const movies = items - .filter((item) => item.type === 'movie') - .flatMap((item) => toHistoryMovie(item) ?? []); + const collect = ( + type: ImportType, + map: (item: UniversalImportItem) => HistoryEntry | null, + ) => + items + .filter((item) => item.type === type) + .flatMap((item) => map(item) ?? []); - const episodes: HistoryEpisode[] = idEpisodes.flatMap( - ({ ids, watched_at }) => { - const resolvedIds = pickIds(ids, EPISODE_IDS); - return resolvedIds ? [{ ids: resolvedIds as never, watched_at }] : []; - }, - ); - - const shows: HistoryShow[] = [ - ...items - .filter((item) => item.type === 'show') - .flatMap((item) => toHistoryShow(item) ?? []), - ...toPositionalShows(positionalEpisodes), - ...leftoverEpisodes.flatMap(({ ids, watched_at }) => - ids.imdb ? [{ ids: { imdb: ids.imdb } as never, watched_at }] : [] + return { + movies: collect('movie', (item) => toHistoryEntry(item, MOVIE_IDS)), + shows: [ + ...collect('show', toHistoryShow), + ...toPositionalShows(positionalEpisodes), + ], + seasons: collect('season', (item) => toHistoryEntry(item, SEASON_IDS)), + episodes: idEpisodes.flatMap((item) => + toHistoryEntry(item, toEpisodeIdPriority(item)) ?? [] ), - ]; - - const seasons = items - .filter((item) => item.type === 'season') - .flatMap((item) => toHistorySeason(item) ?? []); - - return { movies, shows, seasons, episodes }; + } as HistoryAddRequest; } diff --git a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts index 496a1fcfa3..2294aecd5c 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts @@ -1,92 +1,54 @@ import type { RatingsSyncRequest } from '@trakt/api'; -import type { UniversalImportItem } from '../ImportTypes.ts'; +import type { ImportType, UniversalImportItem } from '../ImportTypes.ts'; import { - EPISODE_IDS, + type IdPriority, MOVIE_IDS, pickIds, + type ResolvedIds, SEASON_IDS, SHOW_IDS, + toEpisodeIdPriority, } from './pickIds.ts'; -type RatingsMovie = NonNullable[number]; -type RatingsShow = NonNullable[number]; -type RatingsSeason = NonNullable[number]; -type RatingsEpisode = NonNullable[number]; +type RatingsEntry = { + rating: number; + ids: ResolvedIds; + rated_at?: string; +}; function clampRating(rating: number): number { return Math.min(10, Math.max(1, Math.round(rating))); } -function toRatingsMovie( +function toRatingsEntry( { ids, rating, rated_at }: UniversalImportItem, -): RatingsMovie | null { + priority: IdPriority, +): RatingsEntry | null { if (rating == null) return null; - const resolvedIds = pickIds(ids, MOVIE_IDS); + const resolvedIds = pickIds(ids, priority); if (!resolvedIds) return null; return { rating: clampRating(rating), - ids: resolvedIds as never, - ...(rated_at ? { rated_at } : {}), - }; -} - -function toRatingsShow( - { ids, rating, rated_at }: UniversalImportItem, -): RatingsShow | null { - if (rating == null) return null; - const resolvedIds = pickIds(ids, SHOW_IDS); - if (!resolvedIds) return null; - return { - rating: clampRating(rating), - ids: resolvedIds as never, - ...(rated_at ? { rated_at } : {}), - }; -} - -function toRatingsSeason( - { ids, rating, rated_at }: UniversalImportItem, -): RatingsSeason | null { - if (rating == null) return null; - const resolvedIds = pickIds(ids, SEASON_IDS); - if (!resolvedIds) return null; - return { - rating: clampRating(rating), - ids: resolvedIds as never, - ...(rated_at ? { rated_at } : {}), - }; -} - -function toRatingsEpisode( - { ids, rating, rated_at }: UniversalImportItem, -): RatingsEpisode | null { - if (rating == null) return null; - const resolvedIds = pickIds(ids, EPISODE_IDS); - if (!resolvedIds) return null; - return { - rating: clampRating(rating), - ids: resolvedIds as never, + ids: resolvedIds, ...(rated_at ? { rated_at } : {}), }; } export function buildRatingsPayload( - items: UniversalImportItem[], + items: ReadonlyArray, ): RatingsSyncRequest { - const movies = items - .filter((item) => item.type === 'movie') - .flatMap((item) => toRatingsMovie(item) ?? []); - - const shows = items - .filter((item) => item.type === 'show') - .flatMap((item) => toRatingsShow(item) ?? []); + const collect = ( + type: ImportType, + toPriority: (item: UniversalImportItem) => IdPriority, + ) => + items + .filter((item) => item.type === type) + .flatMap((item) => toRatingsEntry(item, toPriority(item)) ?? []); - const seasons = items - .filter((item) => item.type === 'season') - .flatMap((item) => toRatingsSeason(item) ?? []); - - const episodes = items - .filter((item) => item.type === 'episode') - .flatMap((item) => toRatingsEpisode(item) ?? []); - - return { movies, shows, seasons, episodes }; + return { + movies: collect('movie', () => MOVIE_IDS), + shows: collect('show', () => SHOW_IDS), + seasons: collect('season', () => SEASON_IDS), + episodes: collect('episode', toEpisodeIdPriority), + } as RatingsSyncRequest; } diff --git a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts index 73d429563b..8309859fa8 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.spec.ts @@ -127,14 +127,15 @@ describe('buildWatchlistPayload', () => { expect(result.episodes).toEqual([{ ids: { tmdb: 66452 } }]); }); - it('should drop an episode carrying only an imdb id', () => { + it('should resolve an episode by imdb id', () => { const result = buildWatchlistPayload([{ action: 'watchlist', type: 'episode', ids: { imdb: 'tt0306414' }, }]); - expect(result.episodes).toEqual([]); + expect(result.episodes).toEqual([{ ids: { imdb: 'tt0306414' } }]); + expect(result.shows).toEqual([]); }); }); }); diff --git a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts index 525f2bf36a..bddfe2cdef 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildWatchlistPayload.ts @@ -1,72 +1,58 @@ import type { WatchlistRequest } from '@trakt/api'; -import type { UniversalImportItem } from '../ImportTypes.ts'; +import type { ImportType, UniversalImportItem } from '../ImportTypes.ts'; import { - EPISODE_IDS, + type IdPriority, MOVIE_IDS, pickIds, + type ResolvedIds, SEASON_IDS, SHOW_IDS, + toEpisodeIdPriority, } from './pickIds.ts'; -type WatchlistMovie = NonNullable[number]; -type WatchlistShow = NonNullable[number]; -type WatchlistSeason = NonNullable[number]; -type WatchlistEpisode = NonNullable[number]; +type WatchlistEntry = + | { ids: ResolvedIds } + | { title: string; year: number }; -// Movies never fall back to {title, year}: server-side text matching -// is too fuzzy and mismatches pollute the watchlist. Unresolved movies -// are dropped instead (resolveMovieIds runs before this). -function toWatchlistMovie( +function toWatchlistEntry( { ids }: UniversalImportItem, -): WatchlistMovie | null { - const resolvedIds = pickIds(ids, MOVIE_IDS); - if (resolvedIds) return { ids: resolvedIds as never }; + priority: IdPriority, +): WatchlistEntry | null { + const resolvedIds = pickIds(ids, priority); + if (resolvedIds) return { ids: resolvedIds }; return null; } -function toWatchlistShow( - { ids, title, year }: UniversalImportItem, -): WatchlistShow | null { - const resolvedIds = pickIds(ids, SHOW_IDS); - if (resolvedIds) return { ids: resolvedIds as never }; - if (title && year) return { title, year }; - return null; -} - -function toWatchlistSeason( - { ids }: UniversalImportItem, -): WatchlistSeason | null { - const resolvedIds = pickIds(ids, SEASON_IDS); - if (resolvedIds) return { ids: resolvedIds as never }; - return null; -} +// Only shows fall back to {title, year}: server-side text matching is too fuzzy +// for movies and mismatches pollute the watchlist, so unresolved movies are +// dropped instead (resolveMovieIds runs before this). +function toWatchlistShow(item: UniversalImportItem): WatchlistEntry | null { + const entry = toWatchlistEntry(item, SHOW_IDS); + if (entry) return entry; -function toWatchlistEpisode( - { ids }: UniversalImportItem, -): WatchlistEpisode | null { - const resolvedIds = pickIds(ids, EPISODE_IDS); - if (resolvedIds) return { ids: resolvedIds as never }; + const { title, year } = item; + if (title && year) return { title, year }; return null; } export function buildWatchlistPayload( - items: UniversalImportItem[], + items: ReadonlyArray, ): WatchlistRequest { - const movies = items - .filter((item) => item.type === 'movie') - .flatMap((item) => toWatchlistMovie(item) ?? []); - - const shows = items - .filter((item) => item.type === 'show') - .flatMap((item) => toWatchlistShow(item) ?? []); - - const seasons = items - .filter((item) => item.type === 'season') - .flatMap((item) => toWatchlistSeason(item) ?? []); - - const episodes = items - .filter((item) => item.type === 'episode') - .flatMap((item) => toWatchlistEpisode(item) ?? []); - - return { movies, shows, seasons, episodes }; + const collect = ( + type: ImportType, + map: (item: UniversalImportItem) => WatchlistEntry | null, + ) => + items + .filter((item) => item.type === type) + .flatMap((item) => map(item) ?? []); + + return { + movies: collect('movie', (item) => toWatchlistEntry(item, MOVIE_IDS)), + shows: collect('show', toWatchlistShow), + seasons: collect('season', (item) => toWatchlistEntry(item, SEASON_IDS)), + episodes: collect( + 'episode', + (item) => toWatchlistEntry(item, toEpisodeIdPriority(item)), + ), + } as WatchlistRequest; } diff --git a/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts b/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts index 71583841df..cbe066322f 100644 --- a/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/pickIds.spec.ts @@ -43,11 +43,15 @@ describe('pickIds', () => { }); }); - it('should fall back to tmdb when imdb and tvdb are absent', () => { - expect(pickIds({ tmdb: 67324, trakt: 6 }, SHOW_IDS)).toEqual({ - tmdb: 67324, + it('should prefer trakt over tmdb', () => { + expect(pickIds({ trakt: 6, tmdb: 67324 }, SHOW_IDS)).toEqual({ + trakt: 6, }); }); + + it('should fall back to tmdb when no other id is present', () => { + expect(pickIds({ tmdb: 67324 }, SHOW_IDS)).toEqual({ tmdb: 67324 }); + }); }); describe('with SEASON_IDS priority', () => { @@ -57,9 +61,9 @@ describe('pickIds', () => { }); }); - it('should fall back to tmdb when tvdb is absent', () => { - expect(pickIds({ tmdb: 34, trakt: 56 }, SEASON_IDS)).toEqual({ - tmdb: 34, + it('should prefer trakt over tmdb', () => { + expect(pickIds({ trakt: 56, tmdb: 34 }, SEASON_IDS)).toEqual({ + trakt: 56, }); }); @@ -81,18 +85,30 @@ describe('pickIds', () => { }); }); - it('should fall back to tmdb when tvdb is absent', () => { - expect(pickIds({ tmdb: 66452, trakt: 88 }, EPISODE_IDS)).toEqual({ - tmdb: 66452, + it('should prefer trakt over tmdb', () => { + expect(pickIds({ trakt: 88, tmdb: 66452 }, EPISODE_IDS)).toEqual({ + trakt: 88, }); }); + it('should fall back to tmdb when tvdb and trakt are absent', () => { + expect(pickIds({ tmdb: 66452 }, EPISODE_IDS)).toEqual({ tmdb: 66452 }); + }); + it('should fall back to trakt when tvdb and tmdb are absent', () => { expect(pickIds({ trakt: 88 }, EPISODE_IDS)).toEqual({ trakt: 88 }); }); - it('should return null when only imdb is present (not in episode priority)', () => { - expect(pickIds({ imdb: 'tt0000000' }, EPISODE_IDS)).toBeNull(); + it('should fall back to imdb when no other id is present', () => { + expect(pickIds({ imdb: 'tt0000000' }, EPISODE_IDS)).toEqual({ + imdb: 'tt0000000', + }); + }); + + it('should prefer trakt over imdb', () => { + expect(pickIds({ trakt: 88, imdb: 'tt0000000' }, EPISODE_IDS)).toEqual({ + trakt: 88, + }); }); }); }); diff --git a/projects/client/src/lib/sections/settings/import/engine/pickIds.ts b/projects/client/src/lib/sections/settings/import/engine/pickIds.ts index 30009f5970..ecdb959fb7 100644 --- a/projects/client/src/lib/sections/settings/import/engine/pickIds.ts +++ b/projects/client/src/lib/sections/settings/import/engine/pickIds.ts @@ -3,14 +3,27 @@ import type { ImportIds } from '../ImportTypes.ts'; export type IdPriority = ReadonlyArray; export const MOVIE_IDS: IdPriority = ['imdb', 'tmdb', 'trakt']; -export const SHOW_IDS: IdPriority = ['imdb', 'tvdb', 'tmdb', 'trakt']; -export const SEASON_IDS: IdPriority = ['tvdb', 'tmdb', 'trakt']; -export const EPISODE_IDS: IdPriority = ['tvdb', 'tmdb', 'trakt']; +export const SHOW_IDS: IdPriority = ['imdb', 'tvdb', 'trakt', 'tmdb']; +export const SEASON_IDS: IdPriority = ['tvdb', 'trakt', 'tmdb']; +export const EPISODE_IDS: IdPriority = ['tvdb', 'trakt', 'tmdb', 'imdb']; +// An episode row carrying season/episode numbers may be describing its show +// (TV Time liberator and hand written CSVs both do), so tmdb and imdb are only +// trusted as episode ids when no positional numbers accompany them. +export const POSITIONAL_EPISODE_IDS: IdPriority = ['tvdb', 'trakt']; + +export function toEpisodeIdPriority( + item: { season?: number; episode?: number }, +): IdPriority { + const isPositionalShaped = item.season != null && item.episode != null; + return isPositionalShaped ? POSITIONAL_EPISODE_IDS : EPISODE_IDS; +} + +export type ResolvedIds = Record; export function pickIds( ids: ImportIds, priority: IdPriority, -): Record | null { +): ResolvedIds | null { const key = priority.find((k) => ids[k] != null); if (!key) return null; diff --git a/projects/client/src/lib/sections/settings/import/parsers/utils/isValidItem.spec.ts b/projects/client/src/lib/sections/settings/import/parsers/utils/isValidItem.spec.ts new file mode 100644 index 0000000000..d4269a82a2 --- /dev/null +++ b/projects/client/src/lib/sections/settings/import/parsers/utils/isValidItem.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import type { UniversalImportItem } from '../../ImportTypes.ts'; +import { isValidItem } from './isValidItem.ts'; + +const base = { action: 'history' } as const; + +describe('util: isValidItem', () => { + it('should accept a season resolvable by tvdb', () => { + expect(isValidItem({ + ...base, + type: 'season', + ids: { tvdb: 12345 }, + } as UniversalImportItem)).toBe(true); + }); + + it('should reject a season carrying only an imdb id', () => { + expect(isValidItem({ + ...base, + type: 'season', + ids: { imdb: 'tt0306414' }, + } as UniversalImportItem)).toBe(false); + }); + + it('should reject a movie carrying only a tvdb id', () => { + expect(isValidItem({ + ...base, + type: 'movie', + ids: { tvdb: 79126 }, + } as UniversalImportItem)).toBe(false); + }); + + it('should accept a show carrying only a tvdb id', () => { + expect(isValidItem({ + ...base, + type: 'show', + ids: { tvdb: 79126 }, + } as UniversalImportItem)).toBe(true); + }); + + it('should accept an unresolvable item that still has title and year', () => { + expect(isValidItem({ + ...base, + type: 'movie', + ids: { tvdb: 79126 }, + title: 'Heretic', + year: 2024, + } as UniversalImportItem)).toBe(true); + }); + + it('should accept a positional episode with no ids of its own', () => { + expect(isValidItem({ + ...base, + type: 'episode', + ids: {}, + showTvdb: 81189, + season: 3, + episode: 7, + } as UniversalImportItem)).toBe(true); + }); + + it('should reject an episode with no ids and no positional key', () => { + expect(isValidItem({ + ...base, + type: 'episode', + ids: {}, + } as UniversalImportItem)).toBe(false); + }); +}); diff --git a/projects/client/src/lib/sections/settings/import/parsers/utils/isValidItem.ts b/projects/client/src/lib/sections/settings/import/parsers/utils/isValidItem.ts index dc70a92a15..17339bd874 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/utils/isValidItem.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/utils/isValidItem.ts @@ -1,12 +1,35 @@ -import type { UniversalImportItem } from '../../ImportTypes.ts'; +import type { ImportType, UniversalImportItem } from '../../ImportTypes.ts'; +import { + type IdPriority, + MOVIE_IDS, + pickIds, + SEASON_IDS, + SHOW_IDS, + toEpisodeIdPriority, +} from '../../engine/pickIds.ts'; -export function isValidItem(item: UniversalImportItem): boolean { - const hasId = Boolean(item.ids.trakt) || - Boolean(item.ids.imdb) || - Boolean(item.ids.tmdb) || - Boolean(item.ids.tvdb); +const PRIORITY_BY_TYPE: Record< + ImportType, + (item: UniversalImportItem) => IdPriority +> = { + movie: () => MOVIE_IDS, + show: () => SHOW_IDS, + season: () => SEASON_IDS, + episode: toEpisodeIdPriority, +}; + +function hasPositionalKey(item: UniversalImportItem): boolean { + return (item.showTvdb != null || item.showImdb != null) && + item.season != null && item.episode != null; +} +// An id its type cannot resolve by is no better than no id at all: the item +// would sail through review, inflate the imported count and never reach a +// payload. Seasons carrying only an imdb id are the common case. +export function isValidItem(item: UniversalImportItem): boolean { + const hasUsableId = pickIds(item.ids, PRIORITY_BY_TYPE[item.type](item)) != + null; const hasTitleAndYear = Boolean(item.title) && Boolean(item.year); - return hasId || hasTitleAndYear; + return hasUsableId || hasTitleAndYear || hasPositionalKey(item); } diff --git a/projects/client/src/lib/sections/settings/import/syncToTrakt.ts b/projects/client/src/lib/sections/settings/import/syncToTrakt.ts index 444e01d93e..a60818ab74 100644 --- a/projects/client/src/lib/sections/settings/import/syncToTrakt.ts +++ b/projects/client/src/lib/sections/settings/import/syncToTrakt.ts @@ -98,7 +98,7 @@ async function syncLists( const listSlug = slug; await run( chunk(items, SYNC_CHUNK_SIZE), - (batch) => buildWatchlistPayload([...batch]), + (batch) => buildWatchlistPayload(batch), (payload) => client.users.lists.list.add({ params: { id: 'me', list_id: listSlug }, @@ -155,7 +155,7 @@ export async function syncToTrakt( if (historyItems.length > 0) { await run( chunk(historyItems, SYNC_CHUNK_SIZE), - (batch) => buildHistoryPayload([...batch], episodeMatch), + (batch) => buildHistoryPayload(batch, episodeMatch), (payload) => client.sync.history.add({ body: payload }), ); } @@ -163,7 +163,7 @@ export async function syncToTrakt( if (watchlistItems.length > 0) { await run( chunk(watchlistItems, SYNC_CHUNK_SIZE), - (batch) => buildWatchlistPayload([...batch]), + (batch) => buildWatchlistPayload(batch), (payload) => client.sync.watchlist.add({ body: payload }), ); } @@ -171,7 +171,7 @@ export async function syncToTrakt( if (ratingItems.length > 0) { await run( chunk(ratingItems, SYNC_CHUNK_SIZE), - (batch) => buildRatingsPayload([...batch]), + (batch) => buildRatingsPayload(batch), (payload) => client.sync.ratings.add({ body: payload }), ); } From 0003971a69525220dee9aa4a9b8af0b0bb4ce7f4 Mon Sep 17 00:00:00 2001 From: seferturan Date: Thu, 20 Aug 2026 20:09:41 +0200 Subject: [PATCH 9/9] fix(import): match the file names a trakt export actually uses An export names its entries watched-history-1.json, ratings-movies-1.json and lists-watchlist.json, but the zip reader only looked for watched/history*, ratings/ratings* and lists/watchlist.json. Nothing matched, so dropping a raw export on the JSON tab imported zero items. Separators are folded together rather than swapped, so the v2 layout keeps working for anyone holding an older export. --- .../import/engine/buildRatingsPayload.spec.ts | 17 ++- .../import/engine/buildRatingsPayload.ts | 12 +- .../import/parsers/TraktJsonParser.spec.ts | 107 ++++++++++++++++++ .../import/parsers/TraktJsonParser.ts | 59 +++++----- 4 files changed, 162 insertions(+), 33 deletions(-) diff --git a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts index e486dd1c08..0567a376d1 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.spec.ts @@ -109,7 +109,7 @@ describe('buildRatingsPayload', () => { ]); }); - it('should clamp ratings below 1 to 1', () => { + it('should drop a rating of 0 rather than clamp it up to 1', () => { const item: UniversalImportItem = { action: 'ratings', type: 'movie', @@ -117,9 +117,18 @@ describe('buildRatingsPayload', () => { rating: 0, }; - expect(buildRatingsPayload([item]).movies).toEqual([ - { rating: 1, ids: { imdb: 'tt0000002' } }, - ]); + expect(buildRatingsPayload([item]).movies).toEqual([]); + }); + + it('should drop a negative rating', () => { + const item: UniversalImportItem = { + action: 'ratings', + type: 'movie', + ids: { imdb: 'tt0000002' }, + rating: -3, + }; + + expect(buildRatingsPayload([item]).movies).toEqual([]); }); it('should round fractional ratings', () => { diff --git a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts index 2294aecd5c..e82ca95945 100644 --- a/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts +++ b/projects/client/src/lib/sections/settings/import/engine/buildRatingsPayload.ts @@ -16,8 +16,12 @@ type RatingsEntry = { rated_at?: string; }; -function clampRating(rating: number): number { - return Math.min(10, Math.max(1, Math.round(rating))); +// Trakt ratings are 1-10. A third party dump that writes 0 for "unrated" must +// not be clamped up into a real 1/10 rating, so drop anything below the scale. +function toRating(rating: number): number | null { + const rounded = Math.round(rating); + if (rounded < 1) return null; + return Math.min(10, rounded); } function toRatingsEntry( @@ -25,10 +29,12 @@ function toRatingsEntry( priority: IdPriority, ): RatingsEntry | null { if (rating == null) return null; + const resolved = toRating(rating); + if (resolved == null) return null; const resolvedIds = pickIds(ids, priority); if (!resolvedIds) return null; return { - rating: clampRating(rating), + rating: resolved, ids: resolvedIds, ...(rated_at ? { rated_at } : {}), }; diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts index b4eecb488b..6d0e5ddd81 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.spec.ts @@ -446,6 +446,36 @@ describe('TraktJsonParser', () => { }); }); + describe('parse – numeric root id', () => { + it('does not read ids off an export play id', async () => { + mockParseJsonFile.mockResolvedValue([ + { + id: 14292717085, + type: 'movie', + title: 'Upside Down', + year: 2012, + watched_at: '2026-08-18T01:18:00.000Z', + }, + ]); + + const result = await TraktJsonParser.parse([makeFile('history.json')]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + action: 'history', + type: 'movie', + title: 'Upside Down', + year: 2012, + }); + expect(result[0]?.ids).toEqual({ + trakt: undefined, + imdb: undefined, + tmdb: undefined, + tvdb: undefined, + }); + }); + }); + describe('parse – nested season entries', () => { it('uses the season ids, not the parent show ids', async () => { mockParseJsonFile.mockResolvedValue([ @@ -662,6 +692,83 @@ describe('TraktJsonParser', () => { }); }); + it('parses history from an export using hyphenated file names', async () => { + setupZip({ + 'watched-history-1.json': [ + { + watched_at: '2026-08-18T01:18:00.000Z', + action: 'watch', + type: 'movie', + movie: { title: 'The Bounty', year: 1984, ids: { trakt: 1800 } }, + }, + ], + }); + + const result = await TraktJsonParser.parse([makeFile('export.zip')]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ action: 'history', type: 'movie' }); + }); + + it('parses ratings from an export using hyphenated file names', async () => { + setupZip({ + 'ratings-movies-1.json': [ + { + rated_at: '2026-08-17T20:59:32.000Z', + rating: 8, + type: 'movie', + movie: { title: 'The Bounty', year: 1984, ids: { trakt: 1800 } }, + }, + ], + }); + + const result = await TraktJsonParser.parse([makeFile('export.zip')]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ action: 'ratings', rating: 8 }); + }); + + it('parses the watchlist from an export using hyphenated file names', async () => { + setupZip({ + 'lists-watchlist.json': [ + { + listed_at: '2026-08-01T00:00:00.000Z', + type: 'movie', + movie: { title: 'Dune', year: 2021, ids: { trakt: 1 } }, + }, + ], + }); + + const result = await TraktJsonParser.parse([makeFile('export.zip')]); + + expect(result).toHaveLength(1); + expect(result[0]?.action).toBe('watchlist'); + }); + + it('parses a paginated watchlist from an export', async () => { + setupZip({ + 'lists-watchlist-1.json': [ + { + listed_at: '2026-08-01T00:00:00.000Z', + type: 'movie', + movie: { title: 'Dune', year: 2021, ids: { trakt: 1 } }, + }, + ], + 'lists-watchlist-2.json': [ + { + listed_at: '2026-08-02T00:00:00.000Z', + type: 'movie', + movie: { title: 'Heretic', year: 2024, ids: { trakt: 2 } }, + }, + ], + }); + + const result = await TraktJsonParser.parse([makeFile('export.zip')]); + + expect(result).toHaveLength(2); + expect(result.every((item) => item.action === 'watchlist')).toBe(true); + }); + it('parses ratings from zip', async () => { setupZip({ 'ratings/ratings-movies.json': [ diff --git a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts index 79a5e433ab..3c387f5d98 100644 --- a/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts +++ b/projects/client/src/lib/sections/settings/import/parsers/TraktJsonParser.ts @@ -43,8 +43,9 @@ type TraktJsonEntry = { number?: number; ids?: TraktJsonIds; }; - // Flat format with nested id object (e.g. shared list exports) - id?: TraktJsonIds; + // Flat format with nested id object (e.g. shared list exports). Exports put a + // numeric play id here instead, which is not an id block. + id?: TraktJsonIds | number; // Flat format with *_id fields at root level (e.g. third-party exports) imdb_id?: string; tvdb_id?: string | number; @@ -110,10 +111,17 @@ function isFlatEntry(entry: TraktJsonEntry): boolean { ); } -function toFlatBase(entry: TraktJsonEntry): ImportItemBase { +function toFlatIds(id: TraktJsonEntry['id']): TraktJsonIds { + return typeof id === 'object' && id !== null ? id : {}; +} + +function toFlatBase( + entry: TraktJsonEntry, + ids: TraktJsonIds, +): ImportItemBase { return { type: resolveType(entry), - ids: toImportIds(entry.id ?? {}), + ids: toImportIds(ids), title: entry.title, year: entry.year, }; @@ -131,20 +139,6 @@ function isMultiIdFlatEntry(entry: TraktJsonEntry): boolean { ); } -function toMultiIdFlatBase(entry: TraktJsonEntry): ImportItemBase { - return { - type: resolveType(entry), - ids: toImportIds({ - trakt: entry.trakt_id, - imdb: entry.imdb_id, - tmdb: entry.tmdb_id, - tvdb: entry.tvdb_id, - }), - title: entry.title, - year: entry.year, - }; -} - function toNestedBase(entry: TraktJsonEntry): ImportItemBase { const type = resolveType(entry); const media = type === 'episode' ? entry.show : (entry.movie ?? entry.show); @@ -164,8 +158,15 @@ function toNestedBase(entry: TraktJsonEntry): ImportItemBase { } function toEntryBase(entry: TraktJsonEntry): ImportItemBase { - if (isFlatEntry(entry)) return toFlatBase(entry); - if (isMultiIdFlatEntry(entry)) return toMultiIdFlatBase(entry); + if (isFlatEntry(entry)) return toFlatBase(entry, toFlatIds(entry.id)); + if (isMultiIdFlatEntry(entry)) { + return toFlatBase(entry, { + trakt: entry.trakt_id, + imdb: entry.imdb_id, + tmdb: entry.tmdb_id, + tvdb: entry.tvdb_id, + }); + } return toNestedBase(entry); } @@ -188,7 +189,7 @@ function parseTraktJsonEntry(entry: TraktJsonEntry): UniversalImportItem[] { ...watchedAt ? [{ ...base, action: 'history' as const, watched_at: watchedAt }] : [], - ...entry.rating != null + ...(entry.rating ?? 0) > 0 ? [{ ...base, action: 'ratings' as const, @@ -216,15 +217,21 @@ function parseEntries(entries: TraktJsonEntry[]): UniversalImportItem[] { .filter(isValidItem); } +function toComparablePath(filename: string): string { + return filename.replaceAll('/', '-'); +} + function isRelevantJsonFile(filename: string): boolean { - return filename.startsWith('watched/history') || - filename === 'lists/watchlist.json' || - filename.startsWith('ratings/ratings'); + const path = toComparablePath(filename); + return path.startsWith('watched-history') || + path.startsWith('lists-watchlist') || + path.startsWith('ratings-'); } function inferActionFromPath(filename: string): ImportAction { - if (filename.startsWith('ratings/')) return 'ratings'; - if (filename === 'lists/watchlist.json') return 'watchlist'; + const path = toComparablePath(filename); + if (path.startsWith('ratings-')) return 'ratings'; + if (path.startsWith('lists-watchlist')) return 'watchlist'; return 'history'; }