diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts index 529cda48d..73161cf1d 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts @@ -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". diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts index 5c9c2f3c6..ceb05258a 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts @@ -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(); + 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 ` ); diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index f6e60b61e..fc2b39fec 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -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 diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index e5ebf5ee1..b87054a3d 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -468,8 +468,6 @@ describe('PlaylistRefreshActionService', () => { }, ], playbackPositions: [], - // Normalized state always carries every collection, empty or not. - sourcePins: [], }); expect(store.dispatch).toHaveBeenCalledWith( PlaylistActions.updatePlaylistMeta({ diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index 8c6b5e251..b9de1ca97 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -555,8 +555,6 @@ describe('RecentPlaylistsComponent busy state', () => { }, ], playbackPositions: [], - // Normalized state always carries every collection, empty or not. - sourcePins: [], }); expect(router.navigate).toHaveBeenCalledWith([ '/workspace', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 10ed9703d..6498e10b9 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -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((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 diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index a5e0e655a..b1343e3d3 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -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); diff --git a/libs/services/src/lib/playlist-backup.service.pins.spec.ts b/libs/services/src/lib/playlist-backup.service.pins.spec.ts index 3492a32e5..c1f7b2a76 100644 --- a/libs/services/src/lib/playlist-backup.service.pins.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.pins.spec.ts @@ -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, }, diff --git a/libs/shared/interfaces/src/lib/vod-source-match-key.util.spec.ts b/libs/shared/interfaces/src/lib/vod-source-match-key.util.spec.ts index 800787371..ff7da2d11 100644 --- a/libs/shared/interfaces/src/lib/vod-source-match-key.util.spec.ts +++ b/libs/shared/interfaces/src/lib/vod-source-match-key.util.spec.ts @@ -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']); + }); +}); diff --git a/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts b/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts index 6cf96e44d..637a2d4b0 100644 --- a/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts +++ b/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts @@ -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); } diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts index 6c117b995..e7e13040a 100644 --- a/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts @@ -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: [ diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts index 5b99a207a..6f17b039b 100644 --- a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts @@ -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) }), }; }