mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
fix(xtream): preserve favorites across catalog refreshes
This commit is contained in:
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.');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user