fix(xtream): retain saved snapshots through browser restore retries

This commit is contained in:
4gray committed 2026-10-14 04:14:14 +02:00
1 parent ef90961a3a
commit 29b0333fde
15 files changed
+704 -414

No files matched your search

@@ -0,0 +1,114 @@
import { expect, test } from './fixtures';
import {
addXtreamPortal,
interceptXtreamRequests,
MOCK_SERVER,
} from './xtream-series-playback.fixture';
test.beforeEach(async ({ page, request }) => {
await request.post(`${MOCK_SERVER}/reset`);
await page.goto('/');
await interceptXtreamRequests(page);
});
test('@xtream @collections PWA retries retain saved favorite metadata when snapshot storage is full', async ({
page,
}) => {
await addXtreamPortal(page, { name: 'Pending Favorites Portal' });
const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, '');
const rawId = playlistPath.split('/').pop();
if (!rawId) throw new Error('Missing playlist ID in the portal route');
const playlistId = decodeURIComponent(rawId);
const favorite = {
contentType: 'movie',
xtreamId: 987654,
addedAt: '2026-01-02T12:00:00.000Z',
position: 3,
snapshot: {
title: 'Saved offline movie',
posterUrl: '/assets/icons/icon-192x192.png',
tmdbId: 949,
releaseYear: 1995,
},
};
await page.goto('/');
await page.evaluate(
({ playlistId, favorite }) => {
localStorage.setItem(
`xtream-restore-${playlistId}`,
JSON.stringify({
hiddenCategories: [],
favorites: [favorite],
recentlyViewed: [],
playbackPositions: [],
})
);
sessionStorage.setItem('block-favorite-snapshot', '1');
},
{ playlistId, favorite }
);
await page.addInitScript(() => {
const setItem = Storage.prototype.setItem;
Storage.prototype.setItem = function (this: Storage, key, value) {
if (
this === localStorage &&
key === 'xtream-collection-items' &&
sessionStorage.getItem('block-favorite-snapshot') === '1'
) {
sessionStorage.setItem('favorite-snapshot-attempted', '1');
throw new DOMException('full', 'QuotaExceededError');
}
return setItem.call(this, key, value);
};
});
await page.goto(`${playlistPath}/vod`);
await expect
.poll(() =>
page.evaluate(() =>
sessionStorage.getItem('favorite-snapshot-attempted')
)
)
.toBe('1');
expect(
await page.evaluate(
(id) => localStorage.getItem(`xtream-restore-${id}`),
playlistId
)
).not.toBeNull();
await page.evaluate(() =>
sessionStorage.removeItem('block-favorite-snapshot')
);
await page.reload();
await expect
.poll(() =>
page.evaluate(
(id) => localStorage.getItem(`xtream-restore-${id}`),
playlistId
)
)
.toBeNull();
await page.goto(`${playlistPath}/favorites`);
await expect(
page.getByText(favorite.snapshot.title, { exact: true })
).toBeVisible();
await page.reload();
await expect(
page.getByText(favorite.snapshot.title, { exact: true })
).toBeVisible();
expect(
await page.evaluate(
(id) =>
JSON.parse(
localStorage.getItem('xtream-collection-items') ?? '{}'
)[id]?.['movie:987654'],
playlistId
)
).toMatchObject({
title: favorite.snapshot.title,
poster_url: favorite.snapshot.posterUrl,
tmdb_id: 949,
release_year: 1995,
added_at: favorite.addedAt,
position: 3,
});
});
@@ -250,6 +250,18 @@ context panel use, out of that barrel for the same reason, and
22 KB, used only by the lazy settings page) out of the `@iptvnator/services`
barrel.
`@iptvnator/services/pwa-xtream-backup-storage` exposes the framework-free browser
backup persistence implementation without re-exporting it from the eager services
barrel. The injected backup service wraps it; deferred restore loads it through a
local dynamic-import module with the data source's storage. Its map keys live in
a separate pure module so it does not import the Angular storage token at runtime.
Electron favorites likewise load their
legacy pending-conversion helper only when a favorites operation needs it.
Those lazy helpers value-import the pure normalizer through
`@iptvnator/shared/interfaces/xtream-restore-state`; their remaining contracts
use type-only imports so a deferred conversion does not load the interfaces
barrel's Angular/IndexedDB dependencies.
The web app's root shell (`app.component.ts`, `app.config.ts` and the services
they construct) is on the renderer's initial path, where a barrel costs its
whole library: esbuild keeps every Angular component module a barrel
@@ -101,6 +101,14 @@ Browser imports convert desktop favorite snapshots into typed collection-cache
rows so saved titles and artwork remain visible offline. Browser exports include
the normalized nested favorite snapshots as well as their optional collection
cache, preserving metadata when the archive returns to Electron.
Initial browser imports and deferred retries share `PwaXtreamBackupStorage`;
`PwaXtreamBackupStateService` supplies its injected storage for the importer.
Both write the lists first and require the nested favorite snapshots to persist
before consuming pending state. A quota or storage failure keeps the archive
parked for another attempt, including when the provider returns an empty catalog.
Legacy browser-only `collectionItems` remain an optional cache; their quota
failure does not invalidate a restore with no durable nested favorite snapshots.
The PWA retry loads that storage implementation on demand through a local module.
Both Xtream data-source and portal-activity capabilities require the pending
favorite conversion bridge; activity reads also require playlist lookup. The
@@ -1,5 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
import {
ParentalLockService,
PlaylistsService,
USER_DATA_STORAGE,
XtreamPendingRestoreService,
} from '@iptvnator/services';
import { PwaXtreamBackupStorage } from '@iptvnator/services/pwa-xtream-backup-storage';
import { XtreamPlaylistBackupEntry } from '@iptvnator/shared/interfaces';
import { of } from 'rxjs';
import { XtreamApiService } from '../services/xtream-api.service';
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
@@ -367,6 +374,101 @@ describe('PWA Xtream collection identity', () => {
});
});
it.each(['xtream-favorites', 'xtream-collection-items'])(
'retains saved metadata through %s quota failure, replay, offline reads and re-export',
async (failedKey) => {
const entry: XtreamPlaylistBackupEntry = {
portalType: 'xtream',
exportedId: 'p1',
title: 'Saved portal',
autoRefresh: false,
connection: credentials,
userState: {
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
favorites: types.map((contentType, position) => ({
contentType,
xtreamId: 42,
position,
addedAt: '2026-10-01T12:00:00.000Z',
snapshot: {
title: `Saved ${contentType}`,
posterUrl: `https://img.example.com/${contentType}.jpg`,
backdropUrl: `https://img.example.com/${contentType}-wide.jpg`,
tmdbId: 100 + position,
releaseYear: 1995 + position,
originalTitle: `Original ${contentType}`,
},
})),
},
};
const pending = TestBed.inject(XtreamPendingRestoreService);
const parked = pending.set('p1', entry.userState);
if (!parked) throw new Error('Expected parked restore');
const backup = new PwaXtreamBackupStorage(
TestBed.inject(USER_DATA_STORAGE)
);
let blockedKey = failedKey;
const setItem = Storage.prototype.setItem;
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(
function (this: Storage, key, value) {
if (key === blockedKey)
throw new DOMException('full', 'QuotaExceededError');
setItem.call(this, key, value);
}
);
await expect(
pending.applyAndConsume('p1', parked, (state) =>
backup.restore('p1', state, entry)
)
).rejects.toMatchObject({ name: 'QuotaExceededError' });
expect(pending.getOrThrow('p1')?.favorites).toEqual(
entry.userState.favorites
);
// Lists fit now, but the sole saved metadata still must not be discarded.
blockedKey = 'xtream-collection-items';
await expect(
pending.applyAndConsume('p1', parked, (state) =>
source.restoreUserData('p1', state)
)
).rejects.toMatchObject({ name: 'QuotaExceededError' });
expect(pending.getOrThrow('p1')?.favorites).toEqual(
entry.userState.favorites
);
blockedKey = '';
await expect(
pending.applyAndConsume('p1', parked, (state) =>
source.restoreUserData('p1', state)
)
).resolves.toBe('consumed');
expect(pending.getOrThrow('p1')).toBeNull();
api.getStreams.mockResolvedValue([]);
reload();
const items = await source.getFavorites('p1');
expect(
items.map(({ title, type, xtream_id }) => ({
title,
type,
xtream_id,
}))
).toEqual(
types.map((type) => ({
title: `Saved ${type}`,
type,
xtream_id: 42,
}))
);
expect(
new PwaXtreamBackupStorage(
TestBed.inject(USER_DATA_STORAGE)
).exportInto(entry).userState.favorites
).toEqual(entry.userState.favorites);
}
);
it('keeps the artwork saved with each view through clear/restore', async () => {
await source.restoreUserData('p1', {
hiddenCategories: [],
@@ -1388,44 +1388,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
options?: XtreamOperationOptions
): Promise<void> {
void options;
// Restore favorites
const favorites = this.collectionStorage.getFavoritesFromStorage();
favorites[playlistId] = restoreState.favorites
.map((item) =>
collectionKey({ id: item.xtreamId, type: item.contentType })
)
.filter((key): key is CollectionKey => key !== null);
this.collectionStorage.saveFavoritesToStorage(favorites);
// Restore recent items
const recentItems = this.collectionStorage.getRecentItemsFromStorage();
recentItems[playlistId] = [];
for (const item of restoreState.recentlyViewed) {
const id = collectionKey({
id: item.xtreamId,
type: item.contentType,
});
if (id !== null)
recentItems[playlistId].push({
id,
viewedAt: item.viewedAt,
...normalizeStoredBackdrop(item),
});
}
this.collectionStorage.saveRecentItemsToStorage(recentItems);
// Restore playback positions under this playlist: an archive's carry
// the id they were exported with, and the player matches on it.
this.clearPlaybackPositionsForPlaylist(playlistId);
const playbackPositions = this.getPlaybackPositionsFromStorage();
playbackPositions[playlistId] = restoreState.playbackPositions.map(
(position) => ({
...position,
playlistId,
updatedAt: position.updatedAt ?? new Date().toISOString(),
})
const { restorePwaXtreamUserData } = await import(
'./pwa-xtream-restore'
);
await restorePwaXtreamUserData(
this.userDataStorage,
playlistId,
restoreState
);
this.savePlaybackPositionsToStorage(playbackPositions);
}
// =========================================================================
@@ -0,0 +1,12 @@
import type { UserDataStorage } from '@iptvnator/services';
import { PwaXtreamBackupStorage } from '@iptvnator/services/pwa-xtream-backup-storage';
import type { XtreamPendingRestoreState } from '@iptvnator/shared/interfaces';
/** Load backup persistence only for a restore, sharing the initial import contract. */
export function restorePwaXtreamUserData(
storage: UserDataStorage,
playlistId: string,
state: XtreamPendingRestoreState
): Promise<void> {
return new PwaXtreamBackupStorage(storage).restore(playlistId, state);
}
@@ -200,7 +200,7 @@ describe('DatabaseService browser guards', () => {
} else {
const getItem = Storage.prototype.getItem;
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(
function (key) {
function (this: Storage, key) {
if (key === 'xtream-restore-broken')
return unavailable();
return getItem.call(this, key);
@@ -1,7 +1,3 @@
import {
assertXtreamFavoriteRestoreSupported,
materializePendingXtreamFavorites,
} from './xtream-pending-favorites';
import type {
ElectronBridgeXtreamFavorite,
XtreamFavoriteRemovalRef,
@@ -306,6 +302,9 @@ export class DatabaseService {
restoreId?: string
): Promise<void> {
if (favorites.length === 0) return;
const { assertXtreamFavoriteRestoreSupported } = await import(
'./xtream-pending-favorites'
);
assertXtreamFavoriteRestoreSupported(window.electron);
const result = await window.electron.dbRestoreXtreamFavorites(
playlistId,
@@ -1074,3 +1073,13 @@ export class DatabaseService {
}
}
}
/** Defer legacy conversion code until a favorites operation actually needs it. */
async function materializePendingXtreamFavorites(
...args: Parameters<
typeof import('./xtream-pending-favorites').materializePendingXtreamFavorites
>
): Promise<void> {
const converter = await import('./xtream-pending-favorites');
return converter.materializePendingXtreamFavorites(...args);
}
@@ -535,7 +535,7 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
expect(
pendingRestore(service).getOrThrow('xtream-1')
).not.toBeNull();
// That replay carries no titles, so they are merged regardless.
// Cache-only rows are not carried by pending state, so preserve them here too.
expect(read(KEYS.collectionItems)).toEqual({
'xtream-1': { 'movie:501': heat, 'series:77': legacyShow },
});
@@ -570,10 +570,46 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
expect(summary.errors[0]).toContain('denied');
});
it('still restores the lists when the snapshots exceed the quota', async () => {
it('keeps durable favorite snapshots parked when their write exceeds 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(xtreamOnly(manifest));
expect(summary.failed).toBe(1);
expect(
pendingRestore(service).getOrThrow('xtream-1')
).toMatchObject({
favorites: xtreamEntry(manifest).userState.favorites,
});
const { manifest: reexport } = await service.exportBackup();
expect(xtreamEntry(reexport).userState.favorites).toEqual(
xtreamEntry(manifest).userState.favorites
);
});
it('still restores legacy lists when their optional cache exceeds the quota', async () => {
const manifest = await exportSeeded();
// Old browser archives carry only optional collectionItems, not durable snapshots.
for (const entry of manifest.playlists) {
if (entry.portalType === 'xtream')
entry.userState.favorites = entry.userState.favorites.map(
({ contentType, xtreamId }) => ({
contentType,
xtreamId,
})
);
}
localStorage.clear();
const storage = createBrowserBackedStorage();
storage.setItem.mockImplementation((key: string, value: string) => {
if (key === KEYS.collectionItems) {
throw new DOMException('full', 'QuotaExceededError');
@@ -627,6 +663,10 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => {
async function importInto(budget: number) {
const manifest = await exportSeeded();
const entry = xtreamEntry(manifest);
entry.userState.favorites = entry.userState.favorites.map(
({ contentType, xtreamId }) => ({ contentType, xtreamId })
);
const quota = quotaStorage(budget);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
@@ -1,361 +1,11 @@
import { inject, Injectable } from '@angular/core';
import {
PlaybackPositionData,
normalizeXtreamPendingRestoreState,
XtreamBackupCollectionItem,
XtreamBackupContentType,
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
XtreamPendingRestoreState,
XtreamPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import { USER_DATA_STORAGE, XTREAM_USER_DATA_KEYS } from './user-data-storage';
import { PwaXtreamBackupStorage } from './pwa-xtream-backup-storage';
import { USER_DATA_STORAGE } 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.
*/
/** Angular owner of the shared browser import/retry persistence contract. */
@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 byKey = snapshotsByKey(Object.values(snapshots));
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,
favoriteWithSnapshot(ref, byKey[ref.key])
);
}
// 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 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.
* Browser-only cache rows come from `entry`; desktop favorite snapshots
* also travel in `state`. Merge both unless a full store would crowd out
* the lists that still need to be restored.
*/
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 : []
);
for (const favorite of state.favorites) {
const saved = favorite.snapshot;
const key = typedKey(favorite.contentType, favorite.xtreamId);
if (!saved || key === null) continue;
snapshots[key] = {
...snapshots[key],
id: favorite.xtreamId,
xtream_id: favorite.xtreamId,
type: favorite.contentType,
title: saved.title,
...(saved.posterUrl !== undefined
? { poster_url: saved.posterUrl }
: {}),
...(saved.backdropUrl !== undefined
? { backdrop_url: saved.backdropUrl }
: {}),
...(saved.tmdbId !== undefined
? { tmdb_id: saved.tmdbId }
: {}),
...(saved.releaseYear !== undefined
? { release_year: saved.releaseYear }
: {}),
...(saved.originalTitle !== undefined
? { original_title: saved.originalTitle }
: {}),
...(favorite.addedAt !== undefined
? { added_at: favorite.addedAt }
: {}),
...(favorite.position !== undefined
? { position: favorite.position }
: {}),
};
}
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));
export class PwaXtreamBackupStateService extends PwaXtreamBackupStorage {
constructor() {
super(inject(USER_DATA_STORAGE));
}
}
/** Share desktop's allowlisted snapshot fields while retaining browser cache rows. */
function favoriteWithSnapshot(
ref: ResolvedKey,
saved?: XtreamBackupCollectionItem
): XtreamBackupFavoriteItem {
if (!saved) return ref.item;
return normalizeXtreamPendingRestoreState({
favorites: [
{
...ref.item,
addedAt: saved['added_at'],
position: saved['position'],
snapshot: {
title: saved['title'],
posterUrl: saved['poster_url'],
backdropUrl: saved['backdrop_url'],
tmdbId: saved['tmdb_id'],
releaseYear: saved['release_year'],
originalTitle: saved['original_title'],
},
},
],
}).favorites[0];
}
/**
* 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);
}
@@ -0,0 +1,366 @@
import type {
PlaybackPositionData,
XtreamBackupCollectionItem,
XtreamBackupContentType,
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
XtreamPendingRestoreState,
XtreamPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import { normalizeXtreamPendingRestoreState } from '@iptvnator/shared/interfaces/xtream-restore-state';
import type { UserDataStorage } from './user-data-storage';
import { XTREAM_USER_DATA_KEYS } from './xtream-user-data-keys';
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.
*/
export class PwaXtreamBackupStorage {
constructor(private readonly storage: UserDataStorage) {}
/**
* 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 byKey = snapshotsByKey(Object.values(snapshots));
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,
favoriteWithSnapshot(ref, byKey[ref.key])
);
}
// 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 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.
* Browser-only cache rows come from `entry`; durable favorite snapshots
* also travel in `state` through deferred retries. A durable snapshot
* write must succeed before the caller consumes that pending state.
*/
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 : []
);
for (const favorite of state.favorites) {
const saved = favorite.snapshot;
const key = typedKey(favorite.contentType, favorite.xtreamId);
if (!saved || key === null) continue;
snapshots[key] = {
...snapshots[key],
id: favorite.xtreamId,
xtream_id: favorite.xtreamId,
type: favorite.contentType,
title: saved.title,
...(saved.posterUrl !== undefined
? { poster_url: saved.posterUrl }
: {}),
...(saved.backdropUrl !== undefined
? { backdrop_url: saved.backdropUrl }
: {}),
...(saved.tmdbId !== undefined
? { tmdb_id: saved.tmdbId }
: {}),
...(saved.releaseYear !== undefined
? { release_year: saved.releaseYear }
: {}),
...(saved.originalTitle !== undefined
? { original_title: saved.originalTitle }
: {}),
...(favorite.addedAt !== undefined
? { added_at: favorite.addedAt }
: {}),
...(favorite.position !== undefined
? { position: favorite.position }
: {}),
};
}
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,
state.favorites.some((favorite) => !!favorite.snapshot)
);
}
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. Durable nested favorite snapshots are the exception:
* they may be the only saved metadata and must stay pending on failure.
*/
private mergeSnapshots(
playlistId: string,
incoming: Record<string, XtreamBackupCollectionItem>,
required = false
): 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 (required || !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));
}
}
/** Share desktop's allowlisted snapshot fields while retaining browser cache rows. */
function favoriteWithSnapshot(
ref: ResolvedKey,
saved?: XtreamBackupCollectionItem
): XtreamBackupFavoriteItem {
if (!saved) return ref.item;
return normalizeXtreamPendingRestoreState({
favorites: [
{
...ref.item,
addedAt: saved['added_at'],
position: saved['position'],
snapshot: {
title: saved['title'],
posterUrl: saved['poster_url'],
backdropUrl: saved['backdrop_url'],
tmdbId: saved['tmdb_id'],
releaseYear: saved['release_year'],
originalTitle: saved['original_title'],
},
},
],
}).favorites[0];
}
/**
* 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);
}
+1 -11
View File
@@ -20,17 +20,7 @@ export function createBrowserUserDataStorage(): 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;
export { XTREAM_USER_DATA_KEYS } from './xtream-user-data-keys';
/**
* Browser persistence for PWA user data such as Xtream favorites, recently
@@ -1,8 +1,8 @@
import {
ElectronBridgeApi,
getXtreamPendingRestoreStorageKey,
normalizeXtreamPendingRestoreState,
} from '@iptvnator/shared/interfaces';
} from '@iptvnator/shared/interfaces/xtream-restore-state';
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
type FavoriteRestoreSupport = Partial<
Pick<ElectronBridgeApi, 'dbReplaceAllPlaybackPositions'>
@@ -0,0 +1,11 @@
/**
* 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;
+6
View File
@@ -133,9 +133,15 @@
"@iptvnator/services/playlist-backup": [
"libs/services/src/lib/playlist-backup.service.ts"
],
"@iptvnator/services/pwa-xtream-backup-storage": [
"libs/services/src/lib/pwa-xtream-backup-storage.ts"
],
"@iptvnator/shared/interfaces": [
"libs/shared/interfaces/src/index.ts"
],
"@iptvnator/shared/interfaces/xtream-restore-state": [
"libs/shared/interfaces/src/lib/xtream-restore-state.util.ts"
],
"@iptvnator/shared/interfaces/ipc-commands": [
"libs/shared/interfaces/src/lib/ipc-commands.ts"
],