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

+1
View File
@@ -16,6 +16,7 @@ export const PlaylistActions = createActionGroup({
'Add Playlist': props<{ playlist: Playlist }>(),
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
'Playlist Removal Committed': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{
playlist: PlaylistMetaUpdate;
/**
+13 -2
View File
@@ -325,6 +325,17 @@ export class PlaylistEffects {
}
}
playlistRemovalCommitted$ = createEffect(
() =>
this.actions$.pipe(
ofType(PlaylistActions.playlistRemovalCommitted),
tap(({ playlistId }) =>
this.playlistScopedEpgFetchKeys.delete(playlistId)
)
),
{ dispatch: false }
);
removePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
@@ -471,8 +482,8 @@ export class PlaylistEffects {
this.playlistsService
.updateManyPlaylists(action.playlists)
.pipe(
tap(() => {
action.playlists.forEach((playlist) =>
tap((persisted) => {
persisted.forEach((playlist) =>
this.fetchPlaylistScopedEpg(playlist)
);
})
@@ -28,22 +28,26 @@ export const playlistReducers = [
}),
};
}),
on(PlaylistActions.removePlaylist, (state, action): PlaylistState => {
const playlists = playlistsAdapter.removeOne(
action.playlistId,
state.playlists
);
return {
...state,
playlists: {
...playlists,
selectedId:
state.playlists.selectedId === action.playlistId
? ''
: playlists.selectedId,
},
};
}),
on(
PlaylistActions.removePlaylist,
PlaylistActions.playlistRemovalCommitted,
(state, action): PlaylistState => {
const playlists = playlistsAdapter.removeOne(
action.playlistId,
state.playlists
);
return {
...state,
playlists: {
...playlists,
selectedId:
state.playlists.selectedId === action.playlistId
? ''
: playlists.selectedId,
},
};
}
),
on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => {
const isActivePlaylist =
state.playlists.selectedId === action.playlistId;
+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);
}
}
@@ -4,3 +4,5 @@ export * from './lib/multi-source';
export * from './lib/live-channel-playback-queue';
export * from './lib/source-health.service';
export * from './lib/source-cleanup.service';
@@ -0,0 +1,180 @@
import { TestBed } from '@angular/core/testing';
import { PlaylistDeleteActionService } from '@iptvnator/services';
import {
PlaylistMeta,
SourceHealthSnapshot,
} from '@iptvnator/shared/interfaces';
import { SourceCleanupService } from './source-cleanup.service';
import { SourceHealthService } from './source-health.service';
const p = (id: string): PlaylistMeta =>
({ _id: id, title: id, url: `https://${id}.test/list` }) as PlaylistMeta;
const result = (
state: 'expired' | 'active' | 'unavailable'
): SourceHealthSnapshot => ({
state,
reason:
state === 'unavailable'
? 'timeout'
: state === 'expired'
? 'expired'
: 'available',
confirmedInactive: state === 'expired',
checkedAt: Date.now(),
});
describe('source cleanup', () => {
let service: SourceCleanupService;
let check: jest.Mock;
let remove: jest.Mock;
let removed: jest.Mock;
let protectedIds: Set<string>;
let current: Map<string, PlaylistMeta>;
let health: {
check: jest.Mock;
recheck: jest.Mock;
get: jest.Mock;
invalidate: jest.Mock;
};
beforeEach(() => {
check = jest.fn().mockResolvedValue(result('expired'));
remove = jest.fn().mockResolvedValue({ success: true });
removed = jest.fn();
protectedIds = new Set();
current = new Map();
health = {
check,
recheck: check,
get: jest.fn(),
invalidate: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
SourceCleanupService,
{ provide: SourceHealthService, useValue: health },
{
provide: PlaylistDeleteActionService,
useValue: { deletePlaylistWithResult: remove },
},
],
});
service = TestBed.inject(SourceCleanupService);
});
async function start(ids = ['a', 'b']) {
ids.forEach((id) => current.set(id, p(id)));
await service.start([...current.values()], {
current: (id) => current.get(id),
protected: (id) => protectedIds.has(id),
removed,
});
}
it('preselects confirmed accounts and leaves timeouts unchecked', async () => {
check
.mockResolvedValueOnce(result('expired'))
.mockResolvedValueOnce(result('unavailable'));
await start();
expect(service.entries().map((e) => e.selected)).toEqual([true, false]);
service.select('b', true);
expect(service.entries()[1].selected).toBe(true);
});
it('clears automatic selection when newer evidence is uncertain', async () => {
await start(['a']);
health.get.mockReturnValue(result('unavailable'));
await service.removeSelected();
expect(remove).not.toHaveBeenCalled();
expect(service.entries()[0]).toMatchObject({
selected: false,
health: { confirmedInactive: false },
});
service.select('a', true);
await service.removeSelected();
expect(remove).toHaveBeenCalledTimes(1);
});
it('removes all except the source unchecked by the user', async () => {
await start();
service.select('b', false);
await service.removeSelected();
expect(remove).toHaveBeenCalledTimes(1);
expect(remove).toHaveBeenCalledWith(p('a'));
expect(removed).toHaveBeenCalledWith('a');
expect(service.entries()[1].status).toBe('ready');
});
it('preserves unchecked choices on recheck and excludes recovered sources', async () => {
await start();
service.select('a', false);
await service.recheck(service.entries()[0]);
expect(service.entries()[0].selected).toBe(false);
check.mockResolvedValueOnce(result('active'));
await service.recheck(service.entries()[1]);
expect(service.entries()[1].selected).toBe(false);
});
it('skips playing sources both at scan time and immediately before deletion', async () => {
protectedIds.add('a');
await start();
protectedIds.add('b');
await service.removeSelected();
expect(remove).not.toHaveBeenCalled();
expect(service.entries().every((e) => e.status === 'skipped')).toBe(
true
);
});
it('skips changed credentials and sources removed since the dialog opened', async () => {
await start();
current.set('a', { ...p('a'), userAgent: 'changed' });
current.delete('b');
await service.removeSelected();
expect(remove).not.toHaveBeenCalled();
});
it.each(['stop', 'dispose'] as const)(
'finishes only the current deletion on %s and prevents double submission',
async (action) => {
await start();
let finish!: (value: { success: boolean }) => void;
remove.mockImplementationOnce(
() =>
new Promise((r) => {
finish = r;
})
);
const deleting = service.removeSelected();
await service.removeSelected();
service[action]();
finish({ success: true });
await deleting;
expect(remove).toHaveBeenCalledTimes(1);
expect(service.entries()[1].status).toBe('ready');
}
);
it('isolates failures and reports post-delete warnings as deleted', async () => {
await start();
remove
.mockRejectedValueOnce(new Error('DB failure'))
.mockResolvedValueOnce({ success: true, cleanupWarnings: 1 });
await service.removeSelected();
expect(service.entries().map((e) => e.status)).toEqual([
'failed',
'deleted',
]);
expect(service.entries()[1].warning).toBe(true);
expect(removed).toHaveBeenCalledTimes(1);
});
it('requires confirmation again after refreshing stale evidence', async () => {
check.mockResolvedValue({
...result('expired'),
checkedAt: Date.now() - 310000,
});
await start();
check.mockResolvedValue(result('expired'));
await service.removeSelected();
expect(remove).not.toHaveBeenCalled();
expect(service.phase()).toBe('ready');
await service.removeSelected();
expect(remove).toHaveBeenCalledTimes(2);
});
it('does not preselect failed scans and cancels dialog-owned work on close', async () => {
check.mockRejectedValue(new Error('network failure'));
await start();
expect(service.entries().every((e) => !e.selected)).toBe(true);
service.dispose();
expect(check.mock.calls[0][1].signal.aborted).toBe(true);
});
});
@@ -0,0 +1,215 @@
import { Injectable, inject, signal } from '@angular/core';
import { PlaylistDeleteActionService } from '@iptvnator/services';
import {
PlaylistMeta,
SourceHealthSnapshot,
sourceHealthKey,
sourceHealthType,
sourceHealthError,
} from '@iptvnator/shared/interfaces';
import { SourceHealthService } from './source-health.service';
export interface SourceCleanupEntry {
playlist: PlaylistMeta;
health?: SourceHealthSnapshot;
selected: boolean;
status:
'checking' | 'ready' | 'skipped' | 'deleting' | 'deleted' | 'failed';
warning?: boolean;
}
export interface SourceCleanupContext {
current: (id: string) => PlaylistMeta | undefined;
protected: (id: string) => boolean;
removed: (id: string) => void;
}
/** One instance per dialog; closing it retires only its own status requests. */
@Injectable()
export class SourceCleanupService {
private readonly health = inject(SourceHealthService);
private readonly deletion = inject(PlaylistDeleteActionService);
readonly entries = signal<SourceCleanupEntry[]>([]);
readonly phase = signal<'checking' | 'ready' | 'deleting' | 'done'>(
'checking'
);
readonly stopRequested = signal(false);
readonly totalSelected = signal(0);
readonly processed = signal(0);
private readonly controller = new AbortController();
private context!: SourceCleanupContext;
private readonly touched = new Set<string>();
private round = 0;
async start(
playlists: readonly PlaylistMeta[],
context: SourceCleanupContext
): Promise<void> {
this.context = context;
const entries = playlists
.filter((p) => sourceHealthType(p))
.map((p) => ({
playlist: { ...p },
selected: false,
status: 'checking' as const,
}));
this.entries.set(entries);
await Promise.all(entries.map((entry) => this.check(entry)));
if (!this.controller.signal.aborted) this.phase.set('ready');
}
candidate(entry: SourceCleanupEntry): boolean {
return (
['ready', 'failed'].includes(entry.status) &&
!!entry.health &&
entry.health.state !== 'active' &&
entry.health.reason !== 'cancelled'
);
}
select(id: string, selected: boolean): void {
this.touched.add(id);
this.update(id, (entry) => ({
...entry,
selected: selected && this.candidate(entry),
}));
}
selectAll(selected: boolean): void {
this.entries().forEach((e) => this.select(e.playlist._id, selected));
}
async recheck(entry: SourceCleanupEntry): Promise<void> {
if (this.phase() === 'deleting') return;
await this.check(entry, true);
}
private async check(
entry: SourceCleanupEntry,
retry = false
): Promise<void> {
const id = entry.playlist._id;
if (this.context.protected(id) || !this.context.current(id)) {
this.update(id, (e) => ({
...e,
status: 'skipped',
selected: false,
}));
return;
}
this.update(id, (e) => ({ ...e, status: 'checking' }));
let result: SourceHealthSnapshot;
try {
result = retry
? await this.health.recheck(
entry.playlist,
this.controller.signal
)
: await this.health.check(entry.playlist, {
fresh: true,
signal: this.controller.signal,
});
} catch (error) {
result = { ...sourceHealthError(error), checkedAt: Date.now() };
}
if (this.controller.signal.aborted) return;
this.update(id, (e) => ({
...e,
health: result,
status: 'ready',
selected:
result.state !== 'active' &&
result.reason !== 'cancelled' &&
(this.touched.has(id) ? e.selected : result.confirmedInactive),
}));
}
async removeSelected(): Promise<void> {
if (
this.phase() === 'deleting' ||
this.entries().some((e) => e.status === 'checking')
)
return;
const selected = this.entries().filter(
(e) => e.selected && this.candidate(e)
);
if (!selected.length) return;
const stale = selected.filter(
(e) => !e.health || Date.now() - e.health.checkedAt > 300000
);
if (stale.length) {
this.phase.set('checking');
await Promise.all(stale.map((e) => this.check(e)));
this.phase.set('ready');
return;
}
const round = ++this.round;
this.totalSelected.set(selected.length);
this.processed.set(0);
this.phase.set('deleting');
this.stopRequested.set(false);
for (const entry of selected) {
if (this.stopRequested() || round !== this.round) break;
if (entry.health && Date.now() - entry.health.checkedAt > 300000) {
await this.check(entry);
this.phase.set('ready');
return;
}
const id = entry.playlist._id;
const current = this.context.current(id);
const latest = current ? this.health.get(current) : undefined;
if (
!current ||
this.context.protected(id) ||
sourceHealthKey(current) !== sourceHealthKey(entry.playlist) ||
latest?.state === 'active'
) {
this.update(id, (e) => ({
...e,
status: 'skipped',
selected: false,
}));
this.processed.update((n) => n + 1);
continue;
}
if (latest && !latest.confirmedInactive && !this.touched.has(id)) {
this.update(id, (e) => ({
...e,
health: latest,
status: 'ready',
selected: false,
}));
this.processed.update((n) => n + 1);
continue;
}
this.update(id, (e) => ({ ...e, status: 'deleting' }));
this.health.invalidate(id);
try {
const result =
await this.deletion.deletePlaylistWithResult(current);
if (!result.success) throw new Error('Deletion failed');
this.context.removed(id);
this.update(id, (e) => ({
...e,
status: 'deleted',
selected: false,
warning: !!result.cleanupWarnings,
}));
} catch {
this.update(id, (e) => ({
...e,
status: 'failed',
selected: false,
}));
}
this.processed.update((n) => n + 1);
}
this.phase.set('done');
}
stop(): void {
this.stopRequested.set(true);
}
dispose(): void {
this.stop();
this.controller.abort();
}
private update(
id: string,
update: (entry: SourceCleanupEntry) => SourceCleanupEntry
): void {
this.entries.update((entries) =>
entries.map((e) => (e.playlist._id === id ? update(e) : e))
);
}
}
+2
View File
@@ -28,3 +28,5 @@ export * from './lib/xtream-connection-test.service';
export * from './lib/xtream-connection-test-state';
export * from './lib/source-health-evidence.service';
export * from './lib/source-activity.service';
@@ -1,10 +1,11 @@
import { SourceActivityService } from './source-activity.service';
import {
EnvironmentInjector,
Injector,
createEnvironmentInjector,
runInInjectionContext,
} from '@angular/core';
import { of } from 'rxjs';
import { of, throwError, Subject } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DatabaseService } from './database-electron.service';
import { PlaylistDeleteActionService } from './playlist-delete-action.service';
@@ -48,6 +49,10 @@ describe('PlaylistDeleteActionService', () => {
injector = createEnvironmentInjector(
[
{
provide: SourceActivityService,
useValue: new SourceActivityService(),
},
{ provide: DatabaseService, useValue: databaseService },
{ provide: PlaylistsService, useValue: playlistsService },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
@@ -67,6 +72,26 @@ describe('PlaylistDeleteActionService', () => {
);
}
it('keeps shared deletion activity reserved until persistence and cleanup finish', async () => {
const completion = new Subject<{ success: boolean }>();
playlistsService.deletePlaylist.mockReturnValue(completion);
const activity = injector.get(SourceActivityService);
const pending = createService().deletePlaylist(playlist);
expect(activity.isBusy(playlist._id)).toBe(true);
completion.next({ success: true });
await pending;
expect(activity.isBusy(playlist._id)).toBe(false);
});
it('preserves the boolean failure contract for single-source callers', async () => {
playlistsService.deletePlaylist.mockReturnValue(
throwError(() => new Error('worker failed'))
);
await expect(createService().deletePlaylist(playlist)).resolves.toBe(
false
);
});
it('deletes browser playlists through PlaylistsService', async () => {
const service = createService();
@@ -78,7 +103,7 @@ describe('PlaylistDeleteActionService', () => {
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
});
it('deletes SQLite-backed Xtream playlists through DatabaseService with progress options', async () => {
it('routes SQLite deletion through the serialized owner with progress options', async () => {
runtime.supportsXtreamSqliteDataSource = true;
const onEvent = jest.fn();
const service = createService();
@@ -90,14 +115,14 @@ describe('PlaylistDeleteActionService', () => {
expect(databaseService.createOperationId).toHaveBeenCalledWith(
'playlist-delete'
);
expect(databaseService.deletePlaylist).toHaveBeenCalledWith(
expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(
'playlist-1',
{
operationId: 'playlist-delete-1',
onEvent,
}
);
expect(playlistsService.deletePlaylist).not.toHaveBeenCalled();
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
});
it('deletes SQLite-backed non-Xtream playlists without progress options', async () => {
@@ -112,10 +137,10 @@ describe('PlaylistDeleteActionService', () => {
).resolves.toBe(true);
expect(databaseService.createOperationId).not.toHaveBeenCalled();
expect(databaseService.deletePlaylist).toHaveBeenCalledWith(
'playlist-1',
undefined
expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(
'playlist-1'
);
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
});
it('uses browser playlist storage when an Xtream playlist lacks Xtream SQLite support', async () => {
@@ -1,4 +1,4 @@
import { SourceHealthEvidenceService } from './source-health-evidence.service';
import { SourceActivityService } from './source-activity.service';
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -20,7 +20,7 @@ export interface PlaylistDeleteActionOptions {
@Injectable({ providedIn: 'root' })
export class PlaylistDeleteActionService {
private readonly healthEvidence = inject(SourceHealthEvidenceService, {
private readonly activity = inject(SourceActivityService, {
optional: true,
});
private readonly databaseService = inject(DatabaseService);
@@ -31,42 +31,45 @@ export class PlaylistDeleteActionService {
playlist: PlaylistMeta,
options: PlaylistDeleteActionOptions = {}
): Promise<boolean> {
const supportsElectronDelete = playlist.serverUrl
? this.runtime.supportsXtreamSqliteDataSource
: this.runtime.supportsSqlite;
if (supportsElectronDelete) {
const deleted = await this.deletePlaylistInElectron(
try {
const result = await this.deletePlaylistWithResult(
playlist,
options
);
if (deleted)
this.healthEvidence?.connections.next({ id: playlist._id });
return deleted;
return result.success;
} catch {
return false;
}
const result = await firstValueFrom(
this.playlistsService.deletePlaylist(playlist._id)
);
return result.success;
}
private deletePlaylistInElectron(
async deletePlaylistWithResult(
playlist: PlaylistMeta,
options: PlaylistDeleteActionOptions
): Promise<boolean> {
const operationId = playlist.serverUrl
? this.databaseService.createOperationId('playlist-delete')
: undefined;
return this.databaseService.deletePlaylist(
playlist._id,
operationId
options: PlaylistDeleteActionOptions = {}
): Promise<{ success: boolean; cleanupWarnings?: number }> {
const workerOptions =
playlist.serverUrl && this.runtime.supportsXtreamSqliteDataSource
? {
operationId,
operationId:
this.databaseService.createOperationId(
'playlist-delete'
),
onEvent: options.onEvent,
}
: undefined
);
: undefined;
// Persistence owns serialization, the single worker invocation, and
// post-delete cleanup. UI callers only commit the resulting state.
const release = this.activity?.begin([playlist._id]);
try {
return await firstValueFrom(
workerOptions
? this.playlistsService.deletePlaylist(
playlist._id,
workerOptions
)
: this.playlistsService.deletePlaylist(playlist._id)
);
} finally {
release?.();
}
}
}
@@ -125,6 +125,61 @@ describe('PlaylistsService', () => {
expect(cleanup).toHaveBeenCalledWith('playlist-1');
});
it('owns one worker deletion and waits for cleanup before completion', async () => {
const service = createService();
const deleteWorker = jest.fn().mockResolvedValue(true);
let finishCleanup!: () => void;
const cleanup = jest.fn(
() =>
new Promise<void>((resolve) => {
finishCleanup = resolve;
})
);
Object.defineProperties(service, {
isElectronStorageAvailable: { value: true },
electronApi: { value: { dbDeletePlaylist: jest.fn() } },
});
Object.assign(service, {
ensureElectronPlaylistMigrations: jest
.fn()
.mockResolvedValue(undefined),
databaseService: { deletePlaylist: deleteWorker },
playlistDeleteCleanups: [cleanup],
});
let completed = false;
const deletion = firstValueFrom(
service.deletePlaylist('a', { operationId: 'delete-a' })
).then((result) => {
completed = true;
return result;
});
for (let i = 0; i < 20 && !finishCleanup; i++) await Promise.resolve();
expect(deleteWorker).toHaveBeenCalledTimes(1);
expect(completed).toBe(false);
finishCleanup();
await expect(deletion).resolves.toEqual({ success: true });
expect(cleanup).toHaveBeenCalledTimes(1);
});
it('reports cleanup warnings without resurrecting an already deleted row', async () => {
const service = createService();
Object.assign(service, {
playlistDeleteCleanups: [
() => Promise.reject(new Error('cleanup failed')),
],
});
const warning = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
try {
await expect(
firstValueFrom(service.deletePlaylist('a'))
).resolves.toEqual({ success: true, cleanupWarnings: 1 });
} finally {
warning.mockRestore();
}
});
it('returns browser playlist summaries without embedded playlist payloads', async () => {
const dbService = {
getAll: jest.fn(() =>
@@ -982,7 +1037,7 @@ describe('PlaylistsService', () => {
jest.spyOn(Date, 'now').mockReturnValue(1770000000000);
// Auto-refresh snapshots always come from playlists that had
// autoRefresh enabled; the batch write preserves that flag from the
// current row (or the snapshot when the row is missing) instead of
// current row instead of
// force-enabling it.
const playlists = [
{
@@ -1004,6 +1059,7 @@ describe('PlaylistsService', () => {
] as Playlist[];
const dbService = {
getAll: jest.fn(() => of([])),
getByID: jest.fn((_store: string, id: string) => of(playlists.find((p) => p._id === id))),
update: jest.fn((_storeName: string, playlist: Playlist) =>
of(playlist)
),
@@ -1036,6 +1092,20 @@ describe('PlaylistsService', () => {
);
});
it.each([false, true])('does not resurrect a deleted source when startup refresh finishes (SQLite=%s)', async (sqlite) => {
const upsert = jest.fn();
testWindow.electron = sqlite ? {
dbGetAppState: jest.fn().mockResolvedValue('1'), dbSetAppState: jest.fn(),
dbGetAppPlaylists: jest.fn().mockResolvedValue([]), dbGetAppPlaylist: jest.fn().mockResolvedValue(null),
dbUpsertAppPlaylist: upsert,
} : undefined;
const update = jest.fn(() => of(undefined));
const service = createService({ update });
expect(await firstValueFrom(service.updateManyPlaylists([{ _id: 'deleted', autoRefresh: true } as Playlist]))).toEqual([]);
expect(update).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
});
it('short-circuits updateManyPlaylists when no playlists are provided', async () => {
const dbService = {
update: jest.fn(),
@@ -1693,6 +1763,19 @@ describe('PlaylistsService', () => {
);
});
it.each([false, true])('does not recreate a removed source on singular refresh (SQLite: %s)', async (sqlite) => {
const upsert = jest.fn();
testWindow.electron = sqlite ? {
dbGetAllPlaylists: jest.fn().mockResolvedValue([]),
dbGetAppPlaylist: jest.fn().mockResolvedValue(null),
dbUpsertAppPlaylist: upsert,
} as unknown as typeof testWindow.electron : undefined;
const update = jest.fn(() => of(undefined));
const service = createService({ update });
await expect(firstValueFrom(service.updatePlaylist('deleted', { _id: 'deleted' } as Playlist))).rejects.toThrow('Playlist no longer exists');
expect(update).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
});
it('keeps hiddenGroupTitles when refreshing a playlist payload', async () => {
const existingPlaylist: Playlist = {
_id: 'playlist-2',
+33 -6
View File
@@ -1,3 +1,7 @@
import {
DatabaseService,
type DbOperationOptions,
} from './database-electron.service';
import { SourceHealthEvidenceService } from './source-health-evidence.service';
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -105,6 +109,7 @@ export function resolvePlaylistParser(parserModule: PlaylistParserModule) {
export class PlaylistsService {
private readonly healthEvidence = inject(SourceHealthEvidenceService, { optional: true });
private readonly dbService = inject(NgxIndexedDBService);
private readonly databaseService = inject(DatabaseService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
@@ -534,7 +539,10 @@ export class PlaylistsService {
);
}
deletePlaylist(playlistId: string): Observable<{ success: boolean }> {
deletePlaylist(
playlistId: string,
options?: DbOperationOptions
): Observable<{ success: boolean; cleanupWarnings?: number }> {
// Deletion goes through the SAME per-playlist queue as every write:
// a queued mutation (e.g. the Stalker portal repair's conditional
// transform) landing after an unserialized delete would upsert the
@@ -547,7 +555,17 @@ export class PlaylistsService {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
await electron.dbDeletePlaylist(playlistId);
if (options) {
const deleted =
await this.databaseService.deletePlaylist(
playlistId,
options
);
if (!deleted)
throw new Error(
'Playlist deletion did not complete'
);
} else await electron.dbDeletePlaylist(playlistId);
}
return undefined;
}
@@ -561,13 +579,19 @@ export class PlaylistsService {
return delete$.pipe(
tap(() => this.healthEvidence?.connections.next({ id: playlistId })),
switchMap(() => from(this.runPlaylistDeleteCleanups(playlistId))),
map(() => ({ success: true }))
map((cleanupWarnings) =>
cleanupWarnings
? { success: true, cleanupWarnings }
: { success: true }
)
);
}
private async runPlaylistDeleteCleanups(playlistId: string): Promise<void> {
private async runPlaylistDeleteCleanups(
playlistId: string
): Promise<number> {
if (this.playlistDeleteCleanups.length === 0) {
return;
return 0;
}
const failures = (
@@ -589,6 +613,7 @@ export class PlaylistsService {
failure
);
}
return failures.length;
}
/**
@@ -650,6 +675,7 @@ export class PlaylistsService {
const currentPlaylist = await firstValueFrom(
this.getPlaylistById(playlistId, operationId)
);
if (!currentPlaylist) throw new Error('Playlist no longer exists');
const mergedPlaylist = this.mergeRefreshedPlaylist(
currentPlaylist,
updatedPlaylist,
@@ -1004,6 +1030,7 @@ export class PlaylistsService {
const current = await firstValueFrom(
this.getPlaylistById(playlist._id)
);
if (!current) return null;
// The merge takes autoRefresh from the current row first,
// so disabling auto-refresh while a refresh is in flight
// is not reverted by the completing batch write.
@@ -1017,7 +1044,7 @@ export class PlaylistsService {
return nextPlaylist;
})
)
);
).pipe(map((rows) => rows.filter((row): row is Playlist => row !== null)));
}
getFavoriteChannels(playlistId: string) {
@@ -0,0 +1,32 @@
import { Injectable, signal } from '@angular/core';
/** Shared renderer work that source cleanup must not interrupt. */
@Injectable({ providedIn: 'root' })
export class SourceActivityService {
private readonly counts = signal<ReadonlyMap<string, number>>(new Map());
isBusy(id: string): boolean {
return (this.counts().get(id) ?? 0) > 0;
}
begin(ids: readonly string[]): () => void {
const unique = [...new Set(ids)];
this.counts.update((value) => {
const next = new Map(value);
unique.forEach((id) => next.set(id, (next.get(id) ?? 0) + 1));
return next;
});
let released = false;
return () => {
if (released) return;
released = true;
this.counts.update((value) => {
const next = new Map(value);
unique.forEach((id) => {
const remaining = (next.get(id) ?? 1) - 1;
if (remaining) next.set(id, remaining);
else next.delete(id);
});
return next;
});
};
}
}
@@ -869,7 +869,9 @@ export class WorkspaceDashboardRailsComponent {
}
this.store.dispatch(
PlaylistActions.removePlaylist({ playlistId: playlist._id })
PlaylistActions.playlistRemovalCommitted({
playlistId: playlist._id,
})
);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
@@ -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);