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(