feat(playlist): clean up selected inactive desktop sources (#1593) (#1596)

This commit is contained in:
4gray authored and GitHub committed 2026-09-13 10:39:14 +02:00
1 parent cbc918b096
commit bd848aaad6
53 files changed
+1980 -87

No files matched your search

@@ -34,6 +34,14 @@ export class WorkspaceShellXtreamImportService {
{ initialValue: null }
);
isSourceBusy(playlistId: string): boolean {
return (
this.refreshPreparation()?.playlistId === playlistId ||
(this.xtreamStore.isImporting() &&
this.xtreamStore.playlistId() === playlistId)
);
}
private get isElectron(): boolean {
return this.runtime.isElectron;
}
@@ -2,12 +2,24 @@
class="sources-page"
[class.sources-page--empty]="visibleSourcesCount() === 0"
>
@if (visibleSourcesCount() > 0) {
@if (visibleSourcesCount() > 0 || canCleanSources()) {
<header class="sources-header">
<div class="sources-header__meta">
<h2 class="sources-title">{{ title() }}</h2>
<span class="sources-subtitle">{{ subtitle() }}</span>
</div>
@if (canCleanSources()) {
<button
mat-stroked-button
type="button"
class="cleanup-trigger"
[attr.aria-label]="'SOURCE_CLEANUP.TITLE' | translate"
(click)="openCleanup()"
>
<mat-icon>cleaning_services</mat-icon>
<span>{{ 'SOURCE_CLEANUP.TITLE' | translate }}</span>
</button>
}
<button
type="button"
mat-stroked-button
@@ -96,3 +96,13 @@
padding: 12px 16px 16px;
}
}
.cleanup-trigger {
flex-shrink: 0;
app-region: no-drag;
}
@media (max-width: 1100px) {
.cleanup-trigger span {
display: none;
}
}
@@ -1,4 +1,8 @@
import { Component, input, output } from '@angular/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { SourceActivityService } from '@iptvnator/services';
import { MatDialog } from '@angular/material/dialog';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule } from '@angular/material/button';
@@ -33,6 +37,18 @@ describe('WorkspaceSourcesComponent', () => {
await TestBed.configureTestingModule({
imports: [WorkspaceSourcesComponent, NoopAnimationsModule],
providers: [
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: signal(null) },
},
{
provide: MatDialog,
useValue: { getDialogById: jest.fn(), open: jest.fn() },
},
{
provide: PlaylistRefreshActionService,
useValue: { isSourceBusy: jest.fn((id) => id === 'busy') },
},
provideMockStore({
selectors: [
{
@@ -125,14 +141,60 @@ describe('WorkspaceSourcesComponent', () => {
fixture = TestBed.createComponent(WorkspaceSourcesComponent);
});
it.each([
['launching', true, true],
['opened', true, true],
['playing', true, true],
['closed', false, false],
['error', false, false],
['error', true, true],
])(
'protects only a live external session (%s, closable=%s)',
(status, canClose, expected) => {
const playback = TestBed.inject(
PORTAL_EXTERNAL_PLAYBACK
) as unknown as { activeSession: { set(value: unknown): void } };
playback.activeSession.set({
status,
canClose,
contentInfo: { playlistId: 'external' },
});
fixture.componentInstance.openCleanup();
const context = (TestBed.inject(MatDialog).open as jest.Mock).mock
.calls[0][1].data;
expect(context.protected('external')).toBe(expected);
}
);
it('protects startup auto-refresh sources across the whole library', () => {
const release = TestBed.inject(SourceActivityService).begin([
'startup',
]);
fixture.componentInstance.openCleanup();
const context = (TestBed.inject(MatDialog).open as jest.Mock).mock
.calls[0][1].data;
expect(context.protected('startup')).toBe(true);
release();
expect(context.protected('startup')).toBe(false);
});
it('protects a source refreshing through the persistent header', () => {
fixture.componentInstance.openCleanup();
const open = TestBed.inject(MatDialog).open as jest.Mock;
const context = open.mock.calls[0][1].data;
expect(context.protected('busy')).toBe(true);
expect(context.protected('other')).toBe(false);
});
it('renders the shared panel header structure without paragraph subtitle margins', async () => {
fixture.detectChanges();
await fixture.whenStable();
const header: HTMLElement =
fixture.nativeElement.querySelector('.sources-header');
const meta: HTMLElement =
fixture.nativeElement.querySelector('.sources-header__meta');
const meta: HTMLElement = fixture.nativeElement.querySelector(
'.sources-header__meta'
);
const title: HTMLElement =
fixture.nativeElement.querySelector('.sources-title');
const subtitle: HTMLElement =
@@ -1,3 +1,23 @@
import { WorkspaceShellXtreamImportService } from '../workspace-shell/services/workspace-shell-xtream-import.service';
import { Injector, viewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
SourceCleanupDialogComponent,
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/ui';
import {
RuntimeCapabilitiesService,
SourceActivityService,
} from '@iptvnator/services';
import {
PORTAL_EXTERNAL_PLAYBACK,
isLiveExternalPlayerSession,
} from '@iptvnator/portal/shared/util';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
sourceHealthType,
PlaylistUpdateState,
} from '@iptvnator/shared/interfaces';
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@@ -7,7 +27,10 @@ import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { RecentPlaylistsComponent } from '@iptvnator/playlist/shared/ui';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { selectActiveTypeFilters, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import {
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from '@iptvnator/m3u-state';
import { map, startWith } from 'rxjs';
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
import {
@@ -35,6 +58,55 @@ interface SortOption {
styleUrl: './workspace-sources.component.scss',
})
export class WorkspaceSourcesComponent {
private readonly injector = inject(Injector);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly playback = inject(PORTAL_EXTERNAL_PLAYBACK, {
optional: true,
});
private readonly imports = inject(WorkspaceShellXtreamImportService, {
optional: true,
});
private readonly sourceList = viewChild(RecentPlaylistsComponent);
readonly canCleanSources = computed(
() =>
this.runtime.supportsSourceHealth &&
this.playlists().some((p) => sourceHealthType(p))
);
openCleanup(): void {
const dialogs = this.injector.get(MatDialog);
const refresh = this.injector.get(PlaylistRefreshActionService);
const activity = this.injector.get(SourceActivityService);
if (dialogs.getDialogById('source-cleanup')) return;
dialogs.open(SourceCleanupDialogComponent, {
id: 'source-cleanup',
width: '680px',
maxWidth: '95vw',
data: {
playlists: this.playlists(),
current: (id: string) =>
this.playlists().find((p) => p._id === id),
protected: (id: string) =>
(isLiveExternalPlayerSession(
this.playback?.activeSession()
) &&
this.playback?.activeSession()?.contentInfo
?.playlistId === id) ||
activity.isBusy(id) ||
refresh.isSourceBusy(id) ||
!!this.imports?.isSourceBusy(id) ||
!!this.sourceList()?.isDeletePending(id) ||
!!this.sourceList()?.isRefreshPending(id) ||
this.playlists().find((p) => p._id === id)?.updateState ===
PlaylistUpdateState.IN_PROGRESS,
removed: (id: string) =>
this.store.dispatch(
PlaylistActions.playlistRemovalCommitted({
playlistId: id,
})
),
},
});
}
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);