mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
1 parent
bfce3bd04e
commit
ecddc518ba
5 files changed
+79
-5
No files matched your search
+2
@@ -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)"
|
||||
|
||||
+28
@@ -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();
|
||||
|
||||
+17
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user