mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06: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
@@ -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 `
|
||||
);
|
||||
|
||||
@@ -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) }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user