mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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 },
|
||||
|
||||
+21
-15
@@ -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
|
||||
: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user