From e4d22cbac853a618ce1e7069d22b7ecc57fc2138 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 23:43:56 +0200 Subject: [PATCH] fix(sources): keep a browser refresh busy until the backend answers The browser build's PLAYLIST_UPDATE returned at once while its request ran on, so the row stayed refreshable and removable and the shared registry saw the source as idle. The event now settles when the refresh does (the PWA service resolves after its success or error report; the Electron fallback awaits its handler), and both the Sources row and the switcher keep the source busy and held until then. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/services/electron.service.ts | 3 +- apps/web/src/app/services/pwa.service.spec.ts | 49 +++++++++++++++ apps/web/src/app/services/pwa.service.ts | 62 +++++++++++-------- .../playlist-refresh-action.service.spec.ts | 18 +++++- .../lib/playlist-refresh-action.service.ts | 32 +++++++--- .../source-list-refresh.service.spec.ts | 20 +++++- .../source-list-refresh.service.ts | 30 +++++++-- 7 files changed, 172 insertions(+), 42 deletions(-) diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index c3652150c..46ec142e2 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -164,7 +164,8 @@ export class ElectronService extends DataService { } if (type === PLAYLIST_UPDATE) { - this.updateM3uPlaylistFromFile( + // Settles when the refresh does, so callers keep the source busy. + await this.updateM3uPlaylistFromFile( payload as { id: string; filePath?: string; diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index e3bc14800..a132d5038 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -201,6 +201,55 @@ describe('PwaService', () => { } }); + it('settles a playlist refresh only once the backend has answered', async () => { + let settled = false; + const pending = Promise.resolve( + service.sendIpcEvent(PLAYLIST_UPDATE, { + id: 'playlist-1', + url: 'https://provider.example/list.m3u', + }) + ).then(() => { + settled = true; + }); + + http.expectOne((req) => req.url.endsWith('/provider-targets')).flush({ + targetId: 'target-1', + }); + await new Promise((resolve) => setTimeout(resolve)); + // The caller keeps the source busy while the parse is in flight. + expect(settled).toBe(false); + + http.expectOne((req) => req.url.endsWith('/parse')).flush({ + _id: 'playlist-1', + }); + await pending; + expect(settled).toBe(true); + expect(TestBed.inject(Store).dispatch).toHaveBeenCalled(); + }); + + it('settles a failed playlist refresh after reporting it', async () => { + const pending = service.sendIpcEvent(PLAYLIST_UPDATE, { + id: 'playlist-1', + url: 'https://provider.example/list.m3u', + }); + + http.expectOne((req) => req.url.endsWith('/provider-targets')).flush({ + targetId: 'target-1', + }); + await new Promise((resolve) => setTimeout(resolve)); + http.expectOne((req) => req.url.endsWith('/parse')).flush( + { message: 'failed', status: 500 }, + { status: 500, statusText: 'Internal Server Error' } + ); + + await expect(pending).resolves.toBeUndefined(); + expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith( + expect.any(String), + 'CLOSE', + { duration: 5000 } + ); + }); + it('appends the proxy network code to the playlist-refresh failure toast', async () => { const unhandled: unknown[] = []; const previousHandler = rxjsConfig.onUnhandledError; diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 1d7152983..0b5369ddb 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -133,8 +133,10 @@ export class PwaService extends DataService { } if (type === PLAYLIST_UPDATE) { - this.refreshPlaylist(payload as Partial); - return undefined as T; + // Settles when the refresh does, so callers keep the source busy. + return this.refreshPlaylist( + payload as Partial + ) as T; } if (type === XTREAM_REQUEST) { @@ -215,16 +217,39 @@ export class PwaService extends DataService { } } - refreshPlaylist(payload?: Partial) { + /** Resolves once the backend has answered, whether it succeeded or not. */ + refreshPlaylist( + payload?: Partial + ): Promise { if (!payload?.url || !payload?.id) { - return; + return Promise.resolve(); } const playlistId = payload.id; + const { url, userAgent } = payload; - this.getPlaylistFromUrl(payload.url, payload.userAgent) - .pipe( - catchError((error) => { + return new Promise((resolve) => { + this.getPlaylistFromUrl(url, userAgent).subscribe({ + next: (playlist: Playlist) => { + this.store.dispatch( + PlaylistActions.updatePlaylist({ + playlist, + playlistId, + refreshEpg: true, + }) + ); + + this.snackBar.open( + this.translateService.instant( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' + ), + undefined, + { duration: 2000 } + ); + }, + error: (error: unknown) => { + // Redacted: the error can carry the playlist URL. + this.logger.error('Playlist refresh error:', error); this.snackBar.open( this.appendProviderErrorCode( this.getPlaylistRefreshErrorMessage(error), @@ -235,26 +260,11 @@ export class PwaService extends DataService { duration: 5000, } ); - return throwError(() => error); - }) - ) - .subscribe((playlist: Playlist) => { - this.store.dispatch( - PlaylistActions.updatePlaylist({ - playlist, - playlistId, - refreshEpg: true, - }) - ); - - this.snackBar.open( - this.translateService.instant( - 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' - ), - undefined, - { duration: 2000 } - ); + resolve(); + }, + complete: () => resolve(), }); + }); } private getPlaylistRefreshErrorMessage(error: unknown): string { 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 7132ed481..50a613c1e 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 @@ -312,8 +312,15 @@ describe('PlaylistRefreshActionService', () => { ).toBe(true); }); - it('uses the browser URL refresh path when the refresh bridge is unavailable', () => { + it('uses the browser URL refresh path when the refresh bridge is unavailable', async () => { runtime.supportsPlaylistRefresh = false; + let answer!: () => void; + dataService.sendIpcEvent.mockReturnValueOnce( + new Promise((resolve) => { + answer = resolve; + }) + ); + const activity = TestBed.inject(SourceActivityService); const playlist = createPlaylistMeta({ _id: 'playlist-url', title: 'URL playlist', @@ -333,6 +340,15 @@ describe('PlaylistRefreshActionService', () => { userAgent: 'IPTVnator-Test/1.0', }); expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled(); + // Busy, and held for removal and other refreshes, until it answers. + expect(service.isSourceBusy('playlist-url')).toBe(true); + expect(activity.isBusy('playlist-url')).toBe(true); + + answer(); + await Promise.resolve(); + await Promise.resolve(); + expect(service.isSourceBusy('playlist-url')).toBe(false); + expect(activity.isBusy('playlist-url')).toBe(false); }); it('tracks a header M3U refresh by source until the operation settles', async () => { 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 991460649..afbe89387 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 @@ -94,14 +94,32 @@ export class PlaylistRefreshActionService { ) { void this.refreshM3u(playlist); } else if (playlist.url) { - this.dataService.sendIpcEvent(PLAYLIST_UPDATE, { - id: playlist._id, - title: playlist.title, - url: playlist.url, - ...(playlist.userAgent - ? { userAgent: playlist.userAgent } - : {}), + void this.refreshViaBackend(playlist, playlist.url); + } + } + + /** + * The browser build refreshes through the backend, which reports its own + * success or failure; the source stays busy until it has answered. + */ + private async refreshViaBackend( + item: PlaylistMeta, + url: string + ): Promise { + this.m3uRefreshId.set(item._id); + this.isRefreshing.set(true); + const release = this.activity.begin([item._id]); + try { + await this.dataService.sendIpcEvent(PLAYLIST_UPDATE, { + id: item._id, + title: item.title, + url, + ...(item.userAgent ? { userAgent: item.userAgent } : {}), }); + } finally { + this.m3uRefreshId.set(null); + this.isRefreshing.set(false); + release(); } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts index 82f0e6928..90a28940e 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.spec.ts @@ -465,8 +465,15 @@ describe('SourceListRefreshService', () => { dateNowSpy.mockRestore(); }); - it('uses the legacy IPC refresh flow for non-Xtream playlists', () => { + it('uses the legacy IPC refresh flow for non-Xtream playlists', async () => { runtime.supportsPlaylistRefresh = false; + let answer!: () => void; + dataService.sendIpcEvent.mockReturnValueOnce( + new Promise((resolve) => { + answer = resolve; + }) + ); + const activity = TestBed.inject(SourceActivityService); const item = createPlaylistMeta({ _id: 'playlist-m3u-1', serverUrl: undefined, @@ -485,6 +492,17 @@ describe('SourceListRefreshService', () => { url: item.url, userAgent: item.userAgent, }); + // Busy, and held for removal and other refreshes, until it answers. + expect(component.isRefreshing(item._id)).toBe(true); + expect(activity.isBusy(item._id)).toBe(true); + component.refresh(item); + expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1); + + answer(); + await Promise.resolve(); + await Promise.resolve(); + expect(component.isRefreshing(item._id)).toBe(false); + expect(activity.isBusy(item._id)).toBe(false); }); it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts index 78c40523a..fe50cd3c1 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/source-list-refresh.service.ts @@ -99,12 +99,7 @@ export class SourceListRefreshService { ) { void this.refreshM3u(item); } else if (item.url) { - this.dataService.sendIpcEvent(PLAYLIST_UPDATE, { - id: item._id, - title: item.title, - url: item.url, - ...(item.userAgent ? { userAgent: item.userAgent } : {}), - }); + void this.refreshViaBackend(item, item.url); } } @@ -231,6 +226,29 @@ export class SourceListRefreshService { } } + /** + * The browser build refreshes through the backend; the row stays busy, + * and held in the shared registry, until the backend answers. + */ + private async refreshViaBackend( + item: PlaylistMeta, + url: string + ): Promise { + this.setRefreshing(item._id, true); + try { + await this.dataService.sendIpcEvent(PLAYLIST_UPDATE, { + id: item._id, + title: item.title, + url, + ...(item.userAgent ? { userAgent: item.userAgent } : {}), + }); + } catch (error) { + this.logger.error('Error refreshing playlist:', error); + } finally { + this.setRefreshing(item._id, false); + } + } + private refreshErrorMessage(error: unknown, item: PlaylistMeta): string { if (item.filePath) { const message = String(