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

@@ -93,6 +93,18 @@ describe('title-sources.operations', () => {
expect(scanQuery.params).toContain('Ça');
});
it('pairs raw tokens by meaning, not by position', async () => {
// Normalization drops whole words, so "FR: Ça" becomes just "ca".
// Pairing positionally would hand "ca" the raw token "FR:" and
// send it down the ASCII branch, where it cannot match "Ça".
const scan = createDbMock([]);
await findTitleSources(scan.db, { title: 'FR: Ça' });
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.sql).toContain('instr');
expect(scanQuery.params).toContain('Ça');
});
it('keeps the word boundary for ASCII tokens', async () => {
// The looser substring test must not leak into the ASCII path,
// where it would let "it" match "Titanic".
@@ -160,10 +160,20 @@ function scanCandidateQuery(
// multiword titles like "I Am" — matching on "i" alone would return most
// of the catalog and hand it all to the TypeScript pass to throw away.
const tokens = base.split(' ').filter(Boolean);
const rawTokens = rawTitle.split(/\s+/).filter(Boolean);
// Paired by NORMALIZED form, not by position: normalization drops whole
// words ("FR: Ça" -> "ca"), so a positional pairing hands "ca" the raw
// token "FR:" and picks the wrong branch for it.
const rawByBase = new Map<string, string>();
for (const raw of rawTitle.split(/\s+/).filter(Boolean)) {
const normalized = normalizeTitleKeys(raw).base;
if (normalized && !rawByBase.has(normalized)) {
rawByBase.set(normalized, raw);
}
}
const wordMatches = sql.join(
tokens.map((token, index) =>
tokenPredicate(token, rawTokens[index] ?? token)
tokens.map((token) =>
tokenPredicate(token, rawByBase.get(token) ?? token)
),
sql` AND `
);
+6 -1
View File
@@ -162,7 +162,12 @@ worker IPC boundary in the snake_case wire shape declared by
`XCategoryFromDb`/`XtreamCategoryFromDb`; the category operations project
their Drizzle rows explicitly to keep that contract true.
`sourcePins` (VOD multi-source) is the one **optional** collection: archives
`sourcePins` (VOD multi-source) is the one **optional** collection, and the
normalizer preserves that: an absent field stays absent rather than becoming
`[]`, because restore treats a PRESENT collection as authoritative and clears
the playlist's existing pins before applying it. Materializing an empty array
would turn "this archive predates pins" into "this archive says there are
none": archives
written before multi-source existed simply do not have it, so its absence is
age rather than damage and only a wrong type is rejected. A pin is carried
under the playlist it points AT — exporting it anywhere else would restore a
@@ -468,8 +468,6 @@ describe('PlaylistRefreshActionService', () => {
},
],
playbackPositions: [],
// Normalized state always carries every collection, empty or not.
sourcePins: [],
});
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
@@ -555,8 +555,6 @@ describe('RecentPlaylistsComponent busy state', () => {
},
],
playbackPositions: [],
// Normalized state always carries every collection, empty or not.
sourcePins: [],
});
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
@@ -59,6 +59,9 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
useValue: {
currentPlaylist: signal({ id: ROUTE_PLAYLIST }),
addRecentItem,
constructVodStreamUrl: jest
.fn()
.mockReturnValue('https://example.com/route.mkv'),
},
},
{
@@ -206,6 +209,39 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
);
});
it('drops a switch that a plain Play overtook', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
let releaseClose: (() => void) | undefined;
const closing = new Promise<void>((resolve) => {
releaseClose = () => resolve();
});
closeSession.mockReturnValue(closing);
openResolvedPlayback.mockClear();
const switching = service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
});
// The user presses Play on the route copy while that close is pending.
service.playVod({
movie_data: { stream_id: ROUTE_VOD_ID, name: 'Example Movie' },
} as never);
releaseClose?.();
await switching;
// Only the route copy may be playing; the switch was overtaken.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
expect(openResolvedPlayback).not.toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/alt.mkv',
}),
true
);
closeSession.mockResolvedValue(undefined);
});
it('launches only the newest source when two switches overlap', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
// One shared promise: both calls see the same running session, so
@@ -376,6 +376,11 @@ export class VodDetailsPlaybackService {
private startPlayback(playback: ResolvedPortalPlayback): void {
// EVERY start claims the generation, not just the switch path. Play,
// Resume and Restart reach here directly, and a switch still waiting
// on its `closeSession` would otherwise pass the check afterwards and
// launch on top of what the user just chose.
this.startGeneration++;
this.positionWriter.reset();
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
@@ -25,7 +25,6 @@ describe('PlaylistBackupService Xtream source pins', () => {
localStorage.clear();
});
it('exports the pins that point at this playlist', async () => {
const collaborators = createRestoreCollaborators();
const service = createPlaylistBackupService({
@@ -147,7 +146,16 @@ describe('PlaylistBackupService Xtream source pins', () => {
const service = createPlaylistBackupService({
...collaborators,
vodSourcePinService: {
listForPlaylist: jest.fn().mockResolvedValue([]),
// A pin EXISTS, or an empty list would make this pass whether
// or not the clear was skipped.
listForPlaylist: jest.fn().mockResolvedValue([
{
matchKey: 'tmdb:999',
playlistId: 'xtream-1',
contentId: 7,
portalType: 'xtream',
},
]),
set: jest.fn().mockResolvedValue(true),
clear: clearPins,
},
@@ -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) }),
};
}