fix(portals): absent is not empty, and every start claims the generation

Four more from review, three of them defects in last round's fixes.

The restore normalizer materialized `sourcePins: []` for archives that never
had the field, so "absent means no opinion" became "this archive says there
are no pins" and a merge cleared the user's. Absent now stays absent. My test
for that behaviour had passed for the wrong reason — it stubbed an empty pin
list, so the clear was skipped whether or not the guard worked.

`startGeneration` was claimed only by the switch path, so a plain Play, Resume
or Restart could be overtaken by a switch still awaiting its close. Every
start claims it now.

Raw and normalized tokens were paired by position, which breaks when
normalization drops a whole word: "FR: Ça" normalizes to "ca" and got handed
the raw token "FR:", sending it down the ASCII branch it cannot match from.
They are paired by normalized form instead.

And the ambiguous yearless alias (`title:dune:`) is no longer written or
retired beside a precise key — it may hold another remake's pre-enrichment
pin. It stays available when it is the only key there is, since refusing to
pin at all would be worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-29 02:13:40 +02:00
1 parent 481d368019
commit 7c1b433d47
12 files changed
+147 -14

No files matched your search

@@ -2,6 +2,7 @@ import {
buildVodSourceMatchKey,
buildVodSourceMatchKeyCandidates,
isTmdbMatchKey,
buildVodSourceMatchKeyWriteKeys,
} from './vod-source-match-key.util';
const MOVIE = { title: 'The Matrix', year: 1999, tmdbId: 603 };
@@ -57,3 +58,34 @@ describe('buildVodSourceMatchKeyCandidates', () => {
expect(buildVodSourceMatchKeyCandidates({ title: '' })).toEqual([]);
});
});
describe('buildVodSourceMatchKeyWriteKeys — the ambiguous alias', () => {
it('is not written beside a precise key', () => {
// `title:dune:` names every remake. Retiring it while storing the
// tmdb pin could delete another film's pre-enrichment preference.
expect(
buildVodSourceMatchKeyWriteKeys({
tmdbId: 603,
title: 'Dune',
year: null,
})
).toEqual(['tmdb:603']);
});
it('is still used when it is the only key there is', () => {
// Refusing to pin at all would be worse than an imprecise pin.
expect(
buildVodSourceMatchKeyWriteKeys({ title: 'Dune', year: null })
).toEqual(['title:dune:']);
});
it('is unnecessary once a year disambiguates the title', () => {
expect(
buildVodSourceMatchKeyWriteKeys({
tmdbId: 603,
title: 'Dune',
year: 2021,
})
).toEqual(['tmdb:603', 'title:dune:2021']);
});
});
@@ -99,7 +99,19 @@ export function buildVodSourceMatchKeyWriteKeys(input: {
}
const titleKey = buildVodSourceMatchKey({ ...input, tmdbId: null });
if (titleKey && !keys.includes(titleKey)) {
// The yearless form (`title:dune:`) names every remake at once, so once a
// precise key exists it must not be written OR retired alongside it — the
// row may hold a different film's pre-enrichment preference. When it is
// the ONLY key we have, it is still better than refusing to pin at all,
// and `retirablePinKeys` re-adds it whenever this session actually read
// it.
const isAmbiguous = titleKey?.endsWith(':') === true;
const hasPreciseKey = keys.length > 0;
if (
titleKey &&
!keys.includes(titleKey) &&
!(isAmbiguous && hasPreciseKey)
) {
keys.push(titleKey);
}
@@ -1,12 +1,13 @@
import { normalizeXtreamPendingRestoreState } from './xtream-restore-state.util';
describe('normalizeXtreamPendingRestoreState', () => {
// No `sourcePins`: absent has to stay absent, or an older archive would
// read as "there are no pins" and restore would clear the user's.
const emptyState = {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
sourcePins: [],
};
it.each([null, undefined, 'text', 42, []])(
@@ -19,6 +20,18 @@ describe('normalizeXtreamPendingRestoreState', () => {
);
describe('source pins', () => {
it('leaves the field absent when the input has none', () => {
expect(
normalizeXtreamPendingRestoreState({ favorites: [] })
).not.toHaveProperty('sourcePins');
});
it('keeps a present-but-empty collection, which is an answer', () => {
expect(
normalizeXtreamPendingRestoreState({ sourcePins: [] })
).toHaveProperty('sourcePins', []);
});
it('keeps usable pins and drops the rest', () => {
const state = normalizeXtreamPendingRestoreState({
sourcePins: [
@@ -106,7 +106,6 @@ export function normalizeXtreamPendingRestoreState(
favorites: [],
recentlyViewed: [],
playbackPositions: [],
sourcePins: [],
};
}
@@ -144,7 +143,12 @@ export function normalizeXtreamPendingRestoreState(
playbackPositions: toArray(candidate.playbackPositions).filter(
(item): item is PlaybackPositionData => isRecord(item)
),
sourcePins: normalizeSourcePins(candidate.sourcePins),
// Absent stays ABSENT. Materializing `[]` here would make an older
// archive — which says nothing about pins — look like one that says
// "there are none", and restore would clear the user's pins.
...(candidate.sourcePins === undefined
? {}
: { sourcePins: normalizeSourcePins(candidate.sourcePins) }),
};
}