Files
iptvnator/libs/m3u-state/src/lib/effects.ts
T
4grayandClaude Opus 5.5 082c56608b fix(ui): destructive confirmations, verb labels and provider icons
Confirmations: ConfirmDialogData.confirmLabel is required, so no dialog can
fall back to "Yes"/"No"; the dismiss defaults to "Cancel" and
`tone: 'destructive'` styles the confirm with .app-destructive-button. Every
caller names its action ("Remove playlist", "Clear", "Refresh playlist",
"Cancel download" with a "Close" dismiss). The confirm button has the
confirm-dialog-confirm test id and drops its no-op color="primary".

The no-op `warn` color input becomes .app-destructive-button on the EPG
mapping, playlist item, error view, EPG/reset settings, delete-all and source
cleanup buttons, and on the unsaved-changes dialog's Discard.

Provider icons come from SOURCE_TYPE_ICONS in shared/interfaces (Xtream
cloud, Stalker cast, M3U playlist_play / link / description / subject) in the
add dialog, auto-import, empty state, playlist switcher, playlist rows,
dashboard source rail, command palette, Sources filters and both reset
summaries. Stalker no longer borrows the Dashboard icon, and Xtream no longer
shares a glyph with M3U URL playlists.

The playlist error view removed a playlist through the stale
PlaylistActions.removePlaylist: it dropped the playlist from state before the
delete ran, swallowed failures, skipped the source activity guard and showed
no toast. It now uses PlaylistDeleteActionService like every other removal,
commits only a completed delete, toasts and goes home. The unused action and
its effect are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:59:23 +02:00

585 lines
20 KiB
TypeScript

