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

@@ -0,0 +1,6 @@
---
type: feature
area: playlist
---
Desktop users can review inactive sources and delete selected ones together. The dialog checks the whole library, preselects confirmed expired or disabled accounts, and lets you keep individual sources. Busy sources are skipped, and deletion results are reported individually.
+12
View File
@@ -1156,3 +1156,15 @@ account expiry/disablement is distinct from failed authorization or network
checks. Stalker probes reuse session ownership without endpoint repair; M3U
reads stop at 64 KiB. PWA retains existing Xtream behavior. Contract:
`docs/architecture/m3u-playlist-module.md` (Desktop source health).
Desktop Sources also offers library-wide selective cleanup through dialog-scoped
`SourceCleanupService`. Only confirmed expired/disabled accounts are preselected;
playback/import/refresh/delete-busy sources are skipped. Deletion goes through
one serialized `PlaylistsService` operation and awaited cleanup hooks;
`PlaylistActions.playlistRemovalCommitted` updates state without another DB
delete. Stop finishes the current source. Same contract: Desktop inactive-source
cleanup in `docs/architecture/m3u-playlist-module.md`.
Startup source auto-refresh uses `SourceActivityService` to protect busy IDs
from cleanup. Late batch refreshes skip deleted rows instead of recreating them.
Contract: `docs/architecture/m3u-playlist-module.md` (Desktop inactive-source cleanup).
+12
View File
@@ -1961,3 +1961,15 @@ account expiry/disablement is distinct from failed authorization or network
checks. Stalker probes reuse session ownership without endpoint repair; M3U
reads stop at 64 KiB. PWA retains existing Xtream behavior. Contract:
`docs/architecture/m3u-playlist-module.md` (Desktop source health).
Desktop Sources also offers library-wide selective cleanup through dialog-scoped
`SourceCleanupService`. Only confirmed expired/disabled accounts are preselected;
playback/import/refresh/delete-busy sources are skipped. Deletion goes through
one serialized `PlaylistsService` operation and awaited cleanup hooks;
`PlaylistActions.playlistRemovalCommitted` updates state without another DB
delete. Stop finishes the current source. Same contract: Desktop inactive-source
cleanup in `docs/architecture/m3u-playlist-module.md`.
Startup source auto-refresh uses `SourceActivityService` to protect busy IDs
from cleanup. Late batch refreshes skip deleted rows instead of recreating them.
Contract: `docs/architecture/m3u-playlist-module.md` (Desktop inactive-source cleanup).
@@ -1,4 +1,6 @@
import { createServer } from 'node:http';
import {
xtreamMockServer,
addStalkerPortal,
addXtreamPortal,
closeElectronApp,
@@ -506,6 +508,185 @@ https://streams.example.test/url-omega.m3u8
}
});
test('skips a source whose startup auto-refresh is still fetching', async ({
dataDir,
}) => {
let requests = 0;
const server = createServer(() => {
requests++;
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const address = server.address();
if (!address || typeof address === 'string')
throw new Error('Missing server address');
const url = `http://127.0.0.1:${address.port}/slow.m3u`;
let app = await launchElectronApp(dataDir);
try {
await app.mainWindow.evaluate(async (url) => {
await window.electron.dbUpsertAppPlaylist({
_id: 'startup-busy',
title: 'Startup busy source',
url,
count: 0,
importDate: '2026-01-01',
lastUsage: '2026-01-01',
autoRefresh: true,
});
}, url);
app = await restartElectronApp(app, dataDir);
await expect.poll(() => requests).toBeGreaterThan(0);
await openSources(app.mainWindow);
await app.mainWindow
.getByRole('button', {
name: 'Clean up inactive sources…',
exact: true,
})
.click();
const dialog = app.mainWindow.getByRole('dialog');
const row = dialog.locator('[data-source-id="startup-busy"]');
await expect(row).toHaveAttribute('data-status', 'skipped');
await expect(row.getByRole('checkbox')).toBeDisabled();
expect(
await app.mainWindow.evaluate(async () =>
(await window.electron.dbGetAppPlaylists()).some(
(p) => p._id === 'startup-busy'
)
)
).toBe(true);
} finally {
await closeElectronApp(app);
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test('cleans selected inactive sources across filters and preserves unselected SQLite data after restart', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const urlServer = await createMutableTextServer(
'<html>Temporarily unavailable</html>',
{ resourcePath: '/uncertain.m3u' }
);
let app = await launchElectronApp(dataDir);
try {
await app.mainWindow.evaluate(
async ({ serverUrl, url }) => {
const common = {
count: 0,
importDate: '2026-01-01',
lastUsage: '2026-01-01',
autoRefresh: false,
};
for (const id of ['expired-delete', 'expired-keep']) {
await window.electron.dbUpsertAppPlaylist({
...common,
_id: id,
title: id,
serverUrl,
username: 'expired',
password: 'expired',
});
}
await window.electron.dbUpsertAppPlaylist({
...common,
_id: 'local-keep',
title: 'local-keep',
favorites: ['preserved-channel'],
});
await window.electron.dbUpsertAppPlaylist({
...common,
_id: 'uncertain-keep',
title: 'uncertain-keep',
url,
});
},
{ serverUrl: xtreamMockServer, url: urlServer.resourceUrl }
);
app = await restartElectronApp(app, dataDir);
await openSources(app.mainWindow);
await selectSourceTypeFilter(app.mainWindow, 'M3U');
await app.mainWindow
.getByRole('button', {
name: 'Clean up inactive sources…',
exact: true,
})
.click();
const dialog = app.mainWindow.getByRole('dialog');
await expect(
dialog.getByRole('checkbox', {
name: 'expired-delete',
exact: true,
})
).toBeChecked({ timeout: 20000 });
await expect(
dialog.getByRole('checkbox', {
name: 'expired-keep',
exact: true,
})
).toBeChecked();
await expect(
dialog.getByRole('checkbox', {
name: 'uncertain-keep',
exact: true,
})
).not.toBeChecked();
await expect(
dialog.getByRole('checkbox', {
name: 'local-keep',
exact: true,
})
).toHaveCount(0);
await app.mainWindow.screenshot({
path: '/tmp/iptvnator-source-cleanup-light.png',
animations: 'disabled',
});
await app.mainWindow.evaluate(() =>
document.body.classList.add('dark-theme')
);
await app.mainWindow.screenshot({
path: '/tmp/iptvnator-source-cleanup-dark.png',
animations: 'disabled',
});
await dialog
.getByRole('checkbox', { name: 'expired-keep', exact: true })
.uncheck();
await dialog
.getByRole('button', {
name: 'Delete selected · 1',
exact: true,
})
.click();
await expect(
dialog.locator('[data-source-id="expired-delete"]')
).toHaveAttribute('data-status', 'deleted', { timeout: 20000 });
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
app = await restartElectronApp(app, dataDir);
const persisted = await app.mainWindow.evaluate(async () => {
const playlists = await window.electron.dbGetAppPlaylists();
return {
ids: playlists.map((p) => p._id),
favorites: playlists.find((p) => p._id === 'local-keep')
?.favorites,
};
});
expect(persisted.ids.sort()).toEqual([
'expired-keep',
'local-keep',
'uncertain-keep',
]);
expect(persisted.favorites).toEqual(['preserved-channel']);
} finally {
await closeElectronApp(app);
await urlServer.close();
}
});
test('deletes M3U, Stalker, and Xtream sources from the sources view', async ({
dataDir,
request,
@@ -1,3 +1,4 @@
import { SourceActivityService } from '@iptvnator/services';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
@@ -108,6 +109,18 @@ describe('ElectronService', () => {
jest.restoreAllMocks();
});
it('protects all startup-refresh sources until the request settles, including failure', async () => {
let reject!: (error: Error) => void;
electronBridge.autoUpdatePlaylists.mockReturnValue(new Promise((_, r) => { reject = r; }));
const pending = service.sendIpcEvent(AUTO_UPDATE_PLAYLISTS, [{ _id: 'startup' }]);
const activity = TestBed.inject(SourceActivityService);
expect(activity.isBusy('startup')).toBe(true);
expect(activity.isBusy('other')).toBe(false);
reject(new Error('refresh failed'));
await expect(pending).rejects.toThrow('refresh failed');
expect(activity.isBusy('startup')).toBe(false);
});
it.each([401, 403])('preserves a health HTTP %s rejection without global error handling', async (status) => {
const error = new Error(`Error invoking remote method: Error: HTTP Error ${status}`);
electronBridge.xtreamRequest.mockRejectedValue(error);
+22 -12
View File
@@ -4,7 +4,11 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { DialogService } from '@iptvnator/ui/components';
import { DataService, SettingsStore } from '@iptvnator/services';
import {
DataService,
SettingsStore,
SourceActivityService,
} from '@iptvnator/services';
import {
AUTO_UPDATE_PLAYLISTS,
AutoUpdatePlaylistsResult,
@@ -57,6 +61,7 @@ interface ErrorStatus {
export class ElectronService extends DataService {
private eventListeners: { [key: string]: () => void } = {};
private messageListeners = new Map<string, EventListener>();
private readonly sourceActivity = inject(SourceActivityService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogService = inject(DialogService);
private readonly store = inject(Store);
@@ -237,17 +242,22 @@ export class ElectronService extends DataService {
if (type === AUTO_UPDATE_PLAYLISTS) {
const data = payload as Playlist[];
const result = await window.electron.autoUpdatePlaylists(
data,
this.settingsStore.getTrustOptions()
);
this.store.dispatch(
PlaylistActions.updateManyPlaylists({
playlists: result.playlists,
})
);
this.reportAutoUpdatePlaylistsResult(result);
return result as T;
const release = this.sourceActivity.begin(data.map((p) => p._id));
try {
const result = await window.electron.autoUpdatePlaylists(
data,
this.settingsStore.getTrustOptions()
);
this.store.dispatch(
PlaylistActions.updateManyPlaylists({
playlists: result.playlists,
})
);
this.reportAutoUpdatePlaylistsResult(result);
return result as T;
} finally {
release();
}
}
this.logger.debug('Unknown IPC event type:', type);
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Проверка отменена",
"checking": "Проверяется…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Очистить неактивные…",
"SCOPE": "Все сетевые источники библиотеки, независимо от фильтров страницы.",
"CONSEQUENCES": "Удаляются только выбранные источники, вместе с их избранным, историей и прогрессом воспроизведения. Скачанные файлы сохраняются.",
"SUMMARY": "Проверено {{checked}}/{{total}} · Доступно {{available}} · Удалено {{deleted}} · Пропущено {{skipped}} · Ошибок {{failed}}",
"SELECT_ALL": "Выбрать всех кандидатов",
"SELECT_NONE": "Снять выбор",
"UNTITLED": "Источник без названия",
"DELETE": "Удалить выбранные · {{count}}",
"CLOSE": "Закрыть",
"STOP": "Остановить после текущего",
"CLEANUP_WARNING": "Источник удалён; часть дополнительной очистки не выполнена",
"GROUP": {
"confirmed": "Подтверждённо неактивные аккаунты",
"uncertain": "Требуют решения",
"skipped": "Пропущенные источники",
"results": "Результаты удаления"
},
"STATUS": {
"checking": "Проверяется…",
"skipped": "Воспроизводится, занят, изменён или уже отсутствует",
"deleting": "Удаляется…",
"deleted": "Удалён",
"failed": "Не удалось удалить — выберите для повтора"
},
"DELETE_PROGRESS": "Обработано {{done}} из {{total}} выбранных источников"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
+27
View File
@@ -1807,5 +1807,32 @@
"cancelled": "Check cancelled",
"checking": "Checking…"
}
},
"SOURCE_CLEANUP": {
"TITLE": "Clean up inactive sources…",
"SCOPE": "All network sources in your library, regardless of page filters.",
"CONSEQUENCES": "Delete only sources you select. Their saved favourites, history and playback progress will also be removed. Downloaded files are kept.",
"SUMMARY": "Checked {{checked}}/{{total}} · Available {{available}} · Deleted {{deleted}} · Skipped {{skipped}} · Failed {{failed}}",
"SELECT_ALL": "Select all candidates",
"SELECT_NONE": "Deselect all",
"UNTITLED": "Untitled source",
"DELETE": "Delete selected · {{count}}",
"CLOSE": "Close",
"STOP": "Stop after current source",
"CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
"GROUP": {
"confirmed": "Confirmed inactive accounts",
"uncertain": "Review before deleting",
"skipped": "Skipped sources",
"results": "Deletion results"
},
"STATUS": {
"checking": "Checking…",
"skipped": "Playing, busy, changed or no longer present",
"deleting": "Deleting…",
"deleted": "Deleted",
"failed": "Deletion failed — select to retry"
},
"DELETE_PROGRESS": "Processed {{done}} of {{total}} selected sources"
}
}
@@ -681,3 +681,13 @@ available, orange expired, red disabled/access refused, and neutral means
checking or unverified. Accessible tooltip text distinguishes refusal from a
confirmed disabled account and includes the check time. M3U indicators describe
the source URL only. Check again is a separate action, not a playlist refresh.
### Inactive-source cleanup dialog
Electron Sources offers a library-wide cleanup action even when page filters
hide all rows. The confirmation dialog groups confirmed inactive accounts,
uncertain candidates, skipped sources and deletion outcomes. It preserves
checkbox choices on recheck, labels destructive consequences, and announces
progress. During deletion Escape/backdrop closing is disabled; Stop after
current source finishes the current operation. Healthy sources appear only in
the summary. Source titles wrap; credential-bearing URLs are not displayed.
+39
View File
@@ -1716,3 +1716,42 @@ are never included in indicator text.
Explicit source-health checks retain their deadline while queued, including
background jobs promoted by Retry. Expired queued checks resolve without opening
a transport; cancellation and admission clear the queue timer.
### Desktop inactive-source cleanup
Sources > Clean up inactive sources scans every network source, independently
of page filters. The dialog uses the shared health queue with fresh checks;
only explicit account expiry/disablement is preselected. Uncertain network,
HTTP, content and authorization failures require manual selection. Local files
and text imports are excluded. Sources with active external playback, import,
refresh or deletion are skipped; identity, presence, current health and busy
state are checked again before each deletion. The shared header refresh action
tracks its M3U source ID through completion, independently of source-row state.
All shared delete actions reserve source IDs in `SourceActivityService` until
persistence and cleanup settle, including header-switcher deletion. Closed
external-player sessions and failures without a live process are not busy; the
canonical `isLiveExternalPlayerSession` predicate governs that protection.
Startup auto-refresh reserves source IDs in `SourceActivityService` until its
fetch settles. Batch persistence also skips rows deleted while a refresh was
running, and only surviving writes trigger scoped EPG fetches. Shared refresh
actions refuse IDs reserved by deletion; singular refresh persistence also
rejects missing rows inside the write queue, so late results cannot recreate them.
Evidence older than five minutes
is refreshed and requires another confirmation.
`SourceCleanupService` is dialog-scoped. User deselection survives rechecks;
recovered sources leave the candidate list. Newer uncertain evidence clears an
automatic selection and requires an explicit checkbox choice before deletion. Deletions run sequentially through
`PlaylistDeleteActionService` and the serialized `PlaylistsService` write queue.
The latter owns the single worker invocation and awaited cleanup hooks.
`PlaylistActions.playlistRemovalCommitted` updates NgRx and clears scoped EPG
request keys without a second storage deletion. Legacy request-style
`removePlaylist` still owns its persistence effect.
Stopping or destroying the dialog (including history navigation) finishes the
current source before stopping the queue; committed
work is not rolled back. Results distinguish failed deletes from successful
deletes with follow-up cleanup warnings. The UI does not resurrect a deleted
row after a cleanup failure. Downloaded files are not removed. The dialog's
confirmation covers deletion of the source and associated favorites, history
and playback positions; no deletion happens on merely opening the dialog.
+1
View File
@@ -16,6 +16,7 @@ export const PlaylistActions = createActionGroup({
'Add Playlist': props<{ playlist: Playlist }>(),
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
'Playlist Removal Committed': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{
playlist: PlaylistMetaUpdate;
/**
+13 -2
View File
@@ -325,6 +325,17 @@ export class PlaylistEffects {
}
}
playlistRemovalCommitted$ = createEffect(
() =>
this.actions$.pipe(
ofType(PlaylistActions.playlistRemovalCommitted),
tap(({ playlistId }) =>
this.playlistScopedEpgFetchKeys.delete(playlistId)
)
),
{ dispatch: false }
);
removePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
@@ -471,8 +482,8 @@ export class PlaylistEffects {
this.playlistsService
.updateManyPlaylists(action.playlists)
.pipe(
tap(() => {
action.playlists.forEach((playlist) =>
tap((persisted) => {
persisted.forEach((playlist) =>
this.fetchPlaylistScopedEpg(playlist)
);
})
@@ -28,22 +28,26 @@ export const playlistReducers = [
}),
};
}),
on(PlaylistActions.removePlaylist, (state, action): PlaylistState => {
const playlists = playlistsAdapter.removeOne(
action.playlistId,
state.playlists
);
return {
...state,
playlists: {
...playlists,
selectedId:
state.playlists.selectedId === action.playlistId
? ''
: playlists.selectedId,
},
};
}),
on(
PlaylistActions.removePlaylist,
PlaylistActions.playlistRemovalCommitted,
(state, action): PlaylistState => {
const playlists = playlistsAdapter.removeOne(
action.playlistId,
state.playlists
);
return {
...state,
playlists: {
...playlists,
selectedId:
state.playlists.selectedId === action.playlistId
? ''
: playlists.selectedId,
},
};
}
),
on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => {
const isActivePlaylist =
state.playlists.selectedId === action.playlistId;
+2
View File
@@ -6,3 +6,5 @@ export * from './lib/recent-playlists/recent-playlists.component';
export * from './lib/recent-playlists/empty-state/empty-state.component';
export * from './lib/playlist-refresh-action.service';
export * from './lib/xtream-refresh-flow.service';
export * from './lib/source-health/source-cleanup-dialog.component';
@@ -14,6 +14,7 @@ import {
PlaylistRefreshService,
RuntimeCapabilitiesService,
SettingsStore,
SourceActivityService,
} from '@iptvnator/services';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import {
@@ -252,6 +253,20 @@ describe('PlaylistRefreshActionService', () => {
localStorage.clear();
});
it('rejects refresh while a shared deletion owns the source', () => {
const playlist = createPlaylistMeta({
serverUrl: undefined,
url: 'https://example.test/list.m3u',
});
const release = TestBed.inject(SourceActivityService).begin([
playlist._id,
]);
expect(service.canRefresh(playlist)).toBe(false);
service.refresh(playlist);
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
release();
expect(service.canRefresh(playlist)).toBe(true);
});
it('treats file-backed M3U playlists as refreshable when the refresh bridge is available', () => {
runtime.supportsPlaylistRefresh = true;
@@ -320,6 +335,29 @@ describe('PlaylistRefreshActionService', () => {
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
});
it('tracks a header M3U refresh by source until the operation settles', async () => {
const playlist = createPlaylistMeta({
_id: 'busy',
serverUrl: undefined,
username: undefined,
password: undefined,
url: 'https://source.test/list',
});
let resolve!: (value: Playlist) => void;
playlistRefreshService.refreshPlaylist.mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
service.refresh(playlist);
expect(service.isSourceBusy('busy')).toBe(true);
expect(service.isSourceBusy('other')).toBe(false);
resolve({ _id: 'busy', playlist: { items: [] } } as Playlist);
await Promise.resolve();
await Promise.resolve();
expect(service.isSourceBusy('busy')).toBe(false);
});
it('passes trusted TLS hosts to URL-backed M3U refreshes', async () => {
const playlist = createPlaylistMeta({
_id: 'playlist-url',
@@ -11,6 +11,7 @@ import {
PlaylistRefreshService,
RuntimeCapabilitiesService,
SettingsStore,
SourceActivityService,
} from '@iptvnator/services';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import {
@@ -37,6 +38,7 @@ export interface XtreamRefreshPreparationState {
@Injectable({ providedIn: 'root' })
export class PlaylistRefreshActionService {
private readonly store = inject(Store);
private readonly activity = inject(SourceActivityService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogService = inject(DialogService);
@@ -51,11 +53,18 @@ export class PlaylistRefreshActionService {
private readonly refreshPreparationState =
signal<XtreamRefreshPreparationState | null>(null);
private readonly m3uRefreshId = signal<string | null>(null);
isSourceBusy(id: string): boolean {
return (
this.m3uRefreshId() === id ||
this.refreshPreparation()?.playlistId === id
);
}
readonly isRefreshing = signal(false);
readonly refreshPreparation = this.refreshPreparationState.asReadonly();
canRefresh(playlist: PlaylistMeta | null): boolean {
if (!playlist) {
if (!playlist || this.activity.isBusy(playlist._id)) {
return false;
}
@@ -73,7 +82,7 @@ export class PlaylistRefreshActionService {
}
refresh(playlist: PlaylistMeta): void {
if (this.isRefreshing()) {
if (this.isRefreshing() || this.activity.isBusy(playlist._id)) {
return;
}
@@ -137,6 +146,7 @@ export class PlaylistRefreshActionService {
this.playlistContext.routeProvider() === 'playlists' &&
this.playlistContext.resolvedPlaylistId() === item._id;
this.m3uRefreshId.set(item._id);
this.isRefreshing.set(true);
if (isActiveM3uRoute) {
this.store.dispatch(
@@ -202,6 +212,7 @@ export class PlaylistRefreshActionService {
ChannelActions.setChannelsLoading({ loading: false })
);
}
this.m3uRefreshId.set(null);
this.isRefreshing.set(false);
}
}
@@ -415,7 +415,7 @@ describe('PlaylistSwitcherComponent', () => {
xtreamPlaylist
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.removePlaylist({
PlaylistActions.playlistRemovalCommitted({
playlistId: xtreamPlaylist._id,
})
);
@@ -345,7 +345,9 @@ export class PlaylistSwitcherComponent {
}
this.store.dispatch(
PlaylistActions.removePlaylist({ playlistId: playlist._id })
PlaylistActions.playlistRemovalCommitted({
playlistId: playlist._id,
})
);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
@@ -307,7 +307,7 @@ describe('RecentPlaylistsComponent busy state', () => {
expect(component.isDeletePending(item._id)).toBe(false);
expect(component.getBusyProgress(item._id)).toBeNull();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.removePlaylist({ playlistId: item._id })
PlaylistActions.playlistRemovalCommitted({ playlistId: item._id })
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS',
@@ -356,7 +356,7 @@ describe('RecentPlaylistsComponent busy state', () => {
});
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.removePlaylist({ playlistId: item._id })
PlaylistActions.playlistRemovalCommitted({ playlistId: item._id })
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS',
@@ -279,7 +279,7 @@ export class RecentPlaylistsComponent {
RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW,
() =>
this.store.dispatch(
PlaylistActions.removePlaylist({
PlaylistActions.playlistRemovalCommitted({
playlistId: item._id,
})
),
@@ -287,7 +287,7 @@ export class RecentPlaylistsComponent {
);
} else {
this.store.dispatch(
PlaylistActions.removePlaylist({
PlaylistActions.playlistRemovalCommitted({
playlistId: item._id,
})
);
@@ -0,0 +1,293 @@
import { Component, DestroyRef, computed, effect, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { TranslatePipe } from '@ngx-translate/core';
import {
SourceCleanupContext,
SourceCleanupService,
} from '@iptvnator/portal/shared/data-access';
import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces';
export interface SourceCleanupDialogData extends SourceCleanupContext {
playlists: readonly PlaylistMeta[];
}
@Component({
selector: 'app-source-cleanup-dialog',
imports: [
DatePipe,
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
MatProgressBarModule,
TranslatePipe,
],
providers: [SourceCleanupService],
template: ` <h2 mat-dialog-title>
{{ 'SOURCE_CLEANUP.TITLE' | translate }}
</h2>
<mat-dialog-content>
<p>{{ 'SOURCE_CLEANUP.SCOPE' | translate }}</p>
<p>{{ 'SOURCE_CLEANUP.CONSEQUENCES' | translate }}</p>
@if (model.phase() === 'checking' || model.phase() === 'deleting') {
<mat-progress-bar mode="determinate" [value]="progress()" />
}
<p role="status" aria-live="polite">
{{ 'SOURCE_CLEANUP.SUMMARY' | translate: summary() }}
</p>
@if (locked()) {
<p role="status">
{{
'SOURCE_CLEANUP.DELETE_PROGRESS'
| translate
: {
done: model.processed(),
total: model.totalSelected(),
}
}}
</p>
}
@if (!locked()) {
<div class="selection-actions">
<button
mat-button
[disabled]="checking()"
(click)="model.selectAll(true)"
>
{{ 'SOURCE_CLEANUP.SELECT_ALL' | translate }}
</button>
<button
mat-button
[disabled]="checking()"
(click)="model.selectAll(false)"
>
{{ 'SOURCE_CLEANUP.SELECT_NONE' | translate }}
</button>
</div>
}
@for (group of groups(); track group.key) {
@if (group.entries.length) {
<h3>
{{ 'SOURCE_CLEANUP.GROUP.' + group.key | translate }}
</h3>
@for (entry of group.entries; track entry.playlist._id) {
<div
class="source-row"
[attr.data-source-id]="entry.playlist._id"
[attr.data-status]="entry.status"
>
<mat-checkbox
[checked]="entry.selected"
[disabled]="
locked() ||
!model.candidate(entry) ||
data.protected(entry.playlist._id)
"
(change)="
model.select(
entry.playlist._id,
$event.checked
)
"
>
<span class="source-title">{{
entry.playlist.title ||
entry.playlist.filename ||
('SOURCE_CLEANUP.UNTITLED' | translate)
}}</span>
</mat-checkbox>
<div class="source-meta">
<span>{{ type(entry.playlist) }}</span>
@if (entry.status === 'ready') {
<span>{{
'SOURCE_HEALTH.REASON.' +
entry.health?.reason | translate
}}</span>
} @else {
<span>{{
'SOURCE_CLEANUP.STATUS.' + entry.status
| translate
}}</span>
}
@if (entry.health?.checkedAt) {
<time>{{
entry.health?.checkedAt
| date: 'shortTime'
}}</time>
}
@if (entry.warning) {
<span>{{
'SOURCE_CLEANUP.CLEANUP_WARNING'
| translate
}}</span>
}
</div>
@if (
!locked() &&
entry.status !== 'deleted' &&
entry.status !== 'skipped'
) {
<button
mat-button
(click)="model.recheck(entry)"
>
{{ 'SOURCE_HEALTH.RECHECK' | translate }}
</button>
}
</div>
}
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (locked()) {
<button
mat-button
[disabled]="model.stopRequested()"
(click)="model.stop()"
>
{{ 'SOURCE_CLEANUP.STOP' | translate }}
</button>
} @else {
<button mat-button mat-dialog-close>
{{ 'SOURCE_CLEANUP.CLOSE' | translate }}
</button>
<button
mat-flat-button
[disabled]="!selectedCount() || checking()"
(click)="model.removeSelected()"
>
{{
'SOURCE_CLEANUP.DELETE'
| translate: { count: selectedCount() }
}}
</button>
}
</mat-dialog-actions>`,
styles: [
`
:host {
display: block;
}
mat-dialog-content {
min-width: 0;
}
.selection-actions {
display: flex;
flex-wrap: wrap;
}
h3 {
font-size: 14px;
margin: 18px 0 6px;
}
.source-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
padding: 8px 0;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.source-title {
overflow-wrap: anywhere;
}
.source-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: 40px;
font-size: 12px;
color: var(--mat-sys-on-surface-variant);
}
.source-row > button {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
}
.source-meta {
grid-column: 1;
}
`,
],
})
export class SourceCleanupDialogComponent {
readonly data = inject<SourceCleanupDialogData>(MAT_DIALOG_DATA);
readonly model = inject(SourceCleanupService);
private readonly ref = inject(MatDialogRef<SourceCleanupDialogComponent>);
readonly type = sourceHealthType;
readonly locked = computed(() => this.model.phase() === 'deleting');
readonly checking = computed(() =>
this.model.entries().some((e) => e.status === 'checking')
);
readonly selectedCount = computed(
() =>
this.model
.entries()
.filter(
(e) =>
e.selected &&
this.model.candidate(e) &&
!this.data.protected(e.playlist._id)
).length
);
readonly summary = computed(() => {
const entries = this.model.entries();
return {
total: entries.length,
checked: entries.filter((e) => e.status !== 'checking').length,
available: entries.filter((e) => e.health?.state === 'active')
.length,
deleted: entries.filter((e) => e.status === 'deleted').length,
skipped: entries.filter((e) => e.status === 'skipped').length,
failed: entries.filter((e) => e.status === 'failed').length,
};
});
readonly progress = computed(() =>
this.locked()
? (100 * this.model.processed()) /
Math.max(1, this.model.totalSelected())
: (100 * this.summary().checked) / Math.max(1, this.summary().total)
);
readonly groups = computed(() => {
const entries = this.model.entries();
return [
{
key: 'confirmed',
entries: entries.filter(
(e) => e.status === 'ready' && e.health?.confirmedInactive
),
},
{
key: 'uncertain',
entries: entries.filter(
(e) =>
e.status === 'checking' ||
(e.status === 'ready' &&
!e.health?.confirmedInactive &&
e.health?.state !== 'active')
),
},
{
key: 'skipped',
entries: entries.filter((e) => e.status === 'skipped'),
},
{
key: 'results',
entries: entries.filter((e) =>
['deleting', 'deleted', 'failed'].includes(e.status)
),
},
];
});
constructor() {
effect(() => {
this.ref.disableClose = this.locked();
});
inject(DestroyRef).onDestroy(() => this.model.dispose());
void this.model.start(this.data.playlists, this.data);
}
}
@@ -4,3 +4,5 @@ export * from './lib/multi-source';
export * from './lib/live-channel-playback-queue';
export * from './lib/source-health.service';
export * from './lib/source-cleanup.service';
@@ -0,0 +1,180 @@
import { TestBed } from '@angular/core/testing';
import { PlaylistDeleteActionService } from '@iptvnator/services';
import {
PlaylistMeta,
SourceHealthSnapshot,
} from '@iptvnator/shared/interfaces';
import { SourceCleanupService } from './source-cleanup.service';
import { SourceHealthService } from './source-health.service';
const p = (id: string): PlaylistMeta =>
({ _id: id, title: id, url: `https://${id}.test/list` }) as PlaylistMeta;
const result = (
state: 'expired' | 'active' | 'unavailable'
): SourceHealthSnapshot => ({
state,
reason:
state === 'unavailable'
? 'timeout'
: state === 'expired'
? 'expired'
: 'available',
confirmedInactive: state === 'expired',
checkedAt: Date.now(),
});
describe('source cleanup', () => {
let service: SourceCleanupService;
let check: jest.Mock;
let remove: jest.Mock;
let removed: jest.Mock;
let protectedIds: Set<string>;
let current: Map<string, PlaylistMeta>;
let health: {
check: jest.Mock;
recheck: jest.Mock;
get: jest.Mock;
invalidate: jest.Mock;
};
beforeEach(() => {
check = jest.fn().mockResolvedValue(result('expired'));
remove = jest.fn().mockResolvedValue({ success: true });
removed = jest.fn();
protectedIds = new Set();
current = new Map();
health = {
check,
recheck: check,
get: jest.fn(),
invalidate: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
SourceCleanupService,
{ provide: SourceHealthService, useValue: health },
{
provide: PlaylistDeleteActionService,
useValue: { deletePlaylistWithResult: remove },
},
],
});
service = TestBed.inject(SourceCleanupService);
});
async function start(ids = ['a', 'b']) {
ids.forEach((id) => current.set(id, p(id)));
await service.start([...current.values()], {
current: (id) => current.get(id),
protected: (id) => protectedIds.has(id),
removed,
});
}
it('preselects confirmed accounts and leaves timeouts unchecked', async () => {
check
.mockResolvedValueOnce(result('expired'))
.mockResolvedValueOnce(result('unavailable'));
await start();
expect(service.entries().map((e) => e.selected)).toEqual([true, false]);
service.select('b', true);
expect(service.entries()[1].selected).toBe(true);
});
it('clears automatic selection when newer evidence is uncertain', async () => {
await start(['a']);
health.get.mockReturnValue(result('unavailable'));
await service.removeSelected();
expect(remove).not.toHaveBeenCalled();
expect(service.entries()[0]).toMatchObject({
selected: false,
health: { confirmedInactive: false },
});
service.select('a', true);
await service.removeSelected();
expect(remove).toHaveBeenCalledTimes(1);
});
it('removes all except the source unchecked by the user', async () => {
await start();
service.select('b', false);
await service.removeSelected();
expect(remove).toHaveBeenCalledTimes(1);
expect(remove).toHaveBeenCalledWith(p('a'));
expect(removed).toHaveBeenCalledWith('a');
expect(service.entries()[1].status).toBe('ready');
});
it('preserves unchecked choices on recheck and excludes recovered sources', async () => {
await start();
service.select('a', false);
await service.recheck(service.entries()[0]);
expect(service.entries()[0].selected).toBe(false);
check.mockResolvedValueOnce(result('active'));
await service.recheck(service.entries()[1]);
expect(service.entries()[1].selected).toBe(false);
});
it('skips playing sources both at scan time and immediately before deletion', async () => {
protectedIds.add('a');
await start();
protectedIds.add('b');
await service.removeSelected();
expect(remove).not.toHaveBeenCalled();
expect(service.entries().every((e) => e.status === 'skipped')).toBe(
true
);
});
it('skips changed credentials and sources removed since the dialog opened', async () => {
await start();
current.set('a', { ...p('a'), userAgent: 'changed' });
current.delete('b');
await service.removeSelected();
expect(remove).not.toHaveBeenCalled();
});
it.each(['stop', 'dispose'] as const)(
'finishes only the current deletion on %s and prevents double submission',
async (action) => {
await start();
let finish!: (value: { success: boolean }) => void;
remove.mockImplementationOnce(
() =>
new Promise((r) => {
finish = r;
})
);
const deleting = service.removeSelected();
await service.removeSelected();
service[action]();
finish({ success: true });
await deleting;
expect(remove).toHaveBeenCalledTimes(1);
expect(service.entries()[1].status).toBe('ready');
}
);
it('isolates failures and reports post-delete warnings as deleted', async () => {
await start();
remove
.mockRejectedValueOnce(new Error('DB failure'))
.mockResolvedValueOnce({ success: true, cleanupWarnings: 1 });
await service.removeSelected();
expect(service.entries().map((e) => e.status)).toEqual([
'failed',
'deleted',
]);
expect(service.entries()[1].warning).toBe(true);
expect(removed).toHaveBeenCalledTimes(1);
});
it('requires confirmation again after refreshing stale evidence', async () => {
check.mockResolvedValue({
...result('expired'),
checkedAt: Date.now() - 310000,
});
await start();
check.mockResolvedValue(result('expired'));
await service.removeSelected();
expect(remove).not.toHaveBeenCalled();
expect(service.phase()).toBe('ready');
await service.removeSelected();
expect(remove).toHaveBeenCalledTimes(2);
});
it('does not preselect failed scans and cancels dialog-owned work on close', async () => {
check.mockRejectedValue(new Error('network failure'));
await start();
expect(service.entries().every((e) => !e.selected)).toBe(true);
service.dispose();
expect(check.mock.calls[0][1].signal.aborted).toBe(true);
});
});
@@ -0,0 +1,215 @@
import { Injectable, inject, signal } from '@angular/core';
import { PlaylistDeleteActionService } from '@iptvnator/services';
import {
PlaylistMeta,
SourceHealthSnapshot,
sourceHealthKey,
sourceHealthType,
sourceHealthError,
} from '@iptvnator/shared/interfaces';
import { SourceHealthService } from './source-health.service';
export interface SourceCleanupEntry {
playlist: PlaylistMeta;
health?: SourceHealthSnapshot;
selected: boolean;
status:
'checking' | 'ready' | 'skipped' | 'deleting' | 'deleted' | 'failed';
warning?: boolean;
}
export interface SourceCleanupContext {
current: (id: string) => PlaylistMeta | undefined;
protected: (id: string) => boolean;
removed: (id: string) => void;
}
/** One instance per dialog; closing it retires only its own status requests. */
@Injectable()
export class SourceCleanupService {
private readonly health = inject(SourceHealthService);
private readonly deletion = inject(PlaylistDeleteActionService);
readonly entries = signal<SourceCleanupEntry[]>([]);
readonly phase = signal<'checking' | 'ready' | 'deleting' | 'done'>(
'checking'
);
readonly stopRequested = signal(false);
readonly totalSelected = signal(0);
readonly processed = signal(0);
private readonly controller = new AbortController();
private context!: SourceCleanupContext;
private readonly touched = new Set<string>();
private round = 0;
async start(
playlists: readonly PlaylistMeta[],
context: SourceCleanupContext
): Promise<void> {
this.context = context;
const entries = playlists
.filter((p) => sourceHealthType(p))
.map((p) => ({
playlist: { ...p },
selected: false,
status: 'checking' as const,
}));
this.entries.set(entries);
await Promise.all(entries.map((entry) => this.check(entry)));
if (!this.controller.signal.aborted) this.phase.set('ready');
}
candidate(entry: SourceCleanupEntry): boolean {
return (
['ready', 'failed'].includes(entry.status) &&
!!entry.health &&
entry.health.state !== 'active' &&
entry.health.reason !== 'cancelled'
);
}
select(id: string, selected: boolean): void {
this.touched.add(id);
this.update(id, (entry) => ({
...entry,
selected: selected && this.candidate(entry),
}));
}
selectAll(selected: boolean): void {
this.entries().forEach((e) => this.select(e.playlist._id, selected));
}
async recheck(entry: SourceCleanupEntry): Promise<void> {
if (this.phase() === 'deleting') return;
await this.check(entry, true);
}
private async check(
entry: SourceCleanupEntry,
retry = false
): Promise<void> {
const id = entry.playlist._id;
if (this.context.protected(id) || !this.context.current(id)) {
this.update(id, (e) => ({
...e,
status: 'skipped',
selected: false,
}));
return;
}
this.update(id, (e) => ({ ...e, status: 'checking' }));
let result: SourceHealthSnapshot;
try {
result = retry
? await this.health.recheck(
entry.playlist,
this.controller.signal
)
: await this.health.check(entry.playlist, {
fresh: true,
signal: this.controller.signal,
});
} catch (error) {
result = { ...sourceHealthError(error), checkedAt: Date.now() };
}
if (this.controller.signal.aborted) return;
this.update(id, (e) => ({
...e,
health: result,
status: 'ready',
selected:
result.state !== 'active' &&
result.reason !== 'cancelled' &&
(this.touched.has(id) ? e.selected : result.confirmedInactive),
}));
}
async removeSelected(): Promise<void> {
if (
this.phase() === 'deleting' ||
this.entries().some((e) => e.status === 'checking')
)
return;
const selected = this.entries().filter(
(e) => e.selected && this.candidate(e)
);
if (!selected.length) return;
const stale = selected.filter(
(e) => !e.health || Date.now() - e.health.checkedAt > 300000
);
if (stale.length) {
this.phase.set('checking');
await Promise.all(stale.map((e) => this.check(e)));
this.phase.set('ready');
return;
}
const round = ++this.round;
this.totalSelected.set(selected.length);
this.processed.set(0);
this.phase.set('deleting');
this.stopRequested.set(false);
for (const entry of selected) {
if (this.stopRequested() || round !== this.round) break;
if (entry.health && Date.now() - entry.health.checkedAt > 300000) {
await this.check(entry);
this.phase.set('ready');
return;
}
const id = entry.playlist._id;
const current = this.context.current(id);
const latest = current ? this.health.get(current) : undefined;
if (
!current ||
this.context.protected(id) ||
sourceHealthKey(current) !== sourceHealthKey(entry.playlist) ||
latest?.state === 'active'
) {
this.update(id, (e) => ({
...e,
status: 'skipped',
selected: false,
}));
this.processed.update((n) => n + 1);
continue;
}
if (latest && !latest.confirmedInactive && !this.touched.has(id)) {
this.update(id, (e) => ({
...e,
health: latest,
status: 'ready',
selected: false,
}));
this.processed.update((n) => n + 1);
continue;
}
this.update(id, (e) => ({ ...e, status: 'deleting' }));
this.health.invalidate(id);
try {
const result =
await this.deletion.deletePlaylistWithResult(current);
if (!result.success) throw new Error('Deletion failed');
this.context.removed(id);
this.update(id, (e) => ({
...e,
status: 'deleted',
selected: false,
warning: !!result.cleanupWarnings,
}));
} catch {
this.update(id, (e) => ({
...e,
status: 'failed',
selected: false,
}));
}
this.processed.update((n) => n + 1);
}
this.phase.set('done');
}
stop(): void {
this.stopRequested.set(true);
}
dispose(): void {
this.stop();
this.controller.abort();
}
private update(
id: string,
update: (entry: SourceCleanupEntry) => SourceCleanupEntry
): void {
this.entries.update((entries) =>
entries.map((e) => (e.playlist._id === id ? update(e) : e))
);
}
}
+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;
});
};
}
}
@@ -869,7 +869,9 @@ export class WorkspaceDashboardRailsComponent {
}
this.store.dispatch(
PlaylistActions.removePlaylist({ playlistId: playlist._id })
PlaylistActions.playlistRemovalCommitted({
playlistId: playlist._id,
})
);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
@@ -34,6 +34,14 @@ export class WorkspaceShellXtreamImportService {
{ initialValue: null }
);
isSourceBusy(playlistId: string): boolean {
return (
this.refreshPreparation()?.playlistId === playlistId ||
(this.xtreamStore.isImporting() &&
this.xtreamStore.playlistId() === playlistId)
);
}
private get isElectron(): boolean {
return this.runtime.isElectron;
}
@@ -2,12 +2,24 @@
class="sources-page"
[class.sources-page--empty]="visibleSourcesCount() === 0"
>
@if (visibleSourcesCount() > 0) {
@if (visibleSourcesCount() > 0 || canCleanSources()) {
<header class="sources-header">
<div class="sources-header__meta">
<h2 class="sources-title">{{ title() }}</h2>
<span class="sources-subtitle">{{ subtitle() }}</span>
</div>
@if (canCleanSources()) {
<button
mat-stroked-button
type="button"
class="cleanup-trigger"
[attr.aria-label]="'SOURCE_CLEANUP.TITLE' | translate"
(click)="openCleanup()"
>
<mat-icon>cleaning_services</mat-icon>
<span>{{ 'SOURCE_CLEANUP.TITLE' | translate }}</span>
</button>
}
<button
type="button"
mat-stroked-button
@@ -96,3 +96,13 @@
padding: 12px 16px 16px;
}
}
.cleanup-trigger {
flex-shrink: 0;
app-region: no-drag;
}
@media (max-width: 1100px) {
.cleanup-trigger span {
display: none;
}
}
@@ -1,4 +1,8 @@
import { Component, input, output } from '@angular/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { SourceActivityService } from '@iptvnator/services';
import { MatDialog } from '@angular/material/dialog';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule } from '@angular/material/button';
@@ -33,6 +37,18 @@ describe('WorkspaceSourcesComponent', () => {
await TestBed.configureTestingModule({
imports: [WorkspaceSourcesComponent, NoopAnimationsModule],
providers: [
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: signal(null) },
},
{
provide: MatDialog,
useValue: { getDialogById: jest.fn(), open: jest.fn() },
},
{
provide: PlaylistRefreshActionService,
useValue: { isSourceBusy: jest.fn((id) => id === 'busy') },
},
provideMockStore({
selectors: [
{
@@ -125,14 +141,60 @@ describe('WorkspaceSourcesComponent', () => {
fixture = TestBed.createComponent(WorkspaceSourcesComponent);
});
it.each([
['launching', true, true],
['opened', true, true],
['playing', true, true],
['closed', false, false],
['error', false, false],
['error', true, true],
])(
'protects only a live external session (%s, closable=%s)',
(status, canClose, expected) => {
const playback = TestBed.inject(
PORTAL_EXTERNAL_PLAYBACK
) as unknown as { activeSession: { set(value: unknown): void } };
playback.activeSession.set({
status,
canClose,
contentInfo: { playlistId: 'external' },
});
fixture.componentInstance.openCleanup();
const context = (TestBed.inject(MatDialog).open as jest.Mock).mock
.calls[0][1].data;
expect(context.protected('external')).toBe(expected);
}
);
it('protects startup auto-refresh sources across the whole library', () => {
const release = TestBed.inject(SourceActivityService).begin([
'startup',
]);
fixture.componentInstance.openCleanup();
const context = (TestBed.inject(MatDialog).open as jest.Mock).mock
.calls[0][1].data;
expect(context.protected('startup')).toBe(true);
release();
expect(context.protected('startup')).toBe(false);
});
it('protects a source refreshing through the persistent header', () => {
fixture.componentInstance.openCleanup();
const open = TestBed.inject(MatDialog).open as jest.Mock;
const context = open.mock.calls[0][1].data;
expect(context.protected('busy')).toBe(true);
expect(context.protected('other')).toBe(false);
});
it('renders the shared panel header structure without paragraph subtitle margins', async () => {
fixture.detectChanges();
await fixture.whenStable();
const header: HTMLElement =
fixture.nativeElement.querySelector('.sources-header');
const meta: HTMLElement =
fixture.nativeElement.querySelector('.sources-header__meta');
const meta: HTMLElement = fixture.nativeElement.querySelector(
'.sources-header__meta'
);
const title: HTMLElement =
fixture.nativeElement.querySelector('.sources-title');
const subtitle: HTMLElement =
@@ -1,3 +1,23 @@
import { WorkspaceShellXtreamImportService } from '../workspace-shell/services/workspace-shell-xtream-import.service';
import { Injector, viewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
SourceCleanupDialogComponent,
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/ui';
import {
RuntimeCapabilitiesService,
SourceActivityService,
} from '@iptvnator/services';
import {
PORTAL_EXTERNAL_PLAYBACK,
isLiveExternalPlayerSession,
} from '@iptvnator/portal/shared/util';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
sourceHealthType,
PlaylistUpdateState,
} from '@iptvnator/shared/interfaces';
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@@ -7,7 +27,10 @@ import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { RecentPlaylistsComponent } from '@iptvnator/playlist/shared/ui';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { selectActiveTypeFilters, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import {
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from '@iptvnator/m3u-state';
import { map, startWith } from 'rxjs';
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
import {
@@ -35,6 +58,55 @@ interface SortOption {
styleUrl: './workspace-sources.component.scss',
})
export class WorkspaceSourcesComponent {
private readonly injector = inject(Injector);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly playback = inject(PORTAL_EXTERNAL_PLAYBACK, {
optional: true,
});
private readonly imports = inject(WorkspaceShellXtreamImportService, {
optional: true,
});
private readonly sourceList = viewChild(RecentPlaylistsComponent);
readonly canCleanSources = computed(
() =>
this.runtime.supportsSourceHealth &&
this.playlists().some((p) => sourceHealthType(p))
);
openCleanup(): void {
const dialogs = this.injector.get(MatDialog);
const refresh = this.injector.get(PlaylistRefreshActionService);
const activity = this.injector.get(SourceActivityService);
if (dialogs.getDialogById('source-cleanup')) return;
dialogs.open(SourceCleanupDialogComponent, {
id: 'source-cleanup',
width: '680px',
maxWidth: '95vw',
data: {
playlists: this.playlists(),
current: (id: string) =>
this.playlists().find((p) => p._id === id),
protected: (id: string) =>
(isLiveExternalPlayerSession(
this.playback?.activeSession()
) &&
this.playback?.activeSession()?.contentInfo
?.playlistId === id) ||
activity.isBusy(id) ||
refresh.isSourceBusy(id) ||
!!this.imports?.isSourceBusy(id) ||
!!this.sourceList()?.isDeletePending(id) ||
!!this.sourceList()?.isRefreshPending(id) ||
this.playlists().find((p) => p._id === id)?.updateState ===
PlaylistUpdateState.IN_PROGRESS,
removed: (id: string) =>
this.store.dispatch(
PlaylistActions.playlistRemovalCommitted({
playlistId: id,
})
),
},
});
}
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);