fix(backup): export and restore hidden Xtream categories by real xtream IDs (#1224)

Category rows crossed the DB-worker IPC boundary with Drizzle's camelCase
property names while the renderer contracts declare snake_case, so backup
export dropped hidden-category IDs and restore degraded to a type-only
match that hid every category. Project category ops to the declared wire
shape, normalize restore state from untrusted sources (dropping entries
without a numeric xtreamId), reject entries with missing user-state
collections, and add full export→import round-trip coverage (unit
manifest-equality + Electron e2e) plus regression tests.

Closes #1017

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-24 20:32:45 +02:00
1 parent bd07e17857
commit b4c0cce741
16 files changed
+1424 -151

No files matched your search

@@ -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 },
@@ -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',
@@ -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)
);
});
});
@@ -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<string, unknown> = {}) {
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,
});
@@ -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<string, unknown> = {}
) {
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<string, string>;
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(),
};
}
@@ -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<void> {
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);
@@ -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<string, unknown[]> = {
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: [] })
);
});
});
@@ -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();
});
});
@@ -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<XtreamPendingRestoreState>;
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
: [],
};
}
}
+2 -1
View File
@@ -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"]
}
@@ -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]);
});
});
@@ -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<string, unknown> {
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<T extends { xtreamId: number }>(
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<XtreamBackupFavoriteItem>(
toArray(candidate.favorites)
),
recentlyViewed: withNumericXtreamId<XtreamBackupRecentlyViewedItem>(
toArray(candidate.recentlyViewed)
),
playbackPositions: toArray(candidate.playbackPositions).filter(
(item): item is PlaybackPositionData => isRecord(item)
),
};
}