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

@@ -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');
}
}