fix(xtream): enforce restore preflight and favorite acknowledgements

This commit is contained in:
4gray committed 2026-10-14 01:26:38 +02:00
1 parent fcbf57db3e
commit a9955ce781
13 files changed
+212 -12

No files matched your search

@@ -60,8 +60,9 @@ waiting for another import. Blank catalog fields retain their saved values.
Conflicting known TMDB IDs or release years prevent automatic reconnection;
changed provider IDs never reconnect by title.
Adding a conflicting replacement returns an explicit failure and retains the
older favorite. The detail toggle stays inactive and explains that the older
entry must be removed from Favorites before the replacement can be saved.
older favorite. Detail and recent-collection toggles stay inactive and explain
that the older entry must be removed from Favorites before the replacement can
be saved. Collection hearts change only after the storage write succeeds.
`catalog_status` is `present`, `missing`, or `unknown`. Only a completed import
of the relevant type can prove absence. Failed, cancelled and in-progress
@@ -99,6 +100,10 @@ favorite conversion bridge; activity reads also require playlist lookup. The
SQLite capability also requires the unfiltered favorite backup reader. An
unsupported atomic restore performs no favorite writes: a fresh archive stays
parked, while an older pending restore is retained without being overwritten.
Subsequent collection reads and early catalog imports must enforce the same
preflight before replaying those favorites. Ordinary reads still return existing
SQLite records. Exports that need to materialize parked favorites fail while
atomic restore is unsupported; exports with no pending favorites remain available.
Records already deleted by an older build cannot be recovered without a
surviving pending state or backup. Opening an Xtream section may initialize
@@ -12,10 +12,14 @@ import {
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
import { UnifiedCollectionDataService } from './unified-collection-data.service';
import { UnifiedRecentDataService } from './unified-recent-data.service';
describe('UnifiedFavoritesDataService', () => {
let service: UnifiedFavoritesDataService;
let electronApi: {
dbGetPlaylist: jest.Mock;
dbRestoreXtreamFavorites: jest.Mock;
dbAddFavorite: jest.Mock;
dbAddRecentItem: jest.Mock;
dbClearPlaylistRecentItems: jest.Mock;
@@ -108,6 +112,8 @@ describe('UnifiedFavoritesDataService', () => {
beforeEach(() => {
electronApi = {
dbGetPlaylist: jest.fn(),
dbRestoreXtreamFavorites: jest.fn(),
dbAddFavorite: jest.fn().mockResolvedValue({ success: true }),
dbAddRecentItem: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
@@ -198,6 +204,8 @@ describe('UnifiedFavoritesDataService', () => {
TestBed.configureTestingModule({
providers: [
UnifiedFavoritesDataService,
UnifiedCollectionDataService,
{ provide: UnifiedRecentDataService, useValue: {} },
{
provide: Store,
useValue: store,
@@ -677,6 +685,31 @@ describe('UnifiedFavoritesDataService', () => {
);
});
it('keeps a recent item heart empty when SQLite rejects its saved identity', async () => {
const item: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::movie:101',
name: 'Replacement movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 101,
contentId: 42,
};
electronApi.dbAddFavorite.mockResolvedValue({
success: false,
error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT',
});
const collection = TestBed.inject(UnifiedCollectionDataService);
await expect(collection.toggleFavorite(item)).rejects.toThrow(
'XTREAM_FAVORITE_IDENTITY_CONFLICT'
);
expect(collection.favoriteUidSet().has(item.uid)).toBe(false);
electronApi.dbAddFavorite.mockResolvedValue({ success: true });
await collection.toggleFavorite(item);
expect(collection.favoriteUidSet().has(item.uid)).toBe(true);
});
it('uses the Xtream id as the favorite key in PWA when cached content is cold', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
@@ -162,11 +162,14 @@ export class UnifiedFavoritesDataService {
return;
}
await electron.dbAddFavorite(
const result = await electron.dbAddFavorite(
contentId,
item.playlistId,
item.posterUrl ?? item.logo ?? undefined
);
if (!result.success) {
throw new Error(result.error ?? 'Favorite could not be saved');
}
}
private async addStalkerFavorite(
@@ -99,6 +99,8 @@ describe('UnifiedRecentDataService', () => {
const createPortalActivityElectronApi = (): Window['electron'] =>
({
dbGetPlaylist: jest.fn(),
dbRestoreXtreamFavorites: jest.fn(),
dbGetRecentlyViewed: jest.fn(),
dbClearRecentlyViewed: jest.fn(),
dbGetAllGlobalFavorites: jest.fn(),
@@ -8,6 +8,7 @@ import {
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatButtonToggleGroup } from '@angular/material/button-toggle';
import { MatSnackBar } from '@angular/material/snack-bar';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -25,6 +26,7 @@ import {
liveSidebarStateStorageKey,
} from '@iptvnator/portal/shared/util';
import {
UnifiedCollectionDataService,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
@@ -158,6 +160,7 @@ describe('UnifiedCollectionPageComponent', () => {
const dialogService = {
openConfirmDialog: jest.fn(),
};
const snackBar = { open: jest.fn() };
const workspaceViewCommands = {
registerCommand: jest.fn(),
};
@@ -251,6 +254,7 @@ describe('UnifiedCollectionPageComponent', () => {
TranslateModule.forRoot(),
],
providers: [
{ provide: MatSnackBar, useValue: snackBar },
{
provide: ActivatedRoute,
useValue: route,
@@ -323,6 +327,39 @@ describe('UnifiedCollectionPageComponent', () => {
fixture.componentRef.setInput('defaultScope', 'all');
});
it.each([
[
'XTREAM_FAVORITE_IDENTITY_CONFLICT',
'PORTALS.FAVORITE_IDENTITY_CONFLICT',
],
['SQLite write failed', 'PORTALS.FAVORITE_SAVE_FAILED'],
])(
'reports %s without filling the recent favorite heart',
async (error, message) => {
fixture.componentRef.setInput('mode', 'recent');
const item: UnifiedCollectionItem = {
uid: 'xtream::source::live:7',
name: 'Recent channel',
sourceType: 'xtream',
contentType: 'live',
playlistId: 'source',
playlistName: 'Source',
contentId: 42,
};
favoritesData.addFavorite.mockRejectedValueOnce(new Error(error));
await expect(
fixture.componentInstance.onFavoriteToggled(item)
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenCalledWith(message, undefined, {
duration: 6000,
});
const data = fixture.debugElement.injector.get(
UnifiedCollectionDataService
);
expect(data.favoriteUidSet().has(item.uid)).toBe(false);
}
);
it('uses compact loading rows when the runtime has no EPG support', () => {
fixture.detectChanges();
@@ -1,4 +1,7 @@
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
import {
isCatalogUnavailable,
XTREAM_FAVORITE_IDENTITY_CONFLICT,
} from '@iptvnator/shared/interfaces';
import { NgTemplateOutlet } from '@angular/common';
import {
AfterContentInit,
@@ -18,9 +21,10 @@ import { MatIconButton } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBar } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ChannelListSkeletonComponent,
MenuItemRadioCheckDirective,
@@ -105,6 +109,8 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
private readonly destroyRef = inject(DestroyRef);
private readonly hostElement = inject(ElementRef<HTMLElement>);
private readonly data = inject(UnifiedCollectionDataService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
@@ -342,7 +348,18 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
return;
}
await this.data.toggleFavorite(item);
try {
await this.data.toggleFavorite(item);
} catch (error) {
const key =
error instanceof Error &&
error.message === XTREAM_FAVORITE_IDENTITY_CONFLICT
? 'PORTALS.FAVORITE_IDENTITY_CONFLICT'
: 'PORTALS.FAVORITE_SAVE_FAILED';
this.snackBar.open(this.translate.instant(key), undefined, {
duration: 6000,
});
}
}
clearAllCurrent(): void {
@@ -689,6 +689,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId: string,
state: XtreamPendingRestoreState
): Promise<void> {
this.playbackService.assertSupportsAtomicReplacement();
await this.dbService.restoreXtreamFavoriteSnapshots(
playlistId,
state.favorites,
@@ -379,6 +379,28 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
).not.toHaveBeenCalled();
});
it('rejects the early pending-favorite restore before calling the database on an unsupported bridge', async () => {
harness.playbackService.assertSupportsAtomicReplacement.mockImplementation(
() => {
throw new Error(
'Playback position replacement method is unavailable'
);
}
);
await expect(
harness.dataSource.restoreFavoriteSnapshots(playlistId, {
favorites: [{ xtreamId: 7, contentType: 'movie' }],
favoritesRestoreId: 'parked-backup',
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
})
).rejects.toThrow('unavailable');
expect(
harness.dbService.restoreXtreamFavoriteSnapshots
).not.toHaveBeenCalled();
});
it('rejects deferred restore when position replacement fails, retaining the caller retry', async () => {
const restoreState = {
hiddenCategories: [],
@@ -61,6 +61,7 @@ describe('DatabaseService browser guards', () => {
JSON.stringify(pending)
);
const bridge = {
dbReplaceAllPlaybackPositions: jest.fn(),
dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }),
dbRestoreXtreamFavorites: jest
.fn()
@@ -115,6 +116,7 @@ describe('DatabaseService browser guards', () => {
Object.defineProperty(window, 'electron', {
configurable: true,
value: {
dbReplaceAllPlaybackPositions: jest.fn(),
dbGetPlaylist: jest
.fn()
.mockResolvedValue({ type: 'xtream' }),
@@ -164,6 +166,7 @@ describe('DatabaseService browser guards', () => {
const rows = [{ favorite_id: 1, title: 'Saved favorite' }];
const read = jest.fn().mockResolvedValue(rows);
const bridge = {
dbReplaceAllPlaybackPositions: jest.fn(),
dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }),
dbRestoreXtreamFavorites: jest
.fn()
@@ -231,6 +234,62 @@ describe('DatabaseService browser guards', () => {
}
);
it('does not replay a parked backup through collection, export or direct snapshot reads until restore is supported', async () => {
const favorites = [{ contentType: 'movie' as const, xtreamId: 7 }];
const pending = JSON.stringify({
favorites,
favoritesRestoreId: 'parked-backup',
});
localStorage.setItem('xtream-restore-playlist-1', pending);
const rows = [{ favorite_id: 1, title: 'Previously saved favorite' }];
const bridge = {
dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }),
dbRestoreXtreamFavorites: jest
.fn()
.mockResolvedValue({ success: true }),
dbGetFavorites: jest.fn().mockResolvedValue(rows),
dbGetGlobalFavorites: jest.fn().mockResolvedValue(rows),
dbGetAllGlobalFavorites: jest.fn().mockResolvedValue(rows),
dbGetFavoritesForBackup: jest.fn().mockResolvedValue(rows),
dbReplaceAllPlaybackPositions: undefined as jest.Mock | undefined,
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: bridge,
});
for (const method of [
'getFavorites',
'getGlobalFavorites',
'getAllGlobalFavorites',
] as const) {
await expect(service[method]('playlist-1')).resolves.toEqual(rows);
}
expect(bridge.dbRestoreXtreamFavorites).not.toHaveBeenCalled();
await expect(service.getFavoritesOrThrow('playlist-1')).rejects.toThrow(
'replacement method is unavailable'
);
await expect(
service.restoreXtreamFavoriteSnapshots(
'playlist-1',
favorites,
'parked-backup'
)
).rejects.toThrow('replacement method is unavailable');
expect(bridge.dbRestoreXtreamFavorites).not.toHaveBeenCalled();
expect(bridge.dbGetFavoritesForBackup).not.toHaveBeenCalled();
expect(localStorage.getItem('xtream-restore-playlist-1')).toBe(pending);
bridge.dbReplaceAllPlaybackPositions = jest.fn();
await expect(
service.getFavoritesOrThrow('playlist-1')
).resolves.toEqual(rows);
expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledWith(
'playlist-1',
favorites,
'parked-backup'
);
expect(bridge.dbReplaceAllPlaybackPositions).not.toHaveBeenCalled();
});
it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => {
localStorage.setItem(
'xtream-restore-orphan',
@@ -239,6 +298,7 @@ describe('DatabaseService browser guards', () => {
})
);
const bridge = {
dbReplaceAllPlaybackPositions: jest.fn(),
dbGetPlaylist: jest.fn().mockResolvedValue(null),
dbRestoreXtreamFavorites: jest
.fn()
@@ -1,4 +1,7 @@
import { materializePendingXtreamFavorites } from './xtream-pending-favorites';
import {
assertXtreamFavoriteRestoreSupported,
materializePendingXtreamFavorites,
} from './xtream-pending-favorites';
import type {
ElectronBridgeXtreamFavorite,
XtreamFavoriteRemovalRef,
@@ -303,6 +306,7 @@ export class DatabaseService {
restoreId?: string
): Promise<void> {
if (favorites.length === 0) return;
assertXtreamFavoriteRestoreSupported(window.electron);
const result = await window.electron.dbRestoreXtreamFavorites(
playlistId,
favorites,
@@ -436,10 +436,8 @@ describe('PlaylistBackupService export → import round-trip', () => {
expect(
collaborators.pendingRestoreService.getOrThrow('xtream-1')
).toEqual({ ...userState, favoritesRestoreId: expect.any(String) });
const parkedExport = await service.exportBackup();
expect(xtreamEntry(parkedExport.manifest).userState).toEqual(
userState
);
await expect(service.exportBackup()).rejects.toThrow('unavailable');
expect(favorites).not.toHaveBeenCalled();
preflight.mockImplementation(() => undefined);
collaborators.databaseService.getXtreamImportStatus = async () =>
@@ -324,6 +324,9 @@ export class PlaylistBackupService {
// then export the current saved records (including explicit removals).
let favorites = pending.favorites;
if (this.runtime.supportsXtreamSqliteDataSource) {
if (favorites.length) {
this.playbackPositionService.assertSupportsAtomicReplacement();
}
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlist._id,
pending.favorites,
@@ -4,12 +4,26 @@ import {
normalizeXtreamPendingRestoreState,
} from '@iptvnator/shared/interfaces';
type FavoriteRestoreSupport = Partial<
Pick<ElectronBridgeApi, 'dbReplaceAllPlaybackPositions'>
>;
/** Parked archives must not bypass the restore-only atomic replacement preflight. */
export function assertXtreamFavoriteRestoreSupported(
bridge: FavoriteRestoreSupport
): void {
if (typeof bridge.dbReplaceAllPlaybackPositions !== 'function') {
throw new Error('Playback position replacement method is unavailable');
}
}
/** Reads must recover older parked favorites without entering a network bootstrap. */
export async function materializePendingXtreamFavorites(
bridge: Pick<
ElectronBridgeApi,
'dbGetPlaylist' | 'dbRestoreXtreamFavorites'
>,
> &
FavoriteRestoreSupport,
playlistId?: string,
failureMode: 'throw' | 'continue' = 'throw'
): Promise<void> {
@@ -49,6 +63,7 @@ export async function materializePendingXtreamFavorites(
// Never recreate a playlist or let an orphan block other saved entries.
const playlist = await bridge.dbGetPlaylist(id);
if (!playlist || playlist.type !== 'xtream') continue;
assertXtreamFavoriteRestoreSupported(bridge);
const result = await bridge.dbRestoreXtreamFavorites(
id,
state.favorites,