feat: enhance playlist backup export functionality and UI feedback

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-21 08:54:03 +02:00
1 parent 1c1a53743a
commit c8c93798f9
26 files changed
+2254 -375

No files matched your search

@@ -756,7 +756,10 @@
<div class="setting-item__control inline-actions">
<button
mat-button
[disabled]="isRemovingAllPlaylists()"
class="inline-actions__button"
[disabled]="
isRemovingAllPlaylists() || isExportingData()
"
(click)="
$event.preventDefault();
$event.stopPropagation();
@@ -767,13 +770,34 @@
</button>
<button
mat-button
class="inline-actions__button"
[class.inline-actions__button--busy]="
isExportingData()
"
[disabled]="
isRemovingAllPlaylists() || isExportingData()
"
[attr.aria-busy]="isExportingData()"
(click)="
$event.preventDefault();
$event.stopPropagation();
exportData()
"
>
{{ 'SETTINGS.EXPORT_DATA' | translate }}
@if (isExportingData()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
<span class="inline-actions__label">{{
'SETTINGS.EXPORTING_DATA' | translate
}}</span>
} @else {
<span class="inline-actions__label">{{
'SETTINGS.EXPORT_DATA' | translate
}}</span>
}
</button>
</div>
</div>
@@ -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;
@@ -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(
+105 -47
View File
@@ -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<HTMLElement>);
private readonly injector = inject(Injector);
private readonly dialogData = inject<{ isDialog: boolean } | null>(
@@ -241,11 +243,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
visibleQrCodeIp = signal<string | null>(null);
readonly isRemovingAllPlaylists = signal(false);
readonly isClearingEpgData = signal(false);
readonly isExportingData = signal(false);
private settingsStore = inject(SettingsStore);
private sectionObserver?: IntersectionObserver;
private pendingScrollClearTimer: ReturnType<typeof window.setTimeout> | 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<void> {
if (typeof window.requestAnimationFrame !== 'function') {
await Promise.resolve();
return;
}
await new Promise<void>((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);
+3 -2
View File
@@ -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.",
+3 -1
View File
@@ -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.",
@@ -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 }
: {}),
@@ -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<void> | 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([
@@ -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<string>('');
@@ -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<void> {
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(
@@ -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<typeof signal<'playlists' | 'xtreams' | null>>;
let playbackPositionService: {
getAllPlaybackPositions: jest.Mock;
};
let routeProvider: ReturnType<
typeof signal<'playlists' | 'xtreams' | null>
>;
let resolvedPlaylistId: ReturnType<typeof signal<string | null>>;
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<string | null>(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();
@@ -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');
}
}
@@ -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<XtreamPendingRestoreState> {
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<void> {
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
);
}
}
}
@@ -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<string, StoredRecentItem[]> {
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<XtreamPendingRestoreState> {
// 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> {
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);
}
// =========================================================================
@@ -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<XtreamPendingRestoreState>;
/**
* Restore user data after refresh
*/
restoreUserData(
playlistId: string,
favoritedXtreamIds: number[],
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[],
restoreState: XtreamPendingRestoreState,
options?: XtreamOperationOptions
): Promise<void>;
}
@@ -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<void> | 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<void> {
async fetchAllCategories(options?: {
sessionId?: string;
}): Promise<void> {
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<ContentType>;
}
): Promise<void> {
async fetchAllContent(options?: {
importSessionId?: string;
sessionId?: string;
completedTypes?: Set<ContentType>;
}): Promise<void> {
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);
+2
View File
@@ -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';
@@ -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<void> {
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<GlobalSearchResult[]> {
return await window.electron.dbGlobalSearch(searchTerm, types, excludeHidden);
return await window.electron.dbGlobalSearch(
searchTerm,
types,
excludeHidden
);
}
/**
@@ -63,7 +63,9 @@ export class PlaybackPositionService {
}
}
async getAllPlaybackPositions(playlistId: string): Promise<PlaybackPositionData[]> {
async getAllPlaybackPositions(
playlistId: string
): Promise<PlaybackPositionData[]> {
try {
return await window.electron.dbGetAllPlaybackPositions(playlistId);
} catch (error) {
@@ -72,6 +74,14 @@ export class PlaybackPositionService {
}
}
async clearAllPlaybackPositions(playlistId: string): Promise<void> {
try {
await window.electron.dbClearAllPlaybackPositions(playlistId);
} catch (error) {
console.error('Error clearing all playback positions:', error);
}
}
async clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
@@ -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<string, unknown> = {}) {
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'],
})
);
});
});
File diff suppressed because it is too large. Load diff
+19 -6
View File
@@ -451,7 +451,9 @@ export class PlaylistsService {
}
return this.runOnIndexedDb(() =>
firstValueFrom(this.dbService.getByID<Playlist>(DbStores.Playlists, id))
firstValueFrom(
this.dbService.getByID<Playlist>(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)
),
};
@@ -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() {
@@ -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<XtreamPendingRestoreState>;
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
: [],
};
}
}
+2
View File
@@ -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';
@@ -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[];
}
@@ -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}`;
}