fix(sources): checks behind filters, keyboard Undo in the switcher, check causes

Local review round 1 (Codex + Greptile) on the redesign:

- A saved Responding / Not responding filter hid every source without a
  result, and the hidden rows were what started the checks, so the list
  stayed empty. keepSourcesChecked (portal shared data-access) now keeps
  every library source checked while the Sources page lives, on desktop
  where a check is one shared job that rows join.
- Removing from the switcher left its focus trap open around the search,
  outside of which the Undo toast sits: Tab could not reach Undo. The
  popover now closes first; Undo takes the focus and returns it to the
  trigger (SourceRemovalOptions.returnFocusTo). Every switcher action
  closes through one path, so a closing row menu no longer pulls the focus
  back into the popover.
- A running desktop Xtream delete brought its source back into the
  switcher as an ordinary option that could be opened or removed again; the
  switcher now leaves it out until the delete ends (the Sources page shows
  its progress and Cancel).
- Undo re-activated the removed source over a source the user had chosen
  since, when nothing had replaced it (it was the only row a filter
  showed). Undo now restores only while no other source is active.
- The switcher highlight stays on its option, or in range, when the list
  changes under the same search, so Enter still opens something.
- The status dot named every failure "Could not connect". SourceStatus
  now carries the check's cause (access denied, account disabled, timeout…,
  kept with the health history and validated on read), and M3U dots again
  say the check covers the playlist URL, not its channels.

Tests: new specs for keepSourcesChecked, the saved-filter check, the Undo
guard and focus return, the switcher close/highlight/deleting cases and
the dot's cause; the filter and Undo specs fail without their fixes. The
switcher keyboard E2E now deletes and undoes from the keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 09:51:33 +02:00
1 parent 845d295474
commit 366745f16d
23 files changed
+616 -67

No files matched your search

