From bd848aaad66fd1a0dbea05fa009bd397a4badb69 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:39:14 +0200 Subject: [PATCH] feat(playlist): clean up selected inactive desktop sources (#1593) (#1596) --- .changes/playlist-inactive-source-cleanup.md | 6 + AGENTS.md | 12 + CLAUDE.md | 12 + apps/electron-backend-e2e/src/sources.e2e.ts | 181 +++++++++++ .../src/app/services/electron.service.spec.ts | 13 + apps/web/src/app/services/electron.service.ts | 34 +- apps/web/src/assets/i18n/ar.json | 27 ++ apps/web/src/assets/i18n/ary.json | 27 ++ apps/web/src/assets/i18n/by.json | 27 ++ apps/web/src/assets/i18n/de.json | 27 ++ apps/web/src/assets/i18n/el.json | 27 ++ apps/web/src/assets/i18n/en.json | 27 ++ apps/web/src/assets/i18n/es.json | 27 ++ apps/web/src/assets/i18n/fr.json | 27 ++ apps/web/src/assets/i18n/hu.json | 27 ++ apps/web/src/assets/i18n/it.json | 27 ++ apps/web/src/assets/i18n/ja.json | 27 ++ apps/web/src/assets/i18n/ko.json | 27 ++ apps/web/src/assets/i18n/nl.json | 27 ++ apps/web/src/assets/i18n/pl.json | 27 ++ apps/web/src/assets/i18n/pt.json | 27 ++ apps/web/src/assets/i18n/ru.json | 27 ++ apps/web/src/assets/i18n/tr.json | 27 ++ apps/web/src/assets/i18n/zh.json | 27 ++ apps/web/src/assets/i18n/zhtw.json | 27 ++ docs/architecture/iptvnator-ui-guidelines.md | 10 + docs/architecture/m3u-playlist-module.md | 39 +++ libs/m3u-state/src/lib/actions.ts | 1 + libs/m3u-state/src/lib/effects.ts | 15 +- .../src/lib/reducers/playlist.reducers.ts | 36 ++- libs/playlist/shared/ui/src/index.ts | 2 + .../playlist-refresh-action.service.spec.ts | 38 +++ .../lib/playlist-refresh-action.service.ts | 15 +- .../playlist-switcher.component.spec.ts | 2 +- .../playlist-switcher.component.ts | 4 +- .../recent-playlists.component.spec.ts | 4 +- .../recent-playlists.component.ts | 4 +- .../source-cleanup-dialog.component.ts | 293 ++++++++++++++++++ libs/portal/shared/data-access/src/index.ts | 2 + .../src/lib/source-cleanup.service.spec.ts | 180 +++++++++++ .../src/lib/source-cleanup.service.ts | 215 +++++++++++++ libs/services/src/index.ts | 2 + .../playlist-delete-action.service.spec.ts | 39 ++- .../src/lib/playlist-delete-action.service.ts | 61 ++-- .../src/lib/playlists.service.spec.ts | 85 ++++- libs/services/src/lib/playlists.service.ts | 39 ++- .../src/lib/source-activity.service.ts | 32 ++ .../workspace-dashboard-rails.component.ts | 4 +- .../workspace-shell-xtream-import.service.ts | 8 + .../workspace-sources.component.html | 14 +- .../workspace-sources.component.scss | 10 + .../workspace-sources.component.spec.ts | 68 +++- .../workspace-sources.component.ts | 74 ++++- 53 files changed, 1980 insertions(+), 87 deletions(-) create mode 100644 .changes/playlist-inactive-source-cleanup.md create mode 100644 libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-cleanup.service.spec.ts create mode 100644 libs/portal/shared/data-access/src/lib/source-cleanup.service.ts create mode 100644 libs/services/src/lib/source-activity.service.ts diff --git a/.changes/playlist-inactive-source-cleanup.md b/.changes/playlist-inactive-source-cleanup.md new file mode 100644 index 000000000..0c1703673 --- /dev/null +++ b/.changes/playlist-inactive-source-cleanup.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 3e4a2a87d..751bc2c61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/CLAUDE.md b/CLAUDE.md index 6a58da90c..2d7aea709 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 365467575..33b541f7f 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -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((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((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( + 'Temporarily unavailable', + { 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, diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index 804a4994e..123dcade9 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -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); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 2594ccfe8..525ff5645 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -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(); + 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); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 4c154c958..c6c699add 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index ce080b87e..3ea8f4604 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index d34d6f1d4..c15c7d871 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index b73b1961c..418a73e69 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 84569f38c..ad8d6501d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 5dc5045b3..e95bb7c70 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 6ad2e3f61..028e86667 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 7eda5a55a..e9975c103 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index a7ec42d07..9b76312fe 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 9cbe42c85..543494654 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 26a435a12..42d98c2f9 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 7660d848b..d3d472070 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index b78dc22a9..11b4d7ef7 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 389321381..213a834e9 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e573e468c..6b3702806 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 04b7c078e..71956f8a8 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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}} выбранных источников" } } diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 584c17e7a..2e2e4a90c 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 89984fbfb..f3dc37731 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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" } } diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index c99abfdca..2c2ea6e9a 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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" } } diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 39cfd5346..27b5f5e69 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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. diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 68708b839..e698f4f1f 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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. diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index f7c12b267..43b36874a 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -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; /** diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 5b3e0ed90..e88f90629 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -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) ); }) diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts index e3bd19ead..eff69f9e7 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -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; diff --git a/libs/playlist/shared/ui/src/index.ts b/libs/playlist/shared/ui/src/index.ts index 9d418e613..16a7edff9 100644 --- a/libs/playlist/shared/ui/src/index.ts +++ b/libs/playlist/shared/ui/src/index.ts @@ -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'; diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index de91415ca..686e85b13 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -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', diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 925ed42e3..016f22630 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -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(null); + private readonly m3uRefreshId = signal(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); } } diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts index 6e4364391..93407f1d5 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -415,7 +415,7 @@ describe('PlaylistSwitcherComponent', () => { xtreamPlaylist ); expect(store.dispatch).toHaveBeenCalledWith( - PlaylistActions.removePlaylist({ + PlaylistActions.playlistRemovalCommitted({ playlistId: xtreamPlaylist._id, }) ); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index c1cc3a8f2..6563f04a7 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -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'), diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index 29e950e3c..95c85af92 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -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', diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index a4db12aea..570af73f2 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -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, }) ); diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts b/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts new file mode 100644 index 000000000..d61019e16 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts @@ -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: `

+ {{ 'SOURCE_CLEANUP.TITLE' | translate }} +

+ +

{{ 'SOURCE_CLEANUP.SCOPE' | translate }}

+

{{ 'SOURCE_CLEANUP.CONSEQUENCES' | translate }}

+ @if (model.phase() === 'checking' || model.phase() === 'deleting') { + + } +

+ {{ 'SOURCE_CLEANUP.SUMMARY' | translate: summary() }} +

+ @if (locked()) { +

+ {{ + 'SOURCE_CLEANUP.DELETE_PROGRESS' + | translate + : { + done: model.processed(), + total: model.totalSelected(), + } + }} +

+ } + @if (!locked()) { +
+ + +
+ } + @for (group of groups(); track group.key) { + @if (group.entries.length) { +

+ {{ 'SOURCE_CLEANUP.GROUP.' + group.key | translate }} +

+ @for (entry of group.entries; track entry.playlist._id) { +
+ + {{ + entry.playlist.title || + entry.playlist.filename || + ('SOURCE_CLEANUP.UNTITLED' | translate) + }} + +
+ {{ type(entry.playlist) }} + @if (entry.status === 'ready') { + {{ + 'SOURCE_HEALTH.REASON.' + + entry.health?.reason | translate + }} + } @else { + {{ + 'SOURCE_CLEANUP.STATUS.' + entry.status + | translate + }} + } + @if (entry.health?.checkedAt) { + + } + @if (entry.warning) { + {{ + 'SOURCE_CLEANUP.CLEANUP_WARNING' + | translate + }} + } +
+ @if ( + !locked() && + entry.status !== 'deleted' && + entry.status !== 'skipped' + ) { + + } +
+ } + } + } +
+ + @if (locked()) { + + } @else { + + + } + `, + 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(MAT_DIALOG_DATA); + readonly model = inject(SourceCleanupService); + private readonly ref = inject(MatDialogRef); + 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); + } +} diff --git a/libs/portal/shared/data-access/src/index.ts b/libs/portal/shared/data-access/src/index.ts index 6a7a4b126..e6ea97322 100644 --- a/libs/portal/shared/data-access/src/index.ts +++ b/libs/portal/shared/data-access/src/index.ts @@ -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'; diff --git a/libs/portal/shared/data-access/src/lib/source-cleanup.service.spec.ts b/libs/portal/shared/data-access/src/lib/source-cleanup.service.spec.ts new file mode 100644 index 000000000..f1ca5d59a --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/source-cleanup.service.spec.ts @@ -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; + let current: Map; + 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); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/source-cleanup.service.ts b/libs/portal/shared/data-access/src/lib/source-cleanup.service.ts new file mode 100644 index 000000000..f7ee9df66 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/source-cleanup.service.ts @@ -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([]); + 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(); + private round = 0; + async start( + playlists: readonly PlaylistMeta[], + context: SourceCleanupContext + ): Promise { + 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 { + if (this.phase() === 'deleting') return; + await this.check(entry, true); + } + private async check( + entry: SourceCleanupEntry, + retry = false + ): Promise { + 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 { + 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)) + ); + } +} diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index c32501451..cf052bf34 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -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'; diff --git a/libs/services/src/lib/playlist-delete-action.service.spec.ts b/libs/services/src/lib/playlist-delete-action.service.spec.ts index 123ecf137..eafe9351c 100644 --- a/libs/services/src/lib/playlist-delete-action.service.spec.ts +++ b/libs/services/src/lib/playlist-delete-action.service.spec.ts @@ -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 () => { diff --git a/libs/services/src/lib/playlist-delete-action.service.ts b/libs/services/src/lib/playlist-delete-action.service.ts index 518013ef5..9314f4b03 100644 --- a/libs/services/src/lib/playlist-delete-action.service.ts +++ b/libs/services/src/lib/playlist-delete-action.service.ts @@ -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 { - 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 { - 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?.(); + } } } diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 03630a9b5..71a4961f6 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -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((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', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index e3d35c5d1..9a3069fc9 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -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 { + private async runPlaylistDeleteCleanups( + playlistId: string + ): Promise { 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) { diff --git a/libs/services/src/lib/source-activity.service.ts b/libs/services/src/lib/source-activity.service.ts new file mode 100644 index 000000000..0ecb8717f --- /dev/null +++ b/libs/services/src/lib/source-activity.service.ts @@ -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>(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; + }); + }; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index eab93674f..1b851edf3 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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'), diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts index ffe24f15c..951de74b8 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts @@ -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; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html index 3d9994597..865166985 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html @@ -2,12 +2,24 @@ class="sources-page" [class.sources-page--empty]="visibleSourcesCount() === 0" > - @if (visibleSourcesCount() > 0) { + @if (visibleSourcesCount() > 0 || canCleanSources()) {

{{ title() }}

{{ subtitle() }}
+ @if (canCleanSources()) { + + }