mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(xtream): retain saved snapshots through browser restore retries
This commit is contained in:
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
|
||||
|
||||
+103
-1
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user