From 481d3680193369bf65326da90f2d61af8433c0ec Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 29 Jul 2026 01:42:22 +0200 Subject: [PATCH] fix(portals): close the player we launched, not the one we now own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups, two of them to last round's own fixes. The external-session close was defeated in exactly the case it was written for: `switchToSource` marks the DESTINATION active before handing playback over, so by the time the service ran, the process still playing no longer looked like ours and was left running beside its replacement. The service now remembers the ids it launched with, independently of what is active. The ASCII/Unicode branch was decided from the NORMALIZED token, which folds diacritics — "Ça" arrived as "ca", looked like plain ASCII, and took the GLOB path while the stored title still read "Ça". Decided from the raw token now. Backup restore upserted archived pins but never removed the playlist's existing ones, so a present-but-empty collection left stale preferences alive — unlike the playback positions cleared beside it. An absent collection (an older archive) still means "no opinion" and is left alone. Four files crossed the size cap on the way; the split ones now share `title-sources.spec-data.ts` and `playlist-backup.xtream-fixtures.ts`, and the external-session ownership moved to its own module. Co-Authored-By: Claude Opus 5 --- .../operations/title-sources-matching.spec.ts | 248 +++++++++++++++ .../title-sources.operations.spec.ts | 291 ++---------------- .../operations/title-sources.operations.ts | 12 +- .../operations/title-sources.spec-data.ts | 42 +++ docs/architecture/vod-multi-source.md | 10 +- .../vod-details-external-session.ts | 77 +++++ .../vod-details-playback.service.spec.ts | 44 +++ .../vod-details-playback.service.ts | 50 +-- .../lib/playlist-backup.service.pins.spec.ts | 185 +++++++++++ .../playlist-backup.service.test-helpers.ts | 7 + .../src/lib/playlist-backup.service.ts | 19 ++ ...list-backup.service.xtream-restore.spec.ts | 212 +------------ .../lib/playlist-backup.xtream-fixtures.ts | 119 +++++++ 13 files changed, 808 insertions(+), 508 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/title-sources.spec-data.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts create mode 100644 libs/services/src/lib/playlist-backup.service.pins.spec.ts create mode 100644 libs/services/src/lib/playlist-backup.xtream-fixtures.ts diff --git a/apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts b/apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts new file mode 100644 index 000000000..6c0dbfcb5 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts @@ -0,0 +1,248 @@ +import { findTitleSources } from './title-sources.operations'; +import { + compiledQuery, + createDbMock, + duneRow, +} from './title-sources.spec-data'; + +/** + * What survives the normalized confirmation, and which playlists are in scope. + * Split from the query-shape suite so both stay inside the file-size rule. + */ +describe('title-sources.operations — confirmation and scoping', () => { + describe('match confirmation', () => { + it('maps a confirmed row onto the candidate shape as an exact match', async () => { + const { db } = createDbMock([duneRow]); + + await expect( + findTitleSources(db, { title: 'Dune' }) + ).resolves.toEqual([ + { + playlistId: 'playlist-1', + playlistName: 'Portal One', + categoryId: 12, + xtreamId: 501, + title: 'Dune', + posterUrl: 'https://cdn.example.com/dune.jpg', + matchConfidence: 'exact', + year: null, + }, + ]); + }); + + it('confirms a year-stripped candidate as a fuzzy match', async () => { + const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]); + + const matches = await findTitleSources(db, { title: 'Dune' }); + + expect(matches).toHaveLength(1); + expect(matches[0].matchConfidence).toBe('fuzzy'); + expect(matches[0].year).toBe(1984); + }); + + it('drops candidates whose normalized title differs', async () => { + const { db } = createDbMock([ + { ...duneRow, title: 'Dune Part Two' }, + ]); + + await expect( + findTitleSources(db, { title: 'Dune' }) + ).resolves.toEqual([]); + }); + + it('rejects a remake whose year is written in brackets', async () => { + // "Dune (1984)" normalizes to exactly "dune" — the brackets are + // stripped as tag noise — so without reading the year out first it + // is an EXACT match for the 2021 film, ranked above every fuzzy + // one, and auto-failover would switch to the wrong movie. + const { db } = createDbMock([{ ...duneRow, title: 'Dune (1984)' }]); + + await expect( + findTitleSources(db, { title: 'Dune', year: 2021 }) + ).resolves.toEqual([]); + }); + + it('matches a bracketed year against the same film', async () => { + const { db } = createDbMock([{ ...duneRow, title: 'Dune (2021)' }]); + + const matches = await findTitleSources(db, { + title: 'Dune', + year: 2021, + }); + + expect(matches).toHaveLength(1); + // Reported too, so the row can say which film it is. + expect(matches[0].year).toBe(2021); + }); + + it('rejects a base-tier match whose year contradicts the request', async () => { + const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]); + + // Same base title, but the user is watching the 2021 film — a + // year-stripped match is only trustworthy when the two sides do + // not actively disagree. + await expect( + findTitleSources(db, { title: 'Dune', year: 2021 }) + ).resolves.toEqual([]); + }); + + it('keeps a base-tier match when one side has no known year', async () => { + const { db } = createDbMock([duneRow]); + + // Query carries the year tag, the candidate does not: an unknown + // year must never block the match. + const matches = await findTitleSources(db, { title: 'Dune 2021' }); + + expect(matches).toHaveLength(1); + expect(matches[0].matchConfidence).toBe('fuzzy'); + expect(matches[0].year).toBeNull(); + }); + + it('tolerates a one-year drift between the two sides', async () => { + const { db } = createDbMock([{ ...duneRow, title: 'Dune 2021' }]); + + const matches = await findTitleSources(db, { + title: 'Dune', + year: 2020, + }); + + expect(matches).toHaveLength(1); + expect(matches[0].matchConfidence).toBe('fuzzy'); + }); + }); + + describe('playlist scoping and duplicates', () => { + it('never returns the playlist the user is already on', async () => { + const { db } = createDbMock([ + duneRow, + { + ...duneRow, + content_id: 2, + xtream_id: 777, + playlist_id: 'playlist-2', + playlist_name: 'Portal Two', + }, + ]); + + const matches = await findTitleSources(db, { + title: 'Dune', + excludePlaylistId: 'playlist-1', + }); + + expect(matches).toHaveLength(1); + expect(matches[0].playlistId).toBe('playlist-2'); + }); + + it('excludes the current playlist inside both queries, not after them', async () => { + // Filtering afterwards is not enough: the current playlist often + // lists the film several times, and those rows would spend the row + // budget the alternatives need — the chip then vanishes even + // though other playlists have the movie. + const fts = createDbMock([duneRow]); + await findTitleSources(fts.db, { + title: 'Dune', + excludePlaylistId: 'playlist-1', + }); + + const ftsQuery = compiledQuery(fts.all); + expect(ftsQuery.sql).toContain('cat.playlist_id <> ?'); + expect(ftsQuery.params).toContain('playlist-1'); + expect(ftsQuery.sql.indexOf('cat.playlist_id <> ?')).toBeLessThan( + ftsQuery.sql.indexOf('LIMIT') + ); + + // The short-title scan is a separate statement and needs it too. + const scan = createDbMock([duneRow]); + await findTitleSources(scan.db, { + title: 'It', + excludePlaylistId: 'playlist-1', + }); + + const scanQuery = compiledQuery(scan.all); + expect(scanQuery.sql).toContain('cat.playlist_id <> ?'); + expect(scanQuery.params).toContain('playlist-1'); + // The scan takes no window, so the cost it saves here is the rows + // read rather than the rows kept. + expect(scanQuery.sql.indexOf('cat.playlist_id <> ?')).toBeLessThan( + scanQuery.sql.indexOf('ORDER BY') + ); + }); + + it('collapses one playlist’s copies before the FTS limit applies', async () => { + // A playlist listing the film in dozens of categories produces + // identically ranked rows. Collapsing them only in TypeScript + // cannot recover the other playlists the window never reached. + const { db, all } = createDbMock([duneRow]); + + await findTitleSources(db, { title: 'Dune' }); + + const query = compiledQuery(all); + expect(query.sql).toContain( + 'GROUP BY cat.playlist_id, c.xtream_id' + ); + expect(query.sql.indexOf('GROUP BY')).toBeLessThan( + query.sql.indexOf('LIMIT') + ); + }); + + it('keeps one named copy of the excluded playlist', async () => { + // A pin can point at another copy of the film inside the playlist + // being viewed. Excluding the playlist wholesale would drop that + // row, and the explicit preference would be silently ignored. + const { db, all } = createDbMock([ + { ...duneRow, xtream_id: 777, content_id: 9 }, + ]); + + const matches = await findTitleSources(db, { + title: 'Dune', + excludePlaylistId: 'playlist-1', + keepContentId: 777, + }); + + const query = compiledQuery(all); + expect(query.sql).toContain('OR c.xtream_id = ?'); + expect(query.params).toContain(777); + // And the TypeScript pass must not throw it away either. + expect(matches.map((match) => match.xtreamId)).toEqual([777]); + }); + + it('leaves the queries unfiltered when no playlist is excluded', async () => { + const { db, all } = createDbMock([duneRow]); + + await findTitleSources(db, { title: 'Dune' }); + + expect(compiledQuery(all).sql).not.toContain('cat.playlist_id <>'); + }); + + it('collapses the same film listed in several categories', async () => { + const { db } = createDbMock([ + duneRow, + { ...duneRow, content_id: 2, category_xtream_id: 13 }, + { ...duneRow, content_id: 3, category_xtream_id: 14 }, + ]); + + const matches = await findTitleSources(db, { title: 'Dune' }); + + expect(matches).toHaveLength(1); + expect(matches[0].categoryId).toBe(12); + }); + + it('keeps the same film when it lives in different playlists', async () => { + const { db } = createDbMock([ + duneRow, + { + ...duneRow, + playlist_id: 'playlist-2', + playlist_name: 'Portal Two', + }, + ]); + + const matches = await findTitleSources(db, { title: 'Dune' }); + + expect(matches.map((match) => match.playlistId)).toEqual([ + 'playlist-1', + 'playlist-2', + ]); + }); + }); +}); 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 f5a81c699..529cda48d 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 @@ -1,40 +1,10 @@ -import type { SQL } from 'drizzle-orm'; -import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core'; -import type { AppDatabase } from '../database.types'; import { findTitleSources } from './title-sources.operations'; - -function createDbMock(rows: unknown[] = []) { - const all = jest.fn().mockResolvedValue(rows); - return { db: { all } as unknown as AppDatabase, all }; -} - -/** - * The statement as SQLite will actually see it. - * - * `better-sqlite3` is built against the Electron ABI and cannot be loaded by - * Jest, so the row-shaping tests below run against a mock that returns rows - * whatever the query says. What rows the DATABASE is asked for is therefore - * only observable here — and it is exactly what the window-crowding fixes - * changed. - */ -function compiledQuery(all: jest.Mock, call = 0) { - return new SQLiteSyncDialect().sqlToQuery(all.mock.calls[call][0] as SQL); -} - -function createFailingDbMock() { - const all = jest.fn().mockRejectedValue(new Error('fts syntax')); - return { db: { all } as unknown as AppDatabase, all }; -} - -const duneRow = { - content_id: 1, - title: 'Dune', - xtream_id: 501, - poster_url: 'https://cdn.example.com/dune.jpg', - category_xtream_id: 12, - playlist_id: 'playlist-1', - playlist_name: 'Portal One', -}; +import { + compiledQuery, + createDbMock, + createFailingDbMock, + duneRow, +} from './title-sources.spec-data'; describe('title-sources.operations', () => { describe('unusable queries', () => { @@ -110,6 +80,19 @@ describe('title-sources.operations', () => { expect(scanQuery.params).toContain('Он'); }); + it('does not treat a diacritic-folded token as ASCII', async () => { + // "Ça" normalizes to "ca", which LOOKS like plain ASCII — but the + // stored title still reads "Ça" and SQLite folds neither the + // cedilla nor the case, so the GLOB branch filtered it out before + // the confirmation could run. + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'Ç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". @@ -157,240 +140,4 @@ describe('title-sources.operations', () => { expect(all).toHaveBeenCalledTimes(1); }); }); - - describe('match confirmation', () => { - it('maps a confirmed row onto the candidate shape as an exact match', async () => { - const { db } = createDbMock([duneRow]); - - await expect( - findTitleSources(db, { title: 'Dune' }) - ).resolves.toEqual([ - { - playlistId: 'playlist-1', - playlistName: 'Portal One', - categoryId: 12, - xtreamId: 501, - title: 'Dune', - posterUrl: 'https://cdn.example.com/dune.jpg', - matchConfidence: 'exact', - year: null, - }, - ]); - }); - - it('confirms a year-stripped candidate as a fuzzy match', async () => { - const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]); - - const matches = await findTitleSources(db, { title: 'Dune' }); - - expect(matches).toHaveLength(1); - expect(matches[0].matchConfidence).toBe('fuzzy'); - expect(matches[0].year).toBe(1984); - }); - - it('drops candidates whose normalized title differs', async () => { - const { db } = createDbMock([ - { ...duneRow, title: 'Dune Part Two' }, - ]); - - await expect( - findTitleSources(db, { title: 'Dune' }) - ).resolves.toEqual([]); - }); - - it('rejects a remake whose year is written in brackets', async () => { - // "Dune (1984)" normalizes to exactly "dune" — the brackets are - // stripped as tag noise — so without reading the year out first it - // is an EXACT match for the 2021 film, ranked above every fuzzy - // one, and auto-failover would switch to the wrong movie. - const { db } = createDbMock([{ ...duneRow, title: 'Dune (1984)' }]); - - await expect( - findTitleSources(db, { title: 'Dune', year: 2021 }) - ).resolves.toEqual([]); - }); - - it('matches a bracketed year against the same film', async () => { - const { db } = createDbMock([{ ...duneRow, title: 'Dune (2021)' }]); - - const matches = await findTitleSources(db, { - title: 'Dune', - year: 2021, - }); - - expect(matches).toHaveLength(1); - // Reported too, so the row can say which film it is. - expect(matches[0].year).toBe(2021); - }); - - it('rejects a base-tier match whose year contradicts the request', async () => { - const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]); - - // Same base title, but the user is watching the 2021 film — a - // year-stripped match is only trustworthy when the two sides do - // not actively disagree. - await expect( - findTitleSources(db, { title: 'Dune', year: 2021 }) - ).resolves.toEqual([]); - }); - - it('keeps a base-tier match when one side has no known year', async () => { - const { db } = createDbMock([duneRow]); - - // Query carries the year tag, the candidate does not: an unknown - // year must never block the match. - const matches = await findTitleSources(db, { title: 'Dune 2021' }); - - expect(matches).toHaveLength(1); - expect(matches[0].matchConfidence).toBe('fuzzy'); - expect(matches[0].year).toBeNull(); - }); - - it('tolerates a one-year drift between the two sides', async () => { - const { db } = createDbMock([{ ...duneRow, title: 'Dune 2021' }]); - - const matches = await findTitleSources(db, { - title: 'Dune', - year: 2020, - }); - - expect(matches).toHaveLength(1); - expect(matches[0].matchConfidence).toBe('fuzzy'); - }); - }); - - describe('playlist scoping and duplicates', () => { - it('never returns the playlist the user is already on', async () => { - const { db } = createDbMock([ - duneRow, - { - ...duneRow, - content_id: 2, - xtream_id: 777, - playlist_id: 'playlist-2', - playlist_name: 'Portal Two', - }, - ]); - - const matches = await findTitleSources(db, { - title: 'Dune', - excludePlaylistId: 'playlist-1', - }); - - expect(matches).toHaveLength(1); - expect(matches[0].playlistId).toBe('playlist-2'); - }); - - it('excludes the current playlist inside both queries, not after them', async () => { - // Filtering afterwards is not enough: the current playlist often - // lists the film several times, and those rows would spend the row - // budget the alternatives need — the chip then vanishes even - // though other playlists have the movie. - const fts = createDbMock([duneRow]); - await findTitleSources(fts.db, { - title: 'Dune', - excludePlaylistId: 'playlist-1', - }); - - const ftsQuery = compiledQuery(fts.all); - expect(ftsQuery.sql).toContain('cat.playlist_id <> ?'); - expect(ftsQuery.params).toContain('playlist-1'); - expect(ftsQuery.sql.indexOf('cat.playlist_id <> ?')).toBeLessThan( - ftsQuery.sql.indexOf('LIMIT') - ); - - // The short-title scan is a separate statement and needs it too. - const scan = createDbMock([duneRow]); - await findTitleSources(scan.db, { - title: 'It', - excludePlaylistId: 'playlist-1', - }); - - const scanQuery = compiledQuery(scan.all); - expect(scanQuery.sql).toContain('cat.playlist_id <> ?'); - expect(scanQuery.params).toContain('playlist-1'); - // The scan takes no window, so the cost it saves here is the rows - // read rather than the rows kept. - expect(scanQuery.sql.indexOf('cat.playlist_id <> ?')).toBeLessThan( - scanQuery.sql.indexOf('ORDER BY') - ); - }); - - it('collapses one playlist’s copies before the FTS limit applies', async () => { - // A playlist listing the film in dozens of categories produces - // identically ranked rows. Collapsing them only in TypeScript - // cannot recover the other playlists the window never reached. - const { db, all } = createDbMock([duneRow]); - - await findTitleSources(db, { title: 'Dune' }); - - const query = compiledQuery(all); - expect(query.sql).toContain( - 'GROUP BY cat.playlist_id, c.xtream_id' - ); - expect(query.sql.indexOf('GROUP BY')).toBeLessThan( - query.sql.indexOf('LIMIT') - ); - }); - - it('keeps one named copy of the excluded playlist', async () => { - // A pin can point at another copy of the film inside the playlist - // being viewed. Excluding the playlist wholesale would drop that - // row, and the explicit preference would be silently ignored. - const { db, all } = createDbMock([ - { ...duneRow, xtream_id: 777, content_id: 9 }, - ]); - - const matches = await findTitleSources(db, { - title: 'Dune', - excludePlaylistId: 'playlist-1', - keepContentId: 777, - }); - - const query = compiledQuery(all); - expect(query.sql).toContain('OR c.xtream_id = ?'); - expect(query.params).toContain(777); - // And the TypeScript pass must not throw it away either. - expect(matches.map((match) => match.xtreamId)).toEqual([777]); - }); - - it('leaves the queries unfiltered when no playlist is excluded', async () => { - const { db, all } = createDbMock([duneRow]); - - await findTitleSources(db, { title: 'Dune' }); - - expect(compiledQuery(all).sql).not.toContain('cat.playlist_id <>'); - }); - - it('collapses the same film listed in several categories', async () => { - const { db } = createDbMock([ - duneRow, - { ...duneRow, content_id: 2, category_xtream_id: 13 }, - { ...duneRow, content_id: 3, category_xtream_id: 14 }, - ]); - - const matches = await findTitleSources(db, { title: 'Dune' }); - - expect(matches).toHaveLength(1); - expect(matches[0].categoryId).toBe(12); - }); - - it('keeps the same film when it lives in different playlists', async () => { - const { db } = createDbMock([ - duneRow, - { - ...duneRow, - playlist_id: 'playlist-2', - playlist_name: 'Portal Two', - }, - ]); - - const matches = await findTitleSources(db, { title: 'Dune' }); - - expect(matches.map((match) => match.playlistId)).toEqual([ - 'playlist-1', - 'playlist-2', - ]); - }); - }); }); 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 a8b0c51b9..5c9c2f3c6 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 @@ -135,9 +135,15 @@ function excludePlaylistClause( * that safe — a looser filter here costs transfer, never a wrong match. */ function tokenPredicate(token: string, rawToken: string): SQL { - // Normalized, so an ASCII token holds letters and digits only — nothing - // GLOB would read as a metacharacter. - if (/^[a-z0-9]+$/.test(token)) { + // Decided from the RAW token, not the normalized one. Normalization folds + // diacritics, so "Ça" arrives here as "ca" — which looks like plain ASCII + // while the stored title still reads "Ça", and SQLite folds neither the + // cedilla nor the case. Branching on the folded form sent that title down + // the GLOB path and filtered it out before the confirmation ever ran. + // + // Normalized ASCII tokens hold letters and digits only, so nothing here + // is a GLOB metacharacter. + if (/^[\x20-\x7e]*$/.test(rawToken) && /^[a-z0-9]+$/.test(token)) { return sql`' ' || LOWER(c.title) || ' ' GLOB ${`*[^a-z0-9]${token}[^a-z0-9]*`}`; } diff --git a/apps/electron-backend/src/app/database/operations/title-sources.spec-data.ts b/apps/electron-backend/src/app/database/operations/title-sources.spec-data.ts new file mode 100644 index 000000000..cce0055d0 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/title-sources.spec-data.ts @@ -0,0 +1,42 @@ +import type { SQL } from 'drizzle-orm'; +import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core'; +import type { AppDatabase } from '../database.types'; + +/** + * Shared scaffolding for the title-source suites, so the query-shape tests and + * the match-confirmation tests can live in separate files without each + * carrying its own copy. + */ + +export function createDbMock(rows: unknown[] = []) { + const all = jest.fn().mockResolvedValue(rows); + return { db: { all } as unknown as AppDatabase, all }; +} + +/** + * The statement as SQLite will actually see it. + * + * `better-sqlite3` is built against the Electron ABI and cannot be loaded by + * Jest, so the row-shaping tests below run against a mock that returns rows + * whatever the query says. What rows the DATABASE is asked for is therefore + * only observable here — and it is exactly what the window-crowding fixes + * changed. + */ +export function compiledQuery(all: jest.Mock, call = 0) { + return new SQLiteSyncDialect().sqlToQuery(all.mock.calls[call][0] as SQL); +} + +export function createFailingDbMock() { + const all = jest.fn().mockRejectedValue(new Error('fts syntax')); + return { db: { all } as unknown as AppDatabase, all }; +} + +export const duneRow = { + content_id: 1, + title: 'Dune', + xtream_id: 501, + poster_url: 'https://cdn.example.com/dune.jpg', + category_xtream_id: 12, + playlist_id: 'playlist-1', + playlist_name: 'Portal One', +}; diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 6e4c5d9f3..ce82bb830 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -473,8 +473,9 @@ object carrying `codec_name`/`width`/`height`. `readStreamInfo` accepts both — reading only the object silently lost the codec on every array response, and with it the "dub may differ" warning, which compares stated audio tracks. -Switching sources through `startResolvedPlayback` closes a matched external -session first. It REPLACES what is playing — with MPV or VLC and instance +Switching sources through `startResolvedPlayback` closes the external session +it LAUNCHED first — tracked separately from the controller's active source, +which a switch has already moved to the destination by then. It REPLACES what is playing — with MPV or VLC and instance reuse off, the backend would otherwise spawn a second detached player, leaving both sources running and Stop owning only the newer one. @@ -482,7 +483,10 @@ both sources running and Stop owning only the newer one. SQLite's `LOWER()` and GLOB character classes are ASCII-only, so a short non-ASCII title could not be folded or word-bounded and simply never matched — -the film stayed absent from the chip. ASCII tokens keep the word-boundary GLOB +the film stayed absent from the chip. The ASCII/Unicode branch is decided from the RAW token, not the normalized +one: normalization folds diacritics, so "Ça" arrives as "ca" and looks like +plain ASCII while the stored title still reads "Ça". ASCII tokens keep the +word-boundary GLOB (what stops "it" matching "Titanic"); a non-ASCII token falls back to a substring test against both the folded and the as-typed form. That covers a title stored in the same case as the request or in lower case, and deliberately diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts new file mode 100644 index 000000000..fa0d73eb3 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-session.ts @@ -0,0 +1,77 @@ +import type { + ExternalPlayerSession, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; + +/** + * Which external player process, and which position rows, belong to this page. + * + * Multi-source makes both questions harder than they look: playback can be on + * a copy of the film in ANOTHER playlist, whose ids the session and the + * position rows then carry, and during a switch the controller has already + * moved "active" to the destination before playback is handed over. + */ + +/** Ids this page owns, beyond the route's own copy. */ +export interface OwnedContentIds { + routePlaylistId: string | undefined; + routeContentId: number | undefined; + /** The alternative currently in use, if it is not the route's own. */ + alternative: PlayerContentInfo | null; +} + +/** + * Whether a position update refers to content this page owns. + * + * An absent playlist id must never match an absent current playlist, or + * `undefined === undefined` would adopt a stranger's progress. + */ +export function ownsContent( + info: + | { playlistId?: string; contentXtreamId?: number; contentType?: string } + | undefined, + owned: OwnedContentIds +): boolean { + if (!info?.playlistId || info.contentType !== 'vod') { + return false; + } + + const { alternative } = owned; + return ( + (info.playlistId === owned.routePlaylistId && + info.contentXtreamId === owned.routeContentId) || + (!!alternative && + info.playlistId === alternative.playlistId && + info.contentXtreamId === alternative.contentXtreamId) + ); +} + +/** + * The external process this page started, if it is still up. + * + * Matched on the ids we LAUNCHED with rather than on what is active now: a + * switch marks the destination active before handing playback over, so asking + * "is this session ours?" at that moment answers no and leaves the running + * process playing beside its replacement. + */ +export function runningExternalSession( + session: ExternalPlayerSession | null, + launched: PlayerContentInfo | null, + matched: ExternalPlayerSession | null +): ExternalPlayerSession | null { + if ( + !session?.contentInfo || + session.status === 'closed' || + session.status === 'error' + ) { + return null; + } + + const info = session.contentInfo; + const isLaunchedOne = + !!launched && + info.playlistId === launched.playlistId && + info.contentXtreamId === launched.contentXtreamId; + + return isLaunchedOne ? session : matched; +} 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 1fbded3cc..10ed9703d 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 @@ -162,6 +162,50 @@ describe('VodDetailsPlaybackService — external session ownership', () => { expect(closeSession).toHaveBeenCalled(); }); + it('closes the alternative it launched, once the badge has moved on', async () => { + // The controller marks the DESTINATION active before playback is + // handed over, so by the time the switch reaches the service the + // running process no longer looks like "ours" — and was left playing + // beside its replacement. + activeSession.set(sessionFor('playlist-2', 991)); + activeSource.set({ + playlistId: 'playlist-3', + contentXtreamId: 77, + contentType: 'vod', + }); + closeSession.mockClear(); + + // Pretend the alternative we are replacing is the one we started. + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/first.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }); + closeSession.mockClear(); + + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/second.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-3', + contentXtreamId: 77, + contentType: 'vod', + }, + }); + + expect(closeSession).toHaveBeenCalledWith( + expect.objectContaining({ + contentInfo: expect.objectContaining({ + contentXtreamId: 991, + }), + }) + ); + }); + 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 f18d8d432..a5e0e655a 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 @@ -26,6 +26,10 @@ import { getXtreamVodInfo, } from '@iptvnator/shared/interfaces'; import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; +import { + ownsContent, + runningExternalSession, +} from './vod-details-external-session'; import { formatPlaybackPosition } from './vod-primary-action-position'; export interface VodDetailsPlaybackBindings { @@ -144,14 +148,6 @@ export class VodDetailsPlaybackService { inject(DestroyRef).onDestroy(() => unsubscribePositionUpdates?.()); } - /** - * Whether this page owns the content a position update refers to. - * - * Multi-source can put playback on a movie in ANOTHER playlist, whose ids - * the incoming rows then carry, so this has to agree with the Play/Stop - * button's `alsoOwns` — otherwise the page offers Stop for a session whose - * progress it silently drops. - */ private ownsContent( info: | { @@ -161,19 +157,11 @@ export class VodDetailsPlaybackService { } | undefined ): boolean { - // An absent playlist id must never match an absent current playlist. - if (!info?.playlistId || info.contentType !== 'vod') { - return false; - } - - const active = this.bindings()?.activeSource?.(); - return ( - (info.playlistId === this.xtreamStore.currentPlaylist()?.id && - info.contentXtreamId === this.bindings()?.vodId()) || - (!!active && - info.playlistId === active.playlistId && - info.contentXtreamId === active.contentXtreamId) - ); + return ownsContent(info, { + routePlaylistId: this.xtreamStore.currentPlaylist()?.id, + routeContentId: this.bindings()?.vodId(), + alternative: this.bindings()?.activeSource?.() ?? null, + }); } /** Wires the host component's context signals. Call once at construction. */ @@ -345,6 +333,17 @@ export class VodDetailsPlaybackService { /** Bumped by every start; only the newest may launch after its close. */ private startGeneration = 0; + /** + * What we last launched externally, remembered independently of the + * controller's active source. + * + * `matchedExternalPlayback` cannot answer this during a switch: the + * controller marks the DESTINATION active before playback is handed over, + * so by the time we get here the running process no longer looks like + * ours and would be left playing beside its replacement. + */ + private launchedExternally: PlayerContentInfo | null = null; + async startResolvedPlayback( playback: ResolvedPortalPlayback ): Promise { @@ -353,7 +352,11 @@ export class VodDetailsPlaybackService { // A switch REPLACES what is playing. With MPV or VLC and instance // reuse off, the backend spawns a second detached player otherwise — // both sources keep running and Stop owns only the newer one. - const running = this.matchedExternalPlayback(); + const running = runningExternalSession( + this.externalPlayback.activeSession(), + this.launchedExternally, + this.matchedExternalPlayback() + ); if (running) { await this.externalPlayback.closeSession(running); } @@ -371,14 +374,17 @@ export class VodDetailsPlaybackService { this.startPlayback(playback); } + private startPlayback(playback: ResolvedPortalPlayback): void { this.positionWriter.reset(); if (this.portalPlayer.isEmbeddedPlayer()) { this.inlinePlayback.set(playback); + this.launchedExternally = null; return; } this.closeInlinePlayer(); + this.launchedExternally = playback.contentInfo ?? null; void this.portalPlayer.openResolvedPlayback(playback, true); } } diff --git a/libs/services/src/lib/playlist-backup.service.pins.spec.ts b/libs/services/src/lib/playlist-backup.service.pins.spec.ts new file mode 100644 index 000000000..3492a32e5 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.pins.spec.ts @@ -0,0 +1,185 @@ +import { XtreamPlaylistBackupEntry } from '@iptvnator/shared/interfaces'; +import { createPlaylistBackupService } from './playlist-backup.service.test-helpers'; +import { + createRestoreCollaborators, + createXtreamManifest, +} from './playlist-backup.xtream-fixtures'; + +/** + * VOD source pins through backup and restore. + * + * A pin is carried under the playlist it points AT, its match key names the + * film rather than the portal, and a present-but-empty collection is an + * answer — split from the hidden-category suite, which owns its own concern. + */ +describe('PlaylistBackupService Xtream source pins', () => { + const electronWindow = window as unknown as { electron?: unknown }; + + beforeEach(() => { + electronWindow.electron = {}; + }); + + afterEach(() => { + delete electronWindow.electron; + jest.restoreAllMocks(); + localStorage.clear(); + }); + + + it('exports the pins that point at this playlist', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService({ + playlistsService: collaborators.playlistsService, + databaseService: collaborators.databaseService, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([ + { + matchKey: 'tmdb:603', + playlistId: 'xtream-1', + contentId: 501, + portalType: 'xtream', + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ]), + set: jest.fn().mockResolvedValue(true), + }, + }); + + const backup = await service.exportBackup(); + + const entry = backup.manifest + .playlists[0] as XtreamPlaylistBackupEntry; + // Without this every "main source" choice vanishes on restore, with + // nothing in the archive to say it was ever made. + expect(entry.userState.sourcePins).toEqual([ + { + matchKey: 'tmdb:603', + contentId: 501, + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ]); + }); + + it('restores pins against the imported playlist, not the exported one', async () => { + const collaborators = createRestoreCollaborators(); + const setPin = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([]), + set: setPin, + clear: jest.fn().mockResolvedValue(true), + }, + }); + + const manifest = createXtreamManifest( + [], + [{ matchKey: 'tmdb:603', contentId: 501 }] + ); + + await service.importBackup(JSON.stringify(manifest)); + + // The match key identifies the film and carries over as-is; the + // playlist id is this installation's, not the archive's. + expect(setPin).toHaveBeenCalledWith({ + matchKey: 'tmdb:603', + playlistId: 'xtream-1', + contentId: 501, + portalType: 'xtream', + }); + }); + + it('reports a pin that could not be written', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([]), + // `set` reports failure rather than throwing, so ignoring the + // result would drop the preference while the summary claims + // the import succeeded. + set: jest.fn().mockResolvedValue(false), + }, + }); + + const manifest = createXtreamManifest( + [], + [{ matchKey: 'tmdb:603', contentId: 501 }] + ); + + const summary = await service.importBackup(JSON.stringify(manifest)); + + expect(summary).toEqual( + expect.objectContaining({ merged: 0, failed: 1 }) + ); + }); + + it('drops pins the backup does not contain', async () => { + const collaborators = createRestoreCollaborators(); + const clearPins = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([ + { + matchKey: 'tmdb:999', + playlistId: 'xtream-1', + contentId: 7, + portalType: 'xtream', + }, + ]), + set: jest.fn().mockResolvedValue(true), + clear: clearPins, + }, + }); + + // Present-but-empty is an answer, like the playback positions cleared + // beside it: leaving the current pins would resurrect preferences the + // archive deliberately does not contain. + await service.importBackup(JSON.stringify(createXtreamManifest([], []))); + + expect(clearPins).toHaveBeenCalledWith(['tmdb:999']); + }); + + it('leaves pins alone for an archive that has no opinion', async () => { + const collaborators = createRestoreCollaborators(); + const clearPins = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([]), + set: jest.fn().mockResolvedValue(true), + clear: clearPins, + }, + }); + + // No `sourcePins` field at all — an older archive, which says nothing + // about pins rather than saying there are none. + await service.importBackup(JSON.stringify(createXtreamManifest([]))); + + expect(clearPins).not.toHaveBeenCalled(); + }); + + it('imports an archive written before pins existed', async () => { + const collaborators = createRestoreCollaborators(); + const setPin = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([]), + set: setPin, + clear: jest.fn().mockResolvedValue(true), + }, + }); + + // No `sourcePins` at all — absence is age, not damage. + const summary = await service.importBackup( + JSON.stringify(createXtreamManifest([])) + ); + + expect(summary).toEqual( + expect.objectContaining({ merged: 1, failed: 0 }) + ); + expect(setPin).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/services/src/lib/playlist-backup.service.test-helpers.ts b/libs/services/src/lib/playlist-backup.service.test-helpers.ts index 7e074dbc9..91a27d246 100644 --- a/libs/services/src/lib/playlist-backup.service.test-helpers.ts +++ b/libs/services/src/lib/playlist-backup.service.test-helpers.ts @@ -65,6 +65,7 @@ export function createPlaylistBackupService( vodSourcePinService: { listForPlaylist: jest.fn().mockResolvedValue([]), set: jest.fn().mockResolvedValue(true), + clear: jest.fn().mockResolvedValue(true), }, pendingRestoreService: { set: jest.fn(), @@ -251,6 +252,12 @@ export function createStatefulBackupCollaborators( state.sourcePins.push({ ...pin }); return true; }, + clear: async (matchKeys: string[]) => { + state.sourcePins = state.sourcePins.filter( + (pin) => !matchKeys.includes(pin.matchKey) + ); + return true; + }, }, pendingRestoreService: new XtreamPendingRestoreService(), }; diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index b79290249..f419b09d7 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -774,6 +774,16 @@ export class PlaylistBackupService { }); } + /** Drops every pin currently pointing at this playlist. */ + private async clearPinsForPlaylist(playlistId: string): Promise { + const existing = + await this.vodSourcePinService.listForPlaylist(playlistId); + const matchKeys = existing.map((pin) => pin.matchKey).filter(Boolean); + if (matchKeys.length > 0) { + await this.vodSourcePinService.clear(matchKeys); + } + } + private async restoreXtreamEntry( playlistId: string, entry: XtreamPlaylistBackupEntry @@ -864,6 +874,15 @@ export class PlaylistBackupService { ); } + // Present-but-empty is an answer, like the positions cleared above: a + // backup that holds no pin for this playlist means the user had none, + // so leaving the current ones would resurrect preferences the archive + // deliberately does not contain. Absent (an older archive) means "no + // opinion", and those are left alone. + if (state.sourcePins) { + await this.clearPinsForPlaylist(playlistId); + } + // The match key identifies the film and survives untouched; only the // playlist has a new id in this installation. for (const pin of state.sourcePins ?? []) { diff --git a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts index 99ec37b62..61f8fe308 100644 --- a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts @@ -7,6 +7,10 @@ import { XtreamPlaylistBackupEntry, } from '@iptvnator/shared/interfaces'; import { createPlaylistBackupService } from './playlist-backup.service.test-helpers'; +import { + createRestoreCollaborators, + createXtreamManifest, +} from './playlist-backup.xtream-fixtures'; /** * Regression coverage for issue #1017: hidden Xtream categories must be @@ -18,111 +22,6 @@ import { createPlaylistBackupService } from './playlist-backup.service.test-help describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => { const electronWindow = window as unknown as { electron?: unknown }; - // Wire-shape rows as returned by the DB worker's category ops. - const categoryRowsByType: Record = { - live: [ - { - id: 11, - playlist_id: 'xtream-1', - name: 'News', - type: 'live', - xtream_id: 101, - hidden: true, - }, - { - id: 12, - playlist_id: 'xtream-1', - name: 'Sports', - type: 'live', - xtream_id: 102, - hidden: false, - }, - ], - movies: [ - { - id: 21, - playlist_id: 'xtream-1', - name: 'Drama', - type: 'movies', - xtream_id: 201, - hidden: true, - }, - ], - series: [], - }; - - const existingXtreamPlaylist = { - _id: 'xtream-1', - title: 'Xtream Portal', - count: 3, - importDate: '2026-04-20T00:00:00.000Z', - lastUsage: '2026-04-20T00:00:00.000Z', - autoRefresh: false, - serverUrl: 'http://portal.example.com', - username: 'user', - password: 'pass', - } as Playlist; - - function createXtreamManifest( - hiddenCategories: unknown[], - sourcePins?: unknown[] - ): PlaylistBackupManifestV1 { - return { - kind: PLAYLIST_BACKUP_KIND, - version: PLAYLIST_BACKUP_VERSION, - exportedAt: '2026-04-21T00:00:00.000Z', - includeSecrets: true, - playlists: [ - { - portalType: 'xtream', - exportedId: 'xtream-1', - title: 'Xtream Portal', - autoRefresh: false, - connection: { - serverUrl: 'http://portal.example.com', - username: 'user', - password: 'pass', - }, - userState: { - hiddenCategories, - favorites: [], - recentlyViewed: [], - playbackPositions: [], - ...(sourcePins ? { sourcePins } : {}), - }, - } as unknown as XtreamPlaylistBackupEntry, - ], - }; - } - - function createRestoreCollaborators() { - return { - playlistsService: { - addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), - getAllData: jest.fn(() => of([existingXtreamPlaylist])), - getRawPlaylistById: jest.fn(() => of('#EXTM3U')), - handlePlaylistParsing: jest.fn(), - }, - databaseService: { - getAllXtreamCategories: jest.fn( - (_playlistId: string, type: string) => - Promise.resolve(categoryRowsByType[type] ?? []) - ), - getFavorites: jest.fn().mockResolvedValue([]), - getRecentItems: jest.fn().mockResolvedValue([]), - getXtreamImportStatus: jest.fn().mockResolvedValue('completed'), - hasXtreamCategories: jest.fn().mockResolvedValue(true), - hasXtreamContent: jest.fn().mockResolvedValue(true), - restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), - updateCategoryVisibility: jest.fn().mockResolvedValue(true), - }, - pendingRestoreService: { - set: jest.fn(), - clear: jest.fn(), - }, - }; - } - beforeEach(() => { electronWindow.electron = {}; }); @@ -192,114 +91,11 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => { ); }); - it('exports the pins that point at this playlist', async () => { - const collaborators = createRestoreCollaborators(); - const service = createPlaylistBackupService({ - playlistsService: collaborators.playlistsService, - databaseService: collaborators.databaseService, - vodSourcePinService: { - listForPlaylist: jest.fn().mockResolvedValue([ - { - matchKey: 'tmdb:603', - playlistId: 'xtream-1', - contentId: 501, - portalType: 'xtream', - updatedAt: '2026-07-06T09:00:00.000Z', - }, - ]), - set: jest.fn().mockResolvedValue(true), - }, - }); - const backup = await service.exportBackup(); - const entry = backup.manifest - .playlists[0] as XtreamPlaylistBackupEntry; - // Without this every "main source" choice vanishes on restore, with - // nothing in the archive to say it was ever made. - expect(entry.userState.sourcePins).toEqual([ - { - matchKey: 'tmdb:603', - contentId: 501, - updatedAt: '2026-07-06T09:00:00.000Z', - }, - ]); - }); - it('restores pins against the imported playlist, not the exported one', async () => { - const collaborators = createRestoreCollaborators(); - const setPin = jest.fn().mockResolvedValue(true); - const service = createPlaylistBackupService({ - ...collaborators, - vodSourcePinService: { - listForPlaylist: jest.fn().mockResolvedValue([]), - set: setPin, - }, - }); - const manifest = createXtreamManifest( - [], - [{ matchKey: 'tmdb:603', contentId: 501 }] - ); - await service.importBackup(JSON.stringify(manifest)); - - // The match key identifies the film and carries over as-is; the - // playlist id is this installation's, not the archive's. - expect(setPin).toHaveBeenCalledWith({ - matchKey: 'tmdb:603', - playlistId: 'xtream-1', - contentId: 501, - portalType: 'xtream', - }); - }); - - it('reports a pin that could not be written', async () => { - const collaborators = createRestoreCollaborators(); - const service = createPlaylistBackupService({ - ...collaborators, - vodSourcePinService: { - listForPlaylist: jest.fn().mockResolvedValue([]), - // `set` reports failure rather than throwing, so ignoring the - // result would drop the preference while the summary claims - // the import succeeded. - set: jest.fn().mockResolvedValue(false), - }, - }); - - const manifest = createXtreamManifest( - [], - [{ matchKey: 'tmdb:603', contentId: 501 }] - ); - - const summary = await service.importBackup(JSON.stringify(manifest)); - - expect(summary).toEqual( - expect.objectContaining({ merged: 0, failed: 1 }) - ); - }); - - it('imports an archive written before pins existed', async () => { - const collaborators = createRestoreCollaborators(); - const setPin = jest.fn().mockResolvedValue(true); - const service = createPlaylistBackupService({ - ...collaborators, - vodSourcePinService: { - listForPlaylist: jest.fn().mockResolvedValue([]), - set: setPin, - }, - }); - - // No `sourcePins` at all — absence is age, not damage. - const summary = await service.importBackup( - JSON.stringify(createXtreamManifest([])) - ); - - expect(summary).toEqual( - expect.objectContaining({ merged: 1, failed: 0 }) - ); - expect(setPin).not.toHaveBeenCalled(); - }); it('rejects entries with missing user-state collections instead of wiping user data', async () => { const collaborators = createRestoreCollaborators(); diff --git a/libs/services/src/lib/playlist-backup.xtream-fixtures.ts b/libs/services/src/lib/playlist-backup.xtream-fixtures.ts new file mode 100644 index 000000000..7e7e95004 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.xtream-fixtures.ts @@ -0,0 +1,119 @@ +import { of } from 'rxjs'; +import { + Playlist, + PlaylistBackupManifestV1, + PLAYLIST_BACKUP_KIND, + PLAYLIST_BACKUP_VERSION, + XtreamPlaylistBackupEntry, +} from '@iptvnator/shared/interfaces'; + +/** + * The Xtream restore scaffolding both backup suites need — the existing + * playlist, a manifest builder, and the collaborator stubs. Shared so the + * hidden-category and source-pin suites cannot drift apart. + */ + +// Wire-shape rows as returned by the DB worker's category ops. +const categoryRowsByType: Record = { + live: [ + { + id: 11, + playlist_id: 'xtream-1', + name: 'News', + type: 'live', + xtream_id: 101, + hidden: true, + }, + { + id: 12, + playlist_id: 'xtream-1', + name: 'Sports', + type: 'live', + xtream_id: 102, + hidden: false, + }, + ], + movies: [ + { + id: 21, + playlist_id: 'xtream-1', + name: 'Drama', + type: 'movies', + xtream_id: 201, + hidden: true, + }, + ], + series: [], +}; + +export const existingXtreamPlaylist = { + _id: 'xtream-1', + title: 'Xtream Portal', + count: 3, + importDate: '2026-04-20T00:00:00.000Z', + lastUsage: '2026-04-20T00:00:00.000Z', + autoRefresh: false, + serverUrl: 'http://portal.example.com', + username: 'user', + password: 'pass', +} as Playlist; + +export function createXtreamManifest( + hiddenCategories: unknown[], + sourcePins?: unknown[] +): PlaylistBackupManifestV1 { + return { + kind: PLAYLIST_BACKUP_KIND, + version: PLAYLIST_BACKUP_VERSION, + exportedAt: '2026-04-21T00:00:00.000Z', + includeSecrets: true, + playlists: [ + { + portalType: 'xtream', + exportedId: 'xtream-1', + title: 'Xtream Portal', + autoRefresh: false, + connection: { + serverUrl: 'http://portal.example.com', + username: 'user', + password: 'pass', + }, + userState: { + hiddenCategories, + favorites: [], + recentlyViewed: [], + playbackPositions: [], + ...(sourcePins ? { sourcePins } : {}), + }, + } as unknown as XtreamPlaylistBackupEntry, + ], + }; +} + +export function createRestoreCollaborators() { + return { + playlistsService: { + addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), + getAllData: jest.fn(() => of([existingXtreamPlaylist])), + getRawPlaylistById: jest.fn(() => of('#EXTM3U')), + handlePlaylistParsing: jest.fn(), + }, + databaseService: { + getAllXtreamCategories: jest.fn( + (_playlistId: string, type: string) => + Promise.resolve(categoryRowsByType[type] ?? []) + ), + getFavorites: jest.fn().mockResolvedValue([]), + getRecentItems: jest.fn().mockResolvedValue([]), + getXtreamImportStatus: jest.fn().mockResolvedValue('completed'), + hasXtreamCategories: jest.fn().mockResolvedValue(true), + hasXtreamContent: jest.fn().mockResolvedValue(true), + restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), + updateCategoryVisibility: jest.fn().mockResolvedValue(true), + }, + pendingRestoreService: { + set: jest.fn(), + clear: jest.fn(), + }, + }; +}