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

+2
View File
@@ -6,3 +6,5 @@ export * from './lib/recent-playlists/recent-playlists.component';
export * from './lib/recent-playlists/empty-state/empty-state.component';
export * from './lib/playlist-refresh-action.service';
export * from './lib/xtream-refresh-flow.service';
export * from './lib/source-health/source-cleanup-dialog.component';
@@ -14,6 +14,7 @@ import {
PlaylistRefreshService,
RuntimeCapabilitiesService,
SettingsStore,
SourceActivityService,
} from '@iptvnator/services';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import {
@@ -252,6 +253,20 @@ describe('PlaylistRefreshActionService', () => {
localStorage.clear();
});
it('rejects refresh while a shared deletion owns the source', () => {
const playlist = createPlaylistMeta({
serverUrl: undefined,
url: 'https://example.test/list.m3u',
});
const release = TestBed.inject(SourceActivityService).begin([
playlist._id,
]);
expect(service.canRefresh(playlist)).toBe(false);
service.refresh(playlist);
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
release();
expect(service.canRefresh(playlist)).toBe(true);
});
it('treats file-backed M3U playlists as refreshable when the refresh bridge is available', () => {
runtime.supportsPlaylistRefresh = true;
@@ -320,6 +335,29 @@ describe('PlaylistRefreshActionService', () => {
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
});
it('tracks a header M3U refresh by source until the operation settles', async () => {
const playlist = createPlaylistMeta({
_id: 'busy',
serverUrl: undefined,
username: undefined,
password: undefined,
url: 'https://source.test/list',
});
let resolve!: (value: Playlist) => void;
playlistRefreshService.refreshPlaylist.mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
service.refresh(playlist);
expect(service.isSourceBusy('busy')).toBe(true);
expect(service.isSourceBusy('other')).toBe(false);
resolve({ _id: 'busy', playlist: { items: [] } } as Playlist);
await Promise.resolve();
await Promise.resolve();
expect(service.isSourceBusy('busy')).toBe(false);
});
it('passes trusted TLS hosts to URL-backed M3U refreshes', async () => {
const playlist = createPlaylistMeta({
_id: 'playlist-url',
@@ -11,6 +11,7 @@ import {
PlaylistRefreshService,
RuntimeCapabilitiesService,
SettingsStore,
SourceActivityService,
} from '@iptvnator/services';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import {
@@ -37,6 +38,7 @@ export interface XtreamRefreshPreparationState {
@Injectable({ providedIn: 'root' })
export class PlaylistRefreshActionService {
private readonly store = inject(Store);
private readonly activity = inject(SourceActivityService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogService = inject(DialogService);
@@ -51,11 +53,18 @@ export class PlaylistRefreshActionService {
private readonly refreshPreparationState =
signal<XtreamRefreshPreparationState | null>(null);
private readonly m3uRefreshId = signal<string | null>(null);
isSourceBusy(id: string): boolean {
return (
this.m3uRefreshId() === id ||
this.refreshPreparation()?.playlistId === id
);
}
readonly isRefreshing = signal(false);
readonly refreshPreparation = this.refreshPreparationState.asReadonly();
canRefresh(playlist: PlaylistMeta | null): boolean {
if (!playlist) {
if (!playlist || this.activity.isBusy(playlist._id)) {
return false;
}
@@ -73,7 +82,7 @@ export class PlaylistRefreshActionService {
}
refresh(playlist: PlaylistMeta): void {
if (this.isRefreshing()) {
if (this.isRefreshing() || this.activity.isBusy(playlist._id)) {
return;
}
@@ -137,6 +146,7 @@ export class PlaylistRefreshActionService {
this.playlistContext.routeProvider() === 'playlists' &&
this.playlistContext.resolvedPlaylistId() === item._id;
this.m3uRefreshId.set(item._id);
this.isRefreshing.set(true);
if (isActiveM3uRoute) {
this.store.dispatch(
@@ -202,6 +212,7 @@ export class PlaylistRefreshActionService {
ChannelActions.setChannelsLoading({ loading: false })
);
}
this.m3uRefreshId.set(null);
this.isRefreshing.set(false);
}
}
@@ -415,7 +415,7 @@ describe('PlaylistSwitcherComponent', () => {
xtreamPlaylist
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.removePlaylist({
PlaylistActions.playlistRemovalCommitted({
playlistId: xtreamPlaylist._id,
})
);
@@ -345,7 +345,9 @@ export class PlaylistSwitcherComponent {
}
this.store.dispatch(
PlaylistActions.removePlaylist({ playlistId: playlist._id })
PlaylistActions.playlistRemovalCommitted({
playlistId: playlist._id,
})
);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
@@ -307,7 +307,7 @@ describe('RecentPlaylistsComponent busy state', () => {
expect(component.isDeletePending(item._id)).toBe(false);
expect(component.getBusyProgress(item._id)).toBeNull();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.removePlaylist({ playlistId: item._id })
PlaylistActions.playlistRemovalCommitted({ playlistId: item._id })
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS',
@@ -356,7 +356,7 @@ describe('RecentPlaylistsComponent busy state', () => {
});
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.removePlaylist({ playlistId: item._id })
PlaylistActions.playlistRemovalCommitted({ playlistId: item._id })
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS',
@@ -279,7 +279,7 @@ export class RecentPlaylistsComponent {
RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW,
() =>
this.store.dispatch(
PlaylistActions.removePlaylist({
PlaylistActions.playlistRemovalCommitted({
playlistId: item._id,
})
),
@@ -287,7 +287,7 @@ export class RecentPlaylistsComponent {
);
} else {
this.store.dispatch(
PlaylistActions.removePlaylist({
PlaylistActions.playlistRemovalCommitted({
playlistId: item._id,
})
);
@@ -0,0 +1,293 @@
import { Component, DestroyRef, computed, effect, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { TranslatePipe } from '@ngx-translate/core';
import {
SourceCleanupContext,
SourceCleanupService,
} from '@iptvnator/portal/shared/data-access';
import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces';
export interface SourceCleanupDialogData extends SourceCleanupContext {
playlists: readonly PlaylistMeta[];
}
@Component({
selector: 'app-source-cleanup-dialog',
imports: [
DatePipe,
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
MatProgressBarModule,
TranslatePipe,
],
providers: [SourceCleanupService],
template: ` <h2 mat-dialog-title>
{{ 'SOURCE_CLEANUP.TITLE' | translate }}
</h2>
<mat-dialog-content>
<p>{{ 'SOURCE_CLEANUP.SCOPE' | translate }}</p>
<p>{{ 'SOURCE_CLEANUP.CONSEQUENCES' | translate }}</p>
@if (model.phase() === 'checking' || model.phase() === 'deleting') {
<mat-progress-bar mode="determinate" [value]="progress()" />
}
<p role="status" aria-live="polite">
{{ 'SOURCE_CLEANUP.SUMMARY' | translate: summary() }}
</p>
@if (locked()) {
<p role="status">
{{
'SOURCE_CLEANUP.DELETE_PROGRESS'
| translate
: {
done: model.processed(),
total: model.totalSelected(),
}
}}
</p>
}
@if (!locked()) {
<div class="selection-actions">
<button
mat-button
[disabled]="checking()"
(click)="model.selectAll(true)"
>
{{ 'SOURCE_CLEANUP.SELECT_ALL' | translate }}
</button>
<button
mat-button
[disabled]="checking()"
(click)="model.selectAll(false)"
>
{{ 'SOURCE_CLEANUP.SELECT_NONE' | translate }}
</button>
</div>
}
@for (group of groups(); track group.key) {
@if (group.entries.length) {
<h3>
{{ 'SOURCE_CLEANUP.GROUP.' + group.key | translate }}
</h3>
@for (entry of group.entries; track entry.playlist._id) {
<div
class="source-row"
[attr.data-source-id]="entry.playlist._id"
[attr.data-status]="entry.status"
>
<mat-checkbox
[checked]="entry.selected"
[disabled]="
locked() ||
!model.candidate(entry) ||
data.protected(entry.playlist._id)
"
(change)="
model.select(
entry.playlist._id,
$event.checked
)
"
>
<span class="source-title">{{
entry.playlist.title ||
entry.playlist.filename ||
('SOURCE_CLEANUP.UNTITLED' | translate)
}}</span>
</mat-checkbox>
<div class="source-meta">
<span>{{ type(entry.playlist) }}</span>
@if (entry.status === 'ready') {
<span>{{
'SOURCE_HEALTH.REASON.' +
entry.health?.reason | translate
}}</span>
} @else {
<span>{{
'SOURCE_CLEANUP.STATUS.' + entry.status
| translate
}}</span>
}
@if (entry.health?.checkedAt) {
<time>{{
entry.health?.checkedAt
| date: 'shortTime'
}}</time>
}
@if (entry.warning) {
<span>{{
'SOURCE_CLEANUP.CLEANUP_WARNING'
| translate
}}</span>
}
</div>
@if (
!locked() &&
entry.status !== 'deleted' &&
entry.status !== 'skipped'
) {
<button
mat-button
(click)="model.recheck(entry)"
>
{{ 'SOURCE_HEALTH.RECHECK' | translate }}
</button>
}
</div>
}
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (locked()) {
<button
mat-button
[disabled]="model.stopRequested()"
(click)="model.stop()"
>
{{ 'SOURCE_CLEANUP.STOP' | translate }}
</button>
} @else {
<button mat-button mat-dialog-close>
{{ 'SOURCE_CLEANUP.CLOSE' | translate }}
</button>
<button
mat-flat-button
[disabled]="!selectedCount() || checking()"
(click)="model.removeSelected()"
>
{{
'SOURCE_CLEANUP.DELETE'
| translate: { count: selectedCount() }
}}
</button>
}
</mat-dialog-actions>`,
styles: [
`
:host {
display: block;
}
mat-dialog-content {
min-width: 0;
}
.selection-actions {
display: flex;
flex-wrap: wrap;
}
h3 {
font-size: 14px;
margin: 18px 0 6px;
}
.source-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
padding: 8px 0;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.source-title {
overflow-wrap: anywhere;
}
.source-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: 40px;
font-size: 12px;
color: var(--mat-sys-on-surface-variant);
}
.source-row > button {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
}
.source-meta {
grid-column: 1;
}
`,
],
})
export class SourceCleanupDialogComponent {
readonly data = inject<SourceCleanupDialogData>(MAT_DIALOG_DATA);
readonly model = inject(SourceCleanupService);
private readonly ref = inject(MatDialogRef<SourceCleanupDialogComponent>);
readonly type = sourceHealthType;
readonly locked = computed(() => this.model.phase() === 'deleting');
readonly checking = computed(() =>
this.model.entries().some((e) => e.status === 'checking')
);
readonly selectedCount = computed(
() =>
this.model
.entries()
.filter(
(e) =>
e.selected &&
this.model.candidate(e) &&
!this.data.protected(e.playlist._id)
).length
);
readonly summary = computed(() => {
const entries = this.model.entries();
return {
total: entries.length,
checked: entries.filter((e) => e.status !== 'checking').length,
available: entries.filter((e) => e.health?.state === 'active')
.length,
deleted: entries.filter((e) => e.status === 'deleted').length,
skipped: entries.filter((e) => e.status === 'skipped').length,
failed: entries.filter((e) => e.status === 'failed').length,
};
});
readonly progress = computed(() =>
this.locked()
? (100 * this.model.processed()) /
Math.max(1, this.model.totalSelected())
: (100 * this.summary().checked) / Math.max(1, this.summary().total)
);
readonly groups = computed(() => {
const entries = this.model.entries();
return [
{
key: 'confirmed',
entries: entries.filter(
(e) => e.status === 'ready' && e.health?.confirmedInactive
),
},
{
key: 'uncertain',
entries: entries.filter(
(e) =>
e.status === 'checking' ||
(e.status === 'ready' &&
!e.health?.confirmedInactive &&
e.health?.state !== 'active')
),
},
{
key: 'skipped',
entries: entries.filter((e) => e.status === 'skipped'),
},
{
key: 'results',
entries: entries.filter((e) =>
['deleting', 'deleted', 'failed'].includes(e.status)
),
},
];
});
constructor() {
effect(() => {
this.ref.disableClose = this.locked();
});
inject(DestroyRef).onDestroy(() => this.model.dispose());
void this.model.start(this.data.playlists, this.data);
}
}