diff --git a/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts new file mode 100644 index 000000000..102cf35f6 --- /dev/null +++ b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts @@ -0,0 +1,277 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Locator, Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + deleteSource, + expect, + launchElectronApp, + openSettings, + openSources, + openWorkspaceSection, + resetMockServers, + restartElectronApp, + sourceRowByTitle, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +/** + * Full backup round-trip through the real UI, DB worker and IPC stack: + * hide a category, export the backup, delete the source, import the file + * back and verify the restored portal hides the same category again after + * its content is re-imported from the mock server (regression for #1017 — + * exported hidden categories lost their xtream IDs and the restore either + * hid everything or nothing). + */ +test.describe('Electron playlist backup round-trip', () => { + test('exports a backup and re-imports it with hidden categories restored', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const portalName = 'Backup Roundtrip Xtream'; + const exportPath = join(dataDir, 'roundtrip-backup.json'); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { name: portalName }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); + + const targetCategory = await pickVisibleCategoryWithContent( + app.mainWindow + ); + + let dialog = await openManageCategoriesDialog(app.mainWindow); + await setManagedCategoryChecked(dialog, targetCategory.name, false); + await dialog + .getByRole('button', { name: 'Save', exact: true }) + .click(); + await app.mainWindow.waitForSelector('mat-dialog-container', { + state: 'detached', + }); + await expect( + sidebarCategoryById(app.mainWindow, targetCategory.id) + ).toHaveCount(0); + + // Export through the real settings flow with the native save + // dialog stubbed to a fixed path inside the test data dir. + await app.electronApp.evaluate(({ dialog: nativeDialog }, path) => { + nativeDialog.showSaveDialog = async () => ({ + canceled: false, + filePath: path, + }); + }, exportPath); + + await openSettings(app.mainWindow); + const backupSection = app.mainWindow.locator('#backup'); + await backupSection + .getByRole('button', { name: 'Export', exact: true }) + .click(); + await expect( + app.mainWindow.getByText('Playlist backup exported.') + ).toBeVisible({ timeout: 15000 }); + + // The exported manifest must reference hidden categories by + // numeric xtream ID — the #1017 regression exported anonymous + // { categoryType } entries. + const manifest = JSON.parse(readFileSync(exportPath, 'utf-8')) as { + playlists: Array<{ + portalType: string; + userState?: { + hiddenCategories?: Array<{ + categoryType?: string; + xtreamId?: unknown; + }>; + }; + }>; + }; + const xtreamEntry = manifest.playlists.find( + (entry) => entry.portalType === 'xtream' + ); + const hiddenCategories = + xtreamEntry?.userState?.hiddenCategories ?? []; + expect(hiddenCategories.length).toBeGreaterThan(0); + expect( + hiddenCategories.every( + (hiddenCategory) => + typeof hiddenCategory.xtreamId === 'number' + ) + ).toBe(true); + + await openSources(app.mainWindow); + await deleteSource(app.mainWindow, portalName); + await expect( + sourceRowByTitle(app.mainWindow, portalName) + ).toHaveCount(0); + + // Import the exported file back through the settings flow; the + // renderer opens a browser file chooser for it. + await openSettings(app.mainWindow); + const fileChooserPromise = + app.mainWindow.waitForEvent('filechooser'); + await backupSection + .getByRole('button', { name: 'Import', exact: true }) + .click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(exportPath); + await expect( + app.mainWindow.getByText(/Backup import finished: 1 imported/) + ).toBeVisible({ timeout: 15000 }); + + // Restart before opening the restored portal: the root-provided + // XtreamStore still holds the deleted portal's in-memory state + // under the same playlist id and would skip content + // initialization in this session. A restart matches the primary + // restore workflow (fresh install) and forces a real re-import. + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + // Opening the restored portal re-imports content from the mock + // server; the pending restore state must hide the same category + // again. + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, portalName).first().click(); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/live/ + ); + await expect( + sidebarCategoryById(app.mainWindow, targetCategory.id) + ).toHaveCount(0); + + // The restored hidden flag must live in the database itself, + // not only in the rendered sidebar state. + const restoredPlaylistId = + app.mainWindow.url().match(/xtreams\/([^/]+)/)?.[1] ?? ''; + expect(restoredPlaylistId).not.toEqual(''); + const restoredDbRows = await app.mainWindow.evaluate( + (playlistId) => + ( + window as unknown as { + electron: { + dbGetAllCategories: ( + id: string, + type: string + ) => Promise< + Array<{ name: string; hidden: boolean }> + >; + }; + } + ).electron.dbGetAllCategories(playlistId, 'live'), + restoredPlaylistId + ); + expect(restoredDbRows.length).toBeGreaterThan(0); + expect( + restoredDbRows + .filter((row) => row.hidden) + .map((row) => row.name) + ).toEqual([targetCategory.name]); + + dialog = await openManageCategoriesDialog(app.mainWindow); + await dialog + .locator('input[type="search"]') + .fill(targetCategory.name); + const restoredRow = dialog.locator('.category-item').first(); + await expect(restoredRow).toBeVisible({ timeout: 15000 }); + await expect( + restoredRow.locator('mat-checkbox input') + ).not.toBeChecked(); + } finally { + await closeElectronApp(app); + } + }); +}); + +async function openManageCategoriesDialog(page: Page): Promise { + await page.getByRole('button', { name: 'Manage categories' }).click(); + const dialog = page.locator('mat-dialog-container').last(); + + await expect(dialog).toBeVisible(); + await expect(dialog.locator('.category-item').first()).toBeVisible({ + timeout: 15000, + }); + return dialog; +} + +async function setManagedCategoryChecked( + dialog: Locator, + categoryName: string, + shouldBeChecked: boolean +): Promise { + await dialog.locator('input[type="search"]').fill(categoryName); + const row = dialog.locator('.category-item').first(); + await expect(row).toBeVisible({ timeout: 15000 }); + const checkbox = row.locator('mat-checkbox input'); + + if (shouldBeChecked) { + await checkbox.check(); + await expect(checkbox).toBeChecked(); + } else { + await checkbox.uncheck(); + await expect(checkbox).not.toBeChecked(); + } +} + +function sidebarCategoryById(page: Page, categoryId: string): Locator { + return page.locator( + `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]` + ); +} + +async function pickVisibleCategoryWithContent( + page: Page +): Promise<{ id: string; name: string }> { + let picked: { id: string; name: string } | null = null; + + await expect + .poll( + async () => { + const categories = page.locator( + 'app-workspace-context-panel .category-item:visible' + ); + const count = await categories.count(); + + for (let index = 0; index < count; index += 1) { + const category = categories.nth(index); + const id = + ( + await category.getAttribute('data-category-id') + )?.trim() ?? ''; + const name = + ( + await category + .locator('.nav-item-label') + .textContent() + )?.trim() ?? ''; + const countText = + ( + await category.locator('.item-count').textContent() + )?.trim() ?? ''; + + if (id && name && (Number.parseInt(countText, 10) || 0) > 0) { + picked = { id, name }; + return true; + } + } + + picked = null; + return false; + }, + { + message: + 'No visible Xtream category with content was found in the sidebar.', + timeout: 15000, + } + ) + .toBe(true); + + return picked!; +} diff --git a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts index b11405790..2c38e5ab6 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts @@ -1,6 +1,23 @@ import type { AppDatabase } from '../database.types'; import * as schema from '@iptvnator/shared/database/schema'; -import { getCategories, saveCategories } from './category.operations'; +import { + getAllCategories, + getCategories, + saveCategories, +} from './category.operations'; + +// Renderer consumers (XCategoryFromDb/XtreamCategoryFromDb) expect category +// rows in this snake_case wire shape. A bare select() would return Drizzle's +// camelCase property names and silently break the playlist backup +// export/restore (issue #1017). +const categoryWireShape = { + id: schema.categories.id, + playlist_id: schema.categories.playlistId, + name: schema.categories.name, + type: schema.categories.type, + xtream_id: schema.categories.xtreamId, + hidden: schema.categories.hidden, +}; function createDbMock(existingCount = 0) { const where = jest.fn().mockResolvedValue([{ count: existingCount }]); @@ -37,6 +54,21 @@ describe('category.operations', () => { await getCategories(db, 'playlist-1', 'live'); expect(orderBy).toHaveBeenCalledWith(schema.categories.id); + expect(select).toHaveBeenCalledWith(categoryWireShape); + }); + + it('projects all categories to the snake_case wire shape', async () => { + const orderBy = jest.fn().mockResolvedValue([]); + const where = jest.fn().mockReturnValue({ orderBy }); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + const db = { + select, + } as unknown as AppDatabase; + + await getAllCategories(db, 'playlist-1', 'movies'); + + expect(select).toHaveBeenCalledWith(categoryWireShape); }); it('restores hidden categories when Xtream API category IDs are strings', async () => { diff --git a/apps/electron-backend/src/app/database/operations/category.operations.ts b/apps/electron-backend/src/app/database/operations/category.operations.ts index a22a4a4e2..27bd2cb46 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -7,6 +7,20 @@ type XtreamCategoryInput = { category_id: string | number; }; +// Category rows cross the DB-worker IPC boundary in the snake_case wire +// shape declared by XCategoryFromDb/XtreamCategoryFromDb. A bare select() +// would leak Drizzle's camelCase property names (xtreamId, playlistId) +// instead, silently breaking consumers such as the playlist backup +// export/restore (issue #1017). +const categoryWireShape = { + id: schema.categories.id, + playlist_id: schema.categories.playlistId, + name: schema.categories.name, + type: schema.categories.type, + xtream_id: schema.categories.xtreamId, + hidden: schema.categories.hidden, +}; + function normalizeXtreamCategoryId( rawCategoryId: string | number ): number | null { @@ -43,7 +57,7 @@ export async function getCategories( // If partial category re-inserts are added later, persist a provider // sort index instead of relying on the insertion id. return db - .select() + .select(categoryWireShape) .from(schema.categories) .where( and( @@ -123,7 +137,7 @@ export async function getAllCategories( type: 'live' | 'movies' | 'series' ) { return db - .select() + .select(categoryWireShape) .from(schema.categories) .where( and( diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index 1a88331e6..2f3f6f045 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -150,6 +150,18 @@ Runtime contract: - settings backup import - Xtream content initialization +Restore state originates from untrusted sources (user-supplied backup files, +stale localStorage entries), so every read and write goes through +`normalizeXtreamPendingRestoreState` (`libs/shared/interfaces`). Entries in +`hiddenCategories`, `favorites`, and `recentlyViewed` without a usable numeric +`xtreamId` are dropped rather than restored: backups exported by builds +affected by issue #1017 contain ID-less hidden-category entries, and matching +them against category rows would otherwise degrade to a type-only comparison +that hides every category of that type. Category rows themselves cross the DB +worker IPC boundary in the snake_case wire shape declared by +`XCategoryFromDb`/`XtreamCategoryFromDb`; the category operations project +their Drizzle rows explicitly to keep that contract true. + Electron restore behavior: 1. Category import reads pending hidden-category state while saving categories. diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index b8e84e9e4..76ea80fa1 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -418,28 +418,34 @@ describe('PlaylistRefreshActionService', () => { ); expect(setItemSpy).toHaveBeenCalledWith( `xtream-restore-${item._id}`, - JSON.stringify({ - hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], - favorites: [ - { - xtreamId: 101, - contentType: 'live', - }, - { - xtreamId: 202, - contentType: 'movie', - }, - ], - recentlyViewed: [ - { - xtreamId: 303, - contentType: 'series', - viewedAt: '2026-04-04T08:00:00.000Z', - }, - ], - playbackPositions: [], - }) + expect.any(String) ); + const persistedRestoreState = JSON.parse( + setItemSpy.mock.calls.find( + ([key]) => key === `xtream-restore-${item._id}` + )?.[1] ?? 'null' + ); + expect(persistedRestoreState).toEqual({ + hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], + favorites: [ + { + xtreamId: 101, + contentType: 'live', + }, + { + xtreamId: 202, + contentType: 'movie', + }, + ], + recentlyViewed: [ + { + xtreamId: 303, + contentType: 'series', + viewedAt: '2026-04-04T08:00:00.000Z', + }, + ], + playbackPositions: [], + }); expect(store.dispatch).toHaveBeenCalledWith( PlaylistActions.updatePlaylistMeta({ playlist: { ...item, updateDate: 1712217600000 }, diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index 1bd6a7812..ff7e04cf1 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -449,22 +449,28 @@ describe('RecentPlaylistsComponent busy state', () => { ); expect(setItemSpy).toHaveBeenCalledWith( `xtream-restore-${item._id}`, - JSON.stringify({ - hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], - favorites: [ - { xtreamId: 101, contentType: 'live' }, - { xtreamId: 202, contentType: 'movie' }, - ], - recentlyViewed: [ - { - xtreamId: 303, - contentType: 'series', - viewedAt: '2026-04-03T11:15:00.000Z', - }, - ], - playbackPositions: [], - }) + expect.any(String) ); + const persistedRestoreState = JSON.parse( + setItemSpy.mock.calls.find( + ([key]) => key === `xtream-restore-${item._id}` + )?.[1] ?? 'null' + ); + expect(persistedRestoreState).toEqual({ + hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], + favorites: [ + { xtreamId: 101, contentType: 'live' }, + { xtreamId: 202, contentType: 'movie' }, + ], + recentlyViewed: [ + { + xtreamId: 303, + contentType: 'series', + viewedAt: '2026-04-03T11:15:00.000Z', + }, + ], + playbackPositions: [], + }); expect(router.navigate).toHaveBeenCalledWith([ '/workspace', 'xtreams', diff --git a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts new file mode 100644 index 000000000..ab5fd7962 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts @@ -0,0 +1,237 @@ +import { + Playlist, + PlaylistBackupManifestV1, +} from '@iptvnator/shared/interfaces'; +import { + createPlaylistBackupService, + createStatefulBackupCollaborators, + FakeBackupBackendState, +} from './playlist-backup.service.test-helpers'; + +/** + * Full export → import → export round-trip over a stateful in-memory + * backend. Guards the property the separate export/import specs cannot: + * that a backup produced by the app restores the complete user state when + * fed back into the app, and that nothing is silently dropped along the + * way (issue #1017 shipped exactly because export and import were only + * ever tested in isolation against hand-built fixtures). + */ +describe('PlaylistBackupService export → import round-trip', () => { + const electronWindow = window as unknown as { electron?: unknown }; + + beforeEach(() => { + electronWindow.electron = {}; + }); + + afterEach(() => { + delete electronWindow.electron; + jest.restoreAllMocks(); + localStorage.clear(); + }); + + function seedState(): FakeBackupBackendState { + return { + playlists: [ + { + _id: 'm3u-1', + title: 'Local M3U', + count: 1, + importDate: '2026-07-01T00:00:00.000Z', + lastUsage: '2026-07-01T00:00:00.000Z', + autoRefresh: false, + position: 1, + favorites: ['https://streams.example.com/one.m3u8'], + recentlyViewed: [ + { + source: 'm3u', + id: 'https://streams.example.com/one.m3u8', + url: 'https://streams.example.com/one.m3u8', + title: 'Channel One', + category_id: 'live', + added_at: '2026-07-02T10:00:00.000Z', + }, + ], + hiddenGroupTitles: ['Shopping'], + } as unknown as Playlist, + { + _id: 'xtream-1', + title: 'Xtream Portal', + count: 4, + importDate: '2026-07-01T00:00:00.000Z', + lastUsage: '2026-07-01T00:00:00.000Z', + autoRefresh: true, + position: 2, + serverUrl: 'http://portal.example.com', + username: 'user', + password: 'pass', + } as Playlist, + { + _id: 'stalker-1', + title: 'Stalker Portal', + count: 0, + importDate: '2026-07-01T00:00:00.000Z', + lastUsage: '2026-07-01T00:00:00.000Z', + autoRefresh: false, + position: 3, + portalUrl: + 'http://stalker.example.com/stalker_portal/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + isFullStalkerPortal: true, + favorites: [ + { id: '42', name: 'Stalker Channel', type: 'itv' }, + ], + recentlyViewed: [ + { id: '43', name: 'Stalker Movie', type: 'vod' }, + ], + stalkerToken: 'session-token', + } as unknown as Playlist, + ], + rawM3uByPlaylistId: new Map([ + [ + 'm3u-1', + '#EXTM3U\n#EXTINF:-1,Channel One\nhttps://streams.example.com/one.m3u8', + ], + ]), + xtreamCategories: [ + { + id: 1, + playlist_id: 'xtream-1', + name: 'News', + type: 'live', + xtream_id: 101, + hidden: true, + }, + { + id: 2, + playlist_id: 'xtream-1', + name: 'Sports', + type: 'live', + xtream_id: 102, + hidden: false, + }, + { + id: 3, + playlist_id: 'xtream-1', + name: 'Drama', + type: 'movies', + xtream_id: 201, + hidden: true, + }, + { + id: 4, + playlist_id: 'xtream-1', + name: 'Docs', + type: 'series', + xtream_id: 301, + hidden: false, + }, + ], + xtreamFavorites: [ + { + xtream_id: 501, + type: 'movie', + added_at: '2026-07-03T12:00:00.000Z', + position: 0, + }, + ], + xtreamRecent: [ + { + xtream_id: 601, + type: 'live', + viewed_at: '2026-07-04T18:30:00.000Z', + }, + ], + playbackPositions: [ + { + contentXtreamId: 501, + contentType: 'vod', + positionSeconds: 120, + durationSeconds: 3600, + updatedAt: '2026-07-05T20:00:00.000Z', + }, + ], + epgUrls: ['https://epg.example.com/guide.xml'], + }; + } + + function normalizeManifest( + manifest: PlaylistBackupManifestV1 + ): PlaylistBackupManifestV1 { + return { ...manifest, exportedAt: 'normalized' }; + } + + it('re-importing its own export restores the full state and exports an identical manifest', async () => { + const state = seedState(); + const collaborators = createStatefulBackupCollaborators(state); + const exportService = createPlaylistBackupService(collaborators); + + const firstExport = await exportService.exportBackup(); + + // Simulate a fresh install that has already cached the same portal + // content (offline cache reports completed) but carries no user + // state: no playlists, no favorites, every category visible. + state.playlists = []; + state.rawM3uByPlaylistId.clear(); + state.xtreamFavorites = []; + state.xtreamRecent = []; + state.playbackPositions = []; + state.epgUrls = []; + for (const row of state.xtreamCategories) { + row.hidden = false; + } + + const importService = createPlaylistBackupService(collaborators); + const summary = await importService.importBackup(firstExport.json); + + expect(summary).toEqual({ + imported: 3, + merged: 0, + skipped: 0, + failed: 0, + errors: [], + }); + + // Category visibility restored by exact xtream ID (issue #1017). + expect( + state.xtreamCategories + .filter((row) => row.hidden) + .map((row) => row.xtream_id) + .sort((left, right) => left - right) + ).toEqual([101, 201]); + expect(state.xtreamFavorites).toEqual([ + { + xtream_id: 501, + type: 'movie', + added_at: '2026-07-03T12:00:00.000Z', + position: 0, + }, + ]); + expect(state.xtreamRecent).toEqual([ + { + xtream_id: 601, + type: 'live', + viewed_at: '2026-07-04T18:30:00.000Z', + }, + ]); + expect(state.playbackPositions).toEqual([ + expect.objectContaining({ + contentXtreamId: 501, + positionSeconds: 120, + }), + ]); + expect(state.epgUrls).toEqual(['https://epg.example.com/guide.xml']); + expect( + state.playlists.map((playlist) => playlist._id) + ).toEqual(['m3u-1', 'xtream-1', 'stalker-1']); + + // Exporting the restored state must reproduce the original + // manifest byte for byte (modulo the export timestamp): any field + // silently dropped by export, import, or the restore mapping shows + // up as a diff here. + const secondExport = await importService.exportBackup(); + + expect(normalizeManifest(secondExport.manifest)).toEqual( + normalizeManifest(firstExport.manifest) + ); + }); +}); diff --git a/libs/services/src/lib/playlist-backup.service.spec.ts b/libs/services/src/lib/playlist-backup.service.spec.ts index 7f6ccbf08..15e6f8dc7 100644 --- a/libs/services/src/lib/playlist-backup.service.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.spec.ts @@ -5,70 +5,10 @@ import { PLAYLIST_BACKUP_KIND, PLAYLIST_BACKUP_VERSION, } from '@iptvnator/shared/interfaces'; -import { - PlaylistBackupError, - PlaylistBackupService, -} from './playlist-backup.service'; +import { PlaylistBackupError } from './playlist-backup.service'; +import { createPlaylistBackupService } from './playlist-backup.service.test-helpers'; describe('PlaylistBackupService', () => { - function createService(overrides: Record = {}) { - const service = Object.create( - PlaylistBackupService.prototype - ) as PlaylistBackupService; - - Object.assign(service as object, { - playlistsService: { - addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), - getAllData: jest.fn(() => of([])), - getRawPlaylistById: jest.fn(() => of('#EXTM3U')), - handlePlaylistParsing: jest.fn( - (_uploadType: string, rawM3u: string, title: string) => ({ - _id: 'generated-id', - title, - filename: title, - count: rawM3u.split('\n').filter(Boolean).length, - playlist: { - header: { raw: '#EXTM3U' }, - items: [], - }, - importDate: '2026-04-21T00:00:00.000Z', - lastUsage: '2026-04-21T00:00:00.000Z', - favorites: [], - autoRefresh: false, - }) - ), - }, - settingsStore: { - getSettings: jest.fn(() => ({ epgUrl: [] })), - updateSettings: jest.fn().mockResolvedValue(undefined), - }, - databaseService: { - getAllXtreamCategories: jest.fn().mockResolvedValue([]), - getFavorites: jest.fn().mockResolvedValue([]), - getRecentItems: jest.fn().mockResolvedValue([]), - getXtreamImportStatus: jest.fn().mockResolvedValue('idle'), - hasXtreamCategories: jest.fn().mockResolvedValue(false), - hasXtreamContent: jest.fn().mockResolvedValue(false), - restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), - updateCategoryVisibility: jest.fn().mockResolvedValue(true), - }, - playbackPositionService: { - getAllPlaybackPositions: jest.fn().mockResolvedValue([]), - clearAllPlaybackPositions: jest - .fn() - .mockResolvedValue(undefined), - savePlaybackPosition: jest.fn().mockResolvedValue(undefined), - }, - pendingRestoreService: { - set: jest.fn(), - clear: jest.fn(), - }, - ...overrides, - }); - - return service; - } - afterEach(() => { jest.restoreAllMocks(); localStorage.clear(); @@ -122,7 +62,7 @@ describe('PlaylistBackupService', () => { })), updateSettings: jest.fn().mockResolvedValue(undefined), }; - const service = createService({ + const service = createPlaylistBackupService({ playlistsService, settingsStore, }); @@ -162,7 +102,7 @@ describe('PlaylistBackupService', () => { }); it('rejects legacy raw playlist arrays on import', async () => { - const service = createService(); + const service = createPlaylistBackupService(); await expect(service.importBackup('[]')).rejects.toBeInstanceOf( PlaylistBackupError @@ -209,7 +149,7 @@ describe('PlaylistBackupService', () => { })), updateSettings: jest.fn().mockResolvedValue(undefined), }; - const service = createService({ + const service = createPlaylistBackupService({ playlistsService, settingsStore, }); diff --git a/libs/services/src/lib/playlist-backup.service.test-helpers.ts b/libs/services/src/lib/playlist-backup.service.test-helpers.ts new file mode 100644 index 000000000..f3e5a57a4 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.test-helpers.ts @@ -0,0 +1,236 @@ +import { of } from 'rxjs'; +import { + PlaybackPositionData, + Playlist, + XtreamBackupFavoriteItem, + XtreamBackupRecentlyViewedItem, +} from '@iptvnator/shared/interfaces'; +import { PlaylistBackupService } from './playlist-backup.service'; +import { XtreamPendingRestoreService } from './xtream-pending-restore.service'; + +/** + * Shared factory for PlaylistBackupService specs. Instantiates the service + * without Angular DI and replaces every collaborator with jest mocks; + * individual specs override only the collaborators they exercise. + */ +export function createPlaylistBackupService( + overrides: Record = {} +) { + const service = Object.create( + PlaylistBackupService.prototype + ) as PlaylistBackupService; + + Object.assign(service as object, { + playlistsService: { + addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), + getAllData: jest.fn(() => of([])), + getRawPlaylistById: jest.fn(() => of('#EXTM3U')), + handlePlaylistParsing: jest.fn( + (_uploadType: string, rawM3u: string, title: string) => ({ + _id: 'generated-id', + title, + filename: title, + count: rawM3u.split('\n').filter(Boolean).length, + playlist: { + header: { raw: '#EXTM3U' }, + items: [], + }, + importDate: '2026-04-21T00:00:00.000Z', + lastUsage: '2026-04-21T00:00:00.000Z', + favorites: [], + autoRefresh: false, + }) + ), + }, + settingsStore: { + getSettings: jest.fn(() => ({ epgUrl: [] })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }, + databaseService: { + getAllXtreamCategories: jest.fn().mockResolvedValue([]), + getFavorites: jest.fn().mockResolvedValue([]), + getRecentItems: jest.fn().mockResolvedValue([]), + getXtreamImportStatus: jest.fn().mockResolvedValue('idle'), + hasXtreamCategories: jest.fn().mockResolvedValue(false), + hasXtreamContent: jest.fn().mockResolvedValue(false), + restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), + updateCategoryVisibility: jest.fn().mockResolvedValue(true), + }, + playbackPositionService: { + getAllPlaybackPositions: jest.fn().mockResolvedValue([]), + clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), + savePlaybackPosition: jest.fn().mockResolvedValue(undefined), + }, + pendingRestoreService: { + set: jest.fn(), + clear: jest.fn(), + }, + ...overrides, + }); + + return service; +} + +export interface FakeXtreamCategoryRow { + id: number; + playlist_id: string; + name: string; + type: 'live' | 'movies' | 'series'; + xtream_id: number; + hidden: boolean; +} + +export interface FakeXtreamContentRow { + xtream_id: number; + type: string; + added_at?: string; + position?: number | null; + viewed_at?: string; +} + +/** + * Mutable in-memory stand-in for everything the backup service reads from + * and writes to. Round-trip specs seed it, export from it, wipe the user + * state, import the export back and compare. + */ +export interface FakeBackupBackendState { + playlists: Playlist[]; + rawM3uByPlaylistId: Map; + xtreamCategories: FakeXtreamCategoryRow[]; + xtreamFavorites: FakeXtreamContentRow[]; + xtreamRecent: FakeXtreamContentRow[]; + playbackPositions: PlaybackPositionData[]; + epgUrls: string[]; +} + +/** + * Stateful collaborator set backing PlaylistBackupService with + * FakeBackupBackendState: exports read the state, imports mutate it. The + * Xtream offline cache always reports "completed" so the import applies the + * restore immediately instead of parking it as pending state. + */ +export function createStatefulBackupCollaborators( + state: FakeBackupBackendState +) { + let lastParsedRawM3u: string | null = null; + + return { + playlistsService: { + getAllData: () => of(state.playlists.map((item) => ({ ...item }))), + addPlaylist: (playlist: Playlist) => { + const index = state.playlists.findIndex( + (item) => item._id === playlist._id + ); + + if (index >= 0) { + state.playlists[index] = playlist; + } else { + state.playlists.push(playlist); + } + + if (lastParsedRawM3u !== null) { + state.rawM3uByPlaylistId.set( + playlist._id, + lastParsedRawM3u + ); + lastParsedRawM3u = null; + } + + return of(playlist); + }, + getRawPlaylistById: (playlistId: string) => + of(state.rawM3uByPlaylistId.get(playlistId) ?? '#EXTM3U'), + handlePlaylistParsing: ( + _uploadType: string, + rawM3u: string, + title: string + ) => { + lastParsedRawM3u = rawM3u; + + return { + _id: 'parsed-transient-id', + title, + filename: title, + count: 1, + playlist: { header: { raw: '#EXTM3U' }, items: [] }, + importDate: '2026-07-23T00:00:00.000Z', + lastUsage: '2026-07-23T00:00:00.000Z', + favorites: [], + autoRefresh: false, + }; + }, + }, + settingsStore: { + getSettings: () => ({ epgUrl: [...state.epgUrls] }), + updateSettings: async ({ epgUrl }: { epgUrl: string[] }) => { + state.epgUrls = [...epgUrl]; + }, + }, + databaseService: { + getAllXtreamCategories: async ( + playlistId: string, + type: 'live' | 'movies' | 'series' + ) => + state.xtreamCategories + .filter( + (row) => + row.playlist_id === playlistId && row.type === type + ) + .map((row) => ({ ...row })), + getFavorites: async () => + state.xtreamFavorites.map((row) => ({ ...row })), + getRecentItems: async () => + state.xtreamRecent.map((row) => ({ ...row })), + getXtreamImportStatus: async () => 'completed', + hasXtreamCategories: async () => true, + hasXtreamContent: async () => true, + updateCategoryVisibility: async ( + categoryIds: number[], + hidden: boolean + ) => { + for (const row of state.xtreamCategories) { + if (categoryIds.includes(row.id)) { + row.hidden = hidden; + } + } + + return true; + }, + restoreXtreamUserData: async ( + _playlistId: string, + favorites: XtreamBackupFavoriteItem[], + recentlyViewed: XtreamBackupRecentlyViewedItem[] + ) => { + state.xtreamFavorites = favorites.map((item) => ({ + xtream_id: item.xtreamId, + type: item.contentType, + ...(item.addedAt !== undefined + ? { added_at: item.addedAt } + : {}), + ...(item.position !== undefined + ? { position: item.position } + : {}), + })); + state.xtreamRecent = recentlyViewed.map((item) => ({ + xtream_id: item.xtreamId, + type: item.contentType, + viewed_at: item.viewedAt, + })); + }, + }, + playbackPositionService: { + getAllPlaybackPositions: async () => + state.playbackPositions.map((item) => ({ ...item })), + clearAllPlaybackPositions: async () => { + state.playbackPositions = []; + }, + savePlaybackPosition: async ( + _playlistId: string, + position: PlaybackPositionData + ) => { + state.playbackPositions.push({ ...position }); + }, + }, + pendingRestoreService: new XtreamPendingRestoreService(), + }; +} diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index bbb29c28d..9a18097fb 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -8,6 +8,7 @@ import { PlaybackPositionService } from './playback-position.service'; import { XtreamPendingRestoreService } from './xtream-pending-restore.service'; import { isM3uRecentlyViewedItem, + normalizeXtreamPendingRestoreState, M3uPlaylistBackupEntry, M3uRecentlyViewedItem, Playlist, @@ -443,6 +444,22 @@ export class PlaylistBackupService { `Xtream backup "${entry.title}" is missing connection metadata.` ); } + + // Restore treats the backup's user state as authoritative and + // replaces the existing state with it. Every v1 export writes + // all four collections, so a missing one signals a damaged or + // hand-edited file — reject it instead of wiping user data + // with normalized empty arrays. + if ( + !Array.isArray(entry.userState?.hiddenCategories) || + !Array.isArray(entry.userState?.favorites) || + !Array.isArray(entry.userState?.recentlyViewed) || + !Array.isArray(entry.userState?.playbackPositions) + ) { + throw new PlaylistBackupError( + `Xtream backup "${entry.title}" has incomplete user state.` + ); + } break; case 'stalker': if ( @@ -737,20 +754,12 @@ export class PlaylistBackupService { playlistId: string, entry: XtreamPlaylistBackupEntry ): Promise { - const restoreState: XtreamPendingRestoreState = { - hiddenCategories: entry.userState.hiddenCategories.map((item) => ({ - ...item, - })), - favorites: entry.userState.favorites.map((item) => ({ ...item })), - recentlyViewed: entry.userState.recentlyViewed.map((item) => ({ - ...item, - })), - playbackPositions: entry.userState.playbackPositions.map( - (item) => ({ - ...item, - }) - ), - }; + // Backup files are user-supplied JSON; normalization drops user-state + // entries without a usable numeric xtreamId (e.g. hiddenCategories + // exported by builds affected by issue #1017) so they cannot match + // arbitrary categories during restore. + const restoreState: XtreamPendingRestoreState = + normalizeXtreamPendingRestoreState(entry.userState); this.pendingRestoreService.set(playlistId, restoreState); 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 new file mode 100644 index 000000000..dcfa79507 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts @@ -0,0 +1,243 @@ +import { of } from 'rxjs'; +import { + Playlist, + PlaylistBackupManifestV1, + PLAYLIST_BACKUP_KIND, + PLAYLIST_BACKUP_VERSION, + XtreamPlaylistBackupEntry, +} from '@iptvnator/shared/interfaces'; +import { createPlaylistBackupService } from './playlist-backup.service.test-helpers'; + +/** + * Regression coverage for issue #1017: hidden Xtream categories must be + * exported with their xtream IDs and restored by exact ID match. The + * original bug exported `xtreamId: undefined` (dropped by JSON.stringify) + * and the restore comparison degraded to `undefined === undefined`, hiding + * every category of the affected type. + */ +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[] + ): 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: [], + }, + } 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 = {}; + }); + + afterEach(() => { + delete electronWindow.electron; + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it('exports hidden categories with their xtream IDs', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService({ + playlistsService: collaborators.playlistsService, + databaseService: collaborators.databaseService, + }); + + const backup = await service.exportBackup(); + + const entry = backup.manifest + .playlists[0] as XtreamPlaylistBackupEntry; + const expectedHiddenCategories = [ + { categoryType: 'live', xtreamId: 101 }, + { categoryType: 'movies', xtreamId: 201 }, + ]; + expect(entry.userState.hiddenCategories).toEqual( + expectedHiddenCategories + ); + + // The IDs must survive JSON serialization; the original bug + // exported `xtreamId: undefined`, which JSON.stringify drops. + const serialized = JSON.parse(backup.json) + .playlists[0] as XtreamPlaylistBackupEntry; + expect(serialized.userState.hiddenCategories).toEqual( + expectedHiddenCategories + ); + }); + + it('restores exactly the hidden categories referenced by the backup', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService(collaborators); + + const manifest = createXtreamManifest([ + { categoryType: 'live', xtreamId: 101 }, + ]); + + const summary = await service.importBackup(JSON.stringify(manifest)); + + expect(summary).toEqual( + expect.objectContaining({ merged: 1, failed: 0 }) + ); + // Per type: reset visibility, then hide only the matched rows. + expect( + collaborators.databaseService.updateCategoryVisibility + ).toHaveBeenNthCalledWith(1, [11, 12], false); + expect( + collaborators.databaseService.updateCategoryVisibility + ).toHaveBeenNthCalledWith(2, [11], true); + expect( + collaborators.databaseService.updateCategoryVisibility + ).toHaveBeenNthCalledWith(3, [21], false); + expect( + collaborators.databaseService.updateCategoryVisibility + ).toHaveBeenCalledTimes(3); + expect(collaborators.pendingRestoreService.clear).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + + it('rejects entries with missing user-state collections instead of wiping user data', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService(collaborators); + + // A damaged or hand-edited manifest without userState must not be + // treated as an authoritative "empty" state: the merge path would + // unhide every category and delete favorites/recent/positions. + const manifest = createXtreamManifest([]); + delete ( + manifest.playlists[0] as unknown as { userState?: unknown } + ).userState; + + await expect( + service.importBackup(JSON.stringify(manifest)) + ).rejects.toThrow(/incomplete user state/); + expect( + collaborators.databaseService.updateCategoryVisibility + ).not.toHaveBeenCalled(); + expect( + collaborators.databaseService.restoreXtreamUserData + ).not.toHaveBeenCalled(); + }); + + it('ignores legacy hidden-category entries without an xtream ID instead of hiding everything', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService(collaborators); + + // Backups exported by builds affected by issue #1017 contain + // hidden categories without any ID. Matching them must not + // degrade to a type-only comparison that hides every category. + const manifest = createXtreamManifest([ + { categoryType: 'live' }, + { categoryType: 'movies' }, + ]); + + const summary = await service.importBackup(JSON.stringify(manifest)); + + expect(summary).toEqual( + expect.objectContaining({ merged: 1, failed: 0 }) + ); + const hideCalls = ( + collaborators.databaseService.updateCategoryVisibility.mock + .calls as unknown[][] + ).filter(([, hidden]) => hidden === true); + expect(hideCalls).toHaveLength(0); + expect(collaborators.pendingRestoreService.set).toHaveBeenCalledWith( + 'xtream-1', + expect.objectContaining({ hiddenCategories: [] }) + ); + }); +}); diff --git a/libs/services/src/lib/xtream-pending-restore.service.spec.ts b/libs/services/src/lib/xtream-pending-restore.service.spec.ts new file mode 100644 index 000000000..772125fab --- /dev/null +++ b/libs/services/src/lib/xtream-pending-restore.service.spec.ts @@ -0,0 +1,65 @@ +import { getXtreamPendingRestoreStorageKey } from '@iptvnator/shared/interfaces'; +import { XtreamPendingRestoreService } from './xtream-pending-restore.service'; + +describe('XtreamPendingRestoreService', () => { + const playlistId = 'playlist-1'; + const storageKey = getXtreamPendingRestoreStorageKey(playlistId); + let service: XtreamPendingRestoreService; + + beforeEach(() => { + service = new XtreamPendingRestoreService(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('sanitizes stale persisted state written by broken builds on read', () => { + // State persisted by versions affected by issue #1017: hidden + // categories without any xtream ID. + localStorage.setItem( + storageKey, + JSON.stringify({ + hiddenCategories: [ + { categoryType: 'live' }, + { categoryType: 'movies' }, + { categoryType: 'series', xtreamId: 301 }, + ], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + }) + ); + + expect(service.get(playlistId)?.hiddenCategories).toEqual([ + { categoryType: 'series', xtreamId: 301 }, + ]); + }); + + it('normalizes state on write', () => { + service.set(playlistId, { + hiddenCategories: [ + { categoryType: 'live', xtreamId: 101 }, + { categoryType: 'live' } as never, + ], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + }); + + const persisted = JSON.parse( + localStorage.getItem(storageKey) ?? 'null' + ); + expect(persisted?.hiddenCategories).toEqual([ + { categoryType: 'live', xtreamId: 101 }, + ]); + }); + + it('returns null for missing or unreadable state', () => { + expect(service.get(playlistId)).toBeNull(); + + localStorage.setItem(storageKey, '{not json'); + expect(service.get(playlistId)).toBeNull(); + }); +}); diff --git a/libs/services/src/lib/xtream-pending-restore.service.ts b/libs/services/src/lib/xtream-pending-restore.service.ts index 1f1431124..59b6178f6 100644 --- a/libs/services/src/lib/xtream-pending-restore.service.ts +++ b/libs/services/src/lib/xtream-pending-restore.service.ts @@ -1,16 +1,10 @@ import { Injectable } from '@angular/core'; import { getXtreamPendingRestoreStorageKey, + normalizeXtreamPendingRestoreState, XtreamPendingRestoreState, } from '@iptvnator/shared/interfaces'; -const EMPTY_RESTORE_STATE: XtreamPendingRestoreState = { - hiddenCategories: [], - favorites: [], - recentlyViewed: [], - playbackPositions: [], -}; - @Injectable({ providedIn: 'root', }) @@ -29,7 +23,10 @@ export class XtreamPendingRestoreService { return null; } - return this.normalize(JSON.parse(rawState)); + // Persisted state may predate the current build (e.g. entries + // written by versions affected by issue #1017), so it is + // re-normalized on every read, not only on write. + return normalizeXtreamPendingRestoreState(JSON.parse(rawState)); } catch { return null; } @@ -43,7 +40,7 @@ export class XtreamPendingRestoreService { try { localStorage.setItem( getXtreamPendingRestoreStorageKey(playlistId), - JSON.stringify(this.normalize(state)) + JSON.stringify(normalizeXtreamPendingRestoreState(state)) ); } catch { // Ignore local storage write failures. @@ -63,27 +60,4 @@ export class XtreamPendingRestoreService { // Ignore local storage remove failures. } } - - private normalize(value: unknown): XtreamPendingRestoreState { - if (!value || typeof value !== 'object') { - return { ...EMPTY_RESTORE_STATE }; - } - - const candidate = value as Partial; - - return { - hiddenCategories: Array.isArray(candidate.hiddenCategories) - ? candidate.hiddenCategories - : [], - favorites: Array.isArray(candidate.favorites) - ? candidate.favorites - : [], - recentlyViewed: Array.isArray(candidate.recentlyViewed) - ? candidate.recentlyViewed - : [], - playbackPositions: Array.isArray(candidate.playbackPositions) - ? candidate.playbackPositions - : [], - }; - } } diff --git a/libs/services/tsconfig.lib.json b/libs/services/tsconfig.lib.json index 61d8f1f15..1767b1326 100644 --- a/libs/services/tsconfig.lib.json +++ b/libs/services/tsconfig.lib.json @@ -12,7 +12,8 @@ "src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", - "src/**/*.test.ts" + "src/**/*.test.ts", + "src/**/*.test-helpers.ts" ], "include": ["src/**/*.ts", "../../../global.d.ts"] } diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts new file mode 100644 index 000000000..45a4226c0 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts @@ -0,0 +1,98 @@ +import { normalizeXtreamPendingRestoreState } from './xtream-restore-state.util'; + +describe('normalizeXtreamPendingRestoreState', () => { + const emptyState = { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + }; + + it.each([null, undefined, 'text', 42, []])( + 'returns an empty state for non-object input %p', + (value) => { + expect(normalizeXtreamPendingRestoreState(value)).toEqual( + emptyState + ); + } + ); + + it('falls back to empty arrays for missing or non-array fields', () => { + expect( + normalizeXtreamPendingRestoreState({ + hiddenCategories: 'broken', + favorites: null, + }) + ).toEqual(emptyState); + }); + + it('keeps hidden categories with a numeric xtream ID and drops the rest', () => { + const state = normalizeXtreamPendingRestoreState({ + hiddenCategories: [ + { categoryType: 'live', xtreamId: 101 }, + // Entries exported by builds affected by issue #1017 carry + // no ID at all and must not survive normalization. + { categoryType: 'live' }, + { categoryType: 'movies', xtreamId: 'not-a-number' }, + { categoryType: 'unknown', xtreamId: 5 }, + { categoryType: 'series', xtreamId: '301' }, + null, + ], + }); + + expect(state.hiddenCategories).toEqual([ + { categoryType: 'live', xtreamId: 101 }, + { categoryType: 'series', xtreamId: 301 }, + ]); + }); + + it('drops favorites and recently viewed entries without a numeric xtream ID', () => { + const state = normalizeXtreamPendingRestoreState({ + favorites: [ + { + contentType: 'movie', + xtreamId: 7, + addedAt: '2026-07-01T00:00:00.000Z', + }, + { contentType: 'movie' }, + ], + recentlyViewed: [ + { + contentType: 'live', + xtreamId: '9', + viewedAt: '2026-07-01T00:00:00.000Z', + }, + { contentType: 'live', xtreamId: Number.NaN }, + ], + }); + + expect(state.favorites).toEqual([ + { + contentType: 'movie', + xtreamId: 7, + addedAt: '2026-07-01T00:00:00.000Z', + }, + ]); + expect(state.recentlyViewed).toEqual([ + { + contentType: 'live', + xtreamId: 9, + viewedAt: '2026-07-01T00:00:00.000Z', + }, + ]); + }); + + it('keeps playback position objects and drops primitives', () => { + const position = { + contentXtreamId: 12, + contentType: 'vod', + positionSeconds: 30, + }; + + const state = normalizeXtreamPendingRestoreState({ + playbackPositions: [position, 'broken', null], + }); + + expect(state.playbackPositions).toEqual([position]); + }); +}); diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts index 965f84ee6..00a9a8093 100644 --- a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts @@ -1,4 +1,5 @@ import { + XtreamBackupCategoryType, XtreamBackupFavoriteItem, XtreamBackupHiddenCategory, XtreamBackupRecentlyViewedItem, @@ -15,3 +16,125 @@ export interface XtreamPendingRestoreState { export function getXtreamPendingRestoreStorageKey(playlistId: string): string { return `xtream-restore-${playlistId}`; } + +const XTREAM_BACKUP_CATEGORY_TYPES: readonly XtreamBackupCategoryType[] = [ + 'live', + 'movies', + 'series', +]; + +interface RestoreStateCandidate { + hiddenCategories?: unknown; + favorites?: unknown; + recentlyViewed?: unknown; + playbackPositions?: unknown; +} + +interface RestoreEntryCandidate { + categoryType?: unknown; + xtreamId?: unknown; +} + +function normalizeXtreamBackupId(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function toArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +// The web build compiles this lib with lib=es2018, so Array#flatMap is not +// available here; stick to filter/map/push. +function withNumericXtreamId( + items: unknown[] +): T[] { + const result: T[] = []; + + for (const item of items) { + if (!isRecord(item)) { + continue; + } + + const xtreamId = normalizeXtreamBackupId( + (item as RestoreEntryCandidate).xtreamId + ); + + if (xtreamId === null) { + continue; + } + + result.push({ ...item, xtreamId } as T); + } + + return result; +} + +/** + * Normalizes restore state coming from untrusted sources: user-supplied + * backup files and persisted localStorage entries. Backups exported by + * broken builds (issue #1017) contain hiddenCategories entries without an + * xtreamId; matching such entries against real category rows would compare + * `undefined === undefined` and hide every category of that type, so any + * entry without a usable numeric xtreamId is dropped instead of restored. + */ +export function normalizeXtreamPendingRestoreState( + value: unknown +): XtreamPendingRestoreState { + if (!isRecord(value)) { + return { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + }; + } + + const candidate = value as RestoreStateCandidate; + + const hiddenCategories: XtreamBackupHiddenCategory[] = []; + + for (const item of toArray(candidate.hiddenCategories)) { + if (!isRecord(item)) { + continue; + } + + const entry = item as RestoreEntryCandidate; + const xtreamId = normalizeXtreamBackupId(entry.xtreamId); + const categoryType = entry.categoryType as XtreamBackupCategoryType; + + if ( + xtreamId === null || + !XTREAM_BACKUP_CATEGORY_TYPES.includes(categoryType) + ) { + continue; + } + + hiddenCategories.push({ categoryType, xtreamId }); + } + + return { + hiddenCategories, + favorites: withNumericXtreamId( + toArray(candidate.favorites) + ), + recentlyViewed: withNumericXtreamId( + toArray(candidate.recentlyViewed) + ), + playbackPositions: toArray(candidate.playbackPositions).filter( + (item): item is PlaybackPositionData => isRecord(item) + ), + }; +}