@@ -565,6 +565,27 @@ test.describe('Electron Playlist Switcher', () => {
await expect(panel).toHaveCount(0);
if (viewport) await page.setViewportSize(viewport);
// A delete from the keyboard: the popover closes, since its
// focus trap would keep Tab from Undo; Undo takes the focus and
// gives it back to the trigger.
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Shift+F10');
const rowMenu = page.locator('.mat-mdc-menu-panel');
await expect(rowMenu).toBeVisible();
await page.keyboard.press('End');
await expect(
rowMenu.getByRole('menuitem', { name: /Remove/ })
).toBeFocused();
await page.keyboard.press('Enter');
await expect(panel).toHaveCount(0);
const undo = page.locator('.source-removal-toast__undo');
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(undo).toHaveCount(0);
await expect(trigger).toBeFocused();
await openSources(page);
await expect(
page.locator('app-workspace-sources app-source-row')
+16 -7
View File
@@ -1371,13 +1371,20 @@ in `@iptvnator/playlist/shared/util` (`source-display`, `source-format`,
every list at once (`SourceRemovalService.pendingIds`) and the delete runs
when the toast closes; its close button commits at once, and quitting
within the four seconds keeps the source. Deleting the active source
switches to the next row. A desktop Xtream delete brings its row back as
"Removing…" with progress and Cancel. When the delete took the row and
with it the focus, the toast's Undo takes the focus. Bulk cleanup keeps
its confirmation dialog.
switches to the next row; Undo switches back only if no other source was
chosen since. A desktop Xtream delete brings its row back as "Removing…"
with progress and Cancel on the Sources page; the switcher leaves the
source out until the delete ends. When the delete took the row and with
it the focus, the toast's Undo takes the focus. A delete from the
switcher closes the popover first, since its focus trap would keep the
keyboard from Undo: Undo takes the focus and returns it to the trigger
(`SourceRemovalOptions.returnFocusTo`). Bulk cleanup keeps its
confirmation dialog.
- **Side list.** Type and, on desktop, Status (Any / Responding / Not
responding) combine and persist (`SourcesViewStateService`); each group
counts within the other's choice.
counts within the other's choice. The page checks every source, not only
the rows a filter shows (`keepSourcesChecked`): a saved status filter
would otherwise hide the unchecked sources it is waiting for.
- **Switcher.** The trigger is a 40px secondary button; ⌘P / Ctrl+P opens it
(⌘K stays the command palette). The popover is a CDK connected overlay, so
row menus open above it, not inside its scrolling list. Focus stays in the
@@ -1400,8 +1407,10 @@ files have no dot. On desktop the live `SourceHealthService` result wins; the
last conclusive result per source is kept across restarts
(`SourceHealthHistoryService`, no URL or credential stored), so a source that
failed yesterday shows as failing before it is checked again, with "Not
responding · 3 days". The dot's tooltip and accessible name give the reason
and check time; M3U checks describe the source URL only. Retry and Check
responding · 3 days". The dot's tooltip and accessible name give the
check's cause (`SourceStatus.reason`: access denied, account disabled,
timeout…, kept with the history) and check time; M3U checks say they
cover the playlist URL, not its channels. Retry and Check
again rerun the check; they are not a playlist refresh. The browser build
keeps the Xtream portal status only.
@@ -162,6 +162,6 @@
(playlistInfoRequested)="openInfo(option.playlist)"
(accountInfoRequested)="openAccountInfo(option.playlist)"
(removeRequested)="remove(option.playlist)"
(menuClosed)="searchInput.focus()"
(menuClosed)="onMenuClosed()"
/>
</ng-template>
@@ -47,6 +47,7 @@ describe('PlaylistSwitcherPanelComponent', () => {
let component: PlaylistSwitcherPanelComponent;
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
let deleting: ReturnType<typeof signal<ReadonlySet<string>>>;
let selectPlaylist: jest.Mock;
let remove: jest.Mock;
let navigate: jest.Mock;
@@ -77,6 +78,7 @@ describe('PlaylistSwitcherPanelComponent', () => {
localStorage.clear();
playlists = signal([ALPHA, BRAVO, CHARLIE]);
pending = signal<ReadonlySet<string>>(new Set());
deleting = signal<ReadonlySet<string>>(new Set());
selectPlaylist = jest.fn();
remove = jest.fn();
navigate = jest.fn();
@@ -121,7 +123,13 @@ describe('PlaylistSwitcherPanelComponent', () => {
},
{
provide: SourceRemovalService,
useValue: { pendingIds: pending, remove },
useValue: {
pendingIds: pending,
remove,
progress: {
isDeleting: (id: string) => deleting().has(id),
},
},
},
{
provide: PlaylistRefreshActionService,
@@ -218,13 +226,52 @@ describe('PlaylistSwitcherPanelComponent', () => {
expect(closed).toHaveBeenCalled();
});
it('leaves out a source whose delete is pending and hands over on delete', () => {
component.remove(BRAVO);
expect(remove).toHaveBeenCalledWith(BRAVO, CHARLIE);
it('closes before a delete, so Undo can take the focus and give it back to the trigger', () => {
const trigger = document.createElement('button');
document.body.appendChild(trigger);
// The host closes the popover and focuses its trigger.
component.closeRequested.subscribe(() => trigger.focus());
component.remove(BRAVO);
expect(remove).toHaveBeenCalledWith(BRAVO, CHARLIE, {
returnFocusTo: trigger,
});
trigger.remove();
});
it('leaves out a source whose delete is pending or running', () => {
pending.set(new Set(['bravo']));
fixture.detectChanges();
expect(optionIds()).not.toContain('playlist-switcher-all-bravo');
pending.set(new Set());
deleting.set(new Set(['alpha']));
fixture.detectChanges();
expect(optionIds()).toEqual([
'playlist-switcher-all-bravo',
'playlist-switcher-all-charlie',
]);
});
it('keeps the highlight on its option, or in range, when the list changes', () => {
press('ArrowUp');
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-charlie'
);
// An earlier option leaves: the highlight follows charlie.
playlists.set([BRAVO, CHARLIE]);
fixture.detectChanges();
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-charlie'
);
// The highlighted last option leaves: Enter opens the new last one.
playlists.set([BRAVO]);
fixture.detectChanges();
expect(input().getAttribute('aria-activedescendant')).toBe(
'playlist-switcher-all-bravo'
);
press('Enter');
expect(selectPlaylist).toHaveBeenCalledWith(BRAVO);
});
it('goes to the Sources page from the footer', () => {
@@ -1,5 +1,5 @@
import { A11yModule } from '@angular/cdk/a11y';
import { NgTemplateOutlet } from '@angular/common';
import { DOCUMENT, NgTemplateOutlet } from '@angular/common';
import {
afterNextRender,
ChangeDetectionStrategy,
@@ -75,6 +75,7 @@ const KINDS: readonly PlaylistSourceKind[] = ['m3u', 'xtream', 'stalker'];
})
export class PlaylistSwitcherPanelComponent {
private readonly context = inject(PlaylistContextFacade);
private readonly document = inject(DOCUMENT);
private readonly dialog = inject(MatDialog);
private readonly recentHistory = inject(PlaylistRecentHistoryService);
private readonly refreshAction = inject(PlaylistRefreshActionService);
@@ -91,6 +92,7 @@ export class PlaylistSwitcherPanelComponent {
});
readonly closeRequested = output<void>();
private closing = false;
readonly playlistSelected = output<string>();
readonly addPlaylistRequested = output<void>();
readonly accountInfoRequested = output<PlaylistMeta>();
@@ -110,11 +112,20 @@ export class PlaylistSwitcherPanelComponent {
return status ? sourceStatusBucket(status.reachability) : null;
};
/**
* Every source except one whose delete is pending or running: a running
* delete shows its progress and Cancel on the Sources page, and its
* source cannot be opened or removed again from here.
*/
readonly playlists = computed(() => {
const pending = this.removal.pendingIds();
return this.context
.playlists()
.filter((playlist) => !pending.has(playlist._id));
.filter(
(playlist) =>
!pending.has(playlist._id) &&
!this.removal.progress.isDeleting(playlist._id)
);
});
readonly typeCounts = computed(() =>
countSourcesByType(this.playlists(), 'all', this.bucketOf)
@@ -145,13 +156,38 @@ export class PlaylistSwitcherPanelComponent {
...this.groups().recent,
...this.groups().all,
]);
/** A search highlights its first match, so Enter opens it. */
/**
* A search highlights its first match, so Enter opens it. When the list
* changes under the same search (a source added or removed), the
* highlight stays on its option, or on the same place, clamped.
*/
readonly highlight = linkedSignal<
readonly [string, SourcesTypeFilter],
{
query: string;
type: SourcesTypeFilter;
ids: readonly string[];
},
number
>({
source: () => [this.query(), this.type()] as const,
computation: ([query]) => (query.trim() ? 0 : -1),
source: () => ({
query: this.query(),
type: this.type(),
ids: this.options().map((option) => option.id),
}),
computation: (source, previous) => {
const sameSearch =
previous &&
previous.source.query === source.query &&
previous.source.type === source.type;
if (!sameSearch) {
return source.query.trim() ? 0 : -1;
}
const id = previous.source.ids[previous.value];
const kept = id === undefined ? -1 : source.ids.indexOf(id);
return kept !== -1
? kept
: Math.min(previous.value, source.ids.length - 1);
},
});
readonly activeOptionId = computed(
() => this.options()[this.highlight()]?.id ?? null
@@ -199,7 +235,7 @@ export class PlaylistSwitcherPanelComponent {
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.closeRequested.emit();
this.close();
break;
case 'ContextMenu':
case 'F10':
@@ -214,7 +250,7 @@ export class PlaylistSwitcherPanelComponent {
select(playlist: PlaylistMeta): void {
this.context.selectPlaylist(playlist);
this.playlistSelected.emit(playlist._id);
this.closeRequested.emit();
this.close();
}
canRefresh(playlist: PlaylistMeta): boolean {
@@ -222,34 +258,67 @@ export class PlaylistSwitcherPanelComponent {
}
refresh(playlist: PlaylistMeta): void {
this.closeRequested.emit();
this.close();
this.refreshAction.refresh(playlist);
}
openInfo(playlist: PlaylistMeta): void {
this.closeRequested.emit();
this.close();
this.dialog.open(PlaylistInfoComponent, { data: playlist });
}
openAccountInfo(playlist: PlaylistMeta): void {
this.closeRequested.emit();
this.close();
this.accountInfoRequested.emit(playlist);
}
/** Deletes with Undo; the active source hands over to its neighbour. */
/**
* Deletes with Undo; the active source hands over to its neighbour. The
* popover closes first: its focus trap would keep the keyboard away
* from Undo, which then takes the focus and returns it to the trigger.
*/
remove(playlist: PlaylistMeta): void {
const all = this.groups().all.map((option) => option.playlist);
const index = all.findIndex((item) => item._id === playlist._id);
this.removal.remove(playlist, all[index + 1] ?? all[index - 1] ?? null);
this.close();
// Closing put the focus back on the trigger.
const returnFocusTo = this.document.activeElement;
this.removal.remove(
playlist,
all[index + 1] ?? all[index - 1] ?? null,
{
returnFocusTo:
returnFocusTo instanceof HTMLElement &&
returnFocusTo !== this.document.body
? returnFocusTo
: null,
}
);
}
/** A row menu gives the focus back to the search, unless closing. */
onMenuClosed(): void {
if (!this.closing) {
this.searchInput().nativeElement.focus();
}
}
/**
* Every action that leaves the popover closes it through here, so a row
* menu closing after its action does not pull the focus back into it.
*/
private close(): void {
this.closing = true;
this.closeRequested.emit();
}
addPlaylist(): void {
this.closeRequested.emit();
this.close();
this.addPlaylistRequested.emit();
}
openAllSources(): void {
this.closeRequested.emit();
this.close();
void this.router.navigate(['/workspace', 'sources']);
}
@@ -167,6 +167,29 @@ describe('RecentPlaylistsComponent', () => {
).toBe(3);
});
it('checks the sources a saved status filter hides', () => {
fixture.destroy();
viewState.setStatus('ok');
statuses.set('xtream', { reachability: 'unknown' });
const ensureChecked = TestBed.inject(SourceStatusService)
.ensureChecked as jest.Mock;
ensureChecked.mockClear();
const restarted = TestBed.createComponent(RecentPlaylistsComponent);
restarted.detectChanges();
TestBed.tick();
expect(
restarted.componentInstance.visiblePlaylists().map((p) => p._id)
).toEqual(['m3u']);
// No row is on screen for it, yet its check runs, so it can join
// the list once it answers.
expect(ensureChecked.mock.calls.map(([p]) => p._id)).toContain(
'xtream'
);
restarted.destroy();
});
it('applies the type and status filters together', () => {
viewState.setType('m3u');
expect(ids()).toEqual(['m3u', 'file']);
@@ -23,7 +23,10 @@ import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import {
keepSourcesChecked,
SourceStatusService,
} from '@iptvnator/portal/shared/data-access';
import {
filterSources,
orderFailingFirst,
@@ -131,6 +134,12 @@ export class RecentPlaylistsComponent {
: sorted;
});
constructor() {
// A status filter hides the sources it has no result for, so the
// checks follow the library rather than the rows on screen.
keepSourcesChecked(() => this.playlists());
}
resetFilters(): void {
this.viewState.reset();
}
@@ -8,11 +8,14 @@ import { SourceRemovalToastComponent } from './source-removal-toast.component';
describe('SourceRemovalToastComponent', () => {
let opened: Subject<void>;
let dismissed: Subject<void>;
let ref: {
afterOpened: () => Subject<void>;
afterDismissed: () => Subject<void>;
dismiss: jest.Mock;
dismissWithAction: jest.Mock;
};
let data: Record<string, unknown>;
const render = () => {
const fixture = TestBed.createComponent(SourceRemovalToastComponent);
@@ -23,22 +26,22 @@ describe('SourceRemovalToastComponent', () => {
beforeEach(() => {
opened = new Subject<void>();
dismissed = new Subject<void>();
ref = {
afterOpened: () => opened,
afterDismissed: () => dismissed,
dismiss: jest.fn(),
dismissWithAction: jest.fn(),
};
data = {
message: 'Removed “Alpha”',
undoLabel: 'Undo',
closeLabel: 'Close',
};
TestBed.configureTestingModule({
imports: [SourceRemovalToastComponent],
providers: [
{
provide: MAT_SNACK_BAR_DATA,
useValue: {
message: 'Removed “Alpha”',
undoLabel: 'Undo',
closeLabel: 'Close',
},
},
{ provide: MAT_SNACK_BAR_DATA, useFactory: () => data },
{ provide: MatSnackBarRef, useValue: ref },
],
});
@@ -71,6 +74,26 @@ describe('SourceRemovalToastComponent', () => {
);
});
it('takes the focus from a closing surface and gives it back after', () => {
const trigger = document.createElement('button');
document.body.appendChild(trigger);
trigger.focus();
data['returnFocusTo'] = trigger;
const element = render();
const undo = element.querySelector<HTMLButtonElement>(
'.source-removal-toast__undo'
);
opened.next();
expect(document.activeElement).toBe(undo);
// The toast leaves the page and its focus with it.
element.remove();
dismissed.next();
expect(document.activeElement).toBe(trigger);
trigger.remove();
});
it('leaves the focus where the user put it', () => {
render();
const elsewhere = document.createElement('button');
@@ -20,13 +20,17 @@ export interface SourceRemovalToastData {
message: string;
undoLabel: string;
closeLabel: string;
/** Undo takes the focus, which returns here when the toast closes. */
returnFocusTo?: HTMLElement | null;
}
/**
* "Removed …" with Undo, and a close button that commits the delete at
* once instead of waiting out the toast. A delete from a row's menu takes
* the row, and with it the focus, off the page: Undo then takes the focus,
* so the keyboard can reach it.
* so the keyboard can reach it. A caller that closes on delete (the
* switcher) passes `returnFocusTo`: Undo takes the focus regardless, and
* the focus goes back there when the toast closes.
*/
@Component({
selector: 'app-source-removal-toast',
@@ -75,11 +79,22 @@ export class SourceRemovalToastComponent {
const document = inject(DOCUMENT);
// After the opening animation: by then a closing menu has tried, and
// failed, to give focus back to the removed row's ⋯ button.
const returnFocusTo = this.data.returnFocusTo ?? null;
this.ref.afterOpened().subscribe(() => {
const active = document.activeElement;
if (!active || active === document.body) {
if (returnFocusTo || !active || active === document.body) {
(this.undo().nativeElement as HTMLElement).focus();
}
});
// The toast is gone by now; focus that left with it lands on body.
this.ref.afterDismissed().subscribe(() => {
const active = document.activeElement;
if (
returnFocusTo?.isConnected &&
(!active || active === document.body)
) {
returnFocusTo.focus();
}
});
}
}
@@ -176,6 +176,29 @@ describe('SourceRemovalService', () => {
expect(context.activatePlaylist).not.toHaveBeenCalled();
});
it('leaves a later switch alone on Undo when nothing replaced the source', () => {
// The source was the only row a filter showed: no replacement.
resolvedPlaylistId.set('a');
routePlaylistId.set('a');
router.url = '/workspace/xtreams/a/vod';
service.remove(playlist('a'));
resolvedPlaylistId.set('c');
toasts[0].action.next();
expect(router.navigateByUrl).not.toHaveBeenCalled();
expect(context.activatePlaylist).not.toHaveBeenCalled();
});
it('passes the focus target of a closing surface to the toast', () => {
const trigger = document.createElement('button');
service.remove(playlist('a'), null, { returnFocusTo: trigger });
const openFromComponent = TestBed.inject(MatSnackBar)
.openFromComponent as jest.Mock;
expect(openFromComponent.mock.calls[0][1].data.returnFocusTo).toBe(
trigger
);
});
it('leaves the deleted source’s own page and returns on Undo', () => {
resolvedPlaylistId.set('a');
routePlaylistId.set('a');
@@ -23,6 +23,15 @@ import {
/** How long Undo stays available before the delete is committed. */
export const SOURCE_REMOVAL_UNDO_MS = 4000;
export interface SourceRemovalOptions {
/**
* For a surface that closes on delete, such as the switcher popover,
* whose focus trap the toast sits outside: Undo takes the focus at once,
* and it comes back here when the toast closes.
*/
returnFocusTo?: HTMLElement | null;
}
interface PendingRemoval {
playlist: PlaylistMeta;
snackBar: MatSnackBarRef<SourceRemovalToastComponent>;
@@ -71,7 +80,11 @@ export class SourceRemovalService {
* @param next The source to switch to when `playlist` is the active one:
* the caller's next row, so the switch follows the list on screen.
*/
remove(playlist: PlaylistMeta, next: PlaylistMeta | null = null): void {
remove(
playlist: PlaylistMeta,
next: PlaylistMeta | null = null,
options: SourceRemovalOptions = {}
): void {
if (this.isPending(playlist._id)) {
return;
}
@@ -108,6 +121,7 @@ export class SourceRemovalService {
'PORTALS.MULTI_SOURCE.UNDO'
),
closeLabel: this.translate.instant('CLOSE'),
returnFocusTo: options.returnFocusTo ?? null,
},
duration: SOURCE_REMOVAL_UNDO_MS,
panelClass: 'app-toast',
@@ -142,11 +156,15 @@ export class SourceRemovalService {
return;
}
// Only take the user back if they are still where the delete left
// them; a switch they made since then wins.
const stillReplaced =
this.playlistContext.resolvedPlaylistId() ===
(removal.next?._id ?? null);
if (!stillReplaced && removal.next) {
// them: on its replacement, or with no other source chosen. A switch
// they made since then wins, also when nothing replaced the source
// (it was the only row a filter showed).
const current = this.playlistContext.resolvedPlaylistId();
const untouched =
current === null ||
current === removal.playlist._id ||
current === removal.next?._id;
if (!untouched) {
return;
}
if (removal.wasOnItsRoute) {
@@ -106,6 +106,32 @@ describe('SourceRowComponent', () => {
).toBe('bad');
});
it("names the check's cause on the dot, and an M3U check's scope", () => {
const dotLabel = (element: HTMLElement) =>
element
.querySelector('.source-tile__dot')
?.getAttribute('aria-label');
statuses.set('m3u', { reachability: 'bad', reason: 'auth' });
expect(dotLabel(render(M3U))).toBe(
'SOURCE_HEALTH.REASON.auth · SOURCE_HEALTH.M3U_SCOPE'
);
// Without a kept cause the dot falls back to its colour's wording.
statuses.set('xtream', { reachability: 'bad' });
expect(
dotLabel(
render({
...M3U,
_id: 'xtream',
url: undefined,
serverUrl: 'http://portal.test',
username: 'joe',
password: 'hunter2',
})
)
).toBe('SOURCE_HEALTH.REASON.network');
});
it('opens on click and on Enter, but not while busy', () => {
const element = render(M3U);
const opened = jest.fn();
@@ -13,11 +13,15 @@ import { injectTranslationTick } from '@iptvnator/services';
import {
getPlaylistSourceIcon,
PlaylistMeta,
resolvePlaylistSourceKind,
SourceReachability,
SourceStatus,
} from '@iptvnator/shared/interfaces';
/** The health reason shown for each reachability when no detail is kept. */
/**
* The health reason shown for each reachability when the check left no
* cause, as in a browser build's "checking" status.
*/
const REACHABILITY_REASON: Record<SourceReachability, string> = {
ok: 'available',
bad: 'network',
@@ -83,8 +87,13 @@ export class SourceTileComponent {
}
const reason = status.pending
? 'checking'
: REACHABILITY_REASON[status.reachability];
: (status.reason ?? REACHABILITY_REASON[status.reachability]);
const label = this.translate.instant(`SOURCE_HEALTH.REASON.${reason}`);
// An M3U check reaches the playlist URL, not its channels.
const scope =
resolvePlaylistSourceKind(this.playlist()) === 'm3u'
? this.translate.instant('SOURCE_HEALTH.M3U_SCOPE')
: '';
const time = status.checkedAt
? new Date(status.checkedAt).toLocaleString(
normalizeDateLocale(this.translate.currentLang || 'en'),
@@ -97,6 +106,6 @@ export class SourceTileComponent {
}
)
: '';
return [label, time].filter(Boolean).join(' · ');
return [label, scope, time].filter(Boolean).join(' · ');
});
}
@@ -6,6 +6,7 @@ export * from './lib/live-channel-playback-queue';
export * from './lib/source-health.service';
export * from './lib/source-health-history.service';
export * from './lib/source-status.service';
export * from './lib/source-status-checks';
export * from './lib/source-cleanup.service';
@@ -36,16 +36,30 @@ describe('SourceHealthHistoryService', () => {
state: 'bad',
checkedAt: 900,
failingSince: 100,
reason: 'network',
});
});
it('stores no URL or credential', () => {
create().record(['a'], failed(100));
expect(localStorage.getItem(SOURCE_HEALTH_HISTORY_STORAGE_KEY)).toBe(
'{"a":{"state":"bad","checkedAt":100,"failingSince":100}}'
'{"a":{"state":"bad","checkedAt":100,"failingSince":100,"reason":"network"}}'
);
});
it('drops a stored cause this build cannot describe', () => {
localStorage.setItem(
SOURCE_HEALTH_HISTORY_STORAGE_KEY,
'{"a":{"state":"bad","checkedAt":1,"reason":"<script>"},"b":{"state":"bad","checkedAt":1,"reason":"auth"}}'
);
const history = create();
expect(history.entries().get('a')).toEqual({
state: 'bad',
checkedAt: 1,
});
expect(history.entries().get('b')?.reason).toBe('auth');
});
it('ignores inconclusive checks', () => {
const history = create();
history.record(['a'], {
@@ -1,6 +1,7 @@
import { Injectable, inject, signal } from '@angular/core';
import { SourceHealthEvidenceService } from '@iptvnator/services';
import {
isSourceHealthReason,
nextSourceHealthHistory,
SourceHealthHistoryEntry,
SourceHealthSnapshot,
@@ -16,15 +17,24 @@ function readHistory(): Map<string, SourceHealthHistoryEntry> {
if (!parsed || typeof parsed !== 'object') {
return new Map();
}
return new Map(
Object.entries(
parsed as Record<string, SourceHealthHistoryEntry>
).filter(
([, entry]) =>
typeof entry?.checkedAt === 'number' &&
['ok', 'bad', 'expired'].includes(entry.state)
)
);
const entries = new Map<string, SourceHealthHistoryEntry>();
for (const [id, entry] of Object.entries(
parsed as Record<string, SourceHealthHistoryEntry>
)) {
if (
typeof entry?.checkedAt !== 'number' ||
!['ok', 'bad', 'expired'].includes(entry.state)
) {
continue;
}
// A reason this build cannot describe would show as a raw key.
const { reason, ...rest } = entry;
entries.set(
id,
isSourceHealthReason(reason) ? { ...rest, reason } : rest
);
}
return entries;
} catch {
return new Map();
}
@@ -42,7 +52,7 @@ function toRecord(
* The last conclusive health result of each source, by playlist ID, kept
* across restarts. Live checks stay in memory (`SourceHealthService`); this
* keeps only what a list needs before they run again: the state, when it
* was checked and since when a source has failed. No URL or credential is
* was checked, since when a source has failed and why. No URL or credential is
* stored, so the entries need no redaction.
*/
@Injectable({ providedIn: 'root' })
@@ -72,7 +82,8 @@ export class SourceHealthHistoryService {
const entry = nextSourceHealthHistory(
previous,
reachability,
snapshot.checkedAt
snapshot.checkedAt,
snapshot.reason
);
if (entry && entry !== previous) {
next.set(id, entry);
@@ -0,0 +1,98 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { keepSourcesChecked } from './source-status-checks';
import { SourceStatusService } from './source-status.service';
const source = (id: string, url?: string): PlaylistMeta =>
({
_id: id,
title: id,
url,
importDate: '',
autoRefresh: false,
count: 0,
}) as PlaylistMeta;
const library = signal<PlaylistMeta[]>([]);
@Component({ selector: 'app-host', template: '' })
class HostComponent {
constructor() {
keepSourcesChecked(() => library());
}
}
describe('keepSourcesChecked', () => {
let status: {
supportsStatusFilter: boolean;
tracks: jest.Mock;
ensureChecked: jest.Mock;
};
const mount = () => {
TestBed.configureTestingModule({
providers: [{ provide: SourceStatusService, useValue: status }],
});
const fixture = TestBed.createComponent(HostComponent);
TestBed.tick();
return fixture;
};
const signalFor = (id: string): AbortSignal =>
status.ensureChecked.mock.calls.find(
([playlist]: [PlaylistMeta]) => playlist._id === id
)?.[1];
beforeEach(() => {
status = {
supportsStatusFilter: true,
tracks: jest.fn((playlist: PlaylistMeta) => !!playlist.url),
ensureChecked: jest.fn(),
};
library.set([
source('a', 'http://a.test/list.m3u'),
source('b', 'http://b.test/list.m3u'),
source('local'),
]);
});
afterEach(() => TestBed.resetTestingModule());
it('checks every network source, shown or not, once', () => {
mount();
expect(status.ensureChecked.mock.calls.map(([p]) => p._id)).toEqual([
'a',
'b',
]);
// A new result elsewhere in the library starts no second check.
library.update((list) => [...list]);
TestBed.tick();
expect(status.ensureChecked).toHaveBeenCalledTimes(2);
});
it('cancels a removed source, checks an edited one again, and stops on destroy', () => {
const fixture = mount();
const firstA = signalFor('a');
library.set([
source('a', 'http://a.test/moved.m3u'),
source('c', 'http://c.test/list.m3u'),
]);
TestBed.tick();
expect(firstA.aborted).toBe(true);
expect(signalFor('b').aborted).toBe(true);
expect(status.ensureChecked).toHaveBeenCalledTimes(4);
fixture.destroy();
const [, , secondA, c] = status.ensureChecked.mock.calls.map(
([, signal]) => signal as AbortSignal
);
expect(secondA.aborted).toBe(true);
expect(c.aborted).toBe(true);
});
it('leaves the browser build to its rows', () => {
status.supportsStatusFilter = false;
mount();
expect(status.ensureChecked).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,58 @@
import { DestroyRef, effect, inject, untracked } from '@angular/core';
import { PlaylistMeta, sourceHealthKey } from '@iptvnator/shared/interfaces';
import { SourceStatusService } from './source-status.service';
/**
* Keeps every listed source checked for as long as the caller lives,
* whatever rows it shows. A status filter hides the sources that have no
* result yet, and their rows were what started the checks, so a saved
* "Responding" filter stayed empty after a restart.
*
* Desktop only: there the status filter exists and a check is one shared
* job per source, which a row's own check joins rather than repeats. A
* source whose address changes is checked again. Call it in an injection
* context.
*/
export function keepSourcesChecked(
playlists: () => readonly PlaylistMeta[]
): void {
const status = inject(SourceStatusService);
if (!status.supportsStatusFilter) {
return;
}
const running = new Map<string, AbortController>();
effect(() => {
const wanted = new Map<string, PlaylistMeta>();
for (const playlist of playlists()) {
if (status.tracks(playlist)) {
wanted.set(
`${playlist._id}\u0000${sourceHealthKey(playlist)}`,
playlist
);
}
}
// The checks publish their results through signals; reading them
// here would restart every check on every result.
untracked(() => {
for (const [key, controller] of running) {
if (!wanted.has(key)) {
controller.abort();
running.delete(key);
}
}
for (const [key, playlist] of wanted) {
if (!running.has(key)) {
const controller = new AbortController();
running.set(key, controller);
status.ensureChecked(playlist, controller.signal);
}
}
});
});
inject(DestroyRef).onDestroy(() => {
running.forEach((controller) => controller.abort());
running.clear();
});
}
@@ -100,9 +100,27 @@ describe('SourceStatusService', () => {
reachability: 'bad',
failingSince: 100,
checkedAt: 900,
reason: 'network',
});
});
it("keeps a failed check's cause across a restart", () => {
history.set(
new Map([
[
'm',
{
state: 'bad',
checkedAt: 50,
failingSince: 10,
reason: 'auth',
},
],
])
);
expect(create(true).statusOf(m3u)?.reason).toBe('auth');
});
it('shows the kept result before and while the source is checked', () => {
history.set(
new Map([
@@ -173,7 +191,10 @@ describe('SourceStatusService', () => {
const service = create(false);
service.ensureChecked(xtream, new AbortController().signal);
expect(portal.checkPortalStatus).not.toHaveBeenCalled();
expect(service.statusOf(xtream)?.reachability).toBe('bad');
expect(service.statusOf(xtream)).toEqual({
reachability: 'bad',
reason: 'disabled',
});
});
it('drops a result that lands after the list closed', async () => {
@@ -7,6 +7,7 @@ import {
import {
isConclusiveReachability,
PlaylistMeta,
SourceHealthReason,
sourceHealthType,
sourceReachabilityFromHealth,
sourceReachabilityFromPortalStatus,
@@ -37,6 +38,18 @@ function isXtreamAccount(playlist: PlaylistMeta): playlist is XtreamAccount {
*
* Local files have no status (`null`).
*/
/** What the browser build's portal status says about its cause. */
const PORTAL_STATUS_REASON: Record<
PortalStatus,
SourceHealthReason | undefined
> = {
active: 'available',
inactive: 'disabled',
expired: 'expired',
unavailable: 'network',
checking: undefined,
};
@Injectable({ providedIn: 'root' })
export class SourceStatusService {
private readonly runtime = inject(RuntimeCapabilitiesService);
@@ -64,11 +77,12 @@ export class SourceStatusService {
}
if (!this.runtime.supportsSourceHealth) {
const status = this.portalStatuses().get(playlist._id);
return {
reachability: status
? sourceReachabilityFromPortalStatus(status)
: 'unknown',
};
return status
? {
reachability: sourceReachabilityFromPortalStatus(status),
reason: PORTAL_STATUS_REASON[status],
}
: { reachability: 'unknown' };
}
const live = this.health.get(playlist);
const reachability = sourceReachabilityFromHealth(live);
@@ -78,6 +92,7 @@ export class SourceStatusService {
reachability,
failingSince: kept?.failingSince,
checkedAt: live?.checkedAt,
reason: live?.reason,
};
}
if (kept) {
@@ -86,6 +101,7 @@ export class SourceStatusService {
failingSince: kept.failingSince,
checkedAt: kept.checkedAt,
pending: reachability === 'checking',
reason: kept.reason,
};
}
return { reachability };
@@ -16,6 +16,27 @@ export type SourceHealthReason =
| 'invalid'
| 'unknown'
| 'cancelled';
const SOURCE_HEALTH_REASONS: ReadonlySet<string> = new Set<SourceHealthReason>([
'available',
'disabled',
'expired',
'auth',
'http',
'timeout',
'network',
'paused',
'invalid',
'unknown',
'cancelled',
]);
/** Whether stored data names a reason this build can describe. */
export function isSourceHealthReason(
value: unknown
): value is SourceHealthReason {
return typeof value === 'string' && SOURCE_HEALTH_REASONS.has(value);
}
export interface SourceHealthResult {
state: SourceHealthState;
reason: SourceHealthReason;
@@ -72,6 +72,14 @@ describe('nextSourceHealthHistory', () => {
});
});
it('keeps the cause of a failure, never of a success', () => {
const failed = nextSourceHealthHistory(undefined, 'bad', 100, 'auth');
expect(failed?.reason).toBe('auth');
expect(nextSourceHealthHistory(failed, 'ok', 200, 'available')).toEqual(
{ state: 'ok', checkedAt: 200, lastSuccessAt: 200 }
);
});
it('ends the run when the source answers again', () => {
expect(
nextSourceHealthHistory(
@@ -1,4 +1,4 @@
import type { SourceHealthSnapshot } from './source-health';
import type { SourceHealthReason, SourceHealthSnapshot } from './source-health';
/**
* What a source list shows about one network source, folded from the six
@@ -26,6 +26,11 @@ export interface SourceStatus {
checkedAt?: number;
/** A check is running; `reachability` is the last known result. */
pending?: boolean;
/**
* Why the check concluded what it did (`auth`, `disabled`, `timeout`…),
* when known; a list otherwise describes the reachability alone.
*/
reason?: SourceHealthReason;
}
/**
@@ -99,6 +104,8 @@ export interface SourceHealthHistoryEntry {
/** When the current run of failed checks began; absent while `ok`. */
failingSince?: number;
lastSuccessAt?: number;
/** The cause of the last failed check; absent while `ok`. */
reason?: SourceHealthReason;
}
/**
@@ -110,7 +117,8 @@ export interface SourceHealthHistoryEntry {
export function nextSourceHealthHistory(
previous: SourceHealthHistoryEntry | undefined,
reachability: SourceReachability,
checkedAt: number
checkedAt: number,
reason?: SourceHealthReason
): SourceHealthHistoryEntry | undefined {
if (!isConclusiveReachability(reachability)) {
return previous;
@@ -128,5 +136,6 @@ export function nextSourceHealthHistory(
...(previous?.lastSuccessAt !== undefined
? { lastSuccessAt: previous.lastSuccessAt }
: {}),
...(reason ? { reason } : {}),
};
}