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