import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { EpgService } from '@iptvnator/epg/data-access';
import {
isDashChannel,
isDashStreamUrl,
normalizeEpgUrls,
} from '@iptvnator/shared/m3u-utils';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateService } from '@ngx-translate/core';
import {
EMPTY,
catchError,
concatMap,
defer,
filter,
firstValueFrom,
from,
map,
mergeMap,
of,
retry,
switchMap,
tap,
withLatestFrom,
} from 'rxjs';
import {
DataService,
EpgSourceSettingsService,
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
Channel,
Playlist,
PlaylistMeta,
STORE_KEY,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import {
ChannelActions,
EpgActions,
FavoritesActions,
PlaylistActions,
} from './actions';
import {
selectActive,
selectActivePlaylist,
selectActivePlaylistId,
selectChannels,
selectFavorites,
} from './selectors';
import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util';
import {
buildExternalPlayerPayload,
type ExternalPlayerHeaderFallback,
shouldAutoLaunchExternalPlayer,
} from './external-player-payload.util';
import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util';
import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util';
import { persistPlaylistUpdate } from './playlist-update.effect-handler';
@Injectable({ providedIn: 'any' })
export class PlaylistEffects {
private actions$ = inject(Actions);
private playlistsService = inject(PlaylistsService);
private dataService = inject(DataService);
private epgService = inject(EpgService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private storage = inject(StorageMap);
private store = inject(Store);
private translate = inject(TranslateService);
private settingsStore = inject(SettingsStore);
private epgSources = inject(EpgSourceSettingsService);
private readonly playlistScopedEpgFetchKeys = new Map<string, string>();
updateFavorites$ = createEffect(
() => {
return this.actions$.pipe(
ofType(FavoritesActions.updateFavorites),
withLatestFrom(
this.store.select(selectFavorites),
this.store.select(selectActivePlaylistId)
),
filter(([, , playlistId]) => !!playlistId),
switchMap(([, favorites, playlistId]) =>
this.playlistsService.updateFavorites(playlistId, favorites)
),
tap(() => {
this.snackBar.open(
this.translate.instant('CHANNELS.FAVORITES_UPDATED'),
undefined,
{ duration: 2000 }
);
})
);
},
{ dispatch: false }
);
setFavorites$ = createEffect(
() => {
return this.actions$.pipe(
ofType(FavoritesActions.setFavorites),
withLatestFrom(this.store.select(selectActivePlaylistId)),
filter(([, playlistId]) => !!playlistId),
switchMap(([action, playlistId]) =>
this.playlistsService.setFavorites(
playlistId,
action.channelIds
)
)
);
},
{
dispatch: false,
}
);
resolveActiveEpgProgram$ = createEffect(() => {
return this.actions$.pipe(
ofType(EpgActions.setActiveEpgProgram),
withLatestFrom(this.store.select(selectActive)),
map(([action, activeChannel]) =>
resolveActiveEpgProgramAction(
action.program,
activeChannel,
() =>
this.snackBar.open(
this.translate.instant(
'EPG.TIMELINE.CATCHUP_FAILED'
),
undefined,
{ duration: 4000 }
)
)
)
);
});
openArchivedPlayback$ = createEffect(
() => {
return this.actions$.pipe(
ofType(EpgActions.setActivePlaybackUrl),
withLatestFrom(
this.store.select(selectActive),
this.store.select(selectActivePlaylist)
),
tap(([action, activeChannel, activePlaylist]) => {
void this.openWithConfiguredExternalPlayer(
action.playbackUrl,
activeChannel,
activePlaylist
);
})
);
},
{ dispatch: false }
);
returnToLivePlayback$ = createEffect(
() => {
return this.actions$.pipe(
ofType(EpgActions.returnToLivePlayback),
withLatestFrom(
this.store.select(selectActive),
this.store.select(selectActivePlaylist)
),
filter(([, activeChannel]) => Boolean(activeChannel?.url)),
tap(([, activeChannel, activePlaylist]) => {
void this.openWithConfiguredExternalPlayer(
activeChannel?.url ?? '',
activeChannel,
activePlaylist
);
})
);
},
{ dispatch: false }
);
setActiveChannel$ = createEffect(() => {
return this.actions$.pipe(
ofType(ChannelActions.setActiveChannel),
// Skip the effect entirely when channel is falsy
filter((action) => !!action.channel),
withLatestFrom(this.store.select(selectActivePlaylist)),
map(([action, activePlaylist]) => {
const { channel } = action;
// Use modern EPG service to get channel programs
const channelId = resolveChannelEpgLookupKey(channel);
if (channelId) {
this.epgService.getChannelPrograms(channelId);
}
void window.electron
?.setUserAgent(
channel.http?.['user-agent'],
channel.http?.referrer,
channel.url
)
.catch((error: unknown) => {
console.warn(
'[PlaylistEffects] Failed to configure Electron request headers:',
error
);
});
firstValueFrom(this.storage.get(STORE_KEY.Settings)).then(
(settings: any) => {
const payload = buildExternalPlayerPayload(
channel,
channel.url,
activePlaylist
);
if (!payload) {
return;
}
if (
shouldAutoLaunchExternalPlayer(
settings,
action.startPlayback,
channel,
VideoPlayer.MPV
)
) {
this.dataService.sendIpcEvent(
OPEN_MPV_PLAYER,
payload
);
} else if (
shouldAutoLaunchExternalPlayer(
settings,
action.startPlayback,
channel,
VideoPlayer.VLC
)
) {
this.dataService.sendIpcEvent(
OPEN_VLC_PLAYER,
payload
);
}
}
);
return ChannelActions.setActiveChannelSuccess({
channel: action.channel,
});
})
);
});
loadPlaylists$ = createEffect(() => {
return this.actions$.pipe(
ofType(PlaylistActions.loadPlaylists),
switchMap(() =>
defer(() => this.playlistsService.getAllPlaylists()).pipe(
// Recreate the storage request once for transient failures.
// A final failure is state, not an empty source inventory.
retry({ count: 1, delay: 300 }),
switchMap((playlists) =>
defer(async () => {
// Settings can register initial cleanup after the
// faster inventory read has already completed.
await this.settingsStore.loadSettings();
await this.epgSources.retryFailedReconciliation();
return playlists;
})
),
tap((playlists) => {
this.fetchPlaylistScopedEpgForPlaylists(playlists);
}),
map((playlists) =>
PlaylistActions.loadPlaylistsSuccess({
playlists,
})
),
catchError(() => of(PlaylistActions.loadPlaylistsFailure()))
)
)
);
});
private async openWithConfiguredExternalPlayer(
playbackUrl: string,
activeChannel: Channel | undefined | null,
activePlaylist?: ExternalPlayerHeaderFallback | null
): Promise<void> {
if (isDashStreamUrl(playbackUrl) || isDashChannel(activeChannel)) {
return;
}
const payload = buildExternalPlayerPayload(
activeChannel,
playbackUrl,
activePlaylist
);
if (!payload) {
return;
}
const settings: any = await firstValueFrom(
this.storage.get(STORE_KEY.Settings)
);
if (!settings || Object.keys(settings).length === 0) {
return;
}
if (settings.player === VideoPlayer.MPV) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, payload);
return;
}
if (settings.player === VideoPlayer.VLC) {
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, payload);
}
}
playlistRemovalCommitted$ = createEffect(
() =>
this.actions$.pipe(
ofType(PlaylistActions.playlistRemovalCommitted),
tap(({ playlistId }) =>
this.playlistScopedEpgFetchKeys.delete(playlistId)
)
),
{ dispatch: false }
);
updatePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylist),
switchMap((action) =>
persistPlaylistUpdate(this.playlistsService, action).pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist, {
force: action.refreshEpg === true,
});
})
)
)
);
},
{ dispatch: false }
);
parsePlaylist$ = createEffect(() => {
return this.actions$.pipe(
ofType(PlaylistActions.parsePlaylist),
mergeMap((action) =>
from(
this.playlistsService.handlePlaylistParsing(
action.uploadType,
action.playlist,
action.title,
action.path
)
)
),
map((playlist) =>
PlaylistActions.addPlaylist({
playlist,
})
)
);
});
// concatMap, not switchMap: every action carries a *different* playlist,
// so a newer one must never cancel the previous playlist's write. Under
// switchMap two adds in quick succession — the OS handing over several
// playlist files at once is the realistic case — dropped the first
// playlist's EPG fetch and navigation on the floor. Serialising also keeps
// the last-added playlist as the one that ends up active.
addPlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(
PlaylistActions.addPlaylist,
PlaylistActions.handleAddingPlaylistByUrl
),
concatMap((action) => {
if ('isTemporary' in action && action.isTemporary) {
return EMPTY;
}
return this.playlistsService
.addPlaylist(action.playlist)
.pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist);
this.navigateToPlaylist(action.playlist);
})
);
})
);
},
{ dispatch: false }
);
updatePlaylistMeta$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistMeta),
filter((action) => action.persist !== false),
switchMap((action) =>
this.playlistsService
.updatePlaylistMeta(action.playlist)
.pipe(
tap(() => {
if (
this.hasPlaylistScopedEpgSourceChange(
action.playlist
)
) {
this.fetchPlaylistScopedEpg(
action.playlist
);
}
})
)
)
);
},
{ dispatch: false }
);
updatePlaylistPositions$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistPositions),
switchMap((action) =>
this.playlistsService.updatePlaylistPositions(
action.positionUpdates
)
)
);
},
{ dispatch: false }
);
addManyPlaylists$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.addManyPlaylists),
switchMap((action) =>
this.playlistsService.addManyPlaylists(action.playlists)
)
);
},
{ dispatch: false }
);
updateManyPlaylists$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updateManyPlaylists),
switchMap((action) =>
this.playlistsService
.updateManyPlaylists(action.playlists)
.pipe(
tap((persisted) => {
persisted.forEach((playlist) =>
this.fetchPlaylistScopedEpg(playlist)
);
})
)
)
);
},
{ dispatch: false }
);
setAdjacentChannelAsActive$ = createEffect(() => {
return this.actions$.pipe(
ofType(ChannelActions.setAdjacentChannelAsActive),
withLatestFrom(
this.store.select(selectChannels),
this.store.select(selectActive)
),
map(([action, channels, activeChannel]) => {
let adjacentChannel;
const index = channels.findIndex(
(channel) => channel.id === activeChannel?.id
);
if (action.direction === 'next') {
if (index === channels.length - 1)
adjacentChannel = activeChannel;
adjacentChannel = channels[index + 1];
} else if (action.direction === 'previous') {
if (index === -1 || index === 0)
adjacentChannel = activeChannel;
adjacentChannel = channels[index - 1];
}
return ChannelActions.setActiveChannelSuccess({
channel: adjacentChannel!,
});
})
);
});
private navigateToPlaylist(playlist: Playlist): void {
if (playlist.serverUrl) {
void this.router.navigate(['/workspace', 'xtreams', playlist._id]);
return;
}
if (playlist.macAddress) {
void this.router.navigate(['/workspace', 'stalker', playlist._id]);
return;
}
void this.router.navigate(['/workspace', 'playlists', playlist._id]);
}
private fetchPlaylistScopedEpg(
playlist: Pick<
Playlist,
'_id' | 'epgUrls' | 'macAddress' | 'manualEpgUrls' | 'serverUrl'
>,
options: { force?: boolean } = {}
): void {
const plan = resolvePlaylistScopedEpgFetchPlan(
playlist,
this.getGlobalEpgUrls(),
this.playlistScopedEpgFetchKeys.get(playlist._id),
options
);
this.playlistScopedEpgFetchKeys.set(playlist._id, plan.key);
if (!plan.shouldFetch) {
return;
}
this.epgService.fetchEpg(plan.urls);
}
private hasPlaylistScopedEpgSourceChange(playlist: PlaylistMeta): boolean {
return (
Object.prototype.hasOwnProperty.call(playlist, 'epgUrls') ||
Object.prototype.hasOwnProperty.call(playlist, 'detectedEpgUrls') ||
Object.prototype.hasOwnProperty.call(playlist, 'manualEpgUrls') ||
Object.prototype.hasOwnProperty.call(playlist, 'disabledEpgUrls')
);
}
private fetchPlaylistScopedEpgForPlaylists(playlists: Playlist[]): void {
const epgUrls = new Set<string>();
const globalEpgUrls = this.getGlobalEpgUrls();
for (const playlist of playlists) {
const plan = resolvePlaylistScopedEpgFetchPlan(
playlist,
globalEpgUrls,
this.playlistScopedEpgFetchKeys.get(playlist._id)
);
this.playlistScopedEpgFetchKeys.set(playlist._id, plan.key);
if (!plan.shouldFetch) {
continue;
}
for (const url of plan.urls) {
epgUrls.add(url);
}
}
if (epgUrls.size > 0) {
this.epgService.fetchEpg(Array.from(epgUrls));
}
}
private getGlobalEpgUrls(): string[] {
return normalizeEpgUrls(this.settingsStore.getSettings().epgUrl ?? []);
}
}