Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ export type ImportAction = 'history' | 'watchlist' | 'ratings' | 'list';
export type ImportActionSelection = Record<ImportAction, boolean>;

// 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';

export type ImportType = 'movie' | 'show' | 'episode';
export type ImportType = 'movie' | 'show' | 'season' | 'episode';

export type ImportStatus =
| 'idle'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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', () => {
Expand All @@ -302,4 +302,98 @@ 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([]);
});
});

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 }]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +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, MOVIE_IDS, pickIds, SHOW_IDS } from './pickIds.ts';
import {
type IdPriority,
MOVIE_IDS,
pickIds,
type ResolvedIds,
SEASON_IDS,
SHOW_IDS,
toEpisodeIdPriority,
} from './pickIds.ts';

type HistoryMovie = NonNullable<HistoryAddRequest['movies']>[number];
type HistoryShow = NonNullable<HistoryAddRequest['shows']>[number];
type HistoryEpisode = NonNullable<HistoryAddRequest['episodes']>[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 };
// 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;

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(
Expand All @@ -48,6 +58,10 @@ function isPositional(
type PositionalEpisode = { number: number; watched_at?: string };
type ShowIds = { tvdb?: number; imdb?: string };
type ShowGroup = { ids: ShowIds; seasons: Map<number, PositionalEpisode[]> };
type PositionalShow = {
ids: ShowIds;
seasons: Array<{ number: number; episodes: PositionalEpisode[] }>;
};

function toShowIds(item: UniversalImportItem): ShowIds {
return {
Expand All @@ -65,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}`
Expand All @@ -86,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<UniversalImportItem>,
episodeMatch: EpisodeMatchMode = DEFAULT_EPISODE_MATCH_MODE,
): HistoryAddRequest {
const episodeItems = items.filter((item) => item.type === 'episode');
Expand All @@ -107,30 +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)),
Comment thread
rudf0rd marked this conversation as resolved.
episodes: idEpisodes.flatMap((item) =>
Comment thread
rudf0rd marked this conversation as resolved.
toHistoryEntry(item, toEpisodeIdPriority(item)) ?? []
),
];

return { movies, shows, episodes };
} as HistoryAddRequest;
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,26 @@ 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',
ids: { imdb: 'tt0000002' },
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', () => {
Expand Down Expand Up @@ -149,4 +158,62 @@ 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([]);
});
});

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([]);
});
});
});
Loading
Loading