feat(epg): support playlist-scoped sources

Add playlist-scoped EPG source support for M3U playlists.
This commit is contained in:
4gray authored and GitHub committed 2026-06-21 22:53:07 +02:00
1 parent 36a7ce9f3d
commit e801028005
71 files changed
+4876 -373

No files matched your search

@@ -623,6 +623,7 @@ describe('PlaylistRefreshActionService', () => {
PlaylistActions.updatePlaylist({
playlist: refreshedPlaylist,
playlistId: item._id,
refreshEpg: true,
})
);
});
@@ -227,6 +227,7 @@ export class PlaylistRefreshActionService {
_id: item._id,
},
playlistId: item._id,
refreshEpg: true,
})
);
@@ -152,6 +152,141 @@
}
</mat-form-field>
}
@if (canManagePlaylistEpgSources) {
<section
class="playlist-epg-sources"
data-test-id="playlist-epg-sources"
>
<div class="playlist-epg-sources__header">
<mat-icon>event_note</mat-icon>
<div>
<h3 class="playlist-epg-sources__title">
{{ 'SETTINGS.EPG_SOURCES' | translate }}
</h3>
<p>
@if (hiddenDetectedPlaylistEpgSourceCount > 0) {
{{
'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED'
| translate
: {
enabled:
playlistEpgUrls.length,
detected:
playlistDetectedEpgUrls.length,
}
}}
} @else {
{{
'SETTINGS.EPG_SOURCES_DESCRIPTION'
| translate
}}
}
</p>
</div>
</div>
@for (epgUrl of playlistEpgUrls; track epgUrl) {
<div class="playlist-epg-source-row">
<mat-form-field
class="w-full"
subscriptSizing="dynamic"
>
<mat-label>{{
'SETTINGS.EPG_URL_LABEL' | translate
}}</mat-label>
<input matInput [value]="epgUrl" readonly />
</mat-form-field>
<button
mat-icon-button
type="button"
[disabled]="!canRefreshPlaylistEpg"
[matTooltip]="'SETTINGS.REFRESH_EPG' | translate"
(click)="refreshPlaylistEpgSource(epgUrl)"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
type="button"
[disabled]="isGlobalEpgSource(epgUrl)"
[matTooltip]="'SETTINGS.ADD_EPG_SOURCE' | translate"
(click)="addPlaylistEpgSourceToSettings(epgUrl)"
>
<mat-icon>playlist_add</mat-icon>
</button>
<button
mat-icon-button
type="button"
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
(click)="removePlaylistEpgSource(epgUrl)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
}
<div
formArrayName="playlistEpgSourceInputs"
class="playlist-epg-source-form"
>
@for (
control of playlistEpgSourceInputs.controls;
track $index
) {
<div class="playlist-epg-source-row">
<mat-form-field
class="w-full"
subscriptSizing="dynamic"
>
<mat-label>{{
'SETTINGS.EPG_URL_LABEL' | translate
}}</mat-label>
<input matInput [formControlName]="$index" />
@if (control.invalid && control.touched) {
<mat-error>{{
'SETTINGS.EPG_URL_ERROR' | translate
}}</mat-error>
}
</mat-form-field>
<button
mat-icon-button
type="button"
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
(click)="removePlaylistEpgSourceInput($index)"
>
<mat-icon>remove</mat-icon>
</button>
</div>
}
<div class="playlist-epg-source-actions">
<button
mat-button
type="button"
(click)="addPlaylistEpgSourceInput()"
>
<mat-icon>add</mat-icon>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
<button
mat-stroked-button
type="button"
[attr.aria-label]="
('HOME.PLAYLISTS.INFO_DIALOG.SAVE'
| translate) +
' ' +
('SETTINGS.EPG_SOURCES' | translate)
"
(click)="savePlaylistEpgSources()"
>
<mat-icon>playlist_add</mat-icon>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.SAVE' | translate }}
</button>
</div>
</div>
</section>
}
@if (isDesktop && (playlist.url || playlist.filePath)) {
<mat-checkbox class="w-full" formControlName="autoRefresh">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
@@ -4,11 +4,13 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { PlaylistInfoComponent } from './playlist-info.component';
@@ -22,11 +24,21 @@ describe('PlaylistInfoComponent', () => {
let databaseService: {
updateXtreamPlaylistDetails: jest.Mock;
};
let epgBridge: {
supportsDataManagement: boolean;
forceFetchEpg: jest.Mock;
clearEpgDataForSource: jest.Mock;
};
let runtime: {
isElectron: boolean;
supportsDesktopFileSave: boolean;
supportsXtreamSqliteDataSource: boolean;
};
let settingsStore: {
getSettings: jest.Mock;
getTrustOptions: jest.Mock;
updateSettings: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
@@ -55,11 +67,28 @@ describe('PlaylistInfoComponent', () => {
databaseService = {
updateXtreamPlaylistDetails: jest.fn(),
};
epgBridge = {
supportsDataManagement: true,
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
clearEpgDataForSource: jest
.fn()
.mockResolvedValue({ success: true }),
};
runtime = {
isElectron: false,
supportsDesktopFileSave: false,
supportsXtreamSqliteDataSource: false,
};
settingsStore = {
getSettings: jest.fn(() => ({
epgUrl: [],
})),
getTrustOptions: jest.fn(() => ({
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
})),
updateSettings: jest.fn().mockResolvedValue(undefined),
};
snackBar = {
open: jest.fn(),
};
@@ -85,10 +114,18 @@ describe('PlaylistInfoComponent', () => {
provide: DatabaseService,
useValue: databaseService,
},
{
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
{
provide: Store,
useValue: store,
},
{
provide: SettingsStore,
useValue: settingsStore,
},
{
provide: MatSnackBar,
useValue: snackBar,
@@ -100,7 +137,18 @@ describe('PlaylistInfoComponent', () => {
{
provide: TranslateService,
useValue: {
currentLang: 'en',
get: jest.fn((key: string) => of(key)),
instant: jest.fn((key: string) => key),
onDefaultLangChange: of({
lang: 'en',
translations: {},
}),
onLangChange: of({ lang: 'en', translations: {} }),
onTranslationChange: of({
lang: 'en',
translations: {},
}),
},
},
{
@@ -265,6 +313,259 @@ describe('PlaylistInfoComponent', () => {
expect(component.isDesktop).toBe(false);
});
it('normalizes detected playlist EPG source URLs for the details UI', () => {
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
epgUrls: [
' https://playlist.example.com/guide.xml ',
'',
'https://playlist.example.com/guide.xml',
'https://playlist.example.com/backup.xml',
],
},
});
createComponent();
expect(component.playlistEpgUrls).toEqual([
'https://playlist.example.com/guide.xml',
'https://playlist.example.com/backup.xml',
]);
});
it('keeps disabled detected playlist EPG candidates visible in the details UI summary', () => {
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
epgUrls: ['https://playlist.example.com/ua.xml'],
detectedEpgUrls: [
'https://playlist.example.com/ua.xml',
'https://playlist.example.com/de.xml',
'https://playlist.example.com/us.xml',
'https://playlist.example.com/fr.xml',
],
},
});
createComponent();
expect(component.playlistDetectedEpgUrls).toEqual([
'https://playlist.example.com/ua.xml',
'https://playlist.example.com/de.xml',
'https://playlist.example.com/us.xml',
'https://playlist.example.com/fr.xml',
]);
expect(component.hiddenDetectedPlaylistEpgSourceCount).toBe(3);
});
it('refreshes a detected playlist EPG source through the runtime bridge', async () => {
createComponent();
await component.refreshPlaylistEpgSource(
' https://playlist.example.com/guide.xml '
);
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(
'https://playlist.example.com/guide.xml',
{
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
}
);
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
expect(snackBar.open).toHaveBeenCalledWith(
'EPG.FETCH_SUCCESS',
'CLOSE',
{ duration: 3000 }
);
});
it('adds a detected playlist EPG source to global settings on request', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: [
'https://global.example.com/guide.xml',
'https://playlist.example.com/guide.xml',
],
});
createComponent();
await component.addPlaylistEpgSourceToSettings(
'https://new-playlist.example.com/guide.xml'
);
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
epgUrl: [
'https://global.example.com/guide.xml',
'https://playlist.example.com/guide.xml',
'https://new-playlist.example.com/guide.xml',
],
});
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.ADD_EPG_SOURCE',
'CLOSE',
{ duration: 3000 }
);
});
it('does not duplicate a playlist EPG source that already exists globally', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://playlist.example.com/guide.xml'],
});
createComponent();
await component.addPlaylistEpgSourceToSettings(
' https://playlist.example.com/guide.xml '
);
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
});
it('removes a detected playlist EPG source from the enabled list and records it as disabled', async () => {
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
epgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
],
detectedEpgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
],
manualEpgUrls: ['https://playlist.example.com/manual.xml'],
disabledEpgUrls: ['https://playlist.example.com/old.xml'],
},
});
createComponent();
await component.removePlaylistEpgSource(
'https://playlist.example.com/remove.xml'
);
expect(epgBridge.clearEpgDataForSource).toHaveBeenCalledWith(
'https://playlist.example.com/remove.xml'
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: expect.objectContaining({
_id: 'playlist-1',
epgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/manual.xml',
],
detectedEpgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
],
manualEpgUrls: ['https://playlist.example.com/manual.xml'],
disabledEpgUrls: [
'https://playlist.example.com/old.xml',
'https://playlist.example.com/remove.xml',
],
}),
})
);
});
it('keeps a playlist EPG source enabled when source data cleanup fails', async () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
epgBridge.clearEpgDataForSource.mockRejectedValueOnce(
new Error('Database cleanup failed')
);
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
epgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
],
detectedEpgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
],
manualEpgUrls: [],
disabledEpgUrls: [],
},
});
createComponent();
await component.removePlaylistEpgSource(
'https://playlist.example.com/remove.xml'
);
expect(epgBridge.clearEpgDataForSource).toHaveBeenCalledWith(
'https://playlist.example.com/remove.xml'
);
expect(store.dispatch).not.toHaveBeenCalled();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.EPG_DATA_CLEAR_FAILED',
'CLOSE',
{ duration: 3000 }
);
} finally {
consoleError.mockRestore();
}
});
it('adds playlist-local EPG sources with URL normalization and deduplication', async () => {
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: {
...playlist,
epgUrls: ['https://playlist.example.com/existing.xml'],
detectedEpgUrls: ['https://playlist.example.com/existing.xml'],
manualEpgUrls: ['https://playlist.example.com/manual.xml'],
disabledEpgUrls: ['https://playlist.example.com/new.xml'],
},
});
createComponent();
component.playlistEpgSourceInputs
.at(0)
.setValue(' https://playlist.example.com/new.xml ');
component.addPlaylistEpgSourceInput();
component.playlistEpgSourceInputs
.at(1)
.setValue('https://playlist.example.com/manual.xml');
component.savePlaylistEpgSources();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: expect.objectContaining({
_id: 'playlist-1',
epgUrls: [
'https://playlist.example.com/existing.xml',
'https://playlist.example.com/new.xml',
'https://playlist.example.com/manual.xml',
],
manualEpgUrls: [
'https://playlist.example.com/manual.xml',
'https://playlist.example.com/new.xml',
],
disabledEpgUrls: [],
}),
})
);
expect(component.playlistEpgSourceInputs.length).toBe(1);
expect(component.playlistEpgSourceInputs.at(0).value).toBe('');
});
it('shows a validation error for invalid playlist-local EPG source URLs', () => {
createComponent();
fixture.detectChanges();
component.playlistEpgSourceInputs.at(0).setValue('not a url');
component.savePlaylistEpgSources();
fixture.detectChanges();
expect(store.dispatch).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain(
'SETTINGS.EPG_URL_ERROR'
);
});
it('falls back to browser download when desktop file saving is unavailable', async () => {
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
@@ -4,6 +4,7 @@ import { Component, inject } from '@angular/core';
import {
FormControl,
ReactiveFormsModule,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormGroup,
Validators,
@@ -21,24 +22,32 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { firstValueFrom } from 'rxjs';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import {
normalizeXtreamServerUrl,
Playlist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import {
normalizeEpgUrls,
resolvePlaylistEpgSourceState,
} from '@iptvnator/shared/m3u-utils';
type DesktopFileSaveBridge = Pick<
typeof window.electron,
'saveFileDialog' | 'writeFile'
>;
const EPG_URL_PATTERN = /^\s*(http|https|file):\/\/[^ "]+\s*$/;
@Component({
selector: 'app-playlist-info',
templateUrl: './playlist-info.component.html',
@@ -68,10 +77,61 @@ type DesktopFileSaveBridge = Pick<
mat-dialog-content p {
margin: 0;
color: var(--app-muted-color);
color: var(--mat-sys-on-surface-variant);
font-size: 12.5px;
line-height: 1.45;
}
.playlist-epg-sources {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid
var(
--app-widget-header-border,
var(--mat-sys-outline-variant)
);
border-radius: 8px;
background: var(--mat-sys-surface-container-low);
}
.playlist-epg-sources__header {
display: flex;
gap: 10px;
align-items: flex-start;
}
.playlist-epg-sources__header mat-icon {
color: var(--mat-sys-primary);
}
.playlist-epg-sources__title {
margin: 0 0 2px;
font-size: 14px;
font-weight: 600;
line-height: 1.25;
}
.playlist-epg-source-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 6px;
align-items: center;
}
.playlist-epg-source-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
@media (max-width: 520px) {
.playlist-epg-source-row {
grid-template-columns: minmax(0, 1fr);
}
}
`,
],
providers: [DatePipe],
@@ -98,6 +158,8 @@ export class PlaylistInfoComponent {
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
private runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly settingsStore = inject(SettingsStore);
private dialogRef = inject(MatDialogRef<PlaylistInfoComponent>, {
optional: true,
});
@@ -107,6 +169,38 @@ export class PlaylistInfoComponent {
return this.runtime.supportsDesktopFileSave;
}
get playlistEpgUrls(): string[] {
return this.normalizeEpgUrls(this.playlist.epgUrls);
}
get playlistDetectedEpgUrls(): string[] {
const detectedUrls = this.normalizeEpgUrls(
this.playlist.detectedEpgUrls
);
return detectedUrls.length > 0 ? detectedUrls : this.playlistEpgUrls;
}
get hiddenDetectedPlaylistEpgSourceCount(): number {
const enabledUrls = new Set(this.playlistEpgUrls);
return this.playlistDetectedEpgUrls.filter(
(url) => !enabledUrls.has(url)
).length;
}
get canRefreshPlaylistEpg(): boolean {
return this.epgBridge.supportsDataManagement;
}
get canManagePlaylistEpgSources(): boolean {
return !this.playlist.serverUrl && !this.playlist.macAddress;
}
get playlistEpgSourceInputs(): UntypedFormArray {
return this.playlistDetails.get(
'playlistEpgSourceInputs'
) as UntypedFormArray;
}
/** Playlist object */
playlist: Playlist & { id: string };
@@ -159,6 +253,9 @@ export class PlaylistInfoComponent {
stalkerDeviceId2: new FormControl(this.playlist.stalkerDeviceId2),
stalkerSignature1: new FormControl(this.playlist.stalkerSignature1),
stalkerSignature2: new FormControl(this.playlist.stalkerSignature2),
playlistEpgSourceInputs: new UntypedFormArray([
this.createPlaylistEpgSourceControl(),
]),
});
}
@@ -238,6 +335,159 @@ export class PlaylistInfoComponent {
}); */
}
async refreshPlaylistEpgSource(url: string): Promise<void> {
const normalizedUrl = url.trim();
if (!normalizedUrl) {
return;
}
const result = await this.epgBridge.forceFetchEpg(
normalizedUrl,
this.settingsStore.getTrustOptions()
);
if (!result) {
return;
}
this.snackBar.open(
this.translate.instant(
result.success ? 'EPG.FETCH_SUCCESS' : 'EPG.ERROR'
),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
}
async addPlaylistEpgSourceToSettings(url: string): Promise<void> {
const epgUrl = url.trim();
if (!epgUrl || this.isGlobalEpgSource(epgUrl)) {
return;
}
const currentSettings = this.settingsStore.getSettings();
await this.settingsStore.updateSettings({
epgUrl: this.normalizeEpgUrls([
...(currentSettings.epgUrl ?? []),
epgUrl,
]),
});
this.snackBar.open(
this.translate.instant('SETTINGS.ADD_EPG_SOURCE'),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
}
isGlobalEpgSource(url: string): boolean {
const normalizedUrl = url.trim();
if (!normalizedUrl) {
return false;
}
return this.normalizeEpgUrls(
this.settingsStore.getSettings().epgUrl
).includes(normalizedUrl);
}
async removePlaylistEpgSource(url: string): Promise<void> {
const epgUrl = url.trim();
if (!epgUrl) {
return;
}
if (this.epgBridge.supportsDataManagement) {
try {
const result =
await this.epgBridge.clearEpgDataForSource(epgUrl);
if (result && result.success === false) {
throw new Error('Clear EPG source returned false');
}
} catch (error) {
console.error(
'Failed to clear playlist EPG source data:',
error
);
this.snackBar.open(
this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED'),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
return;
}
}
const detectedEpgUrls = this.getRawDetectedPlaylistEpgUrls();
const disabledEpgUrls = this.normalizeEpgUrls(
this.playlist.disabledEpgUrls
);
const nextDisabledEpgUrls = detectedEpgUrls.includes(epgUrl)
? this.normalizeEpgUrls([...disabledEpgUrls, epgUrl])
: disabledEpgUrls.filter((disabledUrl) => disabledUrl !== epgUrl);
const state = resolvePlaylistEpgSourceState({
detectedEpgUrls,
enabledEpgUrls: this.playlistEpgUrls.filter(
(enabledUrl) => enabledUrl !== epgUrl
),
manualEpgUrls: this.normalizeEpgUrls(
this.playlist.manualEpgUrls
).filter((manualUrl) => manualUrl !== epgUrl),
disabledEpgUrls: nextDisabledEpgUrls,
});
this.applyPlaylistEpgSourceState(state);
}
addPlaylistEpgSourceInput(): void {
this.playlistEpgSourceInputs.push(
this.createPlaylistEpgSourceControl()
);
}
removePlaylistEpgSourceInput(index: number): void {
if (this.playlistEpgSourceInputs.length <= 1) {
this.playlistEpgSourceInputs.at(0).reset('');
return;
}
this.playlistEpgSourceInputs.removeAt(index);
}
savePlaylistEpgSources(): void {
if (this.playlistEpgSourceInputs.invalid) {
this.playlistEpgSourceInputs.markAllAsTouched();
return;
}
const addedUrls = this.normalizeEpgUrls(
this.playlistEpgSourceInputs.value as string[]
);
if (addedUrls.length === 0) {
return;
}
const addedUrlSet = new Set(addedUrls);
const state = resolvePlaylistEpgSourceState({
detectedEpgUrls: this.getRawDetectedPlaylistEpgUrls(),
enabledEpgUrls: this.normalizeEpgUrls([
...this.playlistEpgUrls,
...addedUrls,
]),
manualEpgUrls: this.normalizeEpgUrls([
...(this.playlist.manualEpgUrls ?? []),
...addedUrls,
]),
disabledEpgUrls: this.normalizeEpgUrls(
this.playlist.disabledEpgUrls
).filter((url) => !addedUrlSet.has(url)),
});
this.applyPlaylistEpgSourceState(state);
this.resetPlaylistEpgSourceInputs();
}
async exportPlaylist() {
const playlistAsString = await firstValueFrom(
this.playlistsService.getRawPlaylistById(this.playlist._id)
@@ -322,4 +572,46 @@ export class PlaylistInfoComponent {
}
}
}
private normalizeEpgUrls(urls?: string[] | null): string[] {
return normalizeEpgUrls(urls ?? []);
}
private createPlaylistEpgSourceControl(value = ''): FormControl<string> {
return new FormControl(value, {
nonNullable: true,
validators: [Validators.pattern(EPG_URL_PATTERN)],
});
}
private getRawDetectedPlaylistEpgUrls(): string[] {
return this.normalizeEpgUrls(this.playlist.detectedEpgUrls);
}
private applyPlaylistEpgSourceState(
state: ReturnType<typeof resolvePlaylistEpgSourceState>
): void {
const playlistMeta = {
_id: this.playlist._id,
epgUrls: state.epgUrls,
detectedEpgUrls: state.detectedEpgUrls,
manualEpgUrls: state.manualEpgUrls,
disabledEpgUrls: state.disabledEpgUrls,
} as PlaylistMeta;
this.playlist = {
...this.playlist,
...playlistMeta,
};
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({ playlist: playlistMeta })
);
}
private resetPlaylistEpgSourceInputs(): void {
this.playlistEpgSourceInputs.clear();
this.playlistEpgSourceInputs.push(
this.createPlaylistEpgSourceControl()
);
}
}
@@ -462,6 +462,7 @@ export class RecentPlaylistsComponent {
_id: item._id,
},
playlistId: item._id,
refreshEpg: true,
})
);