feat(playlist): clean up selected inactive desktop sources (#1593) (#1596)

This commit is contained in:
4gray authored and GitHub committed 2026-09-13 10:39:14 +02:00
1 parent cbc918b096
commit bd848aaad6
53 files changed
+1980 -87

No files matched your search

+2
View File
@@ -28,3 +28,5 @@ export * from './lib/xtream-connection-test.service';
export * from './lib/xtream-connection-test-state';
export * from './lib/source-health-evidence.service';
export * from './lib/source-activity.service';
@@ -1,10 +1,11 @@
import { SourceActivityService } from './source-activity.service';
import {
EnvironmentInjector,
Injector,
createEnvironmentInjector,
runInInjectionContext,
} from '@angular/core';
import { of } from 'rxjs';
import { of, throwError, Subject } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DatabaseService } from './database-electron.service';
import { PlaylistDeleteActionService } from './playlist-delete-action.service';
@@ -48,6 +49,10 @@ describe('PlaylistDeleteActionService', () => {
injector = createEnvironmentInjector(
[
{
provide: SourceActivityService,
useValue: new SourceActivityService(),
},
{ provide: DatabaseService, useValue: databaseService },
{ provide: PlaylistsService, useValue: playlistsService },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
@@ -67,6 +72,26 @@ describe('PlaylistDeleteActionService', () => {
);
}
it('keeps shared deletion activity reserved until persistence and cleanup finish', async () => {
const completion = new Subject<{ success: boolean }>();
playlistsService.deletePlaylist.mockReturnValue(completion);
const activity = injector.get(SourceActivityService);
const pending = createService().deletePlaylist(playlist);
expect(activity.isBusy(playlist._id)).toBe(true);
completion.next({ success: true });
await pending;
expect(activity.isBusy(playlist._id)).toBe(false);
});
it('preserves the boolean failure contract for single-source callers', async () => {
playlistsService.deletePlaylist.mockReturnValue(
throwError(() => new Error('worker failed'))
);
await expect(createService().deletePlaylist(playlist)).resolves.toBe(
false
);
});
it('deletes browser playlists through PlaylistsService', async () => {
const service = createService();
@@ -78,7 +103,7 @@ describe('PlaylistDeleteActionService', () => {
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
});
it('deletes SQLite-backed Xtream playlists through DatabaseService with progress options', async () => {
it('routes SQLite deletion through the serialized owner with progress options', async () => {
runtime.supportsXtreamSqliteDataSource = true;
const onEvent = jest.fn();
const service = createService();
@@ -90,14 +115,14 @@ describe('PlaylistDeleteActionService', () => {
expect(databaseService.createOperationId).toHaveBeenCalledWith(
'playlist-delete'
);
expect(databaseService.deletePlaylist).toHaveBeenCalledWith(
expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(
'playlist-1',
{
operationId: 'playlist-delete-1',
onEvent,
}
);
expect(playlistsService.deletePlaylist).not.toHaveBeenCalled();
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
});
it('deletes SQLite-backed non-Xtream playlists without progress options', async () => {
@@ -112,10 +137,10 @@ describe('PlaylistDeleteActionService', () => {
).resolves.toBe(true);
expect(databaseService.createOperationId).not.toHaveBeenCalled();
expect(databaseService.deletePlaylist).toHaveBeenCalledWith(
'playlist-1',
undefined
expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(
'playlist-1'
);
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
});
it('uses browser playlist storage when an Xtream playlist lacks Xtream SQLite support', async () => {
@@ -1,4 +1,4 @@
import { SourceHealthEvidenceService } from './source-health-evidence.service';
import { SourceActivityService } from './source-activity.service';
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -20,7 +20,7 @@ export interface PlaylistDeleteActionOptions {
@Injectable({ providedIn: 'root' })
export class PlaylistDeleteActionService {
private readonly healthEvidence = inject(SourceHealthEvidenceService, {
private readonly activity = inject(SourceActivityService, {
optional: true,
});
private readonly databaseService = inject(DatabaseService);
@@ -31,42 +31,45 @@ export class PlaylistDeleteActionService {
playlist: PlaylistMeta,
options: PlaylistDeleteActionOptions = {}
): Promise<boolean> {
const supportsElectronDelete = playlist.serverUrl
? this.runtime.supportsXtreamSqliteDataSource
: this.runtime.supportsSqlite;
if (supportsElectronDelete) {
const deleted = await this.deletePlaylistInElectron(
try {
const result = await this.deletePlaylistWithResult(
playlist,
options
);
if (deleted)
this.healthEvidence?.connections.next({ id: playlist._id });
return deleted;
return result.success;
} catch {
return false;
}
const result = await firstValueFrom(
this.playlistsService.deletePlaylist(playlist._id)
);
return result.success;
}
private deletePlaylistInElectron(
async deletePlaylistWithResult(
playlist: PlaylistMeta,
options: PlaylistDeleteActionOptions
): Promise<boolean> {
const operationId = playlist.serverUrl
? this.databaseService.createOperationId('playlist-delete')
: undefined;
return this.databaseService.deletePlaylist(
playlist._id,
operationId
options: PlaylistDeleteActionOptions = {}
): Promise<{ success: boolean; cleanupWarnings?: number }> {
const workerOptions =
playlist.serverUrl && this.runtime.supportsXtreamSqliteDataSource
? {
operationId,
operationId:
this.databaseService.createOperationId(
'playlist-delete'
),
onEvent: options.onEvent,
}
: undefined
);
: undefined;
// Persistence owns serialization, the single worker invocation, and
// post-delete cleanup. UI callers only commit the resulting state.
const release = this.activity?.begin([playlist._id]);
try {
return await firstValueFrom(
workerOptions
? this.playlistsService.deletePlaylist(
playlist._id,
workerOptions
)
: this.playlistsService.deletePlaylist(playlist._id)
);
} finally {
release?.();
}
}
}
@@ -125,6 +125,61 @@ describe('PlaylistsService', () => {
expect(cleanup).toHaveBeenCalledWith('playlist-1');
});
it('owns one worker deletion and waits for cleanup before completion', async () => {
const service = createService();
const deleteWorker = jest.fn().mockResolvedValue(true);
let finishCleanup!: () => void;
const cleanup = jest.fn(
() =>
new Promise<void>((resolve) => {
finishCleanup = resolve;
})
);
Object.defineProperties(service, {
isElectronStorageAvailable: { value: true },
electronApi: { value: { dbDeletePlaylist: jest.fn() } },
});
Object.assign(service, {
ensureElectronPlaylistMigrations: jest
.fn()
.mockResolvedValue(undefined),
databaseService: { deletePlaylist: deleteWorker },
playlistDeleteCleanups: [cleanup],
});
let completed = false;
const deletion = firstValueFrom(
service.deletePlaylist('a', { operationId: 'delete-a' })
).then((result) => {
completed = true;
return result;
});
for (let i = 0; i < 20 && !finishCleanup; i++) await Promise.resolve();
expect(deleteWorker).toHaveBeenCalledTimes(1);
expect(completed).toBe(false);
finishCleanup();
await expect(deletion).resolves.toEqual({ success: true });
expect(cleanup).toHaveBeenCalledTimes(1);
});
it('reports cleanup warnings without resurrecting an already deleted row', async () => {
const service = createService();
Object.assign(service, {
playlistDeleteCleanups: [
() => Promise.reject(new Error('cleanup failed')),
],
});
const warning = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
try {
await expect(
firstValueFrom(service.deletePlaylist('a'))
).resolves.toEqual({ success: true, cleanupWarnings: 1 });
} finally {
warning.mockRestore();
}
});
it('returns browser playlist summaries without embedded playlist payloads', async () => {
const dbService = {
getAll: jest.fn(() =>
@@ -982,7 +1037,7 @@ describe('PlaylistsService', () => {
jest.spyOn(Date, 'now').mockReturnValue(1770000000000);
// Auto-refresh snapshots always come from playlists that had
// autoRefresh enabled; the batch write preserves that flag from the
// current row (or the snapshot when the row is missing) instead of
// current row instead of
// force-enabling it.
const playlists = [
{
@@ -1004,6 +1059,7 @@ describe('PlaylistsService', () => {
] as Playlist[];
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn((_store: string, id: string) => of(playlists.find((p) => p._id === id))),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
@@ -1036,6 +1092,20 @@ describe('PlaylistsService', () => {
);
});
it.each([false, true])('does not resurrect a deleted source when startup refresh finishes (SQLite=%s)', async (sqlite) => {
const upsert = jest.fn();
testWindow.electron = sqlite ? {
dbGetAppState: jest.fn().mockResolvedValue('1'), dbSetAppState: jest.fn(),
dbGetAppPlaylists: jest.fn().mockResolvedValue([]), dbGetAppPlaylist: jest.fn().mockResolvedValue(null),
dbUpsertAppPlaylist: upsert,
} : undefined;
const update = jest.fn(() => of(undefined));
const service = createService({ update });
expect(await firstValueFrom(service.updateManyPlaylists([{ _id: 'deleted', autoRefresh: true } as Playlist]))).toEqual([]);
expect(update).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
});
it('short-circuits updateManyPlaylists when no playlists are provided', async () => {
const dbService = {
update: jest.fn(),
@@ -1693,6 +1763,19 @@ describe('PlaylistsService', () => {
);
});
it.each([false, true])('does not recreate a removed source on singular refresh (SQLite: %s)', async (sqlite) => {
const upsert = jest.fn();
testWindow.electron = sqlite ? {
dbGetAllPlaylists: jest.fn().mockResolvedValue([]),
dbGetAppPlaylist: jest.fn().mockResolvedValue(null),
dbUpsertAppPlaylist: upsert,
} as unknown as typeof testWindow.electron : undefined;
const update = jest.fn(() => of(undefined));
const service = createService({ update });
await expect(firstValueFrom(service.updatePlaylist('deleted', { _id: 'deleted' } as Playlist))).rejects.toThrow('Playlist no longer exists');
expect(update).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
});
it('keeps hiddenGroupTitles when refreshing a playlist payload', async () => {
const existingPlaylist: Playlist = {
_id: 'playlist-2',
+33 -6
View File
@@ -1,3 +1,7 @@
import {
DatabaseService,
type DbOperationOptions,
} from './database-electron.service';
import { SourceHealthEvidenceService } from './source-health-evidence.service';
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -105,6 +109,7 @@ export function resolvePlaylistParser(parserModule: PlaylistParserModule) {
export class PlaylistsService {
private readonly healthEvidence = inject(SourceHealthEvidenceService, { optional: true });
private readonly dbService = inject(NgxIndexedDBService);
private readonly databaseService = inject(DatabaseService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
@@ -534,7 +539,10 @@ export class PlaylistsService {
);
}
deletePlaylist(playlistId: string): Observable<{ success: boolean }> {
deletePlaylist(
playlistId: string,
options?: DbOperationOptions
): Observable<{ success: boolean; cleanupWarnings?: number }> {
// Deletion goes through the SAME per-playlist queue as every write:
// a queued mutation (e.g. the Stalker portal repair's conditional
// transform) landing after an unserialized delete would upsert the
@@ -547,7 +555,17 @@ export class PlaylistsService {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
await electron.dbDeletePlaylist(playlistId);
if (options) {
const deleted =
await this.databaseService.deletePlaylist(
playlistId,
options
);
if (!deleted)
throw new Error(
'Playlist deletion did not complete'
);
} else await electron.dbDeletePlaylist(playlistId);
}
return undefined;
}
@@ -561,13 +579,19 @@ export class PlaylistsService {
return delete$.pipe(
tap(() => this.healthEvidence?.connections.next({ id: playlistId })),
switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))),
map(() => ({ success: true }))
map((cleanupWarnings) =>
cleanupWarnings
? { success: true, cleanupWarnings }
: { success: true }
)
);
}
private async runPlaylistDeleteCleanups(playlistId: string): Promise<void> {
private async runPlaylistDeleteCleanups(
playlistId: string
): Promise<number> {
if (this.playlistDeleteCleanups.length === 0) {
return;
return 0;
}
const failures = (
@@ -589,6 +613,7 @@ export class PlaylistsService {
failure
);
}
return failures.length;
}
/**
@@ -650,6 +675,7 @@ export class PlaylistsService {
const currentPlaylist = await firstValueFrom(
this.getPlaylistById(playlistId, operationId)
);
if (!currentPlaylist) throw new Error('Playlist no longer exists');
const mergedPlaylist = this.mergeRefreshedPlaylist(
currentPlaylist,
updatedPlaylist,
@@ -1004,6 +1030,7 @@ export class PlaylistsService {
const current = await firstValueFrom(
this.getPlaylistById(playlist._id)
);
if (!current) return null;
// The merge takes autoRefresh from the current row first,
// so disabling auto-refresh while a refresh is in flight
// is not reverted by the completing batch write.
@@ -1017,7 +1044,7 @@ export class PlaylistsService {
return nextPlaylist;
})
)
);
).pipe(map((rows) => rows.filter((row): row is Playlist => row !== null)));
}
getFavoriteChannels(playlistId: string) {
@@ -0,0 +1,32 @@
import { Injectable, signal } from '@angular/core';
/** Shared renderer work that source cleanup must not interrupt. */
@Injectable({ providedIn: 'root' })
export class SourceActivityService {
private readonly counts = signal<ReadonlyMap<string, number>>(new Map());
isBusy(id: string): boolean {
return (this.counts().get(id) ?? 0) > 0;
}
begin(ids: readonly string[]): () => void {
const unique = [...new Set(ids)];
this.counts.update((value) => {
const next = new Map(value);
unique.forEach((id) => next.set(id, (next.get(id) ?? 0) + 1));
return next;
});
let released = false;
return () => {
if (released) return;
released = true;
this.counts.update((value) => {
const next = new Map(value);
unique.forEach((id) => {
const remaining = (next.get(id) ?? 1) - 1;
if (remaining) next.set(id, remaining);
else next.delete(id);
});
return next;
});
};
}
}