fix(xtream): preserve favorites across catalog refreshes

This commit is contained in:
4gray committed 2026-10-13 23:31:25 +02:00
1 parent ec4b9286a7
commit 8ccfcb68cc
121 files changed
+4000 -398

No files matched your search

@@ -6,6 +6,7 @@ describe('DatabaseService browser guards', () => {
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
localStorage.clear();
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
@@ -19,6 +20,7 @@ describe('DatabaseService browser guards', () => {
});
afterEach(() => {
localStorage.clear();
consoleErrorSpy.mockRestore();
Object.defineProperty(window, 'electron', {
configurable: true,
@@ -27,6 +29,92 @@ describe('DatabaseService browser guards', () => {
});
});
it('does not report success when the favorites worker rejects a conflicting identity', async () => {
Object.defineProperty(window, 'electron', {
configurable: true,
value: {
dbAddFavorite: jest.fn().mockResolvedValue({
success: false,
error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT',
}),
},
});
await expect(service.addToFavorites(42, 'playlist-1')).resolves.toBe(
false
);
await expect(
service.addToFavoritesOrThrow(42, 'playlist-1')
).rejects.toThrow('XTREAM_FAVORITE_IDENTITY_CONFLICT');
});
it('recovers legacy favorites on collection reads without a portal status or catalog request', async () => {
const pending = {
favorites: [{ contentType: 'movie', xtreamId: 7 }],
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
};
localStorage.setItem(
'xtream-restore-playlist-1',
JSON.stringify(pending)
);
const bridge = {
dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }),
dbRestoreXtreamFavorites: jest
.fn()
.mockResolvedValue({ success: true }),
dbGetFavorites: jest.fn().mockResolvedValue([]),
dbGetAllGlobalFavorites: jest.fn().mockResolvedValue([]),
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: bridge,
});
await service.getFavorites('playlist-1');
expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledWith(
'playlist-1',
pending.favorites,
undefined
);
expect(
bridge.dbRestoreXtreamFavorites.mock.invocationCallOrder[0]
).toBeLessThan(bridge.dbGetFavorites.mock.invocationCallOrder[0]);
await service.getAllGlobalFavorites();
expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledTimes(2);
expect(localStorage.getItem('xtream-restore-playlist-1')).toBe(
JSON.stringify(pending)
);
});
it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => {
localStorage.setItem(
'xtream-restore-orphan',
JSON.stringify({
favorites: [{ contentType: 'movie', xtreamId: 7 }],
})
);
const bridge = {
dbGetPlaylist: jest.fn().mockResolvedValue(null),
dbRestoreXtreamFavorites: jest
.fn()
.mockRejectedValue(new Error('SQLite unavailable')),
dbGetFavoritesForBackup: jest.fn().mockResolvedValue([]),
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: bridge,
});
await expect(service.getFavoritesOrThrow('orphan')).resolves.toEqual(
[]
);
expect(bridge.dbRestoreXtreamFavorites).not.toHaveBeenCalled();
bridge.dbGetPlaylist.mockResolvedValue({ type: 'xtream' });
await expect(service.getFavoritesOrThrow('orphan')).rejects.toThrow(
'SQLite unavailable'
);
expect(bridge.dbGetFavoritesForBackup).toHaveBeenCalledTimes(1);
});
it('treats app state persistence as unavailable without the Electron bridge', async () => {
await expect(service.getAppState('xtream-key')).resolves.toBeNull();
await expect(
@@ -1,3 +1,8 @@
import { materializePendingXtreamFavorites } from './xtream-pending-favorites';
import type {
ElectronBridgeXtreamFavorite,
XtreamFavoriteRemovalRef,
} from '@iptvnator/shared/interfaces';
/**
* Database service for Electron renderer process
* Communicates with the main process database via IPC
@@ -100,7 +105,9 @@ export interface GlobalRecentItem extends XtreamContent {
viewed_at: string;
}
export interface GlobalFavoriteItem extends XtreamContent {
export type XtreamFavorite = ElectronBridgeXtreamFavorite;
export interface GlobalFavoriteItem extends XtreamFavorite {
playlist_id: string;
playlist_name: string;
added_at: string;
@@ -288,8 +295,23 @@ export class DatabaseService {
}
/**
* Restore favorites and recently viewed items after Xtream refresh
* Materialize saved favorites without requiring the provider catalog.
*/
async restoreXtreamFavoriteSnapshots(
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
restoreId?: string
): Promise<void> {
if (favorites.length === 0) return;
const result = await window.electron.dbRestoreXtreamFavorites(
playlistId,
favorites,
restoreId
);
if (!result.success)
throw new Error('Restoring saved favorites failed.');
}
async restoreXtreamUserData(
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
@@ -719,6 +741,7 @@ export class DatabaseService {
}
try {
await materializePendingXtreamFavorites(window.electron);
const items = await window.electron.dbGetGlobalFavorites();
return items || [];
} catch (error) {
@@ -736,6 +759,7 @@ export class DatabaseService {
}
try {
await materializePendingXtreamFavorites(window.electron);
const items = await window.electron.dbGetAllGlobalFavorites();
return items || [];
} catch (error) {
@@ -795,9 +819,8 @@ export class DatabaseService {
if (typeof window.electron?.dbAddFavorite !== 'function') {
return false;
}
try {
await window.electron.dbAddFavorite(
await this.addToFavoritesOrThrow(
contentId,
playlistId,
backdropUrl
@@ -809,11 +832,30 @@ export class DatabaseService {
}
}
/** User-triggered saves must expose a rejected write to the detail toggle. */
async addToFavoritesOrThrow(
contentId: number,
playlistId: string,
backdropUrl?: string
): Promise<void> {
if (typeof window.electron?.dbAddFavorite !== 'function') {
throw new Error('Favorite storage is unavailable');
}
const result = await window.electron.dbAddFavorite(
contentId,
playlistId,
backdropUrl
);
if (!result.success) {
throw new Error(result.error ?? 'Favorite could not be saved');
}
}
/**
* Remove content from favorites
*/
async removeFromFavorites(
contentId: number,
contentId: XtreamFavoriteRemovalRef,
playlistId: string
): Promise<boolean> {
if (typeof window.electron?.dbRemoveFavorite !== 'function') {
@@ -845,15 +887,22 @@ export class DatabaseService {
}
}
/**
* Get all favorites for a playlist
*/
async getFavorites(playlistId: string): Promise<XtreamContent[]> {
/** Backup reads must fail rather than export an empty list after an IPC error. */
async getFavoritesOrThrow(playlistId: string): Promise<XtreamFavorite[]> {
await materializePendingXtreamFavorites(window.electron, playlistId);
return window.electron.dbGetFavoritesForBackup(playlistId);
}
async getFavorites(playlistId: string): Promise<XtreamFavorite[]> {
if (typeof window.electron?.dbGetFavorites !== 'function') {
return [];
}
try {
await materializePendingXtreamFavorites(
window.electron,
playlistId
);
return await window.electron.dbGetFavorites(playlistId);
} catch (error) {
console.error('Error getting favorites:', error);
@@ -209,12 +209,8 @@ describe('PlaylistBackupService export → import round-trip', () => {
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'));
// Durable favorites remain readable independently of the catalog;
// other pending state does not require a completed import.
jest.spyOn(
collaborators.playbackPositionService,
'getAllPlaybackPositionsOrThrow'
@@ -230,6 +226,7 @@ describe('PlaylistBackupService export → import round-trip', () => {
'honors empty pending collections and optional source pins (present: %s)',
async (pinsPresent) => {
const state = seedState();
state.xtreamFavorites = [];
const collaborators = createStatefulBackupCollaborators(state);
const pending: XtreamPendingRestoreState = {
hiddenCategories: [],
@@ -259,6 +256,7 @@ describe('PlaylistBackupService export → import round-trip', () => {
it('keeps absent pending pins absent when the pin store is unavailable', async () => {
const state = seedState();
state.xtreamFavorites = [];
const collaborators = createStatefulBackupCollaborators(state);
collaborators.vodSourcePinService.isAvailable = false;
const pending: XtreamPendingRestoreState = {
@@ -273,6 +271,91 @@ describe('PlaylistBackupService export → import round-trip', () => {
expect(xtreamEntry(exported.manifest).userState).toEqual(pending);
});
it('preserves older pending favorites before merging another archive', async () => {
const state = seedState();
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const archive = await service.exportBackup();
state.xtreamFavorites = [];
collaborators.pendingRestoreService.set('xtream-1', {
favorites: [
{
contentType: 'movie',
xtreamId: 999,
snapshot: { title: 'Older saved ABC' },
},
],
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
});
expect((await service.importBackup(archive.json)).failed).toBe(0);
expect(
state.xtreamFavorites.map((row) => row.xtream_id).sort()
).toEqual([501, 999]);
expect(
state.xtreamFavorites.find((row) => row.xtream_id === 999)?.title
).toBe('Older saved ABC');
});
it('restores and re-exports saved titles while the catalog is offline', async () => {
const state = seedState();
state.xtreamFavorites = [
{
xtream_id: 501,
type: 'movie',
title: 'Saved ABC',
poster_url: 'https://example.test/abc.jpg',
position: 3,
},
];
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const backup = await service.exportBackup();
state.xtreamFavorites = [];
state.xtreamCategories = [];
collaborators.databaseService.getXtreamImportStatus = async () =>
'idle';
expect((await service.importBackup(backup.json)).failed).toBe(0);
expect(state.xtreamFavorites).toEqual([
{
xtream_id: 501,
type: 'movie',
title: 'Saved ABC',
poster_url: 'https://example.test/abc.jpg',
position: 3,
},
]);
const exported = await service.exportBackup();
expect(xtreamEntry(exported.manifest).userState.favorites).toEqual(
xtreamEntry(backup.manifest).userState.favorites
);
expect(xtreamEntry(exported.manifest).userState).not.toHaveProperty(
'favoritesRestoreId'
);
});
it('refuses to export an empty favorites list after a storage read failure, including during pending import', async () => {
const collaborators = createStatefulBackupCollaborators(seedState());
const service = createPlaylistBackupService(collaborators);
jest.spyOn(
collaborators.databaseService,
'getFavoritesOrThrow'
).mockRejectedValue(new Error('Favorites read failed'));
await expect(service.exportBackup()).rejects.toThrow(
'Favorites read failed'
);
collaborators.pendingRestoreService.set('xtream-1', {
favorites: [],
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
});
await expect(service.exportBackup()).rejects.toThrow(
'Favorites read failed'
);
});
it('refuses to export if pending storage cannot be read', async () => {
const collaborators = createStatefulBackupCollaborators(seedState());
jest.spyOn(
@@ -347,7 +430,7 @@ describe('PlaylistBackupService export → import round-trip', () => {
expect(pins).not.toHaveBeenCalled();
expect(
collaborators.pendingRestoreService.getOrThrow('xtream-1')
).toEqual(userState);
).toEqual({ ...userState, favoritesRestoreId: expect.any(String) });
const parkedExport = await service.exportBackup();
expect(xtreamEntry(parkedExport.manifest).userState).toEqual(
userState
@@ -119,11 +119,14 @@ export function createPlaylistBackupService(
},
databaseService: {
getAllXtreamCategories: jest.fn().mockResolvedValue([]),
getFavorites: jest.fn().mockResolvedValue([]),
getFavoritesOrThrow: jest.fn().mockResolvedValue([]),
getRecentItems: jest.fn().mockResolvedValue([]),
getXtreamImportStatus: jest.fn().mockResolvedValue('idle'),
hasXtreamCategories: jest.fn().mockResolvedValue(false),
hasXtreamContent: jest.fn().mockResolvedValue(false),
restoreXtreamFavoriteSnapshots: jest
.fn()
.mockResolvedValue(undefined),
restoreXtreamUserData: jest.fn().mockResolvedValue(undefined),
updateCategoryVisibility: jest.fn().mockResolvedValue(true),
},
@@ -178,6 +181,8 @@ export interface FakeXtreamContentRow {
added_at?: string;
position?: number | null;
viewed_at?: string;
title?: string;
poster_url?: string;
}
/**
@@ -206,6 +211,7 @@ export function createStatefulBackupCollaborators(
state: FakeBackupBackendState
) {
let lastParsedRawM3u: string | null = null;
const favoriteReceipts = new Set<string>();
return {
playlistsService: {
@@ -270,7 +276,7 @@ export function createStatefulBackupCollaborators(
row.playlist_id === playlistId && row.type === type
)
.map((row) => ({ ...row })),
getFavorites: async () =>
getFavoritesOrThrow: async () =>
state.xtreamFavorites.map((row) => ({ ...row })),
getRecentItems: async () =>
state.xtreamRecent.map((row) => ({ ...row })),
@@ -289,21 +295,47 @@ export function createStatefulBackupCollaborators(
return true;
},
restoreXtreamUserData: async (
restoreXtreamFavoriteSnapshots: async (
_playlistId: string,
favorites: XtreamBackupFavoriteItem[],
restoreId?: string
) => {
const receipt = restoreId ?? JSON.stringify(favorites);
if (!favorites.length || favoriteReceipts.has(receipt)) return;
favoriteReceipts.add(receipt);
const additions = favorites.filter(
(item) =>
!state.xtreamFavorites.some(
(row) =>
row.xtream_id === item.xtreamId &&
row.type === item.contentType
)
);
state.xtreamFavorites.push(
...additions.map((item) => ({
xtream_id: item.xtreamId,
type: item.contentType,
...(item.snapshot
? {
title: item.snapshot.title,
poster_url:
item.snapshot.posterUrl ?? undefined,
}
: {}),
...(item.addedAt !== undefined
? { added_at: item.addedAt }
: {}),
...(item.position !== undefined
? { position: item.position }
: {}),
}))
);
},
restoreXtreamUserData: async (
_playlistId: string,
_favorites: XtreamBackupFavoriteItem[],
recentlyViewed: XtreamBackupRecentlyViewedItem[]
) => {
state.xtreamFavorites = favorites.map((item) => ({
xtream_id: item.xtreamId,
type: item.contentType,
...(item.addedAt !== undefined
? { added_at: item.addedAt }
: {}),
...(item.position !== undefined
? { position: item.position }
: {}),
}));
state.xtreamRecent = recentlyViewed.map((item) => ({
xtream_id: item.xtreamId,
type: item.contentType,
@@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistsService } from './playlists.service';
import { SettingsStore } from './settings-store.service';
import { toXtreamBackupFavorite } from './xtream-favorite-backup';
import { DatabaseService } from './database-electron.service';
import { VodSourcePinService } from './vod-source-pin.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
@@ -318,15 +319,30 @@ export class PlaylistBackupService {
};
const pending = this.pendingRestoreService.getOrThrow(playlist._id);
if (pending) {
// A parked import is authoritative, including empty collections.
// Reading the incomplete catalog instead would silently lose it.
// Other parked user state is authoritative during an import. Favorites
// already have a durable owner: materialize old parked identities once,
// then export the current saved records (including explicit removals).
let favorites = pending.favorites;
if (this.runtime.supportsXtreamSqliteDataSource) {
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlist._id,
pending.favorites,
pending.favoritesRestoreId
);
favorites = (
await this.databaseService.getFavoritesOrThrow(playlist._id)
).map(toXtreamBackupFavorite);
}
const sourcePins =
pending.sourcePins ??
(await this.readSourcePinsForExport(playlist._id));
return {
...entry,
userState: {
...pending,
hiddenCategories: pending.hiddenCategories,
favorites,
recentlyViewed: pending.recentlyViewed,
playbackPositions: pending.playbackPositions,
...this.optionalLockedXtreamCategories(playlist._id),
...(sourcePins ? { sourcePins } : {}),
},
@@ -349,7 +365,7 @@ export class PlaylistBackupService {
this.databaseService.getAllXtreamCategories(playlist._id, 'live'),
this.databaseService.getAllXtreamCategories(playlist._id, 'movies'),
this.databaseService.getAllXtreamCategories(playlist._id, 'series'),
this.databaseService.getFavorites(playlist._id),
this.databaseService.getFavoritesOrThrow(playlist._id),
this.databaseService.getRecentItems(playlist._id),
this.playbackPositionService.getAllPlaybackPositionsOrThrow(
playlist._id
@@ -371,14 +387,7 @@ export class PlaylistBackupService {
...this.mapHiddenCategories(seriesCategories, 'series'),
],
...this.optionalLockedXtreamCategories(playlist._id),
favorites: favorites.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamBackupContentType,
...(item.added_at ? { addedAt: item.added_at } : {}),
...(item.position !== undefined
? { position: item.position }
: {}),
})),
favorites: favorites.map(toXtreamBackupFavorite),
recentlyViewed: recent.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamBackupContentType,
@@ -925,7 +934,20 @@ export class PlaylistBackupService {
// arbitrary categories during restore.
const restoreState: XtreamPendingRestoreState =
normalizeXtreamPendingRestoreState(entry.userState);
// A deliberate re-import is a fresh user action. Do not accept an archive's
// internal receipt or confuse it with a retry of an older parked import.
restoreState.favoritesRestoreId = createRandomId();
// Preserve an older unfinished import before parking the incoming backup.
// Canonical favorites are additive; legacy-only ones need the same protection.
const previous = this.pendingRestoreService.getOrThrow(playlistId);
if (previous && this.runtime.supportsXtreamSqliteDataSource) {
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlistId,
previous.favorites,
previous.favoritesRestoreId
);
}
const restoreSnapshot = this.pendingRestoreService.set(
playlistId,
restoreState
@@ -939,6 +961,11 @@ export class PlaylistBackupService {
// Browser storage has no catalog cache to wait for: apply at once.
const sqlite = this.runtime.supportsXtreamSqliteDataSource;
if (sqlite) {
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlistId,
restoreState.favorites,
restoreState.favoritesRestoreId
);
this.playbackPositionService.assertSupportsAtomicReplacement();
if (!(await this.hasCompletedOfflineCache(playlistId))) return;
}
@@ -1007,9 +1034,14 @@ export class PlaylistBackupService {
): Promise<void> {
this.playbackPositionService.assertSupportsAtomicReplacement();
await this.restoreXtreamCategoryVisibility(playlistId, state);
await this.databaseService.restoreXtreamUserData(
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlistId,
state.favorites,
state.favoritesRestoreId
);
await this.databaseService.restoreXtreamUserData(
playlistId,
[],
state.recentlyViewed
);
@@ -97,6 +97,7 @@ export function createRestoreCollaborators() {
let pendingSnapshot: XtreamPendingRestoreSnapshot | null = null;
let lastConsumedRevision: number | null = null;
const pendingRestoreService = {
getOrThrow: jest.fn(() => pendingSnapshot?.state ?? null),
set: jest.fn(
(
playlistId: string,
@@ -125,18 +126,13 @@ export function createRestoreCollaborators() {
? 'consumed'
: 'superseded';
}
if (
pendingSnapshot.revision !== expectedSnapshot.revision
) {
if (pendingSnapshot.revision !== expectedSnapshot.revision) {
return 'superseded';
}
await apply(pendingSnapshot.state);
if (
!pendingRestoreService.clear(
playlistId,
pendingSnapshot.state
)
!pendingRestoreService.clear(playlistId, pendingSnapshot.state)
) {
return 'consume-failed';
}
@@ -158,11 +154,14 @@ export function createRestoreCollaborators() {
(_playlistId: string, type: string) =>
Promise.resolve(categoryRowsByType[type] ?? [])
),
getFavorites: jest.fn().mockResolvedValue([]),
getFavoritesOrThrow: jest.fn().mockResolvedValue([]),
getRecentItems: jest.fn().mockResolvedValue([]),
getXtreamImportStatus: jest.fn().mockResolvedValue('completed'),
hasXtreamCategories: jest.fn().mockResolvedValue(true),
hasXtreamContent: jest.fn().mockResolvedValue(true),
restoreXtreamFavoriteSnapshots: jest
.fn()
.mockResolvedValue(undefined),
restoreXtreamUserData: jest.fn().mockResolvedValue(undefined),
updateCategoryVisibility: jest.fn().mockResolvedValue(true),
},
@@ -118,6 +118,7 @@ describe('RuntimeCapabilitiesService', () => {
onPlaybackPositionUpdate: jest.fn(),
dbDeleteXtreamContent: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
dbRestoreXtreamFavorites: jest.fn(),
dbReplaceAllPlaybackPositions: jest.fn(),
downloadsStart: jest.fn(),
downloadsCancel: jest.fn(),
@@ -196,6 +197,13 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsEpgDataManagement).toBe(true);
expect(service.supportsEpgGuide).toBe(true);
expect(service.supportsEpgProgramSearch).toBe(true);
for (const method of ['dbGetPlaylist', 'dbRestoreXtreamFavorites']) {
const implementation = testWindow.electron[method];
delete testWindow.electron[method];
expect(service.supportsPortalActivityStorage).toBe(false);
testWindow.electron[method] = implementation;
expect(service.supportsPortalActivityStorage).toBe(true);
}
});
it('keeps feature-specific capabilities false when an Electron bridge is partial', () => {
@@ -661,6 +669,7 @@ function createXtreamSqliteBridge(): Record<string, jest.Mock> {
dbRemoveFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
dbRestoreXtreamFavorites: jest.fn(),
dbReplaceAllPlaybackPositions: jest.fn(),
dbSaveCategories: jest.fn(),
dbSaveContent: jest.fn(),
@@ -223,6 +223,7 @@ export class RuntimeCapabilitiesService {
...playbackPositionStorageMethods,
'dbDeleteXtreamContent',
'dbRestoreXtreamUserData',
'dbRestoreXtreamFavorites',
].every((methodName) => this.hasElectronMethod(methodName));
}
@@ -274,6 +275,8 @@ export class RuntimeCapabilitiesService {
get supportsPortalActivityStorage(): boolean {
return [
'dbGetPlaylist',
'dbRestoreXtreamFavorites',
'dbGetRecentlyViewed',
'dbClearRecentlyViewed',
'dbGetAllGlobalFavorites',
@@ -0,0 +1,36 @@
import {
ElectronBridgeXtreamFavorite,
XtreamBackupFavoriteItem,
} from '@iptvnator/shared/interfaces';
/** Export the saved display snapshot, including records without catalog rows. */
export function toXtreamBackupFavorite(
item: ElectronBridgeXtreamFavorite
): XtreamBackupFavoriteItem {
return {
xtreamId: item.xtream_id,
contentType: item.type as XtreamBackupFavoriteItem['contentType'],
...(item.added_at ? { addedAt: item.added_at } : {}),
...(item.position !== undefined ? { position: item.position } : {}),
...(item.title
? {
snapshot: {
title: item.title,
...(item.poster_url != null
? { posterUrl: item.poster_url }
: {}),
...(item.backdrop_url != null
? { backdropUrl: item.backdrop_url }
: {}),
...(item.tmdb_id != null ? { tmdbId: item.tmdb_id } : {}),
...(item.release_year != null
? { releaseYear: item.release_year }
: {}),
...(item.original_title != null
? { originalTitle: item.original_title }
: {}),
},
}
: {}),
};
}
@@ -0,0 +1,48 @@
import {
ElectronBridgeApi,
getXtreamPendingRestoreStorageKey,
normalizeXtreamPendingRestoreState,
} from '@iptvnator/shared/interfaces';
/** Reads must recover older parked favorites without entering a network bootstrap. */
export async function materializePendingXtreamFavorites(
bridge: Pick<
ElectronBridgeApi,
'dbGetPlaylist' | 'dbRestoreXtreamFavorites'
>,
playlistId?: string
): Promise<void> {
const prefix = getXtreamPendingRestoreStorageKey('');
const ids = playlistId
? [playlistId]
: Array.from({ length: localStorage.length }, (_, index) =>
localStorage.key(index)
)
.filter(
(key): key is string => key !== null && key.startsWith(prefix)
)
.map((key) => key.slice(prefix.length));
for (const id of ids) {
const raw = localStorage.getItem(getXtreamPendingRestoreStorageKey(id));
if (!raw) continue;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
continue;
}
const state = normalizeXtreamPendingRestoreState(parsed);
if (!state.favorites.length) continue;
// Old localStorage can outlive deletion of its owning SQLite playlist.
// Never recreate a playlist or let an orphan block other saved entries.
const playlist = await bridge.dbGetPlaylist(id);
if (!playlist || playlist.type !== 'xtream') continue;
const result = await bridge.dbRestoreXtreamFavorites(
id,
state.favorites,
state.favoritesRestoreId
);
if (!result.success)
throw new Error('Recovering saved favorites failed.');
}
}