Merge branch 'master' of https://github.com/4gray/iptvnator into claude/server-storage-pwa

# Conflicts:
#	docs/architecture/pwa-self-hosted.md
This commit is contained in:
4gray committed 2026-10-13 09:14:50 +02:00
commit de87c1e4d3
78 files changed
+4367 -276

No files matched your search

@@ -0,0 +1,669 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import {
Playlist,
PlaylistBackupManifestV1,
XtreamPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import {
createPlaylistBackupService,
createPwaXtreamBackupState,
} from './playlist-backup.service.test-helpers';
import { existingXtreamPlaylist } from './playlist-backup.xtream-fixtures';
import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service';
import {
USER_DATA_STORAGE,
UserDataStorage,
XTREAM_USER_DATA_KEYS,
} from './user-data-storage';
/**
* The browser build keeps Xtream favorites, history, positions and title
* snapshots in USER_DATA_STORAGE rather than SQLite. These specs run the
* backup with no Electron bridge, against the maps exactly as
* `PwaXtreamDataSource` writes them.
*/
describe('PlaylistBackupService Xtream user state in the PWA', () => {
const KEYS = XTREAM_USER_DATA_KEYS;
const otherPlaylist = {
...existingXtreamPlaylist,
_id: 'xtream-2',
title: 'Other Portal',
serverUrl: 'http://other.example.com',
username: 'other',
} as Playlist;
const heat = {
id: 501,
xtream_id: 501,
type: 'movie',
title: 'Heat',
poster_url: 'https://img.example.com/heat.jpg',
backdrop_url: 'https://img.example.com/heat-wide.jpg',
};
// Written under a bare id by builds that predate typed keys.
const legacyShow = { id: 77, xtream_id: 77, type: 'series', title: 'Show' };
const position = {
contentXtreamId: 501,
contentType: 'vod',
positionSeconds: 120,
durationSeconds: 3600,
playlistId: 'xtream-1',
updatedAt: '2026-07-05T20:00:00.000Z',
};
beforeEach(() => {
delete (window as unknown as { electron?: unknown }).electron;
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
TestBed.resetTestingModule();
});
function seed(storage: UserDataStorage = localStorage): void {
const write = (key: string, value: unknown) =>
storage.setItem(key, JSON.stringify(value));
write(KEYS.favorites, {
// 88 is a bare id no snapshot identifies: live, movie or series.
'xtream-1': ['movie:501', 77, 88],
'xtream-2': ['live:9'],
});
write(KEYS.recentItems, {
'xtream-1': [
{ id: 'live:601', viewedAt: '2026-07-04T18:30:00.000Z' },
{
id: 'movie:501',
viewedAt: '2026-07-01T10:00:00.000Z',
backdropUrl: 'https://img.example.com/heat-old.jpg',
},
{
id: 'movie:501',
viewedAt: '2026-07-03T10:00:00.000Z',
// Saved by an older build in snake_case.
backdrop_url: 'https://img.example.com/heat-still.jpg',
},
],
'xtream-2': [
{ id: 'live:9', viewedAt: '2026-07-02T10:00:00.000Z' },
],
});
write(KEYS.collectionItems, {
'xtream-1': {
'movie:501': heat,
'77': legacyShow,
'movie:999': { xtream_id: 999, type: 'movie', title: 'Gone' },
},
'xtream-2': { 'live:9': { xtream_id: 9, type: 'live' } },
});
write(KEYS.playbackPositions, {
'xtream-1': [position],
'xtream-2': [{ ...position, playlistId: 'xtream-2' }],
});
}
function read(key: string, storage: UserDataStorage = localStorage) {
return JSON.parse(storage.getItem(key) ?? 'null');
}
function pendingRestore(service: object) {
return (
service as unknown as {
pendingRestoreService: {
getOrThrow(playlistId: string): unknown;
set(playlistId: string, state: unknown): unknown;
};
}
).pendingRestoreService;
}
function xtreamOnly(manifest: PlaylistBackupManifestV1) {
return JSON.stringify({
...manifest,
playlists: [xtreamEntry(manifest)],
});
}
function playlistsService(playlists: Playlist[]) {
return {
getAllData: jest.fn(() => of(playlists)),
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
};
}
function xtreamEntry(
manifest: PlaylistBackupManifestV1,
exportedId = 'xtream-1'
): XtreamPlaylistBackupEntry {
return manifest.playlists.find(
(entry) => entry.exportedId === exportedId
) as XtreamPlaylistBackupEntry;
}
async function exportSeeded(): Promise<PlaylistBackupManifestV1> {
seed();
const service = createPlaylistBackupService({
playlistsService: playlistsService([
existingXtreamPlaylist,
otherPlaylist,
]),
});
return (await service.exportBackup()).manifest;
}
describe('export', () => {
it('writes each playlist its own favorites, history, positions and snapshots', async () => {
const manifest = await exportSeeded();
expect(xtreamEntry(manifest).userState).toEqual({
hiddenCategories: [],
favorites: [
{ contentType: 'movie', xtreamId: 501 },
{ contentType: 'series', xtreamId: 77 },
],
// One row per title, its newest view.
recentlyViewed: [
{
contentType: 'live',
xtreamId: 601,
viewedAt: '2026-07-04T18:30:00.000Z',
},
{
contentType: 'movie',
xtreamId: 501,
viewedAt: '2026-07-03T10:00:00.000Z',
// The history's own artwork, ahead of the snapshot's.
backdropUrl: 'https://img.example.com/heat-still.jpg',
},
],
playbackPositions: [position],
// Only what the lists reference, not every cached title.
collectionItems: [heat, legacyShow],
});
expect(xtreamEntry(manifest, 'xtream-2').userState).toEqual({
hiddenCategories: [],
favorites: [{ contentType: 'live', xtreamId: 9 }],
recentlyViewed: [
{
contentType: 'live',
xtreamId: 9,
viewedAt: '2026-07-02T10:00:00.000Z',
},
],
playbackPositions: [{ ...position, playlistId: 'xtream-2' }],
collectionItems: [{ xtream_id: 9, type: 'live' }],
});
// No pin store in the browser: absent, never an authoritative [].
expect(xtreamEntry(manifest).userState).not.toHaveProperty(
'sourcePins'
);
});
it('exports empty collections and no snapshots for a playlist with nothing stored', async () => {
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const { manifest } = await service.exportBackup();
expect(xtreamEntry(manifest).userState).toEqual({
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
});
expect(xtreamEntry(manifest).userState).not.toHaveProperty(
'collectionItems'
);
});
it('exports a parked restore as it is instead of the stored lists', async () => {
seed();
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const parked = {
hiddenCategories: [],
favorites: [{ contentType: 'series' as const, xtreamId: 3 }],
recentlyViewed: [],
playbackPositions: [],
};
pendingRestore(service).set('xtream-1', parked);
const { manifest } = await service.exportBackup();
expect(xtreamEntry(manifest).userState).toEqual(parked);
});
it.each([
['malformed JSON', '{"xtream-1": ['],
['a value that is not a map', '["movie:501"]'],
[
'a playlist entry that is not a list',
'{"xtream-1": "movie:501"}',
],
])('refuses to export when the favorites hold %s', async (_, raw) => {
seed();
localStorage.setItem(KEYS.favorites, raw);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
await expect(service.exportBackup()).rejects.toThrow(
/xtream-favorites.*unreadable/
);
});
it('refuses to export when the storage itself cannot be read', async () => {
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState({
getItem: () => {
throw new DOMException('denied', 'SecurityError');
},
setItem: jest.fn(),
removeItem: jest.fn(),
}),
});
await expect(service.exportBackup()).rejects.toThrow('denied');
});
});
describe('import', () => {
it('restores every list under the id the playlist is imported as', async () => {
const manifest = await exportSeeded();
localStorage.clear();
// The same portal, already present under another id: a merge.
const local = { ...existingXtreamPlaylist, _id: 'local-xtream' };
localStorage.setItem(
KEYS.favorites,
JSON.stringify({
'local-xtream': ['live:1'],
unrelated: ['movie:5'],
})
);
localStorage.setItem(
KEYS.collectionItems,
JSON.stringify({
'local-xtream': {
'live:1': { xtream_id: 1, type: 'live' },
},
})
);
const service = createPlaylistBackupService({
playlistsService: playlistsService([local]),
});
const summary = await service.importBackup(xtreamOnly(manifest));
expect(summary).toMatchObject({ merged: 1, failed: 0 });
// The archive's lists replace the playlist's; others stay put.
expect(read(KEYS.favorites)).toEqual({
'local-xtream': ['movie:501', 'series:77'],
unrelated: ['movie:5'],
});
expect(read(KEYS.recentItems)).toEqual({
'local-xtream': [
{ id: 'live:601', viewedAt: '2026-07-04T18:30:00.000Z' },
{
id: 'movie:501',
viewedAt: '2026-07-03T10:00:00.000Z',
backdropUrl: 'https://img.example.com/heat-still.jpg',
},
],
});
expect(read(KEYS.playbackPositions)).toEqual({
'local-xtream': [{ ...position, playlistId: 'local-xtream' }],
});
// Snapshots are a cache: merged, keyed by typed identity.
expect(read(KEYS.collectionItems)).toEqual({
'local-xtream': {
'live:1': { xtream_id: 1, type: 'live' },
'movie:501': heat,
'series:77': legacyShow,
},
});
// Applied at once, so nothing is left for the store to replay.
expect(
pendingRestore(service).getOrThrow('local-xtream')
).toBeNull();
});
it('round-trips the stored lists through export and import', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const service = createPlaylistBackupService({
playlistsService: playlistsService([]),
});
const summary = await service.importBackup(
JSON.stringify(manifest)
);
expect(summary).toMatchObject({ imported: 2, failed: 0 });
const reexport = createPlaylistBackupService({
playlistsService: playlistsService([
existingXtreamPlaylist,
otherPlaylist,
]),
});
const { manifest: again } = await reexport.exportBackup();
expect(xtreamEntry(again).userState).toEqual(
xtreamEntry(manifest).userState
);
expect(xtreamEntry(again, 'xtream-2').userState).toEqual(
xtreamEntry(manifest, 'xtream-2').userState
);
});
it('leaves snapshots alone for an archive without any, and skips damaged ones', async () => {
const manifest = await exportSeeded();
const entry = xtreamEntry(manifest);
const before = read(KEYS.collectionItems);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const withoutSnapshots = {
...manifest,
playlists: [
{
...entry,
userState: {
...entry.userState,
collectionItems: undefined,
},
},
],
};
await service.importBackup(JSON.stringify(withoutSnapshots));
expect(read(KEYS.collectionItems)).toEqual(before);
const damaged = {
...manifest,
playlists: [
{
...entry,
userState: {
...entry.userState,
collectionItems: [
null,
{ xtream_id: 5, type: 'radio' },
{ xtream_id: '6', type: 'movie' },
],
},
},
],
};
const summary = await service.importBackup(JSON.stringify(damaged));
expect(summary.failed).toBe(0);
expect(read(KEYS.collectionItems)).toEqual(before);
});
it('drops entries no runtime could address', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const entry = xtreamEntry(manifest);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const hostile = {
...manifest,
playlists: [
{
...entry,
userState: {
...entry.userState,
favorites: [
{ contentType: 'radio', xtreamId: 1 },
{ contentType: 'movie', xtreamId: -4 },
{ contentType: 'movie', xtreamId: 501 },
{ contentType: 'movie', xtreamId: '501' },
],
recentlyViewed: [
{ contentType: 'live', xtreamId: 601 },
],
playbackPositions: [
{ contentXtreamId: 501, contentType: 'vod' },
{ ...position, updatedAt: undefined },
],
},
},
],
};
await service.importBackup(JSON.stringify(hostile));
expect(read(KEYS.favorites)).toEqual({
'xtream-1': ['movie:501'],
});
expect(read(KEYS.recentItems)).toEqual({ 'xtream-1': [] });
const [restored] = read(KEYS.playbackPositions)['xtream-1'];
expect(read(KEYS.playbackPositions)['xtream-1']).toHaveLength(1);
expect(typeof restored.updatedAt).toBe('string');
});
it('fails the entry and keeps the restore parked when a write fails', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const storage = createBrowserBackedStorage();
storage.setItem.mockImplementation((key: string, value: string) => {
if (key === KEYS.recentItems) throw new Error('disk full');
localStorage.setItem(key, value);
});
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState(storage),
});
const summary = await service.importBackup(xtreamOnly(manifest));
expect(summary.failed).toBe(1);
expect(summary.errors[0]).toContain('disk full');
// The Xtream store replays the whole state when the playlist opens.
expect(
pendingRestore(service).getOrThrow('xtream-1')
).not.toBeNull();
// That replay carries no titles, so they are merged regardless.
expect(read(KEYS.collectionItems)).toEqual({
'xtream-1': { 'movie:501': heat, 'series:77': legacyShow },
});
});
it('replaces a damaged title cache but refuses an unreadable storage', async () => {
const manifest = await exportSeeded();
localStorage.setItem(KEYS.collectionItems, '{"xtream-1": {');
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
expect(
(await service.importBackup(xtreamOnly(manifest))).failed
).toBe(0);
expect(read(KEYS.collectionItems)).toEqual({
'xtream-1': { 'movie:501': heat, 'series:77': legacyShow },
});
const denied = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState({
getItem: () => {
throw new DOMException('denied', 'SecurityError');
},
setItem: jest.fn(),
removeItem: jest.fn(),
}),
});
const summary = await denied.importBackup(xtreamOnly(manifest));
expect(summary.failed).toBe(1);
expect(summary.errors[0]).toContain('denied');
});
it('still restores the lists when the snapshots exceed the quota', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const storage = createBrowserBackedStorage();
storage.setItem.mockImplementation((key: string, value: string) => {
if (key === KEYS.collectionItems) {
throw new DOMException('full', 'QuotaExceededError');
}
localStorage.setItem(key, value);
});
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState(storage),
});
const summary = await service.importBackup(
JSON.stringify(manifest)
);
expect(summary.failed).toBe(0);
expect(read(KEYS.favorites)['xtream-1']).toEqual([
'movie:501',
'series:77',
]);
expect(read(KEYS.collectionItems)).toBeNull();
});
});
describe('near the storage quota', () => {
const listKeys = [
KEYS.favorites,
KEYS.recentItems,
KEYS.playbackPositions,
];
/** Storage that refuses a write once all values exceed `budget`. */
function quotaStorage(budget: number) {
const data = new Map<string, string>();
const used = () =>
[...data].reduce((sum, [k, v]) => sum + k.length + v.length, 0);
const storage: UserDataStorage = {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => {
const previous = data.get(key);
data.set(key, value);
if (used() <= budget) return;
if (previous === undefined) data.delete(key);
else data.set(key, previous);
throw new DOMException('full', 'QuotaExceededError');
},
removeItem: (key) => void data.delete(key),
};
return { data, storage };
}
async function importInto(budget: number) {
const manifest = await exportSeeded();
const quota = quotaStorage(budget);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState(quota.storage),
});
const summary = await service.importBackup(xtreamOnly(manifest));
return { summary, data: quota.data };
}
async function listsSize(): Promise<number> {
const { data } = await importInto(Infinity);
return listKeys.reduce(
(sum, key) => sum + key.length + (data.get(key) ?? '').length,
0
);
}
it('restores the lists that fit without the optional snapshots', async () => {
const { summary, data } = await importInto(await listsSize());
expect(summary.failed).toBe(0);
expect(JSON.parse(data.get(KEYS.favorites) ?? '{}')).toEqual({
'xtream-1': ['movie:501', 'series:77'],
});
expect(data.has(KEYS.collectionItems)).toBe(false);
});
it('leaves the space to the replay when a list does not fit', async () => {
const { summary, data } = await importInto((await listsSize()) - 1);
expect(summary.failed).toBe(1);
// The snapshots would only take room the store's replay needs.
expect(data.has(KEYS.collectionItems)).toBe(false);
});
});
it('follows the Xtream data source on a desktop bridge without its SQLite API', async () => {
// A partial or stale preload: XTREAM_DATA_SOURCE falls back to the
// browser storage, so the backup must read and restore that too.
(window as unknown as { electron?: unknown }).electron = {};
const runtime = { supportsXtreamSqliteDataSource: false };
const manifest = await (async () => {
seed();
const exporter = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
runtime,
});
return (await exporter.exportBackup()).manifest;
})();
expect(xtreamEntry(manifest).userState.favorites).toHaveLength(2);
localStorage.clear();
const importer = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
runtime,
});
const summary = await importer.importBackup(xtreamOnly(manifest));
expect(summary.failed).toBe(0);
expect(read(KEYS.favorites)).toEqual({
'xtream-1': ['movie:501', 'series:77'],
});
expect(pendingRestore(importer).getOrThrow('xtream-1')).toBeNull();
});
it('reads and writes through USER_DATA_STORAGE, not localStorage', async () => {
// The self-hosted server mode provides a server-synced storage here.
const synced = new Map<string, string>();
const serverStorage: UserDataStorage = {
getItem: (key) => synced.get(key) ?? null,
setItem: (key, value) => void synced.set(key, value),
removeItem: (key) => void synced.delete(key),
};
TestBed.configureTestingModule({
providers: [
{ provide: USER_DATA_STORAGE, useValue: serverStorage },
],
});
const pwaXtream = TestBed.inject(PwaXtreamBackupStateService);
seed(serverStorage);
const exporter = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream,
});
const { json, manifest } = await exporter.exportBackup();
expect(xtreamEntry(manifest).userState.favorites).toHaveLength(2);
synced.clear();
const importer = createPlaylistBackupService({
playlistsService: playlistsService([]),
pwaXtream,
});
await importer.importBackup(json);
expect(read(KEYS.favorites, serverStorage)).toEqual({
'xtream-1': ['movie:501', 'series:77'],
});
expect(localStorage.getItem(KEYS.favorites)).toBeNull();
});
function createBrowserBackedStorage() {
return {
getItem: (key: string) => localStorage.getItem(key),
setItem: jest.fn(),
removeItem: (key: string) => localStorage.removeItem(key),
};
}
});
@@ -182,57 +182,49 @@ describe('PlaylistBackupService export → import round-trip', () => {
return entry;
}
it.each([true, false])(
'preserves a fresh pending import on re-export (Electron: %s)',
async (electron) => {
const state = seedState();
state.xtreamFavorites.push({
xtream_id: 501,
type: 'series',
position: 1,
});
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const original = await service.exportBackup();
const originalState = xtreamEntry(original.manifest).userState;
expect(originalState.sourcePins).toHaveLength(1);
state.xtreamCategories = [];
state.xtreamFavorites = [];
state.xtreamRecent = [];
state.playbackPositions = [];
state.sourcePins = [];
collaborators.databaseService.getXtreamImportStatus = async () =>
'idle';
if (!electron) delete electronWindow.electron;
// The browser build applies an import at once (see the PWA Xtream spec),
// so only the desktop leaves one pending while its catalog downloads.
it('preserves a fresh pending import on re-export', async () => {
const state = seedState();
state.xtreamFavorites.push({
xtream_id: 501,
type: 'series',
position: 1,
});
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const original = await service.exportBackup();
const originalState = xtreamEntry(original.manifest).userState;
expect(originalState.sourcePins).toHaveLength(1);
state.xtreamCategories = [];
state.xtreamFavorites = [];
state.xtreamRecent = [];
state.playbackPositions = [];
state.sourcePins = [];
collaborators.databaseService.getXtreamImportStatus = async () =>
'idle';
const summary = await service.importBackup(original.json);
expect(summary.failed).toBe(0);
const snapshot =
collaborators.pendingRestoreService.getSnapshotOrThrow(
'xtream-1'
);
expect(snapshot).not.toBeNull();
// Pending state must remain exportable even while catalog/storage
// reads are unavailable; it is already the complete restore input.
jest.spyOn(
collaborators.databaseService,
'getFavorites'
).mockRejectedValue(new Error('Catalog unavailable'));
jest.spyOn(
collaborators.playbackPositionService,
'getAllPlaybackPositionsOrThrow'
).mockRejectedValue(new Error('Position storage unavailable'));
const exported = await service.exportBackup();
expect(xtreamEntry(exported.manifest).userState).toEqual(
originalState
);
expect(
collaborators.pendingRestoreService.getSnapshotOrThrow(
'xtream-1'
)
).toEqual(snapshot);
}
);
const summary = await service.importBackup(original.json);
expect(summary.failed).toBe(0);
const snapshot =
collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1');
expect(snapshot).not.toBeNull();
// Pending state must remain exportable even while catalog/storage
// reads are unavailable; it is already the complete restore input.
jest.spyOn(
collaborators.databaseService,
'getFavorites'
).mockRejectedValue(new Error('Catalog unavailable'));
jest.spyOn(
collaborators.playbackPositionService,
'getAllPlaybackPositionsOrThrow'
).mockRejectedValue(new Error('Position storage unavailable'));
const exported = await service.exportBackup();
expect(xtreamEntry(exported.manifest).userState).toEqual(originalState);
expect(
collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1')
).toEqual(snapshot);
});
it.each([true, false])(
'honors empty pending collections and optional source pins (present: %s)',
@@ -8,11 +8,29 @@ import {
XtreamPendingRestoreState,
} from '@iptvnator/shared/interfaces';
import { PlaylistBackupService } from './playlist-backup.service';
import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service';
import {
createBrowserUserDataStorage,
UserDataStorage,
} from './user-data-storage';
import {
XtreamPendingRestoreService,
XtreamPendingRestoreSnapshot,
} from './xtream-pending-restore.service';
/**
* The browser build's Xtream backup state over `storage` — jsdom's
* localStorage unless a spec passes its own — without Angular DI.
*/
export function createPwaXtreamBackupState(
storage: UserDataStorage = createBrowserUserDataStorage()
): PwaXtreamBackupStateService {
return Object.assign(
Object.create(PwaXtreamBackupStateService.prototype) as object,
{ storage }
) as unknown as PwaXtreamBackupStateService;
}
/**
* Shared factory for PlaylistBackupService specs. Instantiates the service
* without Angular DI and replaces every collaborator with jest mocks;
@@ -132,6 +150,13 @@ export function createPlaylistBackupService(
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
},
pwaXtream: createPwaXtreamBackupState(),
// Specs pick the runtime by setting or deleting `window.electron`.
runtime: {
get supportsXtreamSqliteDataSource() {
return !!(window as unknown as { electron?: unknown }).electron;
},
},
...overrides,
});
@@ -4,7 +4,9 @@ import { PlaylistsService } from './playlists.service';
import { SettingsStore } from './settings-store.service';
import { DatabaseService } from './database-electron.service';
import { VodSourcePinService } from './vod-source-pin.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import { PlaybackPositionService } from './playback-position.service';
import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service';
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
import { ParentalLockService } from './parental-lock/parental-lock.service';
import { removesParentalLocks } from './parental-lock/parental-lock-store.util';
@@ -62,9 +64,6 @@ export class PlaylistBackupError extends Error {
type PlaylistPortalType = 'm3u' | 'xtream' | 'stalker';
type XtreamContentType = XtreamBackupContentType;
type XtreamCategoryType = XtreamBackupCategoryType;
@Injectable({
providedIn: 'root',
})
@@ -78,6 +77,8 @@ export class PlaylistBackupService {
XtreamPendingRestoreService
);
private readonly parentalLock = inject(ParentalLockService);
private readonly pwaXtream = inject(PwaXtreamBackupStateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private backupImportTail?: Promise<void>;
async exportBackup(): Promise<PlaylistBackupExportPayload> {
@@ -332,7 +333,9 @@ export class PlaylistBackupService {
};
}
// An unavailable pin store is no opinion, never an authoritative [].
if (!this.hasElectronApi()) return entry;
// Same choice as XTREAM_DATA_SOURCE: user state lives where it reads.
if (!this.runtime.supportsXtreamSqliteDataSource)
return this.pwaXtream.exportInto(entry);
const [
liveCategories,
@@ -370,7 +373,7 @@ export class PlaylistBackupService {
...this.optionalLockedXtreamCategories(playlist._id),
favorites: favorites.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamContentType,
contentType: item.type as XtreamBackupContentType,
...(item.added_at ? { addedAt: item.added_at } : {}),
...(item.position !== undefined
? { position: item.position }
@@ -378,7 +381,7 @@ export class PlaylistBackupService {
})),
recentlyViewed: recent.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamContentType,
contentType: item.type as XtreamBackupContentType,
viewedAt: item.viewed_at ?? new Date().toISOString(),
})),
playbackPositions: playbackPositions.map((item) => ({
@@ -933,21 +936,20 @@ export class PlaylistBackupService {
);
}
if (!this.hasElectronApi()) {
return;
}
this.playbackPositionService.assertSupportsAtomicReplacement();
if (!(await this.hasCompletedOfflineCache(playlistId))) {
return;
// Browser storage has no catalog cache to wait for: apply at once.
const sqlite = this.runtime.supportsXtreamSqliteDataSource;
if (sqlite) {
this.playbackPositionService.assertSupportsAtomicReplacement();
if (!(await this.hasCompletedOfflineCache(playlistId))) return;
}
const restoreResult = await this.pendingRestoreService.applyAndConsume(
playlistId,
restoreSnapshot,
(pendingState) =>
this.applyXtreamRestoreState(playlistId, pendingState)
async (state) =>
sqlite
? this.applyXtreamRestoreState(playlistId, state)
: this.pwaXtream.restore(playlistId, state, entry)
);
if (restoreResult !== 'consumed') {
throw new PlaylistBackupError(
@@ -1213,9 +1215,9 @@ export class PlaylistBackupService {
categories: Array<{
hidden: boolean;
xtream_id: number;
type: XtreamCategoryType;
type: XtreamBackupCategoryType;
}>,
categoryType: XtreamCategoryType
categoryType: XtreamBackupCategoryType
): XtreamPendingRestoreState['hiddenCategories'] {
return categories
.filter((category) => category.hidden)
@@ -1359,8 +1361,4 @@ export class PlaylistBackupService {
private getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
private hasElectronApi(): boolean {
return !!(window as Window & { electron?: unknown }).electron;
}
}
@@ -2097,6 +2097,35 @@ describe('PlaylistsService', () => {
}
);
it('reads hidden groups saved by a meta update that is still being written', async () => {
const original = createBasePlaylist('catalog-hidden-groups');
const { store, electron } = createStatefulElectronStore(original);
testWindow.electron = electron;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
electron.dbUpsertAppPlaylist.mockImplementationOnce(
async (playlist) => {
await gate;
store.current = playlist;
}
);
const service = createService();
// The meta effect subscribes while the save is dispatched, so the
// write is queued before the M3U route reopens the playlist.
const write = firstValueFrom(
service.updatePlaylistMeta({
_id: original._id,
hiddenGroupTitles: ['Sports'],
} as PlaylistMeta)
);
const read = firstValueFrom(service.getPlaylist(original._id));
release();
await write;
expect((await read).hiddenGroupTitles).toEqual(['Sports']);
});
it('orders a browser catalog read after a queued IndexedDB refresh', async () => {
const original = createBasePlaylist('browser-catalog');
const state = { current: original };
@@ -0,0 +1,297 @@
import { inject, Injectable } from '@angular/core';
import {
PlaybackPositionData,
XtreamBackupCollectionItem,
XtreamBackupContentType,
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
XtreamPendingRestoreState,
XtreamPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import { USER_DATA_STORAGE, XTREAM_USER_DATA_KEYS } from './user-data-storage';
type StoredMap = Record<string, unknown>;
type UserDataMap = keyof typeof XTREAM_USER_DATA_KEYS;
interface ResolvedKey {
key: string;
item: { contentType: XtreamBackupContentType; xtreamId: number };
}
const CONTENT_TYPES: readonly unknown[] = ['live', 'movie', 'series'];
/**
* Backup export and restore of the Xtream favorites, recently viewed items,
* playback positions and title snapshots the browser build keeps in
* USER_DATA_STORAGE. `PwaXtreamDataSource` owns these maps in a library this
* one cannot import, so their stored shapes are read here: keys are
* `"<type>:<xtreamId>"`, or a bare provider id written by older builds.
*/
@Injectable({ providedIn: 'root' })
export class PwaXtreamBackupStateService {
private readonly storage = inject(USER_DATA_STORAGE);
/**
* The entry with the playlist's stored lists and snapshots filled in;
* hidden categories and pins, which the browser build does not track,
* stay as `entry` has them. An unreadable store fails the export: restore
* treats these lists as authoritative, so "could not read" written as
* `[]` would turn the archive into an instruction to delete.
*/
exportInto(entry: XtreamPlaylistBackupEntry): XtreamPlaylistBackupEntry {
const playlistId = entry.exportedId;
const stored = this.readMap('collectionItems')[playlistId];
const snapshots = isRecord(stored) ? stored : {};
const favorites = new Map<string, XtreamBackupFavoriteItem>();
for (const key of this.listFor('favorites', playlistId)) {
const ref = resolveKey(key, snapshots);
if (ref) favorites.set(ref.key, ref.item);
}
// Newest view wins, artwork included, as in the data source.
const recent = new Map<string, XtreamBackupRecentlyViewedItem>();
for (const item of this.listFor('recentItems', playlistId)) {
if (!isRecord(item)) continue;
const ref = resolveKey(item['id'], snapshots);
const viewedAt = item['viewedAt'];
if (!ref || typeof viewedAt !== 'string') continue;
const previous = recent.get(ref.key)?.viewedAt;
if (!previous || Date.parse(viewedAt) > Date.parse(previous)) {
recent.set(ref.key, {
...ref.item,
viewedAt,
...backdropOf(item),
});
}
}
const byKey = snapshotsByKey(Object.values(snapshots));
const collectionItems = [
...new Set([...favorites.keys(), ...recent.keys()]),
]
.filter((key) => key in byKey)
.map((key) => byKey[key]);
return {
...entry,
userState: {
...entry.userState,
favorites: [...favorites.values()],
recentlyViewed: [...recent.values()],
playbackPositions: this.listFor('playbackPositions', playlistId)
.filter(isPlaybackPosition)
.map((position) => ({ ...position })),
...(collectionItems.length > 0 ? { collectionItems } : {}),
},
};
}
/**
* Replaces the playlist's favorites, history and positions with the
* restore state, keyed and remapped to `playlistId`, then merges the
* archived title snapshots into the playlist's own.
*
* Lists first, so the optional cache never takes space they need. A
* failed write throws with earlier ones applied, and the caller keeps the
* pending snapshot for the Xtream store to replay on the next open. The
* replay carries no titles (they come from `entry`, not `state`), so they
* are still merged then, unless storage is full and they would crowd it.
*/
async restore(
playlistId: string,
state: XtreamPendingRestoreState,
entry: XtreamPlaylistBackupEntry
): Promise<void> {
// A user-supplied file: the field may be anything.
const archived: unknown = entry.userState.collectionItems;
const snapshots = snapshotsByKey(
Array.isArray(archived) ? archived : []
);
try {
this.replaceLists(playlistId, state);
} catch (error) {
if (!isQuotaExceeded(error)) {
try {
this.mergeSnapshots(playlistId, snapshots);
} catch {
// The list failure is the one to report.
}
}
throw error;
}
this.mergeSnapshots(playlistId, snapshots);
}
private replaceLists(
playlistId: string,
state: XtreamPendingRestoreState
): void {
const favorites = state.favorites
.map((item) => typedKey(item.contentType, item.xtreamId))
.filter((key): key is string => key !== null);
this.writeFor('favorites', playlistId, [...new Set(favorites)]);
const recent: StoredMap[] = [];
for (const item of state.recentlyViewed) {
const id = typedKey(item.contentType, item.xtreamId);
if (id !== null && typeof item.viewedAt === 'string') {
recent.push({
id,
viewedAt: item.viewedAt,
...backdropOf(item),
});
}
}
this.writeFor('recentItems', playlistId, recent);
const now = new Date().toISOString();
this.writeFor(
'playbackPositions',
playlistId,
state.playbackPositions.filter(isPlaybackPosition).map((item) => ({
...item,
playlistId,
updatedAt: item.updatedAt ?? now,
}))
);
}
/**
* A display cache the data source itself writes on a best-effort basis:
* a damaged one is replaced, as the data source would, and a full
* storage skips it. Its titles return once the catalog loads.
*/
private mergeSnapshots(
playlistId: string,
incoming: Record<string, XtreamBackupCollectionItem>
): void {
if (Object.keys(incoming).length === 0) return;
const all = this.readMap('collectionItems', { replaceDamaged: true });
const current = isRecord(all[playlistId]) ? all[playlistId] : {};
try {
this.writeMap('collectionItems', {
...all,
[playlistId]: { ...current, ...incoming },
});
} catch (error) {
if (!isQuotaExceeded(error)) throw error;
}
}
private listFor(name: UserDataMap, playlistId: string): unknown[] {
const list = this.readMap(name)[playlistId];
if (list === undefined) return [];
if (!Array.isArray(list)) {
throw new Error(
`Stored ${XTREAM_USER_DATA_KEYS[name]} of playlist "${playlistId}" are unreadable.`
);
}
return list;
}
private writeFor(name: UserDataMap, playlistId: string, list: unknown[]) {
this.writeMap(name, { ...this.readMap(name), [playlistId]: list });
}
/**
* Strict, unlike the data source: a storage error or a damaged value
* throws rather than read as "nothing stored", so a write never replaces
* entries it could not read. A cache may `replaceDamaged` values.
*/
private readMap(
name: UserDataMap,
{ replaceDamaged = false } = {}
): StoredMap {
const key = XTREAM_USER_DATA_KEYS[name];
const raw = this.storage.getItem(key);
if (raw === null) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = null;
}
if (isRecord(parsed)) return parsed;
if (replaceDamaged) return {};
throw new Error(`Stored ${key} are unreadable.`);
}
private writeMap(name: UserDataMap, map: StoredMap): void {
this.storage.setItem(XTREAM_USER_DATA_KEYS[name], JSON.stringify(map));
}
}
/**
* A typed key carries its own identity. A bare provider id resolves only
* through the snapshot stored under it, as the data source does without a
* complete catalog: ids repeat across live, movie and series, so a guess
* could restore a different title. An unresolvable id is left out.
*/
function resolveKey(key: unknown, snapshots: StoredMap): ResolvedKey | null {
if (typeof key === 'string' && key.includes(':')) {
const [type, id, extra] = key.split(':');
return extra === undefined ? resolved(type, Number(id)) : null;
}
if (typeof key !== 'number' && typeof key !== 'string') return null;
const snapshot = snapshots[String(key)];
return isRecord(snapshot) && snapshot['xtream_id'] === Number(key)
? resolved(snapshot['type'], Number(key))
: null;
}
function resolved(type: unknown, xtreamId: number): ResolvedKey | null {
const key = typedKey(type, xtreamId);
if (key === null) return null;
const contentType = type as XtreamBackupContentType;
return { key, item: { contentType, xtreamId } };
}
function typedKey(type: unknown, xtreamId: unknown): string | null {
return CONTENT_TYPES.includes(type) &&
typeof xtreamId === 'number' &&
Number.isSafeInteger(xtreamId) &&
xtreamId > 0
? `${type}:${xtreamId}`
: null;
}
/** Snapshots by typed key, whatever key they were stored under. */
function snapshotsByKey(
items: unknown[]
): Record<string, XtreamBackupCollectionItem> {
const byKey: Record<string, XtreamBackupCollectionItem> = {};
for (const item of items) {
if (!isRecord(item)) continue;
const key = typedKey(item['type'], item['xtream_id']);
if (key !== null) byKey[key] = item as XtreamBackupCollectionItem;
}
return byKey;
}
/** Artwork saved with a view, in either spelling the data source reads. */
function backdropOf(item: object): { backdropUrl?: string } {
const record = item as StoredMap;
const value = record['backdropUrl'] ?? record['backdrop_url'];
return typeof value === 'string' && value.trim()
? { backdropUrl: value.trim() }
: {};
}
function isPlaybackPosition(value: unknown): value is PlaybackPositionData {
return (
isRecord(value) &&
Number.isFinite(value['contentXtreamId']) &&
(value['contentType'] === 'vod' ||
value['contentType'] === 'episode') &&
Number.isFinite(value['positionSeconds'])
);
}
function isQuotaExceeded(error: unknown): boolean {
return error instanceof DOMException && error.name === 'QuotaExceededError';
}
function isRecord(value: unknown): value is StoredMap {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
@@ -29,6 +29,18 @@ export function createBrowserSessionStorage(): UserDataStorage {
};
}
/**
* The PWA Xtream user-data maps in USER_DATA_STORAGE, each a JSON object
* keyed by playlist id. Shared with the playlist backup, which reads and
* writes them directly.
*/
export const XTREAM_USER_DATA_KEYS = {
favorites: 'xtream-favorites',
recentItems: 'xtream-recent-items',
collectionItems: 'xtream-collection-items',
playbackPositions: 'xtream-playback-positions',
} as const;
/**
* Browser persistence for PWA user data such as Xtream favorites, recently
* viewed items and playback positions. Defaults to `localStorage`; the