mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
This commit is contained in:
1 parent
cbc918b096
commit
bd848aaad6
53 files changed
+1980
-87
No files matched your search
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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'),
|
||||
|
||||
+8
@@ -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;
|
||||
}
|
||||
|
||||
+13
-1
@@ -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
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
}
|
||||
+65
-3
@@ -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 =
|
||||
|
||||
+73
-1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user