fix(sources): no opening a source from the switcher mid-refresh

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 01:28:12 +02:00
1 parent bfce3bd04e
commit ecddc518ba
5 files changed
+79 -5

No files matched your search

@@ -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)"
@@ -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<PlaylistMeta> = {}) =>
@@ -52,6 +53,7 @@ describe('PlaylistSwitcherPanelComponent', () => {
let selectPlaylist: jest.Mock;
let remove: jest.Mock;
let navigate: jest.Mock;
let xtreamRefreshing: ReturnType<typeof signal<ReadonlySet<string>>>;
const input = () =>
(fixture.nativeElement as HTMLElement).querySelector(
@@ -83,6 +85,7 @@ describe('PlaylistSwitcherPanelComponent', () => {
selectPlaylist = jest.fn();
remove = jest.fn();
navigate = jest.fn();
xtreamRefreshing = signal<ReadonlySet<string>>(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<HTMLElement>('.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();
@@ -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<ElementRef<HTMLElement>>('list');
private readonly searchInput =
viewChild.required<ElementRef<HTMLInputElement>>('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);
@@ -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]);
@@ -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<string>();
private readonly activeRefreshPlaylistIds = signal<ReadonlySet<string>>(
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);
}