Skip to content

Commit 19caf54

Browse files
committed
feat(import): add episode match-mode toggle (TVDB id vs season/episode)
The importer resolves episodes by their TVDB id by default (exact, survives season/episode renumbering). But some episodes have no TVDB id in Trakt yet, so id-matching drops them. Give users a fallback: a toggle in the import summary to match episodes by show + season/episode number instead. - EpisodeMatchMode ('id' | 'positional'), default 'id'; threaded through syncToTrakt into buildHistoryPayload - buildHistoryPayload: positional mode routes every episode with a positional key by show + season/number (incl. id-carrying ones); id mode keeps the id path with positional as fallback - ImportSummary shows the toggle only when the import contains episodes, with a hint that the default is recommended and positional results vary per show TVDB-id stays the default and recommended path. Positional is opt-in for the still-null episodes while episode tvdb_id backfill catches up.
1 parent 6c93d77 commit 19caf54

8 files changed

Lines changed: 200 additions & 43 deletions

File tree

projects/client/i18n/meta/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5168,6 +5168,14 @@
51685168
}
51695169
}
51705170
},
5171+
"import_match_toggle_label": {
5172+
"default": "Match episodes by season and episode number",
5173+
"description": "Label for the import toggle that switches episode matching from the episode's TVDB id (default) to show + season/episode number."
5174+
},
5175+
"import_match_toggle_hint": {
5176+
"default": "We recommend the default (match by TVDB id). Turn this on only if some episodes land on the wrong entry - results vary from show to show.",
5177+
"description": "Helper text under the episode-match toggle explaining the default is recommended and that positional matching results vary."
5178+
},
51715179
"import_complete_synced": {
51725180
"default": "{count} items synced successfully.",
51735181
"description": "Text shown at the end of a successful import showing the count of synced items.",

projects/client/src/lib/sections/settings/_internal/RawImport.svelte

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
import { slide } from "svelte/transition";
1717
import {
1818
type AmbiguousImportItem,
19+
DEFAULT_EPISODE_MATCH_MODE,
1920
DEFAULT_IMPORT_SOURCE,
21+
type EpisodeMatchMode,
2022
IMPORT_SOURCE_CONFIGS,
2123
type ImportAction,
2224
type ImportActionSelection,
@@ -61,6 +63,7 @@
6163
type ImportUIState = SyncState<ImportStatus> & {
6264
selectedSource: ImportSource;
6365
selectedActions: ImportActionSelection;
66+
episodeMatch: EpisodeMatchMode;
6467
parsedItems: ReadonlyArray<UniversalImportItem>;
6568
errorCount: number;
6669
unresolved: ReadonlyArray<UniversalImportItem>;
@@ -73,6 +76,7 @@
7376
const state = $state<ImportUIState>({
7477
selectedSource: DEFAULT_IMPORT_SOURCE,
7578
selectedActions: defaultSelectedActions(),
79+
episodeMatch: DEFAULT_EPISODE_MATCH_MODE,
7680
status: "idle",
7781
parsedItems: [],
7882
processedCount: 0,
@@ -104,6 +108,9 @@
104108
list: state.selectedActions.list ? counts.list : 0,
105109
});
106110
const selectedTotalCount = $derived(selectedItems.length);
111+
const hasEpisodes = $derived(
112+
state.parsedItems.some((item) => item.type === "episode"),
113+
);
107114
108115
const sourceConfig = $derived(IMPORT_SOURCE_CONFIGS[state.selectedSource]);
109116
const isGuideCollapsed = $derived(
@@ -123,6 +130,7 @@
123130
124131
state.parseError = null;
125132
state.selectedActions = defaultSelectedActions();
133+
state.episodeMatch = DEFAULT_EPISODE_MATCH_MODE;
126134
state.status = "parsing";
127135
128136
try {
@@ -173,6 +181,7 @@
173181
itemsToImport,
174182
{
175183
signal: abortController.signal,
184+
episodeMatch: state.episodeMatch,
176185
onMatchProgress: (processed, total) => {
177186
state.matchProcessedCount = processed;
178187
state.matchTotalCount = total;
@@ -235,6 +244,7 @@
235244
abortController = new AbortController();
236245
const { errorCount } = await syncToTrakt(picked, {
237246
signal: abortController.signal,
247+
episodeMatch: state.episodeMatch,
238248
onProgress: () => {},
239249
onError: (message) => {
240250
// FIXME: properly deal with this when tackling https://github.com/trakt/trakt-web/issues/2055
@@ -257,6 +267,7 @@
257267
abortController?.abort();
258268
state.status = "idle";
259269
state.selectedActions = defaultSelectedActions();
270+
state.episodeMatch = DEFAULT_EPISODE_MATCH_MODE;
260271
state.parsedItems = [];
261272
state.processedCount = 0;
262273
state.errorCount = 0;
@@ -300,6 +311,10 @@
300311
[action]: isIncluded,
301312
};
302313
}
314+
315+
function onMatchChange(mode: EpisodeMatchMode) {
316+
state.episodeMatch = mode;
317+
}
303318
</script>
304319

305320
{#snippet importRow()}
@@ -327,7 +342,10 @@
327342
{counts}
328343
selectedActions={state.selectedActions}
329344
source={state.selectedSource}
345+
episodeMatch={state.episodeMatch}
346+
showMatchToggle={hasEpisodes}
330347
onactionchange={onActionChange}
348+
onmatchchange={onMatchChange}
331349
onstart={startImport}
332350
onreset={reset}
333351
/>
Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { fireEvent, render, screen } from '@testing-library/svelte';
22
import { of } from 'rxjs';
33
import { describe, expect, it, vi } from 'vitest';
4+
import type { EpisodeMatchMode } from '../../import/ImportTypes.ts';
45
import ImportSummary from './ImportSummary.svelte';
56

67
vi.mock('$lib/features/auth/stores/useUser.ts', () => ({
@@ -18,20 +19,30 @@ describe('ImportSummary', () => {
1819
list: 1,
1920
};
2021

21-
it('should show an enabled switch for every import action with items', () => {
22-
render(ImportSummary, {
22+
const allSelected = {
23+
history: true,
24+
watchlist: true,
25+
ratings: true,
26+
list: true,
27+
};
28+
29+
function baseProps(overrides: Record<string, unknown> = {}) {
30+
return {
2331
counts,
24-
selectedActions: {
25-
history: true,
26-
watchlist: true,
27-
ratings: true,
28-
list: true,
29-
},
30-
source: 'tvtime',
32+
selectedActions: allSelected,
33+
source: 'tvtime' as const,
34+
episodeMatch: 'id' as EpisodeMatchMode,
35+
showMatchToggle: false,
3136
onactionchange: vi.fn(),
37+
onmatchchange: vi.fn(),
3238
onstart: vi.fn(),
3339
onreset: vi.fn(),
34-
});
40+
...overrides,
41+
};
42+
}
43+
44+
it('should show an enabled switch for every import action with items', () => {
45+
render(ImportSummary, baseProps());
3546

3647
expect(screen.getByRole('switch', { name: '2 items for History' }))
3748
.toBeChecked();
@@ -45,19 +56,7 @@ describe('ImportSummary', () => {
4556
it('should request skipping an import action when its switch is clicked', async () => {
4657
const onactionchange = vi.fn();
4758

48-
render(ImportSummary, {
49-
counts,
50-
selectedActions: {
51-
history: true,
52-
watchlist: true,
53-
ratings: true,
54-
list: true,
55-
},
56-
source: 'tvtime',
57-
onactionchange,
58-
onstart: vi.fn(),
59-
onreset: vi.fn(),
60-
});
59+
render(ImportSummary, baseProps({ onactionchange }));
6160

6261
await fireEvent.click(
6362
screen.getByRole('switch', { name: '3 items for Watchlist' }),
@@ -67,24 +66,49 @@ describe('ImportSummary', () => {
6766
});
6867

6968
it('should disable start import when all import actions are skipped', () => {
70-
render(ImportSummary, {
71-
counts,
72-
selectedActions: {
73-
history: false,
74-
watchlist: false,
75-
ratings: false,
76-
list: false,
77-
},
78-
source: 'tvtime',
79-
onactionchange: vi.fn(),
80-
onstart: vi.fn(),
81-
onreset: vi.fn(),
82-
});
69+
render(
70+
ImportSummary,
71+
baseProps({
72+
selectedActions: {
73+
history: false,
74+
watchlist: false,
75+
ratings: false,
76+
list: false,
77+
},
78+
}),
79+
);
8380

8481
expect(
8582
screen.getByRole('button', {
8683
name: 'Start importing your data into Trakt.',
8784
}),
8885
).toBeDisabled();
8986
});
87+
88+
it('should not render the match toggle when there are no episodes', () => {
89+
render(ImportSummary, baseProps({ showMatchToggle: false }));
90+
91+
expect(
92+
screen.queryByRole('switch', {
93+
name: 'Match episodes by season and episode number',
94+
}),
95+
).toBeNull();
96+
});
97+
98+
it('should request positional matching when the match toggle is turned on', async () => {
99+
const onmatchchange = vi.fn();
100+
101+
render(
102+
ImportSummary,
103+
baseProps({ showMatchToggle: true, episodeMatch: 'id', onmatchchange }),
104+
);
105+
106+
await fireEvent.click(
107+
screen.getByRole('switch', {
108+
name: 'Match episodes by season and episode number',
109+
}),
110+
);
111+
112+
expect(onmatchchange).toHaveBeenCalledWith('positional');
113+
});
90114
});

projects/client/src/lib/sections/settings/_internal/import/ImportSummary.svelte

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import UpsellCta from "$lib/features/upsell/UpsellCta.svelte";
77
import { slide } from "svelte/transition";
88
import type {
9+
EpisodeMatchMode,
910
ImportAction,
1011
ImportActionSelection,
1112
ImportCounts,
@@ -16,7 +17,10 @@
1617
counts: ImportCounts;
1718
selectedActions: ImportActionSelection;
1819
source: ImportSource;
20+
episodeMatch: EpisodeMatchMode;
21+
showMatchToggle: boolean;
1922
onactionchange: (action: ImportAction, isIncluded: boolean) => void;
23+
onmatchchange: (mode: EpisodeMatchMode) => void;
2024
onstart: () => void;
2125
onreset: () => void;
2226
};
@@ -25,7 +29,10 @@
2529
counts,
2630
selectedActions,
2731
source,
32+
episodeMatch,
33+
showMatchToggle,
2834
onactionchange,
35+
onmatchchange,
2936
onstart,
3037
onreset,
3138
}: ImportSummaryProps = $props();
@@ -104,6 +111,23 @@
104111
{/each}
105112
</div>
106113

114+
{#if showMatchToggle}
115+
<div class="import-summary-match">
116+
<span class="import-summary-switch">
117+
<Switch
118+
label={m.import_match_toggle_label()}
119+
checked={episodeMatch === "positional"}
120+
onclick={() =>
121+
onmatchchange(episodeMatch === "positional" ? "id" : "positional")}
122+
/>
123+
</span>
124+
<div class="match-copy">
125+
<p aria-hidden="true">{m.import_match_toggle_label()}</p>
126+
<p class="secondary match-hint">{m.import_match_toggle_hint()}</p>
127+
</div>
128+
</div>
129+
{/if}
130+
107131
{#if isVipLimitExceeded}
108132
<UpsellCta source="import" variant="small">
109133
{m.import_vip_limit_exceeded({ count: selectedTotalItems })}
@@ -178,6 +202,23 @@
178202
}
179203
}
180204
205+
.import-summary-match {
206+
display: grid;
207+
grid-template-columns: var(--ni-44) minmax(0, 1fr);
208+
align-items: start;
209+
gap: var(--gap-xs);
210+
}
211+
212+
.match-copy {
213+
display: flex;
214+
flex-direction: column;
215+
gap: var(--gap-xxs);
216+
}
217+
218+
.match-hint {
219+
font-size: var(--font-size-tag);
220+
}
221+
181222
.import-summary-actions {
182223
display: flex;
183224
align-items: center;

projects/client/src/lib/sections/settings/import/ImportTypes.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ export type ImportAction = 'history' | 'watchlist' | 'ratings' | 'list';
1313

1414
export type ImportActionSelection = Record<ImportAction, boolean>;
1515

16+
// How watched episodes are matched to Trakt on import:
17+
// - 'id' (default, recommended): the episode's own TVDB id - exact, survives
18+
// season/episode renumbering.
19+
// - 'positional': show + season/episode number - a fallback for episodes whose
20+
// TVDB id Trakt doesn't have, at the cost of numbering-divergence mismatches.
21+
export type EpisodeMatchMode = 'id' | 'positional';
22+
23+
export const DEFAULT_EPISODE_MATCH_MODE: EpisodeMatchMode = 'id';
24+
1625
export type ImportType = 'movie' | 'show' | 'episode';
1726

1827
export type ImportStatus =

projects/client/src/lib/sections/settings/import/engine/buildHistoryPayload.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,40 @@ describe('buildHistoryPayload', () => {
223223
expect(result.shows).toHaveLength(0);
224224
});
225225

226+
it('should resolve positionally over the episode id in positional mode', () => {
227+
const item: UniversalImportItem = {
228+
action: 'history',
229+
type: 'episode',
230+
ids: { tvdb: 4321 },
231+
showTvdb: 9001,
232+
season: 2,
233+
episode: 1,
234+
watched_at,
235+
};
236+
237+
const result = buildHistoryPayload([item], 'positional');
238+
239+
expect(result.episodes).toHaveLength(0);
240+
expect(result.shows).toEqual([{
241+
ids: { tvdb: 9001 },
242+
seasons: [{ number: 2, episodes: [{ number: 1, watched_at }] }],
243+
}]);
244+
});
245+
246+
it('should fall back to the episode id in positional mode when no positional key', () => {
247+
const item: UniversalImportItem = {
248+
action: 'history',
249+
type: 'episode',
250+
ids: { tvdb: 4321 },
251+
watched_at,
252+
};
253+
254+
const result = buildHistoryPayload([item], 'positional');
255+
256+
expect(result.episodes).toEqual([{ ids: { tvdb: 4321 }, watched_at }]);
257+
expect(result.shows).toHaveLength(0);
258+
});
259+
226260
it('should add an episode by its id when not positionally resolvable', () => {
227261
const item: UniversalImportItem = {
228262
action: 'history',

0 commit comments

Comments
 (0)