mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user