mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
1 parent
481d368019
commit
7c1b433d47
12 files changed
+147
-14
No files matched your search
@@ -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) }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user