mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: enhance playlist backup export functionality and UI feedback
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
+45
-16
@@ -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');
|
||||
}
|
||||
}
|
||||
+37
-34
@@ -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,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
@@ -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
|
||||
: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user