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(), + }, + }; +}