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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 23:43:56 +02:00
1 parent a861ef94ac
commit e4d22cbac8
7 files changed
+172 -42

No files matched your search

@@ -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;
@@ -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;
+36 -26
View File
@@ -133,8 +133,10 @@ export class PwaService extends DataService {
}
if (type === PLAYLIST_UPDATE) {
this.refreshPlaylist(payload as Partial<Playlist & { id: string }>);
return undefined as T;
// Settles when the refresh does, so callers keep the source busy.
return this.refreshPlaylist(
payload as Partial<Playlist & { id: string }>
) as T;
}
if (type === XTREAM_REQUEST) {
@@ -215,16 +217,39 @@ export class PwaService extends DataService {
}
}
refreshPlaylist(payload?: Partial<Playlist & { id: string }>) {
/** Resolves once the backend has answered, whether it succeeded or not. */
refreshPlaylist(
payload?: Partial<Playlist & { id: string }>
): Promise<void> {
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<void>((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 {
@@ -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<void>((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 () => {
@@ -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<void> {
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();
}
}
@@ -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<void>((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', () => {
@@ -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<void> {
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(