From a9955ce78162429b8914caffc15a83d7863f70e2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 01:26:38 +0200 Subject: [PATCH] fix(xtream): enforce restore preflight and favorite acknowledgements --- .../xtream-portal-compatibility.md | 9 ++- .../unified-favorites-data.service.spec.ts | 33 ++++++++++ .../unified-favorites-data.service.ts | 5 +- .../unified-recent-data.service.spec.ts | 2 + .../unified-collection-page.component.spec.ts | 37 ++++++++++++ .../unified-collection-page.component.ts | 23 ++++++- .../electron-xtream-data-source.ts | 1 + ...ctron-xtream-data-source.user-data.spec.ts | 22 +++++++ .../src/lib/database-electron.service.spec.ts | 60 +++++++++++++++++++ .../src/lib/database-electron.service.ts | 6 +- .../playlist-backup.service.roundtrip.spec.ts | 6 +- .../src/lib/playlist-backup.service.ts | 3 + .../src/lib/xtream-pending-favorites.ts | 17 +++++- 13 files changed, 212 insertions(+), 12 deletions(-) diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 2146743bf..4d1bdbfb8 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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 diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index b1e8dfa00..9cf17123d 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -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, diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index 7c0ef2825..426e53751 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -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( diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index be491d092..50c1b5e48 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -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(), diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 8712b2e13..c4ffcb9e4 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -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(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index e877881a8..5a6def0f2 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -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); 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 { diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 459207633..8acf858c0 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -689,6 +689,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { playlistId: string, state: XtreamPendingRestoreState ): Promise { + this.playbackService.assertSupportsAtomicReplacement(); await this.dbService.restoreXtreamFavoriteSnapshots( playlistId, state.favorites, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts index 2125ba027..6dd89f719 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts @@ -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: [], diff --git a/libs/services/src/lib/database-electron.service.spec.ts b/libs/services/src/lib/database-electron.service.spec.ts index 87dcc875b..d7e04883e 100644 --- a/libs/services/src/lib/database-electron.service.spec.ts +++ b/libs/services/src/lib/database-electron.service.spec.ts @@ -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() diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 52b62bbf5..c3d3ed8df 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -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 { if (favorites.length === 0) return; + assertXtreamFavoriteRestoreSupported(window.electron); const result = await window.electron.dbRestoreXtreamFavorites( playlistId, favorites, diff --git a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts index 7c49af265..5a985871c 100644 --- a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts @@ -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 () => diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index a43cb2e3e..072d925ca 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -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, diff --git a/libs/services/src/lib/xtream-pending-favorites.ts b/libs/services/src/lib/xtream-pending-favorites.ts index 89ec7dc73..5a271c4da 100644 --- a/libs/services/src/lib/xtream-pending-favorites.ts +++ b/libs/services/src/lib/xtream-pending-favorites.ts @@ -4,12 +4,26 @@ import { normalizeXtreamPendingRestoreState, } from '@iptvnator/shared/interfaces'; +type FavoriteRestoreSupport = Partial< + Pick +>; + +/** 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 { @@ -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,