From c8c93798f94fb280c7f0879a3649ed31736cc29c Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 21 Apr 2026 08:54:03 +0200 Subject: [PATCH] feat: enhance playlist backup export functionality and UI feedback Entire-Checkpoint: c6e522b4276c --- .../src/app/settings/settings.component.html | 28 +- .../src/app/settings/settings.component.scss | 25 + .../app/settings/settings.component.spec.ts | 113 +- .../src/app/settings/settings.component.ts | 152 ++- apps/web/src/assets/i18n/en.json | 5 +- apps/web/src/assets/i18n/es.json | 4 +- .../src/lib/reducers/playlist.reducers.ts | 12 +- .../recent-playlists.component.spec.ts | 61 +- .../recent-playlists.component.ts | 159 ++- .../playlist-refresh-action.service.spec.ts | 57 +- .../lib/playlist-refresh-action.service.ts | 64 +- .../electron-xtream-data-source.ts | 71 +- .../data-sources/pwa-xtream-data-source.ts | 118 +- .../xtream-data-source.interface.ts | 12 +- .../stores/features/with-content.feature.ts | 144 ++- libs/services/src/index.ts | 2 + .../src/lib/database-electron.service.ts | 31 +- .../src/lib/playback-position.service.ts | 12 +- .../src/lib/playlist-backup.service.spec.ts | 279 +++++ .../src/lib/playlist-backup.service.ts | 1028 +++++++++++++++++ libs/services/src/lib/playlists.service.ts | 25 +- .../src/lib/settings-store.service.ts | 6 +- .../src/lib/xtream-pending-restore.service.ts | 89 ++ libs/shared/interfaces/src/index.ts | 2 + .../src/lib/playlist-backup.interface.ts | 113 ++ .../src/lib/xtream-restore-state.util.ts | 17 + 26 files changed, 2254 insertions(+), 375 deletions(-) create mode 100644 libs/services/src/lib/playlist-backup.service.spec.ts create mode 100644 libs/services/src/lib/playlist-backup.service.ts create mode 100644 libs/services/src/lib/xtream-pending-restore.service.ts create mode 100644 libs/shared/interfaces/src/lib/playlist-backup.interface.ts create mode 100644 libs/shared/interfaces/src/lib/xtream-restore-state.util.ts diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 63cd2fb6e..1ef4fb866 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -756,7 +756,10 @@
diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index cebf25fa7..2b54e0be6 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -333,6 +333,31 @@ } } +.inline-actions__button { + min-width: 118px; +} + +.inline-actions__button--busy { + background: color-mix( + in srgb, + var(--app-selection-surface) 74%, + transparent + ); + color: var(--app-selection-color); + box-shadow: + inset 0 0 0 1px color-mix( + in srgb, + var(--app-selection-border) 48%, + transparent + ), + 0 8px 18px -18px var(--settings-group-active-glow); +} + +.inline-actions__label { + display: inline-flex; + align-items: center; +} + .remote-control-urls { display: flex; flex-direction: column; diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 88275daf0..afe317efe 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -20,12 +20,9 @@ import { Store } from '@ngrx/store'; import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { EpgService } from '@iptvnator/epg/data-access'; -import { - MockModule, - MockProvider, -} from 'ng-mocks'; +import { MockModule, MockProvider } from 'ng-mocks'; import { DialogService } from 'components'; -import { DataService, PlaylistsService } from 'services'; +import { DataService, PlaylistBackupService, PlaylistsService } from 'services'; import { Language, StartupBehavior, @@ -90,18 +87,15 @@ class MockSettingsStore { } class MockSettingsService { - getAppVersion = jest - .fn() - .mockReturnValue(from(Promise.resolve('1.0.0'))); + getAppVersion = jest.fn().mockReturnValue(from(Promise.resolve('1.0.0'))); changeTheme = jest.fn(); - isVersionOutdated = jest - .fn() - .mockImplementation((currentVersion: string, latestVersion: string) => + isVersionOutdated = jest.fn().mockImplementation( + (currentVersion: string, latestVersion: string) => currentVersion.localeCompare(latestVersion, undefined, { numeric: true, sensitivity: 'base', }) < 0 - ); + ); } describe('SettingsComponent', () => { @@ -114,6 +108,7 @@ describe('SettingsComponent', () => { let epgService: EpgService; let dialogService: DialogService; let playlistsService: PlaylistsService; + let playlistBackupService: PlaylistBackupService; let store: Store; let snackBar: MatSnackBarStub; const originalElectron = window.electron; @@ -145,6 +140,27 @@ describe('SettingsComponent', () => { getAllData: jest.fn().mockReturnValue(of([])), removeAll: jest.fn(), }), + MockProvider(PlaylistBackupService, { + exportBackup: jest.fn().mockResolvedValue({ + defaultFileName: + 'iptvnator-playlist-backup-2026-04-21.json', + json: '{}', + manifest: { + kind: 'iptvnator-playlist-backup', + version: 1, + exportedAt: '2026-04-21T00:00:00.000Z', + includeSecrets: true, + playlists: [], + }, + }), + importBackup: jest.fn().mockResolvedValue({ + imported: 0, + merged: 0, + skipped: 0, + failed: 0, + errors: [], + }), + }), ], imports: [ SettingsComponent, @@ -172,15 +188,15 @@ describe('SettingsComponent', () => { staleUrls: [], }), clearEpgData: jest.fn().mockResolvedValue({ success: true }), - forceFetchEpg: jest - .fn() - .mockResolvedValue({ success: true }), + forceFetchEpg: jest.fn().mockResolvedValue({ success: true }), getAppVersion: jest.fn().mockResolvedValue('1.0.0'), getLocalIpAddresses: jest.fn().mockResolvedValue([]), platform: 'linux', + saveFileDialog: jest.fn().mockResolvedValue('/tmp/backup.json'), setMpvPlayerPath: jest.fn().mockResolvedValue(undefined), setVlcPlayerPath: jest.fn().mockResolvedValue(undefined), updateSettings: jest.fn().mockResolvedValue(undefined), + writeFile: jest.fn().mockResolvedValue({ success: true }), } as unknown as typeof window.electron; fixture = TestBed.createComponent(SettingsComponent); @@ -191,6 +207,7 @@ describe('SettingsComponent', () => { epgService = TestBed.inject(EpgService); dialogService = TestBed.inject(DialogService); playlistsService = TestBed.inject(PlaylistsService); + playlistBackupService = TestBed.inject(PlaylistBackupService); store = TestBed.inject(Store); snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; @@ -245,9 +262,8 @@ describe('SettingsComponent', () => { const scrollFixture = TestBed.createComponent(SettingsComponent); const scrollComponent = scrollFixture.componentInstance; const settingsContext = TestBed.inject(SettingsContextService); - const originalGetElementById = document.getElementById.bind( - document - ); + const originalGetElementById = + document.getElementById.bind(document); const scrollTo = jest.fn(); const scrollRoot = { scrollTop: 96, @@ -476,6 +492,63 @@ describe('SettingsComponent', () => { expect(refreshSpy).toHaveBeenCalled(); }); + it('shows an export busy state until the backup file has been written', async () => { + let resolveExport: ( + value: { + defaultFileName: string; + json: string; + manifest: { + kind: string; + version: number; + exportedAt: string; + includeSecrets: boolean; + playlists: never[]; + }; + } + ) => void = () => undefined; + + ( + playlistBackupService.exportBackup as jest.Mock + ).mockReturnValueOnce( + new Promise((resolve) => { + resolveExport = resolve; + }) + ); + + const exportPromise = component.exportData(); + + expect(component.isExportingData()).toBe(true); + + resolveExport({ + defaultFileName: 'iptvnator-playlist-backup-2026-04-21.json', + json: '{}', + manifest: { + kind: 'iptvnator-playlist-backup', + version: 1, + exportedAt: '2026-04-21T00:00:00.000Z', + includeSecrets: true, + playlists: [], + }, + }); + + await exportPromise; + + expect(window.electron.saveFileDialog).toHaveBeenCalledWith( + 'iptvnator-playlist-backup-2026-04-21.json', + [ + { + extensions: ['json'], + name: 'JSON', + }, + ] + ); + expect(window.electron.writeFile).toHaveBeenCalledWith( + '/tmp/backup.json', + '{}' + ); + expect(component.isExportingData()).toBe(false); + }); + it('shows a failure snackbar and skips refresh when clearing EPG data rejects', async () => { (window.electron.clearEpgData as jest.Mock).mockRejectedValueOnce( new Error('boom') @@ -518,7 +591,9 @@ describe('SettingsComponent', () => { const nativeElement = fixture.nativeElement as HTMLElement; expect( - nativeElement.querySelector('[data-test-id="toggle-show-dashboard"]') + nativeElement.querySelector( + '[data-test-id="toggle-show-dashboard"]' + ) ).not.toBeNull(); expect(component.settingsForm.value.showDashboard).toBe(true); expect(component.settingsForm.value.startupBehavior).toBe( diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 4027f800d..136e7c6d7 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -30,10 +30,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; -import { - MatSnackBar, - MatSnackBarConfig, -} from '@angular/material/snack-bar'; +import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -44,10 +41,14 @@ import { QRCodeComponent } from 'angularx-qrcode'; import { DialogService } from 'components'; import { PlaylistActions, selectIsEpgAvailable } from 'm3u-state'; import { firstValueFrom, take } from 'rxjs'; -import { DataService, PlaylistsService } from 'services'; +import { + DataService, + PlaylistBackupImportSummary, + PlaylistBackupService, + PlaylistsService, +} from 'services'; import { Language, - Playlist, StartupBehavior, StreamFormat, Theme, @@ -117,6 +118,7 @@ export class SettingsComponent implements OnInit, OnDestroy { private store = inject(Store); private translate = inject(TranslateService); private matDialog = inject(MatDialog); + private playlistBackupService = inject(PlaylistBackupService); private readonly elementRef = inject(ElementRef); private readonly injector = inject(Injector); private readonly dialogData = inject<{ isDialog: boolean } | null>( @@ -241,11 +243,13 @@ export class SettingsComponent implements OnInit, OnDestroy { visibleQrCodeIp = signal(null); readonly isRemovingAllPlaylists = signal(false); readonly isClearingEpgData = signal(false); + readonly isExportingData = signal(false); private settingsStore = inject(SettingsStore); private sectionObserver?: IntersectionObserver; - private pendingScrollClearTimer: ReturnType | null = - null; + private pendingScrollClearTimer: ReturnType< + typeof window.setTimeout + > | null = null; private pendingScrollClearRoot: HTMLElement | null = null; private pendingScrollEndListener: (() => void) | null = null; @@ -398,6 +402,7 @@ export class SettingsComponent implements OnInit, OnDestroy { this.settingsForm.patchValue(currentSettings); if (this.isDesktop && currentSettings.epgUrl) { + this.epgUrl.clear(); this.setEpgUrls(currentSettings.epgUrl); } } @@ -626,9 +631,7 @@ export class SettingsComponent implements OnInit, OnDestroy { } catch (error) { console.error('Failed to clear EPG data:', error); this.openSettingsSnackbar( - this.translate.instant( - 'SETTINGS.EPG_DATA_CLEAR_FAILED' - ) + this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED') ); } finally { this.isClearingEpgData.set(false); @@ -637,21 +640,55 @@ export class SettingsComponent implements OnInit, OnDestroy { }); } - exportData() { - this.playlistsService - .getAllData() - .pipe(take(1)) - .subscribe((data) => { - const blob = new Blob([JSON.stringify(data)], { - type: 'text/plain', + async exportData() { + if (this.isExportingData()) { + return; + } + + this.isExportingData.set(true); + + // Let Angular paint the busy state before the backup build and native + // save dialog handoff start. + await this.waitForUiFeedbackFrame(); + + try { + const backup = await this.playlistBackupService.exportBackup(); + + if (this.isDesktop && window.electron?.saveFileDialog) { + const savePath = await window.electron.saveFileDialog( + backup.defaultFileName, + [ + { + name: 'JSON', + extensions: ['json'], + }, + ] + ); + + if (!savePath) { + return; + } + + await window.electron.writeFile(savePath, backup.json); + } else { + const blob = new Blob([backup.json], { + type: 'application/json', }); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = 'playlists.json'; + link.download = backup.defaultFileName; link.click(); window.URL.revokeObjectURL(url); - }); + } + + this.openSettingsSnackbar('Playlist backup exported.'); + } catch (error) { + console.error('Failed to export playlist backup:', error); + this.openSettingsSnackbar('Playlist backup export failed.'); + } finally { + this.isExportingData.set(false); + } } importData() { @@ -659,45 +696,66 @@ export class SettingsComponent implements OnInit, OnDestroy { input.type = 'file'; input.accept = 'application/json'; - input.addEventListener('change', (event: Event) => { + input.addEventListener('change', async (event: Event) => { const target = event.target as HTMLInputElement; const file = target.files?.[0]; if (file) { - const reader = new FileReader(); - reader.onload = () => { - const contents = reader.result; - - try { - const parsedPlaylists: Playlist[] = JSON.parse( - contents.toString() + try { + const summary = + await this.playlistBackupService.importBackup( + await file.text() ); - if (!Array.isArray(parsedPlaylists)) { - this.openSettingsSnackbar( - this.translate.instant('SETTINGS.IMPORT_ERROR'), - ); - } else { - this.store.dispatch( - PlaylistActions.addManyPlaylists({ - playlists: parsedPlaylists, - }) - ); - } - } catch (error) { - this.openSettingsSnackbar( - this.translate.instant('SETTINGS.IMPORT_ERROR'), + if (summary.imported > 0 || summary.merged > 0) { + this.store.dispatch( + PlaylistActions.removeAllPlaylists() ); - console.error(error); + this.store.dispatch(PlaylistActions.loadPlaylists()); } - }; - reader.readAsText(file); + + this.setSettings(); + this.openSettingsSnackbar( + this.buildBackupImportSummary(summary) + ); + + if (summary.errors.length > 0) { + console.error( + 'Playlist backup import completed with issues:', + summary.errors + ); + } + } catch (error) { + console.error('Failed to import playlist backup:', error); + this.openSettingsSnackbar( + error instanceof Error + ? error.message + : this.translate.instant('SETTINGS.IMPORT_ERROR') + ); + } } }); input.click(); } + private buildBackupImportSummary( + summary: PlaylistBackupImportSummary + ): string { + return `Backup import finished: ${summary.imported} imported, ${summary.merged} merged, ${summary.skipped} skipped, ${summary.failed} failed.`; + } + + private async waitForUiFeedbackFrame(): Promise { + if (typeof window.requestAnimationFrame !== 'function') { + await Promise.resolve(); + return; + } + + await new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); + } + removeAll() { this.dialogService.openConfirmDialog({ title: this.translate.instant('SETTINGS.REMOVE_DIALOG.TITLE'), @@ -713,12 +771,12 @@ export class SettingsComponent implements OnInit, OnDestroy { await firstValueFrom(this.playlistsService.removeAll()); this.store.dispatch(PlaylistActions.removeAllPlaylists()); this.openSettingsSnackbar( - this.translate.instant('SETTINGS.PLAYLISTS_REMOVED'), + this.translate.instant('SETTINGS.PLAYLISTS_REMOVED') ); } catch (error) { console.error('Error removing playlists:', error); this.openSettingsSnackbar( - this.translate.instant('SETTINGS.IMPORT_ERROR'), + this.translate.instant('SETTINGS.IMPORT_ERROR') ); } finally { this.isRemovingAllPlaylists.set(false); diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index c746bf463..8778cf513 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -246,10 +246,11 @@ "SUPPORT_KOFI_HINT": "Buy me a coffee", "EPG_NOTE": "Currently EPG is available only in the electron-based version of IPTVnator.", "EPG_NOTE_URL_TEXT": "Check it here", - "IMPORT_EXPORT_DATA": "Import/export playlists", - "IMPORT_EXPORT_DATA_DESCRIPTION": "Export or import playlists in the JSON format used within the application.", + "IMPORT_EXPORT_DATA": "Import/export playlist backups", + "IMPORT_EXPORT_DATA_DESCRIPTION": "Export or restore playlist backups including M3U snapshots, Xtream/Stalker connection metadata, user state, and EPG source URLs.", "IMPORT_DATA": "Import", "EXPORT_DATA": "Export", + "EXPORTING_DATA": "Exporting…", "REMOVE_ALL": "Remove playlists", "REMOVE_ALL_BUTTON": "Remove", "REMOVE_ALL_DESCRIPTION": "It will remove all existing playlists from the application.", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 181f54e3a..d77e0b694 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -161,6 +161,7 @@ "IMPORT_ERROR": "Error de importación, intente con otro archivo.", "EPG_URL_ERROR": "El valor no es una URL válida.", "EXPORT_DATA": "Exportar", + "EXPORTING_DATA": "Exportando…", "VIDEO_PLAYER_DESCRIPTION": "Seleccionar reproductor de vídeo predeterminado", "IMPORT_EXPORT_DATA": "Importar/exportar listas de reproducción", "VLC_PLAYER_PATH_LABEL": "ruta VLC", @@ -168,7 +169,8 @@ "THEME_DESCRIPTION": "Seleccione el tema visual de la aplicación.", "EPG_NOTE": "Actualmente, EPG está disponible sólo en la versión electrónica de IPTVnator.", "VLC_PLAYER_PATH": "Ruta del reproductor VLC", - "IMPORT_EXPORT_DATA_DESCRIPTION": "Exporte o importe listas de reproducción en el formato JSON utilizado dentro de la aplicación.", + "IMPORT_EXPORT_DATA": "Importar/exportar copias de seguridad", + "IMPORT_EXPORT_DATA_DESCRIPTION": "Exporte o restaure copias de seguridad con instantáneas M3U, metadatos de conexión de Xtream/Stalker, estado del usuario y URLs de EPG.", "EPG_NOTE_URL_TEXT": "Compruébalo aquí", "MPV_PLAYER_PATH_LABEL": "Ruta de mpv", "REMOVE_ALL_DESCRIPTION": "Esto eliminará todas las listas de reproducción existentes de la aplicación.", diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts index 3955733df..8962d606e 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -31,7 +31,8 @@ export const playlistReducers = [ }; }), on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => { - const isActivePlaylist = state.playlists.selectedId === action.playlistId; + const isActivePlaylist = + state.playlists.selectedId === action.playlistId; return { ...state, channels: isActivePlaylist @@ -110,6 +111,10 @@ export const playlistReducers = [ ...(p.userAgent != null ? { userAgent: p.userAgent } : {}), + ...(p.referrer !== undefined + ? { referrer: p.referrer } + : {}), + ...(p.origin !== undefined ? { origin: p.origin } : {}), ...(p.serverUrl != null ? { serverUrl: p.serverUrl } : {}), @@ -121,6 +126,11 @@ export const playlistReducers = [ ...(p.portalUrl != null ? { portalUrl: p.portalUrl } : {}), + ...(p.isFullStalkerPortal !== undefined + ? { + isFullStalkerPortal: p.isFullStalkerPortal, + } + : {}), ...(p.favorites != null ? { favorites: p.favorites } : {}), diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index b62643b34..8786af915 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -17,6 +17,7 @@ import { DialogService } from 'components'; import { DatabaseService, DataService, + PlaybackPositionService, SortBy, SortOrder, SortService, @@ -72,6 +73,9 @@ describe('RecentPlaylistsComponent busy state', () => { let dataService: { sendIpcEvent: jest.Mock; }; + let playbackPositionService: { + getAllPlaybackPositions: jest.Mock; + }; let router: { navigate: jest.Mock; }; @@ -94,6 +98,9 @@ describe('RecentPlaylistsComponent busy state', () => { dataService = { sendIpcEvent: jest.fn(), }; + playbackPositionService = { + getAllPlaybackPositions: jest.fn().mockResolvedValue([]), + }; router = { navigate: jest.fn(), }; @@ -128,6 +135,10 @@ describe('RecentPlaylistsComponent busy state', () => { provide: DataService, useValue: dataService, }, + { + provide: PlaybackPositionService, + useValue: playbackPositionService, + }, { provide: PlaylistContextFacade, useValue: { @@ -157,7 +168,9 @@ describe('RecentPlaylistsComponent busy state', () => { order: SortOrder.DESC, }) ), - sortPlaylists: jest.fn((playlists: PlaylistMeta[]) => playlists), + sortPlaylists: jest.fn( + (playlists: PlaylistMeta[]) => playlists + ), }, }, { @@ -238,9 +251,16 @@ describe('RecentPlaylistsComponent busy state', () => { const item = createPlaylistMeta({ _id: 'playlist-refresh-1' }); const refresh = createDeferred<{ success: boolean; - favoritedXtreamIds: number[]; - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]; - hiddenCategories: { xtreamId: number; type: string }[]; + favorites: Array<{ xtreamId: number; contentType: string }>; + recentlyViewed: Array<{ + xtreamId: number; + contentType: string; + viewedAt: string; + }>; + hiddenCategories: Array<{ + xtreamId: number; + categoryType: string; + }>; }>(); let confirmPromise: Promise | undefined; @@ -323,25 +343,29 @@ describe('RecentPlaylistsComponent busy state', () => { }); databaseService.deleteXtreamPlaylistContent.mockResolvedValue({ success: true, - favoritedXtreamIds: [101, 202], - recentlyViewedXtreamIds: [ + favorites: [ + { xtreamId: 101, contentType: 'live' }, + { xtreamId: 202, contentType: 'movie' }, + ], + recentlyViewed: [ { xtreamId: 303, + contentType: 'series', viewedAt: '2026-04-03T11:15:00.000Z', }, ], - hiddenCategories: [{ xtreamId: 404, type: 'live' }], + hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], }); component.refreshXtreamPlaylist(item); await confirmPromise; - expect(databaseService.updateXtreamPlaylistDetails).toHaveBeenCalledWith( - { - id: item._id, - updateDate: 1712145600000, - } - ); + expect( + databaseService.updateXtreamPlaylistDetails + ).toHaveBeenCalledWith({ + id: item._id, + updateDate: 1712145600000, + }); expect(store.dispatch).toHaveBeenCalledWith( PlaylistActions.updatePlaylistMeta({ playlist: { ...item, updateDate: 1712145600000 }, @@ -350,14 +374,19 @@ describe('RecentPlaylistsComponent busy state', () => { expect(setItemSpy).toHaveBeenCalledWith( `xtream-restore-${item._id}`, JSON.stringify({ - favoritedXtreamIds: [101, 202], - recentlyViewedXtreamIds: [ + hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], + favorites: [ + { xtreamId: 101, contentType: 'live' }, + { xtreamId: 202, contentType: 'movie' }, + ], + recentlyViewed: [ { xtreamId: 303, + contentType: 'series', viewedAt: '2026-04-03T11:15:00.000Z', }, ], - hiddenCategories: [{ xtreamId: 404, type: 'live' }], + playbackPositions: [], }) ); expect(router.navigate).toHaveBeenCalledWith([ diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index bd13efd1c..a85aab62a 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -3,7 +3,14 @@ import { DragDropModule, moveItemInArray, } from '@angular/cdk/drag-drop'; -import { Component, effect, inject, input, output, signal } from '@angular/core'; +import { + Component, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { MatDialog } from '@angular/material/dialog'; import { MatInputModule } from '@angular/material/input'; @@ -25,9 +32,11 @@ import { DataService, DbOperationEvent, isDbAbortError, + PlaybackPositionService, PlaylistRefreshService, SortBy, SortService, + XtreamPendingRestoreService, } from 'services'; import { PLAYLIST_UPDATE, @@ -64,6 +73,7 @@ export class RecentPlaylistsComponent { private readonly dialog = inject(MatDialog); private readonly dialogService = inject(DialogService); private readonly dataService = inject(DataService); + private readonly playbackPositionService = inject(PlaybackPositionService); private readonly playlistRefreshService = inject(PlaylistRefreshService); private readonly router = inject(Router); private readonly snackBar = inject(MatSnackBar); @@ -71,6 +81,9 @@ export class RecentPlaylistsComponent { private readonly store = inject(Store); private readonly translate = inject(TranslateService); private readonly playlistContext = inject(PlaylistContextFacade); + private readonly pendingRestoreService = inject( + XtreamPendingRestoreService + ); readonly sidebarMode = input(false); readonly searchQueryInput = input(''); @@ -205,10 +218,7 @@ export class RecentPlaylistsComponent { * @param playlistId playlist id to remove */ removeClicked(item: PlaylistMeta): void { - if ( - this.isDeletePending(item._id) || - this.isRefreshPending(item._id) - ) { + if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { return; } @@ -228,10 +238,7 @@ export class RecentPlaylistsComponent { * @param playlistId playlist id to remove */ async removePlaylist(item: PlaylistMeta) { - if ( - this.isDeletePending(item._id) || - this.isRefreshPending(item._id) - ) { + if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { return; } @@ -276,10 +283,7 @@ export class RecentPlaylistsComponent { * @param item playlist to update */ refreshPlaylist(item: PlaylistMeta) { - if ( - this.isDeletePending(item._id) || - this.isRefreshPending(item._id) - ) { + if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { return; } @@ -303,10 +307,7 @@ export class RecentPlaylistsComponent { * @param item Xtream playlist to refresh */ async refreshXtreamPlaylist(item: PlaylistMeta) { - if ( - this.isDeletePending(item._id) || - this.isRefreshPending(item._id) - ) { + if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { return; } @@ -326,9 +327,8 @@ export class RecentPlaylistsComponent { } this.setPendingRefresh(item._id, true); - const operationId = this.databaseService.createOperationId( - 'xtream-refresh' - ); + const operationId = + this.databaseService.createOperationId('xtream-refresh'); try { // Show immediate feedback — deletion can take several seconds @@ -344,41 +344,34 @@ export class RecentPlaylistsComponent { // Delete content/categories and update the timestamp in // parallel — both operations are fully independent. const updateDate = Date.now(); - const [ - { - favoritedXtreamIds, - recentlyViewedXtreamIds, - hiddenCategories, - }, - ] = await Promise.all([ - this.databaseService.deleteXtreamPlaylistContent( - item._id, - { - operationId, - onEvent: (workerEvent) => - this.updateBusyOperation( - item._id, - workerEvent - ), - } - ), - this.databaseService.updateXtreamPlaylistDetails({ - id: item._id, - updateDate, - }), - ]); - - const restoreKey = `xtream-restore-${item._id}`; - const restorePayload = { - favoritedXtreamIds, - recentlyViewedXtreamIds, - hiddenCategories, - }; - localStorage.setItem( - restoreKey, - JSON.stringify(restorePayload) + const [restoreState, playbackPositions] = await Promise.all( + [ + this.databaseService.deleteXtreamPlaylistContent( + item._id, + { + operationId, + onEvent: (workerEvent) => + this.updateBusyOperation( + item._id, + workerEvent + ), + } + ), + this.playbackPositionService.getAllPlaybackPositions( + item._id + ), + this.databaseService.updateXtreamPlaylistDetails({ + id: item._id, + updateDate, + }), + ] ); + this.pendingRestoreService.set(item._id, { + ...restoreState, + playbackPositions, + }); + // Update the timestamp in NgRx / IndexedDB this.store.dispatch( PlaylistActions.updatePlaylistMeta({ @@ -390,7 +383,10 @@ export class RecentPlaylistsComponent { this.router.navigate(['/workspace', 'xtreams', item._id]); } catch (error) { if (!isDbAbortError(error)) { - console.error('Error refreshing Xtream playlist:', error); + console.error( + 'Error refreshing Xtream playlist:', + error + ); this.snackBar.open( this.translate.instant( 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR' @@ -408,35 +404,32 @@ export class RecentPlaylistsComponent { } private async refreshM3uPlaylist(item: PlaylistMeta): Promise { - if ( - this.isDeletePending(item._id) || - this.isRefreshPending(item._id) - ) { + if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) { return; } this.setPendingRefresh(item._id, true); - const operationId = this.databaseService.createOperationId( - 'playlist-refresh' - ); + const operationId = + this.databaseService.createOperationId('playlist-refresh'); try { - const refreshedPlaylist = await this.playlistRefreshService.refreshPlaylist( - { - operationId, - playlistId: item._id, - title: item.title, - url: item.url, - filePath: item.filePath, - }, - { - onEvent: (event) => - this.updateBusyOperation( - item._id, - this.toPlaylistRefreshBusyEvent(event) - ), - } - ); + const refreshedPlaylist = + await this.playlistRefreshService.refreshPlaylist( + { + operationId, + playlistId: item._id, + title: item.title, + url: item.url, + filePath: item.filePath, + }, + { + onEvent: (event) => + this.updateBusyOperation( + item._id, + this.toPlaylistRefreshBusyEvent(event) + ), + } + ); this.updateBusyOperation(item._id, { operationId, @@ -458,7 +451,9 @@ export class RecentPlaylistsComponent { this.clearBusyOperation(item._id); this.snackBar.open( - this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'), + this.translate.instant( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' + ), null, { duration: 2000 } ); @@ -540,7 +535,9 @@ export class RecentPlaylistsComponent { } if (operation.operation === 'playlist-refresh') { - await this.playlistRefreshService.cancelRefresh(operation.operationId); + await this.playlistRefreshService.cancelRefresh( + operation.operationId + ); return; } @@ -710,7 +707,9 @@ export class RecentPlaylistsComponent { item: PlaylistMeta ): string { if (item.filePath) { - const message = String((error as { message?: string })?.message ?? error); + const message = String( + (error as { message?: string })?.message ?? error + ); if (/(ENOENT|no such file or directory|not found)/i.test(message)) { return this.translateWithFallback( diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts index 2f6feadf1..293747824 100644 --- a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts @@ -5,7 +5,11 @@ import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { DialogService } from 'components'; -import { DatabaseService, PlaylistRefreshService } from 'services'; +import { + DatabaseService, + PlaybackPositionService, + PlaylistRefreshService, +} from 'services'; import { ChannelActions, PlaylistActions } from 'm3u-state'; import { Playlist, PlaylistMeta } from 'shared-interfaces'; import { PlaylistContextFacade } from './playlist-context.facade'; @@ -46,7 +50,12 @@ describe('PlaylistRefreshActionService', () => { let playlistRefreshService: { refreshPlaylist: jest.Mock; }; - let routeProvider: ReturnType>; + let playbackPositionService: { + getAllPlaybackPositions: jest.Mock; + }; + let routeProvider: ReturnType< + typeof signal<'playlists' | 'xtreams' | null> + >; let resolvedPlaylistId: ReturnType>; beforeEach(() => { @@ -56,14 +65,24 @@ describe('PlaylistRefreshActionService', () => { createOperationId: jest.fn((prefix: string) => `${prefix}-op`), deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({ success: true, - favoritedXtreamIds: [101, 202], - recentlyViewedXtreamIds: [ + favorites: [ + { + xtreamId: 101, + contentType: 'live', + }, + { + xtreamId: 202, + contentType: 'movie', + }, + ], + recentlyViewed: [ { xtreamId: 303, + contentType: 'series', viewedAt: '2026-04-04T08:00:00.000Z', }, ], - hiddenCategories: [{ xtreamId: 404, type: 'live' }], + hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], }), updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true), }; @@ -82,6 +101,9 @@ describe('PlaylistRefreshActionService', () => { playlistRefreshService = { refreshPlaylist: jest.fn(), }; + playbackPositionService = { + getAllPlaybackPositions: jest.fn().mockResolvedValue([]), + }; routeProvider = signal<'playlists' | 'xtreams' | null>('xtreams'); resolvedPlaylistId = signal(null); @@ -118,6 +140,10 @@ describe('PlaylistRefreshActionService', () => { provide: PlaylistRefreshService, useValue: playlistRefreshService, }, + { + provide: PlaybackPositionService, + useValue: playbackPositionService, + }, { provide: PlaylistContextFacade, useValue: { @@ -171,14 +197,25 @@ describe('PlaylistRefreshActionService', () => { expect(setItemSpy).toHaveBeenCalledWith( `xtream-restore-${item._id}`, JSON.stringify({ - favoritedXtreamIds: [101, 202], - recentlyViewedXtreamIds: [ + hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }], + favorites: [ + { + xtreamId: 101, + contentType: 'live', + }, + { + xtreamId: 202, + contentType: 'movie', + }, + ], + recentlyViewed: [ { xtreamId: 303, + contentType: 'series', viewedAt: '2026-04-04T08:00:00.000Z', }, ], - hiddenCategories: [{ xtreamId: 404, type: 'live' }], + playbackPositions: [], }) ); expect(store.dispatch).toHaveBeenCalledWith( @@ -212,7 +249,9 @@ describe('PlaylistRefreshActionService', () => { } as Playlist; routeProvider.set('playlists'); resolvedPlaylistId.set(item._id); - playlistRefreshService.refreshPlaylist.mockResolvedValue(refreshedPlaylist); + playlistRefreshService.refreshPlaylist.mockResolvedValue( + refreshedPlaylist + ); service.refresh(item); await Promise.resolve(); diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts index 2388bca9e..1ec7d848c 100644 --- a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts @@ -7,7 +7,9 @@ import { DialogService } from 'components'; import { DatabaseService, isDbAbortError, + PlaybackPositionService, PlaylistRefreshService, + XtreamPendingRestoreService, } from 'services'; import { ChannelActions, PlaylistActions } from 'm3u-state'; import { PlaylistMeta } from 'shared-interfaces'; @@ -21,8 +23,12 @@ export class PlaylistRefreshActionService { private readonly snackBar = inject(MatSnackBar); private readonly dialogService = inject(DialogService); private readonly databaseService = inject(DatabaseService); + private readonly playbackPositionService = inject(PlaybackPositionService); private readonly playlistRefreshService = inject(PlaylistRefreshService); private readonly playlistContext = inject(PlaylistContextFacade); + private readonly pendingRestoreService = inject( + XtreamPendingRestoreService + ); readonly isRefreshing = signal(false); @@ -31,9 +37,7 @@ export class PlaylistRefreshActionService { return false; } - return Boolean( - playlist.serverUrl || playlist.url || playlist.filePath - ); + return Boolean(playlist.serverUrl || playlist.url || playlist.filePath); } refresh(playlist: PlaylistMeta): void { @@ -75,34 +79,27 @@ export class PlaylistRefreshActionService { ); const updateDate = Date.now(); - const [ - { - favoritedXtreamIds, - recentlyViewedXtreamIds, - hiddenCategories, - }, - ] = await Promise.all([ - this.databaseService.deleteXtreamPlaylistContent( - item._id, - { operationId } - ), - this.databaseService.updateXtreamPlaylistDetails({ - id: item._id, - updateDate, - }), - ]); - - const restoreKey = `xtream-restore-${item._id}`; - const restorePayload = { - favoritedXtreamIds, - recentlyViewedXtreamIds, - hiddenCategories, - }; - localStorage.setItem( - restoreKey, - JSON.stringify(restorePayload) + const [restoreState, playbackPositions] = await Promise.all( + [ + this.databaseService.deleteXtreamPlaylistContent( + item._id, + { operationId } + ), + this.playbackPositionService.getAllPlaybackPositions( + item._id + ), + this.databaseService.updateXtreamPlaylistDetails({ + id: item._id, + updateDate, + }), + ] ); + this.pendingRestoreService.set(item._id, { + ...restoreState, + playbackPositions, + }); + this.store.dispatch( PlaylistActions.updatePlaylistMeta({ playlist: { ...item, updateDate }, @@ -197,10 +194,7 @@ export class PlaylistRefreshActionService { } } - private getRefreshErrorMessage( - error: unknown, - item: PlaylistMeta - ): string { + private getRefreshErrorMessage(error: unknown, item: PlaylistMeta): string { if ( error instanceof Error && error.message?.includes('ENOENT') && @@ -211,8 +205,6 @@ export class PlaylistRefreshActionService { ); } - return this.translate.instant( - 'HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR' - ); + return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'); } } 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 e2b954916..83aae53f4 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 @@ -2,11 +2,13 @@ import { inject, Injectable } from '@angular/core'; import { DatabaseService, PlaybackPositionService, + XtreamPendingRestoreService, XtreamImportStatus, } from 'services'; import { PlaybackPositionData, PlaylistMeta, + XtreamPendingRestoreState, XtreamCategory, XtreamLiveStream, XtreamSerieItem, @@ -37,6 +39,9 @@ import { export class ElectronXtreamDataSource implements IXtreamDataSource { private readonly dbService = inject(DatabaseService); private readonly playbackService = inject(PlaybackPositionService); + private readonly pendingRestoreService = inject( + XtreamPendingRestoreService + ); private readonly apiService = inject(XtreamApiService); private readonly categoryRequests = new Map< string, @@ -135,8 +140,8 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { dbType, options ).finally(() => { - this.categoryRequests.delete(requestKey); - }); + this.categoryRequests.delete(requestKey); + }); this.categoryRequests.set(requestKey, request); return request; @@ -199,30 +204,16 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { playlistId: string, type: 'live' | 'movies' | 'series' ): number[] | undefined { - const restoreKey = `xtream-restore-${playlistId}`; - const restoreDataStr = localStorage.getItem(restoreKey); + const restoreData = this.pendingRestoreService.get(playlistId); + const hiddenCategories = restoreData?.hiddenCategories; - if (!restoreDataStr) { + if (!hiddenCategories || hiddenCategories.length === 0) { return undefined; } - try { - const restoreData = JSON.parse(restoreDataStr) as { - hiddenCategories?: { xtreamId: number; type: string }[]; - }; - const hiddenCategories = restoreData.hiddenCategories; - - if (!hiddenCategories || hiddenCategories.length === 0) { - return undefined; - } - - // Filter for the specific type and return xtreamIds - return hiddenCategories - .filter((cat) => cat.type === type) - .map((cat) => cat.xtreamId); - } catch { - return undefined; - } + return hiddenCategories + .filter((category) => category.categoryType === type) + .map((category) => category.xtreamId); } async getAllCategories( @@ -509,29 +500,41 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { // Electron uses the DB as its cache layer; no in-memory state to evict. } - async clearPlaylistContent(playlistId: string): Promise<{ - favoritedXtreamIds: number[]; - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]; - }> { - const result = - await this.dbService.deleteXtreamPlaylistContent(playlistId); + async clearPlaylistContent( + playlistId: string + ): Promise { + const [result, playbackPositions] = await Promise.all([ + this.dbService.deleteXtreamPlaylistContent(playlistId), + this.playbackService.getAllPlaybackPositions(playlistId), + ]); + return { - favoritedXtreamIds: result.favoritedXtreamIds, - recentlyViewedXtreamIds: result.recentlyViewedXtreamIds, + hiddenCategories: result.hiddenCategories, + favorites: result.favorites, + recentlyViewed: result.recentlyViewed, + playbackPositions, }; } async restoreUserData( playlistId: string, - favoritedXtreamIds: number[], - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[], + restoreState: XtreamPendingRestoreState, options?: XtreamOperationOptions ): Promise { await this.dbService.restoreXtreamUserData( playlistId, - favoritedXtreamIds, - recentlyViewedXtreamIds, + restoreState.favorites, + restoreState.recentlyViewed, options ); + + await this.playbackService.clearAllPlaybackPositions(playlistId); + + for (const playbackPosition of restoreState.playbackPositions) { + await this.playbackService.savePlaybackPosition( + playlistId, + playbackPosition + ); + } } } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 70f499ee6..619e90f28 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -1,6 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { PlaybackPositionData, + XtreamPendingRestoreState, XtreamCategory, XtreamLiveStream, XtreamSerieItem, @@ -509,9 +510,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { for (const item of content) { const itemId = item.stream_id || item.series_id || item.id; - const recentEntry = playlistRecent.find( - (r) => r.id === itemId - ); + const recentEntry = playlistRecent.find((r) => r.id === itemId); if (recentEntry) { results.push({ ...item, @@ -571,10 +570,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { this.clearRecentItemsForPlaylist(playlistId); } - private getRecentItemsFromStorage(): Record< - string, - StoredRecentItem[] - > { + private getRecentItemsFromStorage(): Record { try { const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS); return data ? JSON.parse(data) : {}; @@ -629,51 +625,125 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return null; } + private findContentIdentity( + playlistId: string, + xtreamId: number, + contentType?: 'live' | 'movie' | 'series' + ): { contentType: 'live' | 'movie' | 'series'; xtreamId: number } | null { + const types = contentType + ? [contentType] + : (['live', 'movie', 'series'] as const); + + for (const type of types) { + const cacheKey = `${playlistId}-${type}-content`; + const content = this.contentCache.get(cacheKey) || []; + + const found = content.find((item) => { + const itemXtreamId = + item.stream_id || item.series_id || item.id; + return itemXtreamId === xtreamId; + }); + + if (found) { + return { + contentType: type, + xtreamId, + }; + } + } + + return null; + } + // ========================================================================= // Cleanup Operations // ========================================================================= - async clearPlaylistContent(playlistId: string): Promise<{ - favoritedXtreamIds: number[]; - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]; - }> { + async clearPlaylistContent( + playlistId: string + ): Promise { // Get current favorites and recent items const favorites = this.getFavoritesFromStorage(); const recentItems = this.getRecentItemsFromStorage(); + const playbackPositions = + await this.getAllPlaybackPositions(playlistId); - const favoritedXtreamIds = favorites[playlistId] || []; - const recentlyViewedXtreamIds = (recentItems[playlistId] || []).map( - (r) => ({ - xtreamId: r.id, - viewedAt: r.viewedAt, + const typedFavorites = (favorites[playlistId] || []) + .map((xtreamId) => this.findContentIdentity(playlistId, xtreamId)) + .filter( + ( + value + ): value is { + contentType: 'live' | 'movie' | 'series'; + xtreamId: number; + } => value !== null + ); + + const typedRecentlyViewed = (recentItems[playlistId] || []) + .map((item) => { + const identity = this.findContentIdentity(playlistId, item.id); + + if (!identity) { + return null; + } + + return { + ...identity, + viewedAt: item.viewedAt, + }; }) - ); + .filter( + ( + value + ): value is { + contentType: 'live' | 'movie' | 'series'; + xtreamId: number; + viewedAt: string; + } => value !== null + ); // Clear in-memory cache this.clearCacheForPlaylist(playlistId); - return { favoritedXtreamIds, recentlyViewedXtreamIds }; + return { + hiddenCategories: [], + favorites: typedFavorites, + recentlyViewed: typedRecentlyViewed, + playbackPositions, + }; } async restoreUserData( playlistId: string, - favoritedXtreamIds: number[], - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[], + restoreState: XtreamPendingRestoreState, options?: XtreamOperationOptions ): Promise { void options; // Restore favorites const favorites = this.getFavoritesFromStorage(); - favorites[playlistId] = favoritedXtreamIds; + favorites[playlistId] = restoreState.favorites.map( + (item) => item.xtreamId + ); this.saveFavoritesToStorage(favorites); // Restore recent items const recentItems = this.getRecentItemsFromStorage(); - recentItems[playlistId] = recentlyViewedXtreamIds.map((r) => ({ - id: r.xtreamId, - viewedAt: r.viewedAt, + recentItems[playlistId] = restoreState.recentlyViewed.map((item) => ({ + id: item.xtreamId, + viewedAt: item.viewedAt, })); this.saveRecentItemsToStorage(recentItems); + + // Restore playback positions + this.clearPlaybackPositionsForPlaylist(playlistId); + const playbackPositions = this.getPlaybackPositionsFromStorage(); + playbackPositions[playlistId] = restoreState.playbackPositions.map( + (position) => ({ + ...position, + updatedAt: position.updatedAt ?? new Date().toISOString(), + }) + ); + this.savePlaybackPositionsToStorage(playbackPositions); } // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 6defbac3f..276d1efc2 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -1,6 +1,7 @@ import { InjectionToken } from '@angular/core'; import { PlaybackPositionData, + XtreamPendingRestoreState, XtreamCategory, XtreamLiveStream, XtreamSerieItem, @@ -53,6 +54,7 @@ export interface XtreamContentItem { type: string; added_at?: string; viewed_at?: string; + position?: number | null; // XtreamItem compatibility fields (optional for search/navigation) num?: number; @@ -394,18 +396,16 @@ export interface IXtreamDataSource { * Clear all content and categories for a playlist (for refresh) * Returns user data (favorites, recently viewed) for restoration */ - clearPlaylistContent(playlistId: string): Promise<{ - favoritedXtreamIds: number[]; - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]; - }>; + clearPlaylistContent( + playlistId: string + ): Promise; /** * Restore user data after refresh */ restoreUserData( playlistId: string, - favoritedXtreamIds: number[], - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[], + restoreState: XtreamPendingRestoreState, options?: XtreamOperationOptions ): Promise; } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index ec7a69b4b..087c90da4 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -17,6 +17,7 @@ import { DatabaseService, DbOperationEvent, isDbAbortError, + XtreamPendingRestoreService, XtreamImportStatus, } from 'services'; import { @@ -36,9 +37,8 @@ import { XtreamContentLoadStateByType, } from '../../xtream-state'; -const cancelledPlaylistInitializationLockKey = ( - playlistId: string -): string => `xtream-init-cancelled:${playlistId}`; +const cancelledPlaylistInitializationLockKey = (playlistId: string): string => + `xtream-init-cancelled:${playlistId}`; const hasCancelledPlaylistInitializationLock = ( playlistId: string @@ -54,9 +54,7 @@ const hasCancelledPlaylistInitializationLock = ( } }; -const setCancelledPlaylistInitializationLock = ( - playlistId: string -): void => { +const setCancelledPlaylistInitializationLock = (playlistId: string): void => { try { localStorage.setItem( cancelledPlaylistInitializationLockKey(playlistId), @@ -67,9 +65,7 @@ const setCancelledPlaylistInitializationLock = ( } }; -const clearCancelledPlaylistInitializationLock = ( - playlistId: string -): void => { +const clearCancelledPlaylistInitializationLock = (playlistId: string): void => { try { localStorage.removeItem( cancelledPlaylistInitializationLockKey(playlistId) @@ -208,6 +204,7 @@ export function withContent() { withMethods((store) => { const dataSource = inject(XTREAM_DATA_SOURCE); const databaseService = inject(DatabaseService); + const pendingRestoreService = inject(XtreamPendingRestoreService); const xtreamApiService = inject(XtreamApiService); const importTypes: ContentType[] = ['live', 'vod', 'series']; let activeInitializationPromise: Promise | null = null; @@ -252,7 +249,8 @@ export function withContent() { if ( store.contentInitBlockReason() === 'cancelled' || (expectedImportSessionId != null && - store.activeImportSessionId() !== expectedImportSessionId) + store.activeImportSessionId() !== + expectedImportSessionId) ) { throw createImportAbortError(); } @@ -298,13 +296,9 @@ export function withContent() { ]; const cacheChecks = await Promise.all( - contentTypes.map( - async ({ categoryType, contentType }) => { - const [ - importStatus, - hasCategories, - hasContent, - ] = await Promise.all([ + contentTypes.map(async ({ categoryType, contentType }) => { + const [importStatus, hasCategories, hasContent] = + await Promise.all([ databaseService.getXtreamImportStatus( playlistId, contentType @@ -316,13 +310,12 @@ export function withContent() { dataSource.hasContent(playlistId, contentType), ]); - return ( - importStatus === 'completed' && - hasCategories && - hasContent - ); - } - ) + return ( + importStatus === 'completed' && + hasCategories && + hasContent + ); + }) ); return cacheChecks.every(Boolean); @@ -331,19 +324,16 @@ export function withContent() { const trackImportEvent = (event: DbOperationEvent): void => { const operationId = event.operationId; - if ( - store.contentInitBlockReason() === 'cancelled' && - event.status !== 'cancelled' && - event.status !== 'error' && - event.status !== 'completed' - ) { - return; - } + if ( + store.contentInitBlockReason() === 'cancelled' && + event.status !== 'cancelled' && + event.status !== 'error' && + event.status !== 'completed' + ) { + return; + } - if ( - event.status === 'started' || - event.status === 'progress' - ) { + if (event.status === 'started' || event.status === 'progress') { patchState(store, (state) => ({ isImporting: true, importPhase: event.phase ?? state.importPhase, @@ -375,11 +365,10 @@ export function withContent() { const registerImportOperation = (operationId: string): void => { patchState(store, (state) => ({ - activeImportOperationIds: state.activeImportOperationIds.includes( - operationId - ) - ? state.activeImportOperationIds - : [...state.activeImportOperationIds, operationId], + activeImportOperationIds: + state.activeImportOperationIds.includes(operationId) + ? state.activeImportOperationIds + : [...state.activeImportOperationIds, operationId], })); }; @@ -511,15 +500,12 @@ export function withContent() { throwIfImportCancelled(importSessionId); // Restore user data if needed - const restoreKey = `xtream-restore-${ctx.playlistId}`; - const restoreData = localStorage.getItem(restoreKey); + const restoreData = pendingRestoreService.get( + ctx.playlistId + ); if (restoreData) { try { throwIfImportCancelled(importSessionId); - const { - favoritedXtreamIds, - recentlyViewedXtreamIds, - } = JSON.parse(restoreData); const restoreOperationId = databaseService.createOperationId( 'xtream-restore' @@ -530,21 +516,17 @@ export function withContent() { }); await dataSource.restoreUserData( ctx.playlistId, - favoritedXtreamIds, - recentlyViewedXtreamIds, + restoreData, { onEvent: trackImportEvent, operationId: restoreOperationId, } ); throwIfImportCancelled(importSessionId); - localStorage.removeItem(restoreKey); + pendingRestoreService.clear(ctx.playlistId); } catch (err) { if (!isDbAbortError(err)) { - logger.error( - 'Error restoring user data', - err - ); + logger.error('Error restoring user data', err); } } } @@ -633,9 +615,9 @@ export function withContent() { /** * Fetch all categories in parallel */ - async fetchAllCategories( - options?: { sessionId?: string } - ): Promise { + async fetchAllCategories(options?: { + sessionId?: string; + }): Promise { const ctx = getCredentialsFromStore(); if (!ctx) return; @@ -702,13 +684,11 @@ export function withContent() { /** * Fetch all content/streams with shared progress tracking */ - async fetchAllContent( - options?: { - importSessionId?: string; - sessionId?: string; - completedTypes?: Set; - } - ): Promise { + async fetchAllContent(options?: { + importSessionId?: string; + sessionId?: string; + completedTypes?: Set; + }): Promise { const ctx = getCredentialsFromStore(); if (!ctx) return; @@ -730,9 +710,10 @@ export function withContent() { try { throwIfImportCancelled(options?.importSessionId); - const liveOperationId = databaseService.createOperationId( - 'db-save-content' - ); + const liveOperationId = + databaseService.createOperationId( + 'db-save-content' + ); registerImportOperation(liveOperationId); const live = (await dataSource.getContent( @@ -753,7 +734,11 @@ export function withContent() { } )) as XtreamLiveStream[]; throwIfImportCancelled(options?.importSessionId); - await setImportStatus(ctx.playlistId, 'live', 'completed'); + await setImportStatus( + ctx.playlistId, + 'live', + 'completed' + ); options?.completedTypes?.add('live'); patchState(store, { liveStreams: live, @@ -761,9 +746,10 @@ export function withContent() { updateContentTypeLoadState('live', 'ready'); throwIfImportCancelled(options?.importSessionId); - const vodOperationId = databaseService.createOperationId( - 'db-save-content' - ); + const vodOperationId = + databaseService.createOperationId( + 'db-save-content' + ); registerImportOperation(vodOperationId); const vod = (await dataSource.getContent( ctx.playlistId, @@ -783,7 +769,11 @@ export function withContent() { } )) as XtreamVodStream[]; throwIfImportCancelled(options?.importSessionId); - await setImportStatus(ctx.playlistId, 'vod', 'completed'); + await setImportStatus( + ctx.playlistId, + 'vod', + 'completed' + ); options?.completedTypes?.add('vod'); patchState(store, { vodStreams: vod, @@ -814,7 +804,11 @@ export function withContent() { } )) as XtreamSerieItem[]; throwIfImportCancelled(options?.importSessionId); - await setImportStatus(ctx.playlistId, 'series', 'completed'); + await setImportStatus( + ctx.playlistId, + 'series', + 'completed' + ); options?.completedTypes?.add('series'); patchState(store, { serialStreams: series, @@ -890,9 +884,7 @@ export function withContent() { }); const ctx = getCredentialsFromStore(); if (ctx) { - setCancelledPlaylistInitializationLock( - ctx.playlistId - ); + setCancelledPlaylistInitializationLock(ctx.playlistId); } await xtreamApiService.cancelSession(activeImportSessionId); diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 887f0a323..fae4f3150 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -2,8 +2,10 @@ export * from './lib/data.service'; export * from './lib/database-electron.service'; export * from './lib/downloads.service'; export * from './lib/playback-position.service'; +export * from './lib/playlist-backup.service'; export * from './lib/playlist-refresh.service'; export * from './lib/playlists.service'; export * from './lib/portal-status.service'; export * from './lib/settings-store.service'; export * from './lib/sort.service'; +export * from './lib/xtream-pending-restore.service'; diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 7062246c7..b4000caad 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -6,6 +6,9 @@ import { Injectable } from '@angular/core'; import { PlaylistMeta, + XtreamBackupFavoriteItem, + XtreamBackupHiddenCategory, + XtreamBackupRecentlyViewedItem, XtreamCategory, } from 'shared-interfaces'; @@ -33,6 +36,7 @@ export interface XtreamContent { type: string; added_at?: string; viewed_at?: string; + position?: number | null; } export interface XtreamPlaylist { @@ -251,18 +255,17 @@ export class DatabaseService { } /** - * Delete all content and categories for an Xtream playlist (for refresh) - * Keeps the playlist entry but removes all imported data - * Returns saved favorites, recently viewed xtreamIds, and hidden categories for restoration + * Delete all content and categories for an Xtream playlist (for refresh). + * Keeps the playlist entry but removes all imported data. */ async deleteXtreamPlaylistContent( playlistId: string, options?: DbOperationOptions ): Promise<{ success: boolean; - favoritedXtreamIds: number[]; - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[]; - hiddenCategories: { xtreamId: number; type: string }[]; + favorites: XtreamBackupFavoriteItem[]; + recentlyViewed: XtreamBackupRecentlyViewedItem[]; + hiddenCategories: XtreamBackupHiddenCategory[]; }> { return this.runWithOperationEvents( 'db-delete-xtream-content', @@ -278,8 +281,8 @@ export class DatabaseService { */ async restoreXtreamUserData( playlistId: string, - favoritedXtreamIds: number[], - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[], + favorites: XtreamBackupFavoriteItem[], + recentlyViewed: XtreamBackupRecentlyViewedItem[], options?: DbOperationOptions ): Promise { await this.runWithOperationEvents( @@ -288,8 +291,8 @@ export class DatabaseService { (operationId) => window.electron.dbRestoreXtreamUserData( playlistId, - favoritedXtreamIds, - recentlyViewedXtreamIds, + favorites, + recentlyViewed, operationId ), options @@ -472,7 +475,7 @@ export class DatabaseService { options, onProgress ); - return result.count; + return (result as { count: number }).count; } catch (error) { if (!isDbAbortError(error)) { console.error('Error saving Xtream content:', error); @@ -569,7 +572,11 @@ export class DatabaseService { types: string[], excludeHidden?: boolean ): Promise { - return await window.electron.dbGlobalSearch(searchTerm, types, excludeHidden); + return await window.electron.dbGlobalSearch( + searchTerm, + types, + excludeHidden + ); } /** diff --git a/libs/services/src/lib/playback-position.service.ts b/libs/services/src/lib/playback-position.service.ts index 7149967fe..7df46b540 100644 --- a/libs/services/src/lib/playback-position.service.ts +++ b/libs/services/src/lib/playback-position.service.ts @@ -63,7 +63,9 @@ export class PlaybackPositionService { } } - async getAllPlaybackPositions(playlistId: string): Promise { + async getAllPlaybackPositions( + playlistId: string + ): Promise { try { return await window.electron.dbGetAllPlaybackPositions(playlistId); } catch (error) { @@ -72,6 +74,14 @@ export class PlaybackPositionService { } } + async clearAllPlaybackPositions(playlistId: string): Promise { + try { + await window.electron.dbClearAllPlaybackPositions(playlistId); + } catch (error) { + console.error('Error clearing all playback positions:', error); + } + } + async clearPlaybackPosition( playlistId: string, contentXtreamId: number, diff --git a/libs/services/src/lib/playlist-backup.service.spec.ts b/libs/services/src/lib/playlist-backup.service.spec.ts new file mode 100644 index 000000000..712beffb9 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.spec.ts @@ -0,0 +1,279 @@ +import { of } from 'rxjs'; +import { + Playlist, + PlaylistBackupManifestV1, + PLAYLIST_BACKUP_KIND, + PLAYLIST_BACKUP_VERSION, +} from 'shared-interfaces'; +import { + PlaylistBackupError, + PlaylistBackupService, +} from './playlist-backup.service'; + +describe('PlaylistBackupService', () => { + function createService(overrides: Record = {}) { + const service = Object.create( + PlaylistBackupService.prototype + ) as PlaylistBackupService; + + Object.assign(service as object, { + playlistsService: { + addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), + getAllData: jest.fn(() => of([])), + getRawPlaylistById: jest.fn(() => of('#EXTM3U')), + handlePlaylistParsing: jest.fn( + (_uploadType: string, rawM3u: string, title: string) => ({ + _id: 'generated-id', + title, + filename: title, + count: rawM3u.split('\n').filter(Boolean).length, + playlist: { + header: { raw: '#EXTM3U' }, + items: [], + }, + importDate: '2026-04-21T00:00:00.000Z', + lastUsage: '2026-04-21T00:00:00.000Z', + favorites: [], + autoRefresh: false, + }) + ), + }, + settingsStore: { + getSettings: jest.fn(() => ({ epgUrl: [] })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }, + databaseService: { + getAllXtreamCategories: jest.fn().mockResolvedValue([]), + getFavorites: jest.fn().mockResolvedValue([]), + getRecentItems: jest.fn().mockResolvedValue([]), + getXtreamImportStatus: jest.fn().mockResolvedValue('idle'), + hasXtreamCategories: jest.fn().mockResolvedValue(false), + hasXtreamContent: jest.fn().mockResolvedValue(false), + restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), + updateCategoryVisibility: jest.fn().mockResolvedValue(true), + }, + playbackPositionService: { + getAllPlaybackPositions: jest.fn().mockResolvedValue([]), + clearAllPlaybackPositions: jest + .fn() + .mockResolvedValue(undefined), + savePlaybackPosition: jest.fn().mockResolvedValue(undefined), + }, + pendingRestoreService: { + set: jest.fn(), + clear: jest.fn(), + }, + ...overrides, + }); + + return service; + } + + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it('exports self-contained M3U data and strips Stalker session fields', async () => { + const playlistsService = { + addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), + getAllData: jest.fn(() => + of([ + { + _id: 'm3u-file-1', + title: 'Local Playlist', + count: 2, + importDate: '2026-04-21T00:00:00.000Z', + lastUsage: '2026-04-21T00:00:00.000Z', + autoRefresh: true, + filePath: '/tmp/playlist.m3u', + favorites: ['https://example.com/stream-1'], + recentlyViewed: [], + }, + { + _id: 'stalker-1', + title: 'Stalker Portal', + count: 0, + importDate: '2026-04-21T00:00:00.000Z', + lastUsage: '2026-04-21T00:00:00.000Z', + autoRefresh: false, + portalUrl: + 'http://example.com/stalker_portal/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + stalkerToken: 'session-token', + stalkerAccountInfo: { + login: 'demo', + }, + }, + ] as Playlist[]) + ), + getRawPlaylistById: jest.fn((playlistId: string) => + of( + playlistId === 'm3u-file-1' + ? '#EXTM3U\n#EXTINF:-1,One\nhttps://example.com/stream-1' + : '#EXTM3U' + ) + ), + handlePlaylistParsing: jest.fn(), + }; + const settingsStore = { + getSettings: jest.fn(() => ({ + epgUrl: ['https://example.com/epg.xml'], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; + const service = createService({ + playlistsService, + settingsStore, + }); + + const backup = await service.exportBackup(); + + expect(backup.defaultFileName).toEqual( + expect.stringMatching( + /^iptvnator-playlist-backup-\d{4}-\d{2}-\d{2}\.json$/ + ) + ); + expect(backup.manifest.settings).toEqual({ + epgUrls: ['https://example.com/epg.xml'], + }); + expect(backup.manifest.playlists).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + portalType: 'm3u', + source: expect.objectContaining({ + kind: 'file', + rawM3u: '#EXTM3U\n#EXTINF:-1,One\nhttps://example.com/stream-1', + filePathHint: '/tmp/playlist.m3u', + }), + }), + expect.objectContaining({ + portalType: 'stalker', + connection: expect.objectContaining({ + portalUrl: + 'http://example.com/stalker_portal/server/load.php', + macAddress: '00:1A:79:AA:BB:CC', + }), + }), + ]) + ); + expect(JSON.stringify(backup.manifest)).not.toContain('session-token'); + expect(JSON.stringify(backup.manifest)).not.toContain('demo'); + }); + + it('rejects legacy raw playlist arrays on import', async () => { + const service = createService(); + + await expect(service.importBackup('[]')).rejects.toBeInstanceOf( + PlaylistBackupError + ); + }); + + it('merges onto an existing M3U playlist by normalized URL and restores backup-first EPG urls', async () => { + const existingPlaylist: Playlist = { + _id: 'existing-playlist-id', + title: 'Original Title', + count: 1, + importDate: '2026-04-20T00:00:00.000Z', + lastUsage: '2026-04-20T00:00:00.000Z', + autoRefresh: false, + url: 'https://example.com/playlist.m3u', + favorites: ['https://example.com/original'], + recentlyViewed: [], + hiddenGroupTitles: ['News'], + } as Playlist; + const playlistsService = { + addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), + getAllData: jest.fn(() => of([existingPlaylist])), + getRawPlaylistById: jest.fn(() => of('#EXTM3U')), + handlePlaylistParsing: jest.fn( + (_uploadType: string, rawM3u: string, title: string) => ({ + _id: 'generated-id', + title, + filename: title, + count: rawM3u.split('\n').filter(Boolean).length, + playlist: { + header: { raw: '#EXTM3U' }, + items: [], + }, + importDate: '2026-04-21T00:00:00.000Z', + lastUsage: '2026-04-21T00:00:00.000Z', + favorites: [], + autoRefresh: false, + }) + ), + }; + const settingsStore = { + getSettings: jest.fn(() => ({ + epgUrl: ['https://existing.example.com/epg.xml'], + })), + updateSettings: jest.fn().mockResolvedValue(undefined), + }; + const service = createService({ + playlistsService, + settingsStore, + }); + + const manifest: PlaylistBackupManifestV1 = { + kind: PLAYLIST_BACKUP_KIND, + version: PLAYLIST_BACKUP_VERSION, + exportedAt: '2026-04-21T00:00:00.000Z', + includeSecrets: true, + settings: { + epgUrls: [ + 'https://backup.example.com/epg.xml', + 'https://existing.example.com/epg.xml', + ], + }, + playlists: [ + { + portalType: 'm3u', + exportedId: 'backup-playlist-id', + title: 'Imported Title', + autoRefresh: true, + position: 3, + source: { + kind: 'url', + rawM3u: '#EXTM3U\n#EXTINF:-1,Backup\nhttps://example.com/stream-1', + url: 'https://example.com/playlist.m3u/', + userAgent: 'BackupAgent/1.0', + }, + userState: { + favorites: ['https://example.com/stream-1'], + recentlyViewed: [], + hiddenGroupTitles: ['Sports'], + }, + }, + ], + }; + + const summary = await service.importBackup(JSON.stringify(manifest)); + + expect(summary).toEqual({ + imported: 0, + merged: 1, + skipped: 0, + failed: 0, + errors: [], + }); + expect(settingsStore.updateSettings).toHaveBeenCalledWith({ + epgUrl: [ + 'https://backup.example.com/epg.xml', + 'https://existing.example.com/epg.xml', + ], + }); + expect(playlistsService.addPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'existing-playlist-id', + title: 'Imported Title', + autoRefresh: true, + position: 3, + url: 'https://example.com/playlist.m3u/', + userAgent: 'BackupAgent/1.0', + filePath: undefined, + favorites: ['https://example.com/stream-1'], + hiddenGroupTitles: ['Sports'], + }) + ); + }); +}); diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts new file mode 100644 index 000000000..521918106 --- /dev/null +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -0,0 +1,1028 @@ +import { inject, Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; +import { PlaylistsService } from './playlists.service'; +import { SettingsStore } from './settings-store.service'; +import { DatabaseService } from './database-electron.service'; +import { PlaybackPositionService } from './playback-position.service'; +import { XtreamPendingRestoreService } from './xtream-pending-restore.service'; +import { + isM3uRecentlyViewedItem, + M3uPlaylistBackupEntry, + M3uRecentlyViewedItem, + Playlist, + PlaylistBackupEntry, + PlaylistBackupManifestV1, + PlaylistBackupSettings, + PLAYLIST_BACKUP_KIND, + PLAYLIST_BACKUP_VERSION, + StalkerPlaylistBackupEntry, + StalkerPortalItem, + XtreamBackupCategoryType, + XtreamBackupContentType, + XtreamPlaylistBackupEntry, + XtreamPendingRestoreState, +} from 'shared-interfaces'; + +export interface PlaylistBackupExportPayload { + defaultFileName: string; + json: string; + manifest: PlaylistBackupManifestV1; +} + +export interface PlaylistBackupImportSummary { + imported: number; + merged: number; + skipped: number; + failed: number; + errors: string[]; +} + +export class PlaylistBackupError extends Error { + constructor(message: string) { + super(message); + this.name = 'PlaylistBackupError'; + } +} + +type PlaylistPortalType = 'm3u' | 'xtream' | 'stalker'; + +type XtreamContentType = XtreamBackupContentType; +type XtreamCategoryType = XtreamBackupCategoryType; + +@Injectable({ + providedIn: 'root', +}) +export class PlaylistBackupService { + private readonly playlistsService = inject(PlaylistsService); + private readonly settingsStore = inject(SettingsStore); + private readonly databaseService = inject(DatabaseService); + private readonly playbackPositionService = inject(PlaybackPositionService); + private readonly pendingRestoreService = inject( + XtreamPendingRestoreService + ); + + async exportBackup(): Promise { + const playlists = await firstValueFrom( + this.playlistsService.getAllData() + ); + const settings = this.buildSettingsPayload(); + const playlistEntries = await Promise.all( + playlists.map((playlist) => this.buildPlaylistEntry(playlist)) + ); + const exportedAt = new Date().toISOString(); + const manifest: PlaylistBackupManifestV1 = { + kind: PLAYLIST_BACKUP_KIND, + version: PLAYLIST_BACKUP_VERSION, + exportedAt, + includeSecrets: true, + ...(settings ? { settings } : {}), + playlists: playlistEntries, + }; + + return { + defaultFileName: `iptvnator-playlist-backup-${exportedAt.slice( + 0, + 10 + )}.json`, + json: JSON.stringify(manifest, null, 2), + manifest, + }; + } + + async importBackup(json: string): Promise { + const manifest = this.parseManifest(json); + const existingPlaylists = await firstValueFrom( + this.playlistsService.getAllData() + ); + const existingIds = new Set( + existingPlaylists.map((playlist) => playlist._id) + ); + const fingerprintMap = + await this.buildExistingFingerprintMap(existingPlaylists); + const seenBackupFingerprints = new Set(); + const summary: PlaylistBackupImportSummary = { + imported: 0, + merged: 0, + skipped: 0, + failed: 0, + errors: [], + }; + + await this.restoreSettings(manifest.settings); + + for (const entry of manifest.playlists) { + const entryLabel = + entry.title || entry.exportedId || entry.portalType; + + try { + const fingerprint = this.getEntryFingerprint(entry); + + if (seenBackupFingerprints.has(fingerprint)) { + summary.skipped += 1; + summary.errors.push( + `${entryLabel}: duplicate backup entry skipped` + ); + continue; + } + + seenBackupFingerprints.add(fingerprint); + + const existingMatch = fingerprintMap.get(fingerprint); + const targetId = this.resolveTargetPlaylistId( + entry, + existingMatch?._id, + existingIds + ); + const isMerge = existingMatch != null; + const nextPlaylist = await this.buildImportedPlaylist( + entry, + targetId, + existingMatch ?? null + ); + + await firstValueFrom( + this.playlistsService.addPlaylist(nextPlaylist) + ); + + if (entry.portalType === 'xtream') { + await this.restoreXtreamEntry(targetId, entry); + } + + if (isMerge) { + summary.merged += 1; + } else { + summary.imported += 1; + existingIds.add(targetId); + } + } catch (error) { + summary.failed += 1; + summary.errors.push( + `${entryLabel}: ${this.getErrorMessage(error)}` + ); + } + } + + return summary; + } + + private async buildPlaylistEntry( + playlist: Playlist + ): Promise { + const portalType = this.getPlaylistPortalType(playlist); + + switch (portalType) { + case 'xtream': + return this.buildXtreamEntry(playlist); + case 'stalker': + return this.buildStalkerEntry(playlist); + case 'm3u': + default: + return this.buildM3uEntry(playlist); + } + } + + private async buildM3uEntry( + playlist: Playlist + ): Promise { + const rawM3u = await firstValueFrom( + this.playlistsService.getRawPlaylistById(playlist._id) + ); + const favorites = Array.isArray(playlist.favorites) + ? playlist.favorites.filter( + (item): item is string => + typeof item === 'string' && item.trim().length > 0 + ) + : []; + const recentlyViewed = Array.isArray(playlist.recentlyViewed) + ? playlist.recentlyViewed.filter(isM3uRecentlyViewedItem) + : []; + + return { + portalType: 'm3u', + exportedId: playlist._id, + title: playlist.title, + autoRefresh: Boolean(playlist.autoRefresh), + position: playlist.position, + source: { + kind: this.resolveM3uSourceKind(playlist), + rawM3u, + ...(playlist.url ? { url: playlist.url } : {}), + ...(playlist.userAgent + ? { userAgent: playlist.userAgent } + : {}), + ...(playlist.referrer ? { referrer: playlist.referrer } : {}), + ...(playlist.origin ? { origin: playlist.origin } : {}), + ...(playlist.filePath + ? { filePathHint: playlist.filePath } + : {}), + }, + userState: { + favorites: this.uniqueStrings(favorites), + recentlyViewed: recentlyViewed.map((item) => ({ ...item })), + hiddenGroupTitles: this.uniqueStrings( + Array.isArray(playlist.hiddenGroupTitles) + ? playlist.hiddenGroupTitles + : [] + ), + }, + }; + } + + private async buildXtreamEntry( + playlist: Playlist + ): Promise { + if (!this.hasElectronApi()) { + return { + portalType: 'xtream', + exportedId: playlist._id, + title: playlist.title, + autoRefresh: Boolean(playlist.autoRefresh), + position: playlist.position, + connection: { + serverUrl: playlist.serverUrl ?? '', + username: playlist.username ?? '', + ...(playlist.password + ? { password: playlist.password } + : {}), + }, + userState: { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + }, + }; + } + + const [ + liveCategories, + movieCategories, + seriesCategories, + favorites, + recent, + playbackPositions, + ] = await Promise.all([ + 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.getRecentItems(playlist._id), + this.playbackPositionService.getAllPlaybackPositions(playlist._id), + ]); + + return { + portalType: 'xtream', + exportedId: playlist._id, + title: playlist.title, + autoRefresh: Boolean(playlist.autoRefresh), + position: playlist.position, + connection: { + serverUrl: playlist.serverUrl ?? '', + username: playlist.username ?? '', + ...(playlist.password ? { password: playlist.password } : {}), + }, + userState: { + hiddenCategories: [ + ...this.mapHiddenCategories(liveCategories, 'live'), + ...this.mapHiddenCategories(movieCategories, 'movies'), + ...this.mapHiddenCategories(seriesCategories, 'series'), + ], + favorites: favorites.map((item) => ({ + xtreamId: item.xtream_id, + contentType: item.type as XtreamContentType, + ...(item.added_at ? { addedAt: item.added_at } : {}), + ...(item.position !== undefined + ? { position: item.position } + : {}), + })), + recentlyViewed: recent.map((item) => ({ + xtreamId: item.xtream_id, + contentType: item.type as XtreamContentType, + viewedAt: item.viewed_at ?? new Date().toISOString(), + })), + playbackPositions: playbackPositions.map((item) => ({ + ...item, + })), + }, + }; + } + + private buildStalkerEntry(playlist: Playlist): StalkerPlaylistBackupEntry { + return { + portalType: 'stalker', + exportedId: playlist._id, + title: playlist.title, + autoRefresh: Boolean(playlist.autoRefresh), + position: playlist.position, + connection: { + portalUrl: playlist.portalUrl ?? playlist.url ?? '', + macAddress: playlist.macAddress ?? '', + ...(playlist.isFullStalkerPortal !== undefined + ? { + isFullStalkerPortal: playlist.isFullStalkerPortal, + } + : {}), + ...(playlist.username ? { username: playlist.username } : {}), + ...(playlist.password ? { password: playlist.password } : {}), + ...(playlist.userAgent + ? { userAgent: playlist.userAgent } + : {}), + ...(playlist.referrer ? { referrer: playlist.referrer } : {}), + ...(playlist.origin ? { origin: playlist.origin } : {}), + ...(playlist.stalkerSerialNumber + ? { stalkerSerialNumber: playlist.stalkerSerialNumber } + : {}), + ...(playlist.stalkerDeviceId1 + ? { stalkerDeviceId1: playlist.stalkerDeviceId1 } + : {}), + ...(playlist.stalkerDeviceId2 + ? { stalkerDeviceId2: playlist.stalkerDeviceId2 } + : {}), + ...(playlist.stalkerSignature1 + ? { stalkerSignature1: playlist.stalkerSignature1 } + : {}), + ...(playlist.stalkerSignature2 + ? { stalkerSignature2: playlist.stalkerSignature2 } + : {}), + }, + userState: { + favorites: this.extractStalkerItems(playlist.favorites), + recentlyViewed: this.extractStalkerItems( + playlist.recentlyViewed + ), + }, + }; + } + + private buildSettingsPayload(): PlaylistBackupSettings | undefined { + const epgUrls = this.normalizeSettingsUrls( + this.settingsStore.getSettings().epgUrl ?? [] + ); + + if (epgUrls.length === 0) { + return undefined; + } + + return { epgUrls }; + } + + private parseManifest(json: string): PlaylistBackupManifestV1 { + let parsed: unknown; + + try { + parsed = JSON.parse(json); + } catch (error) { + throw new PlaylistBackupError( + `Invalid backup JSON: ${this.getErrorMessage(error)}` + ); + } + + if (Array.isArray(parsed)) { + throw new PlaylistBackupError( + 'Legacy playlist JSON exports are not supported. Create a versioned backup first.' + ); + } + + if (!parsed || typeof parsed !== 'object') { + throw new PlaylistBackupError('Backup file must be a JSON object.'); + } + + const manifest = parsed as Partial; + + if (manifest.kind !== PLAYLIST_BACKUP_KIND) { + throw new PlaylistBackupError('Unsupported backup file format.'); + } + + if (manifest.version !== PLAYLIST_BACKUP_VERSION) { + throw new PlaylistBackupError( + `Unsupported backup version: ${manifest.version ?? 'unknown'}` + ); + } + + if (!Array.isArray(manifest.playlists)) { + throw new PlaylistBackupError( + 'Backup file must contain playlists.' + ); + } + + for (const entry of manifest.playlists) { + this.validateEntry(entry); + } + + return manifest as PlaylistBackupManifestV1; + } + + private validateEntry(entry: PlaylistBackupEntry): void { + if (!entry || typeof entry !== 'object') { + throw new PlaylistBackupError( + 'Backup contains an invalid playlist entry.' + ); + } + + if (!entry.exportedId || !entry.title) { + throw new PlaylistBackupError( + 'Backup contains a playlist entry without required metadata.' + ); + } + + switch (entry.portalType) { + case 'm3u': + if (!entry.source?.rawM3u) { + throw new PlaylistBackupError( + `M3U backup "${entry.title}" is missing raw playlist data.` + ); + } + break; + case 'xtream': + if ( + !entry.connection?.serverUrl || + !entry.connection?.username + ) { + throw new PlaylistBackupError( + `Xtream backup "${entry.title}" is missing connection metadata.` + ); + } + break; + case 'stalker': + if ( + !entry.connection?.portalUrl || + !entry.connection?.macAddress + ) { + throw new PlaylistBackupError( + `Stalker backup "${entry.title}" is missing connection metadata.` + ); + } + break; + default: + throw new PlaylistBackupError( + `Unsupported backup playlist type: ${(entry as { portalType?: string }).portalType ?? 'unknown'}` + ); + } + } + + private async buildExistingFingerprintMap( + playlists: Playlist[] + ): Promise> { + const map = new Map(); + + for (const playlist of playlists) { + const portalType = this.getPlaylistPortalType(playlist); + + if (portalType === 'm3u' && !playlist.url) { + const rawM3u = await firstValueFrom( + this.playlistsService.getRawPlaylistById(playlist._id) + ); + map.set(this.buildM3uRawFingerprint(rawM3u), playlist); + continue; + } + + map.set(this.getPlaylistFingerprint(playlist), playlist); + } + + return map; + } + + private getPlaylistFingerprint(playlist: Playlist): string { + const portalType = this.getPlaylistPortalType(playlist); + + switch (portalType) { + case 'xtream': + return [ + 'xtream', + this.normalizeUrlIdentity(playlist.serverUrl ?? ''), + this.normalizeIdentityValue(playlist.username ?? ''), + ].join('|'); + case 'stalker': + return [ + 'stalker', + this.normalizeUrlIdentity( + playlist.portalUrl ?? playlist.url ?? '' + ), + this.normalizeIdentityValue( + playlist.macAddress ?? '', + true + ), + ].join('|'); + case 'm3u': + default: + if (playlist.url) { + return this.buildM3uUrlFingerprint(playlist.url); + } + + throw new PlaylistBackupError( + `Unable to fingerprint M3U playlist "${playlist.title}".` + ); + } + } + + private getEntryFingerprint(entry: PlaylistBackupEntry): string { + switch (entry.portalType) { + case 'xtream': + return [ + 'xtream', + this.normalizeUrlIdentity(entry.connection.serverUrl), + this.normalizeIdentityValue(entry.connection.username), + ].join('|'); + case 'stalker': + return [ + 'stalker', + this.normalizeUrlIdentity(entry.connection.portalUrl), + this.normalizeIdentityValue( + entry.connection.macAddress, + true + ), + ].join('|'); + case 'm3u': + default: + if (entry.source.url) { + return this.buildM3uUrlFingerprint(entry.source.url); + } + + return this.buildM3uRawFingerprint(entry.source.rawM3u); + } + } + + private buildM3uUrlFingerprint(url: string): string { + return ['m3u', 'url', this.normalizeUrlIdentity(url)].join('|'); + } + + private buildM3uRawFingerprint(rawM3u: string): string { + return [ + 'm3u', + 'raw', + this.hashString(this.canonicalizeM3u(rawM3u)), + ].join('|'); + } + + private resolveTargetPlaylistId( + entry: PlaylistBackupEntry, + matchedId: string | undefined, + existingIds: Set + ): string { + if (matchedId) { + return matchedId; + } + + if (entry.exportedId && !existingIds.has(entry.exportedId)) { + return entry.exportedId; + } + + let nextId = uuidv4(); + + while (existingIds.has(nextId)) { + nextId = uuidv4(); + } + + return nextId; + } + + private async buildImportedPlaylist( + entry: PlaylistBackupEntry, + playlistId: string, + existing: Playlist | null + ): Promise { + switch (entry.portalType) { + case 'xtream': + return this.buildImportedXtreamPlaylist( + entry, + playlistId, + existing + ); + case 'stalker': + return this.buildImportedStalkerPlaylist( + entry, + playlistId, + existing + ); + case 'm3u': + default: + return this.buildImportedM3uPlaylist( + entry, + playlistId, + existing + ); + } + } + + private async buildImportedM3uPlaylist( + entry: M3uPlaylistBackupEntry, + playlistId: string, + existing: Playlist | null + ): Promise { + const parsedPlaylist = this.playlistsService.handlePlaylistParsing( + 'TEXT', + entry.source.rawM3u, + entry.title + ); + const now = new Date().toISOString(); + + return { + ...existing, + ...parsedPlaylist, + _id: playlistId, + title: entry.title, + filename: entry.title, + importDate: existing?.importDate ?? now, + lastUsage: existing?.lastUsage ?? now, + autoRefresh: entry.autoRefresh, + position: entry.position, + favorites: this.uniqueStrings(entry.userState.favorites), + recentlyViewed: entry.userState.recentlyViewed.map((item) => + this.normalizeM3uRecentlyViewedItem(item) + ), + hiddenGroupTitles: this.uniqueStrings( + entry.userState.hiddenGroupTitles + ), + ...(entry.source.url + ? { url: entry.source.url } + : { url: undefined }), + ...(entry.source.userAgent + ? { userAgent: entry.source.userAgent } + : { userAgent: undefined }), + ...(entry.source.referrer + ? { referrer: entry.source.referrer } + : { referrer: undefined }), + ...(entry.source.origin + ? { origin: entry.source.origin } + : { origin: undefined }), + filePath: undefined, + }; + } + + private buildImportedXtreamPlaylist( + entry: XtreamPlaylistBackupEntry, + playlistId: string, + existing: Playlist | null + ): Playlist { + const now = new Date().toISOString(); + + return { + ...existing, + _id: playlistId, + title: entry.title, + filename: entry.title, + count: existing?.count ?? 0, + importDate: existing?.importDate ?? now, + lastUsage: existing?.lastUsage ?? now, + autoRefresh: entry.autoRefresh, + position: entry.position, + serverUrl: entry.connection.serverUrl, + username: entry.connection.username, + password: entry.connection.password, + favorites: [], + recentlyViewed: [], + }; + } + + private buildImportedStalkerPlaylist( + entry: StalkerPlaylistBackupEntry, + playlistId: string, + existing: Playlist | null + ): Playlist { + const now = new Date().toISOString(); + + return { + ...existing, + _id: playlistId, + title: entry.title, + filename: entry.title, + count: existing?.count ?? 0, + importDate: existing?.importDate ?? now, + lastUsage: existing?.lastUsage ?? now, + autoRefresh: entry.autoRefresh, + position: entry.position, + portalUrl: entry.connection.portalUrl, + url: entry.connection.portalUrl, + macAddress: entry.connection.macAddress, + username: entry.connection.username, + password: entry.connection.password, + userAgent: entry.connection.userAgent, + referrer: entry.connection.referrer, + origin: entry.connection.origin, + isFullStalkerPortal: entry.connection.isFullStalkerPortal, + favorites: entry.userState.favorites.map((item) => ({ ...item })), + recentlyViewed: entry.userState.recentlyViewed.map((item) => ({ + ...item, + })), + stalkerSerialNumber: entry.connection.stalkerSerialNumber, + stalkerDeviceId1: entry.connection.stalkerDeviceId1, + stalkerDeviceId2: entry.connection.stalkerDeviceId2, + stalkerSignature1: entry.connection.stalkerSignature1, + stalkerSignature2: entry.connection.stalkerSignature2, + stalkerToken: undefined, + stalkerAccountInfo: undefined, + }; + } + + private async restoreSettings( + settings: PlaylistBackupSettings | undefined + ): Promise { + if (!settings?.epgUrls) { + return; + } + + const existingSettings = this.settingsStore.getSettings(); + const mergedEpgUrls = this.mergeUrls( + settings.epgUrls, + existingSettings.epgUrl ?? [] + ); + + await this.settingsStore.updateSettings({ + epgUrl: mergedEpgUrls, + }); + } + + private async restoreXtreamEntry( + playlistId: string, + entry: XtreamPlaylistBackupEntry + ): Promise { + const restoreState: XtreamPendingRestoreState = { + hiddenCategories: entry.userState.hiddenCategories.map((item) => ({ + ...item, + })), + favorites: entry.userState.favorites.map((item) => ({ ...item })), + recentlyViewed: entry.userState.recentlyViewed.map((item) => ({ + ...item, + })), + playbackPositions: entry.userState.playbackPositions.map( + (item) => ({ + ...item, + }) + ), + }; + + this.pendingRestoreService.set(playlistId, restoreState); + + if (!this.hasElectronApi()) { + return; + } + + if (!(await this.hasCompletedOfflineCache(playlistId))) { + return; + } + + await this.applyXtreamRestoreState(playlistId, restoreState); + this.pendingRestoreService.clear(playlistId); + } + + private async hasCompletedOfflineCache( + playlistId: string + ): Promise { + const checks = await Promise.all( + [ + { + categoryType: 'live' as const, + contentType: 'live' as const, + }, + { + categoryType: 'movies' as const, + contentType: 'movie' as const, + }, + { + categoryType: 'series' as const, + contentType: 'series' as const, + }, + ].map(async ({ categoryType, contentType }) => { + const [importStatus, hasCategories, hasContent] = + await Promise.all([ + this.databaseService.getXtreamImportStatus( + playlistId, + contentType + ), + this.databaseService.hasXtreamCategories( + playlistId, + categoryType + ), + this.databaseService.hasXtreamContent( + playlistId, + contentType + ), + ]); + + return ( + importStatus === 'completed' && hasCategories && hasContent + ); + }) + ); + + return checks.every(Boolean); + } + + private async applyXtreamRestoreState( + playlistId: string, + state: XtreamPendingRestoreState + ): Promise { + await this.restoreXtreamCategoryVisibility(playlistId, state); + await this.databaseService.restoreXtreamUserData( + playlistId, + state.favorites, + state.recentlyViewed + ); + + await this.playbackPositionService.clearAllPlaybackPositions( + playlistId + ); + + for (const playbackPosition of state.playbackPositions) { + await this.playbackPositionService.savePlaybackPosition( + playlistId, + playbackPosition + ); + } + } + + private async restoreXtreamCategoryVisibility( + playlistId: string, + state: XtreamPendingRestoreState + ): Promise { + const categoriesByType = await Promise.all([ + this.databaseService.getAllXtreamCategories(playlistId, 'live'), + this.databaseService.getAllXtreamCategories(playlistId, 'movies'), + this.databaseService.getAllXtreamCategories(playlistId, 'series'), + ]); + + for (const categories of categoriesByType) { + if (categories.length === 0) { + continue; + } + + await this.databaseService.updateCategoryVisibility( + categories.map((category) => category.id), + false + ); + + const hiddenCategoryIds = categories + .filter((category) => + state.hiddenCategories.some( + (hiddenCategory) => + hiddenCategory.categoryType === category.type && + hiddenCategory.xtreamId === category.xtream_id + ) + ) + .map((category) => category.id); + + if (hiddenCategoryIds.length > 0) { + await this.databaseService.updateCategoryVisibility( + hiddenCategoryIds, + true + ); + } + } + } + + private mapHiddenCategories( + categories: Array<{ + hidden: boolean; + xtream_id: number; + type: XtreamCategoryType; + }>, + categoryType: XtreamCategoryType + ): XtreamPendingRestoreState['hiddenCategories'] { + return categories + .filter((category) => category.hidden) + .map((category) => ({ + categoryType, + xtreamId: category.xtream_id, + })); + } + + private getPlaylistPortalType(playlist: Playlist): PlaylistPortalType { + if (playlist.serverUrl && playlist.username) { + return 'xtream'; + } + + if (playlist.macAddress && (playlist.portalUrl || playlist.url)) { + return 'stalker'; + } + + return 'm3u'; + } + + private resolveM3uSourceKind(playlist: Playlist): 'url' | 'file' | 'text' { + if (playlist.url) { + return 'url'; + } + + if (playlist.filePath) { + return 'file'; + } + + return 'text'; + } + + private extractStalkerItems(items: unknown): StalkerPortalItem[] { + if (!Array.isArray(items)) { + return []; + } + + return items.filter( + (item): item is StalkerPortalItem => + !!item && typeof item === 'object' && !Array.isArray(item) + ); + } + + private normalizeM3uRecentlyViewedItem( + item: M3uRecentlyViewedItem + ): M3uRecentlyViewedItem { + return { + ...item, + source: 'm3u', + id: String(item.id ?? item.url), + url: String(item.url), + title: String(item.title ?? ''), + category_id: 'live', + added_at: item.added_at ?? new Date().toISOString(), + }; + } + + private normalizeSettingsUrls(urls: string[]): string[] { + return this.uniqueStrings( + urls + .map((url) => String(url ?? '').trim()) + .filter((url) => url.length > 0) + ); + } + + private mergeUrls(primary: string[], secondary: string[]): string[] { + const merged = new Map(); + + for (const url of [...primary, ...secondary]) { + const normalized = this.normalizeUrlIdentity(url); + + if (!normalized || merged.has(normalized)) { + continue; + } + + merged.set(normalized, url.trim()); + } + + return [...merged.values()]; + } + + private uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const value of values) { + const normalized = value.trim(); + + if (!normalized || seen.has(normalized)) { + continue; + } + + seen.add(normalized); + result.push(normalized); + } + + return result; + } + + private normalizeUrlIdentity(value: string): string { + const trimmed = value.trim(); + + if (!trimmed) { + return ''; + } + + try { + const url = new URL(trimmed); + const pathname = + url.pathname === '/' + ? '/' + : url.pathname.replace(/\/+$/, '') || '/'; + + return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${pathname}${url.search}${url.hash}`; + } catch { + return trimmed.toLowerCase(); + } + } + + private normalizeIdentityValue(value: string, toLowerCase = false): string { + const trimmed = value.trim(); + return toLowerCase ? trimmed.toLowerCase() : trimmed; + } + + private canonicalizeM3u(rawM3u: string): string { + return rawM3u.replace(/\r\n?/g, '\n').trim(); + } + + private hashString(value: string): string { + let hash = 0x811c9dc5; + + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + + return (hash >>> 0).toString(16); + } + + private getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + + private hasElectronApi(): boolean { + return !!(window as Window & { electron?: unknown }).electron; + } +} diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 979d74042..47d36afbf 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -451,7 +451,9 @@ export class PlaylistsService { } return this.runOnIndexedDb(() => - firstValueFrom(this.dbService.getByID(DbStores.Playlists, id)) + firstValueFrom( + this.dbService.getByID(DbStores.Playlists, id) + ) ); } @@ -469,12 +471,24 @@ export class PlaylistsService { ...(updatedPlaylist.userAgent != null ? { userAgent: updatedPlaylist.userAgent } : {}), + ...(updatedPlaylist.referrer !== undefined + ? { referrer: updatedPlaylist.referrer } + : {}), + ...(updatedPlaylist.origin !== undefined + ? { origin: updatedPlaylist.origin } + : {}), ...(updatedPlaylist.serverUrl != null ? { serverUrl: updatedPlaylist.serverUrl } : {}), ...(updatedPlaylist.portalUrl != null ? { portalUrl: updatedPlaylist.portalUrl } : {}), + ...(updatedPlaylist.isFullStalkerPortal !== undefined + ? { + isFullStalkerPortal: + updatedPlaylist.isFullStalkerPortal, + } + : {}), ...(updatedPlaylist.macAddress != null ? { macAddress: updatedPlaylist.macAddress } : {}), @@ -879,7 +893,9 @@ export class PlaylistsService { return raw.includes(':') ? raw.split(':')[0] : raw; } - private getPlaylistRecentIdentity(item: PlaylistRecentlyViewedItem): string { + private getPlaylistRecentIdentity( + item: PlaylistRecentlyViewedItem + ): string { if (isM3uRecentlyViewedItem(item)) { return String(item.url ?? item.id ?? '').trim(); } @@ -994,10 +1010,7 @@ export class PlaylistsService { playlist.recentlyViewed as PlaylistRecentlyViewedItem[] )?.filter( (item) => - !this.matchesPlaylistRecentIdentity( - item, - identity - ) + !this.matchesPlaylistRecentIdentity(item, identity) ), }; diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 08c718915..cc2f9e6a9 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -79,7 +79,7 @@ export const SettingsStore = signalStore( showCaptions: store.showCaptions(), showDashboard: store.showDashboard(), startupBehavior: store.startupBehavior(), - showExternalPlaybackBar: store.showExternalPlaybackBar(), + showExternalPlaybackBar: store.showExternalPlaybackBar!(), theme: store.theme(), mpvPlayerPath: store.mpvPlayerPath(), mpvReuseInstance: store.mpvReuseInstance(), @@ -87,12 +87,12 @@ export const SettingsStore = signalStore( remoteControl: store.remoteControl(), remoteControlPort: store.remoteControlPort(), epgUrl: store.epgUrl(), - downloadFolder: store.downloadFolder(), + downloadFolder: store.downloadFolder!(), }; }, getDownloadFolder() { - return store.downloadFolder(); + return store.downloadFolder!(); }, getPlayer() { diff --git a/libs/services/src/lib/xtream-pending-restore.service.ts b/libs/services/src/lib/xtream-pending-restore.service.ts new file mode 100644 index 000000000..b032ae721 --- /dev/null +++ b/libs/services/src/lib/xtream-pending-restore.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@angular/core'; +import { + getXtreamPendingRestoreStorageKey, + XtreamPendingRestoreState, +} from 'shared-interfaces'; + +const EMPTY_RESTORE_STATE: XtreamPendingRestoreState = { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], +}; + +@Injectable({ + providedIn: 'root', +}) +export class XtreamPendingRestoreService { + get(playlistId: string): XtreamPendingRestoreState | null { + if (!playlistId) { + return null; + } + + try { + const rawState = localStorage.getItem( + getXtreamPendingRestoreStorageKey(playlistId) + ); + + if (!rawState) { + return null; + } + + return this.normalize(JSON.parse(rawState)); + } catch { + return null; + } + } + + set(playlistId: string, state: XtreamPendingRestoreState): void { + if (!playlistId) { + return; + } + + try { + localStorage.setItem( + getXtreamPendingRestoreStorageKey(playlistId), + JSON.stringify(this.normalize(state)) + ); + } catch { + // Ignore local storage write failures. + } + } + + clear(playlistId: string): void { + if (!playlistId) { + return; + } + + try { + localStorage.removeItem( + getXtreamPendingRestoreStorageKey(playlistId) + ); + } catch { + // Ignore local storage remove failures. + } + } + + private normalize(value: unknown): XtreamPendingRestoreState { + if (!value || typeof value !== 'object') { + return { ...EMPTY_RESTORE_STATE }; + } + + const candidate = value as Partial; + + return { + hiddenCategories: Array.isArray(candidate.hiddenCategories) + ? candidate.hiddenCategories + : [], + favorites: Array.isArray(candidate.favorites) + ? candidate.favorites + : [], + recentlyViewed: Array.isArray(candidate.recentlyViewed) + ? candidate.recentlyViewed + : [], + playbackPositions: Array.isArray(candidate.playbackPositions) + ? candidate.playbackPositions + : [], + }; + } +} diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 27abdf12a..bd587bbe7 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -12,6 +12,7 @@ export * from './lib/ipc-commands'; export * from './lib/language.enum'; export * from './lib/parsed-playlist.interface'; export * from './lib/playback-position.interface'; +export * from './lib/playlist-backup.interface'; export * from './lib/playlist-meta.type'; export * from './lib/playlist-refresh.interface'; export * from './lib/playlist-recently-viewed.interface'; @@ -25,6 +26,7 @@ export * from './lib/stalker-portal-actions.enum'; export * from './lib/store-keys.enum'; export * from './lib/stream-format.enum'; export * from './lib/theme.enum'; +export * from './lib/xtream-restore-state.util'; export * from './lib/xtream-category.interface'; export * from './lib/xtream-code-actions'; export * from './lib/xtream-item.interface'; diff --git a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts new file mode 100644 index 000000000..2ff9300db --- /dev/null +++ b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts @@ -0,0 +1,113 @@ +import { PlaybackPositionData } from './playback-position.interface'; +import { M3uRecentlyViewedItem } from './playlist-recently-viewed.interface'; +import { StalkerPortalItem } from './stalker-portal-item.interface'; + +export const PLAYLIST_BACKUP_KIND = 'iptvnator-playlist-backup'; +export const PLAYLIST_BACKUP_VERSION = 1; + +export type PlaylistBackupPortalType = 'm3u' | 'xtream' | 'stalker'; +export type M3uBackupSourceKind = 'url' | 'file' | 'text'; +export type XtreamBackupContentType = 'live' | 'movie' | 'series'; +export type XtreamBackupCategoryType = 'live' | 'movies' | 'series'; + +export interface PlaylistBackupSettings { + epgUrls: string[]; +} + +export interface PlaylistBackupBaseEntry { + portalType: PlaylistBackupPortalType; + exportedId: string; + title: string; + autoRefresh: boolean; + position?: number; +} + +export interface M3uPlaylistBackupEntry extends PlaylistBackupBaseEntry { + portalType: 'm3u'; + source: { + kind: M3uBackupSourceKind; + rawM3u: string; + url?: string; + userAgent?: string; + referrer?: string; + origin?: string; + filePathHint?: string; + }; + userState: { + favorites: string[]; + recentlyViewed: M3uRecentlyViewedItem[]; + hiddenGroupTitles: string[]; + }; +} + +export interface XtreamBackupHiddenCategory { + categoryType: XtreamBackupCategoryType; + xtreamId: number; +} + +export interface XtreamBackupFavoriteItem { + contentType: XtreamBackupContentType; + xtreamId: number; + addedAt?: string; + position?: number | null; +} + +export interface XtreamBackupRecentlyViewedItem { + contentType: XtreamBackupContentType; + xtreamId: number; + viewedAt: string; +} + +export interface XtreamBackupUserState { + hiddenCategories: XtreamBackupHiddenCategory[]; + favorites: XtreamBackupFavoriteItem[]; + recentlyViewed: XtreamBackupRecentlyViewedItem[]; + playbackPositions: PlaybackPositionData[]; +} + +export interface XtreamPlaylistBackupEntry extends PlaylistBackupBaseEntry { + portalType: 'xtream'; + connection: { + serverUrl: string; + username: string; + password?: string; + }; + userState: XtreamBackupUserState; +} + +export interface StalkerPlaylistBackupEntry extends PlaylistBackupBaseEntry { + portalType: 'stalker'; + connection: { + portalUrl: string; + macAddress: string; + isFullStalkerPortal?: boolean; + username?: string; + password?: string; + userAgent?: string; + referrer?: string; + origin?: string; + stalkerSerialNumber?: string; + stalkerDeviceId1?: string; + stalkerDeviceId2?: string; + stalkerSignature1?: string; + stalkerSignature2?: string; + }; + userState: { + favorites: StalkerPortalItem[]; + recentlyViewed: StalkerPortalItem[]; + }; +} + +export type PlaylistBackupEntry = + | M3uPlaylistBackupEntry + | XtreamPlaylistBackupEntry + | StalkerPlaylistBackupEntry; + +export interface PlaylistBackupManifestV1 { + kind: typeof PLAYLIST_BACKUP_KIND; + version: typeof PLAYLIST_BACKUP_VERSION; + exportedAt: string; + includeSecrets: boolean; + settings?: PlaylistBackupSettings; + playlists: PlaylistBackupEntry[]; +} diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts new file mode 100644 index 000000000..965f84ee6 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts @@ -0,0 +1,17 @@ +import { + XtreamBackupFavoriteItem, + XtreamBackupHiddenCategory, + XtreamBackupRecentlyViewedItem, +} from './playlist-backup.interface'; +import { PlaybackPositionData } from './playback-position.interface'; + +export interface XtreamPendingRestoreState { + hiddenCategories: XtreamBackupHiddenCategory[]; + favorites: XtreamBackupFavoriteItem[]; + recentlyViewed: XtreamBackupRecentlyViewedItem[]; + playbackPositions: PlaybackPositionData[]; +} + +export function getXtreamPendingRestoreStorageKey(playlistId: string): string { + return `xtream-restore-${playlistId}`; +}