mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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>
585 lines
20 KiB
TypeScript
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 ?? []);
|
|
}
|
|
}
|