From ecddc518ba09cf5d9b80832bf635f8c9bd6d2541 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 01:28:12 +0200 Subject: [PATCH] fix(sources): no opening a source from the switcher mid-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While an Xtream refresh was still deleting the catalog, ⌘P could reopen the switcher and selecting that source navigated at once, starting its import before the user data to restore was parked. The refresh flow now exposes `isRunning` (its set of runs is a signal), and the switcher marks a refreshing source aria-disabled and ignores its selection by pointer and Enter alike. Imports alone do not block opening a source. Co-Authored-By: Claude Opus 5.5 --- .../playlist-switcher-panel.component.html | 2 ++ .../playlist-switcher-panel.component.spec.ts | 28 ++++++++++++++++ .../playlist-switcher-panel.component.ts | 17 ++++++++++ .../lib/xtream-refresh-flow.service.spec.ts | 4 +++ .../ui/src/lib/xtream-refresh-flow.service.ts | 33 ++++++++++++++++--- 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html index f5e368d28..758f1caed 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.html @@ -153,10 +153,12 @@ [attr.id]="option.id" [attr.aria-selected]="index === highlight()" [attr.aria-current]="option.playlist._id === currentId() || null" + [attr.aria-disabled]="isRefreshing(option.playlist) || null" [playlist]="option.playlist" [current]="option.playlist._id === currentId()" [highlighted]="index === highlight()" [canRefresh]="canRefresh(option.playlist)" + [refreshing]="isRefreshing(option.playlist)" [removable]="!isBusy(option.playlist)" (activated)="select(option.playlist)" (refreshRequested)="refresh(option.playlist)" diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts index fb0c85900..ad3793886 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.spec.ts @@ -20,6 +20,7 @@ import { import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { PlaylistRefreshActionService } from '../playlist-refresh-action.service'; import { SourceRemovalService } from '../source-removal/source-removal.service'; +import { XtreamRefreshFlowService } from '../xtream-refresh-flow.service'; import { PlaylistSwitcherPanelComponent } from './playlist-switcher-panel.component'; const source = (id: string, fields: Partial = {}) => @@ -52,6 +53,7 @@ describe('PlaylistSwitcherPanelComponent', () => { let selectPlaylist: jest.Mock; let remove: jest.Mock; let navigate: jest.Mock; + let xtreamRefreshing: ReturnType>>; const input = () => (fixture.nativeElement as HTMLElement).querySelector( @@ -83,6 +85,7 @@ describe('PlaylistSwitcherPanelComponent', () => { selectPlaylist = jest.fn(); remove = jest.fn(); navigate = jest.fn(); + xtreamRefreshing = signal>(new Set()); TestBed.configureTestingModule({ imports: [ PlaylistSwitcherPanelComponent, @@ -137,6 +140,13 @@ describe('PlaylistSwitcherPanelComponent', () => { useValue: { canRefresh: () => true, refresh: jest.fn(), + isSourceBusy: () => false, + }, + }, + { + provide: XtreamRefreshFlowService, + useValue: { + isRunning: (id: string) => xtreamRefreshing().has(id), }, }, { provide: MatDialog, useValue: { open: jest.fn() } }, @@ -320,6 +330,24 @@ describe('PlaylistSwitcherPanelComponent', () => { release(); }); + it('does not open a source while its refresh clears the catalog', () => { + xtreamRefreshing.set(new Set(['alpha'])); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + const alpha = element.querySelector('#playlist-switcher-all-alpha'); + expect(alpha?.getAttribute('aria-disabled')).toBe('true'); + + alpha?.querySelector('.source-row')?.click(); + // Enter on the highlighted option goes through the same method. + component.select(ALPHA); + expect(selectPlaylist).not.toHaveBeenCalled(); + + xtreamRefreshing.set(new Set()); + fixture.detectChanges(); + component.select(ALPHA); + expect(selectPlaylist).toHaveBeenCalledWith(ALPHA); + }); + it('leaves out a source whose delete is pending or running', () => { pending.set(new Set(['bravo'])); fixture.detectChanges(); diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts index 2c79baca6..812fe2f8a 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher-panel.component.ts @@ -46,6 +46,7 @@ import { PlaylistRefreshActionService } from '../playlist-refresh-action.service import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component'; import { SourceRemovalService } from '../source-removal/source-removal.service'; import { SourceRowComponent } from '../source-row/source-row.component'; +import { XtreamRefreshFlowService } from '../xtream-refresh-flow.service'; import { buildSwitcherGroups, moveHighlight, @@ -90,6 +91,7 @@ export class PlaylistSwitcherPanelComponent { private readonly router = inject(Router); private readonly sortService = inject(SortService); private readonly statusService = inject(SourceStatusService); + private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService); private readonly list = viewChild.required>('list'); private readonly searchInput = viewChild.required>('searchInput'); @@ -279,6 +281,10 @@ export class PlaylistSwitcherPanelComponent { } select(playlist: PlaylistMeta): void { + // Pointer and Enter alike: opening it would race the refresh. + if (this.isRefreshing(playlist)) { + return; + } this.context.selectPlaylist(playlist); this.playlistSelected.emit(playlist._id); this.close(); @@ -288,6 +294,17 @@ export class PlaylistSwitcherPanelComponent { return this.refreshAction.canRefresh(playlist); } + /** + * A refresh is clearing and refilling the source: it cannot be opened + * until the refresh has parked the user data its import restores. + */ + isRefreshing(playlist: PlaylistMeta): boolean { + return ( + this.refreshAction.isSourceBusy(playlist._id) || + this.xtreamRefreshFlow.isRunning(playlist._id) + ); + } + /** A source cannot be removed while a refresh clears and refills it. */ isBusy(playlist: PlaylistMeta): boolean { return this.activity.isBusy(playlist._id); diff --git a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts index b16d85461..727ab15e0 100644 --- a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts @@ -173,9 +173,11 @@ describe('XtreamRefreshFlowService', () => { const activity = TestBed.inject(SourceActivityService); const item = createPlaylistMeta(); let heldDuringDelete = false; + let runningDuringDelete = false; databaseService.deleteXtreamPlaylistContent.mockImplementationOnce( () => { heldDuringDelete = activity.isBusy(item._id); + runningDuringDelete = service.isRunning(item._id); return Promise.resolve({ success: true, favorites: [], @@ -187,7 +189,9 @@ describe('XtreamRefreshFlowService', () => { service.confirmAndRefresh(item, createReporter().reporter); await confirmPromise; expect(heldDuringDelete).toBe(true); + expect(runningDuringDelete).toBe(true); expect(activity.isBusy(item._id)).toBe(false); + expect(service.isRunning(item._id)).toBe(false); // A delete or another refresh already holds it: nothing runs. const release = activity.begin([item._id]); diff --git a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts index 9f405887d..a3d2788b0 100644 --- a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts +++ b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts @@ -1,4 +1,4 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; @@ -93,7 +93,30 @@ export class XtreamRefreshFlowService { * losing favorites, history, hidden categories and playback positions on * re-import. This service is the only place that sees both. */ - private readonly activeRefreshPlaylistIds = new Set(); + private readonly activeRefreshPlaylistIds = signal>( + new Set() + ); + + /** + * Whether a destructive run is in flight for the playlist. Opening the + * playlist meanwhile would start its import against a half-deleted + * catalog, before the user data to restore has been parked. + */ + isRunning(playlistId: string): boolean { + return this.activeRefreshPlaylistIds().has(playlistId); + } + + private setRunning(playlistId: string, running: boolean): void { + this.activeRefreshPlaylistIds.update((current) => { + const next = new Set(current); + if (running) { + next.add(playlistId); + } else { + next.delete(playlistId); + } + return next; + }); + } /** * Asks for confirmation, then deletes and re-imports the playlist. Callers @@ -124,7 +147,7 @@ export class XtreamRefreshFlowService { // The shared registry also holds deletes, the startup auto-refresh // and M3U refreshes from either entry point. if ( - this.activeRefreshPlaylistIds.has(item._id) || + this.isRunning(item._id) || this.activity.isBusy(item._id) || reporter.isBusy(item._id) ) { @@ -135,7 +158,7 @@ export class XtreamRefreshFlowService { this.databaseService.createOperationId('xtream-refresh'); const run: XtreamRefreshRun = { playlistId: item._id, operationId }; reporter.begin(run); - this.activeRefreshPlaylistIds.add(item._id); + this.setRunning(item._id, true); const release = this.activity.begin([item._id]); try { @@ -210,7 +233,7 @@ export class XtreamRefreshFlowService { ); } } finally { - this.activeRefreshPlaylistIds.delete(item._id); + this.setRunning(item._id, false); release(); reporter.end(run); }