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