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

@@ -66,11 +66,15 @@ describe('EpgRuntimeBridgeService', () => {
const fetchEpg = jest.fn().mockResolvedValue({ success: true });
const forceFetchEpg = jest.fn().mockResolvedValue({ success: true });
const clearEpgData = jest.fn().mockResolvedValue({ success: true });
const clearEpgDataForSource = jest
.fn()
.mockResolvedValue({ success: true });
window.electron = {
...window.electron,
fetchEpg,
forceFetchEpg,
clearEpgData,
clearEpgDataForSource,
} as unknown as typeof window.electron;
runtimeCapabilities.supportsEpgImport = true;
runtimeCapabilities.supportsEpgDataManagement = true;
@@ -84,6 +88,13 @@ describe('EpgRuntimeBridgeService', () => {
await expect(service.clearEpgData()).resolves.toEqual({
success: true,
});
await expect(
service.clearEpgDataForSource(
' https://playlist.example.com/guide.xml '
)
).resolves.toEqual({
success: true,
});
expect(fetchEpg).toHaveBeenCalledWith(
['https://example.com/epg.xml'],
@@ -94,6 +105,9 @@ describe('EpgRuntimeBridgeService', () => {
undefined
);
expect(clearEpgData).toHaveBeenCalledTimes(1);
expect(clearEpgDataForSource).toHaveBeenCalledWith(
'https://playlist.example.com/guide.xml'
);
});
it('delegates read-side EPG calls through the typed Electron bridge', async () => {
@@ -127,15 +141,23 @@ describe('EpgRuntimeBridgeService', () => {
runtimeCapabilities.supportsEpgProgramSearch = true;
await service.getChannelPrograms('channel-1');
await service.getCurrentProgramsBatch(['channel-1']);
await service.getChannelMetadata(['channel-1']);
await service.getCurrentProgramsBatch(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
await service.getChannelMetadata(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
await service.checkFreshness(['https://example.com/epg.xml'], 12);
await service.getChannelsByRange(0, 20);
await service.searchPrograms('news', 20);
expect(getChannelPrograms).toHaveBeenCalledWith('channel-1');
expect(getCurrentProgramsBatch).toHaveBeenCalledWith(['channel-1']);
expect(getEpgChannelMetadata).toHaveBeenCalledWith(['channel-1']);
expect(getCurrentProgramsBatch).toHaveBeenCalledWith(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
expect(getEpgChannelMetadata).toHaveBeenCalledWith(['channel-1'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
expect(checkEpgFreshness).toHaveBeenCalledWith(
['https://example.com/epg.xml'],
12
@@ -7,6 +7,7 @@ import {
ElectronBridgeEpgProgressStatus,
ElectronBridgeEpgChannelWithPrograms,
ElectronBridgeEpgFreshnessResult,
ElectronBridgeEpgLookupOptions,
ELECTRON_BRIDGE_EPG_PROGRESS_STATUSES,
ElectronBridgeResult,
ElectronBridgeTrustOptions,
@@ -22,11 +23,13 @@ export type EpgImportProgress = ElectronBridgeEpgProgress;
export type EpgFetchResult = ElectronBridgeEpgFetchResult;
export type EpgFreshnessResult = ElectronBridgeEpgFreshnessResult;
export type EpgClearResult = ElectronBridgeResult;
export type EpgLookupOptions = ElectronBridgeEpgLookupOptions;
type EpgElectronBridge = Pick<
Partial<ElectronBridgeApi>,
| 'checkEpgFreshness'
| 'clearEpgData'
| 'clearEpgDataForSource'
| 'fetchEpg'
| 'forceFetchEpg'
| 'getChannelPrograms'
@@ -109,41 +112,71 @@ export class EpgRuntimeBridgeService {
return this.bridge?.clearEpgData?.() ?? Promise.resolve(null);
}
getChannelPrograms(channelId: string): Promise<EpgProgram[] | null> {
if (!this.supportsProgramLookup) {
clearEpgDataForSource(sourceUrl: string): Promise<EpgClearResult | null> {
if (!this.supportsDataManagement) {
return Promise.resolve(null);
}
const normalizedSourceUrl = sourceUrl.trim();
if (!normalizedSourceUrl) {
return Promise.resolve(null);
}
return (
this.bridge?.getChannelPrograms?.(channelId) ??
this.bridge?.clearEpgDataForSource?.(normalizedSourceUrl) ??
Promise.resolve(null)
);
}
getChannelPrograms(
channelId: string,
options?: EpgLookupOptions
): Promise<EpgProgram[] | null> {
if (!this.supportsProgramLookup) {
return Promise.resolve(null);
}
if (!this.bridge?.getChannelPrograms) {
return Promise.resolve(null);
}
return options
? this.bridge.getChannelPrograms(channelId, options)
: this.bridge.getChannelPrograms(channelId);
}
getCurrentProgramsBatch(
channelIds: string[]
channelIds: string[],
options?: EpgLookupOptions
): Promise<Record<string, EpgProgram | null> | null> {
if (!this.supportsCurrentProgramBatch) {
return Promise.resolve(null);
}
return (
this.bridge?.getCurrentProgramsBatch?.(channelIds) ??
Promise.resolve(null)
);
if (!this.bridge?.getCurrentProgramsBatch) {
return Promise.resolve(null);
}
return options
? this.bridge.getCurrentProgramsBatch(channelIds, options)
: this.bridge.getCurrentProgramsBatch(channelIds);
}
getChannelMetadata(
channelIds: string[]
channelIds: string[],
options?: EpgLookupOptions
): Promise<Record<string, EpgChannelMetadata | null> | null> {
if (!this.supportsChannelMetadata) {
return Promise.resolve(null);
}
return (
this.bridge?.getEpgChannelMetadata?.(channelIds) ??
Promise.resolve(null)
);
if (!this.bridge?.getEpgChannelMetadata) {
return Promise.resolve(null);
}
return options
? this.bridge.getEpgChannelMetadata(channelIds, options)
: this.bridge.getEpgChannelMetadata(channelIds);
}
checkFreshness(
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, skip } from 'rxjs';
import { SettingsStore } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
import { EpgService } from './epg.service';
@@ -26,6 +26,7 @@ describe('EpgService', () => {
};
settingsStore = {
getSettings: jest.fn(() => ({
epgUrl: [],
trustedPrivateNetworkEpgUrls: ['http://192.168.1.20/guide.xml'],
trustedInsecureTlsHosts: ['playlist.local'],
})),
@@ -75,6 +76,7 @@ describe('EpgService', () => {
'https://example.com/epg.xml',
'',
'https://example.com/other.xml',
' https://example.com/epg.xml ',
]);
expect(epgBridge.fetchEpg).toHaveBeenCalledWith(
@@ -132,4 +134,484 @@ describe('EpgService', () => {
expect(epgBridge.getChannelPrograms).toHaveBeenCalledWith('channel-1');
jest.useRealTimers();
});
it('caches current program lookups separately by EPG source URL scope', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://global.example.com/guide.xml'],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsProgramLookup = true;
epgBridge.getChannelPrograms = jest
.fn()
.mockResolvedValueOnce([
{
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
])
.mockResolvedValueOnce([
{
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Global News Bulletin',
},
])
.mockResolvedValue([]);
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z'));
try {
const playlistResult = await firstValueFrom(
service.getCurrentProgramForChannel('guide-news', {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
const globalResult = await firstValueFrom(
service.getCurrentProgramForChannel('guide-news')
);
const cachedPlaylistResult = await firstValueFrom(
service.getCurrentProgramForChannel('guide-news', {
sourceUrls: [' https://playlist.example.com/guide.xml '],
})
);
const cachedGlobalResult = await firstValueFrom(
service.getCurrentProgramForChannel('guide-news')
);
expect(playlistResult?.title).toBe('Playlist Guide Bulletin');
expect(globalResult?.title).toBe('Global News Bulletin');
expect(cachedPlaylistResult?.title).toBe('Playlist Guide Bulletin');
expect(cachedGlobalResult?.title).toBe('Global News Bulletin');
expect(epgBridge.getChannelPrograms).toHaveBeenCalledTimes(2);
expect(epgBridge.getChannelPrograms).toHaveBeenNthCalledWith(
1,
'guide-news',
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
expect(epgBridge.getChannelPrograms).toHaveBeenNthCalledWith(
2,
'guide-news',
{ sourceUrls: ['https://global.example.com/guide.xml'] }
);
} finally {
jest.useRealTimers();
}
});
it('deduplicates concurrent scoped current program lookups for the same source scope', async () => {
epgBridge.supportsProgramLookup = true;
let resolvePrograms:
| ((
programs: {
channel: string;
start: string;
stop: string;
title: string;
}[]
) => void)
| undefined;
epgBridge.getChannelPrograms = jest.fn(
() =>
new Promise((resolve) => {
resolvePrograms = resolve;
})
);
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z'));
try {
const firstLookup = firstValueFrom(
service.getCurrentProgramForChannel('guide-news', {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
const secondLookup = firstValueFrom(
service.getCurrentProgramForChannel('guide-news', {
sourceUrls: [' https://playlist.example.com/guide.xml '],
})
);
expect(epgBridge.getChannelPrograms).toHaveBeenCalledTimes(1);
resolvePrograms?.([
{
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
]);
await expect(
Promise.all([firstLookup, secondLookup])
).resolves.toEqual([
expect.objectContaining({
title: 'Playlist Guide Bulletin',
}),
expect.objectContaining({
title: 'Playlist Guide Bulletin',
}),
]);
} finally {
jest.useRealTimers();
}
});
it('queries playlist-scoped current programs first and falls back to global EPG for missing channels', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: [
'https://global.example.com/guide.xml',
' https://global.example.com/guide.xml ',
],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockResolvedValueOnce({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
'guide-sports': null,
})
.mockResolvedValueOnce({
'guide-sports': {
channel: 'guide-sports',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Global Sports Bulletin',
},
});
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(
['guide-news', 'guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
)
);
expect(result.get('guide-news')?.title).toBe('Playlist Guide Bulletin');
expect(result.get('guide-sports')?.title).toBe(
'Global Sports Bulletin'
);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenNthCalledWith(
1,
['guide-news', 'guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenNthCalledWith(
2,
['guide-sports'],
{ sourceUrls: ['https://global.example.com/guide.xml'] }
);
});
it('does not fall back to the unscoped EPG pool when no global EPG URLs are configured', async () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest.fn().mockResolvedValue({
'guide-sports': null,
});
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-sports'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
expect(result.get('guide-sports')).toBeNull();
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledWith(
['guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
});
it('caches scoped batch current programs by EPG source URL scope', async () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockResolvedValue({})
.mockResolvedValueOnce({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
});
const firstResult = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
const secondResult = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: [' https://playlist.example.com/guide.xml '],
})
);
expect(firstResult.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
expect(secondResult.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
});
it('clears cached null current programs after a successful EPG import', async () => {
epgBridge.supportsImport = true;
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockResolvedValueOnce({
'guide-news': null,
})
.mockResolvedValueOnce({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
});
const beforeImport = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
expect(beforeImport.get('guide-news')).toBeNull();
const availability = firstValueFrom(service.epgAvailable$.pipe(skip(1)));
service.fetchEpg(['https://playlist.example.com/guide.xml']);
await expect(availability).resolves.toBe(true);
const afterImport = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
expect(afterImport.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(2);
});
it('deduplicates concurrent scoped batch current program lookups for the same source scope', async () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
const batchResolvers: Array<
(programs: Record<string, unknown>) => void
> = [];
epgBridge.getCurrentProgramsBatch = jest.fn(
() =>
new Promise((resolve) => {
batchResolvers.push(resolve);
})
);
const firstLookup = firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
const secondLookup = firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: [' https://playlist.example.com/guide.xml '],
})
);
try {
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
batchResolvers.forEach((resolve) =>
resolve({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
})
);
const [firstResult, secondResult] = await Promise.all([
firstLookup,
secondLookup,
]);
expect(firstResult.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
expect(secondResult.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
} finally {
batchResolvers.forEach((resolve) => resolve({}));
}
});
it('deduplicates concurrent scoped batch current program lookups regardless of channel order', async () => {
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
let resolveBatch:
| ((programs: Record<string, unknown>) => void)
| undefined;
epgBridge.getCurrentProgramsBatch = jest.fn(
() =>
new Promise((resolve) => {
resolveBatch = resolve;
})
);
const firstLookup = firstValueFrom(
service.getCurrentProgramsForChannels(
['guide-news', 'guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
)
);
const secondLookup = firstValueFrom(
service.getCurrentProgramsForChannels(
['guide-sports', 'guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
)
);
try {
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
resolveBatch?.({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Guide Bulletin',
},
'guide-sports': {
channel: 'guide-sports',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Playlist Sports Bulletin',
},
});
const [firstResult, secondResult] = await Promise.all([
firstLookup,
secondLookup,
]);
expect(firstResult.get('guide-news')?.title).toBe(
'Playlist Guide Bulletin'
);
expect(secondResult.get('guide-sports')?.title).toBe(
'Playlist Sports Bulletin'
);
} finally {
resolveBatch?.({});
}
});
it('returns null scoped current programs instead of re-entering global lookup when scoped batch lookup fails', async () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://global.example.com/guide.xml'],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsProgramLookup = true;
epgBridge.supportsCurrentProgramBatch = true;
epgBridge.getCurrentProgramsBatch = jest
.fn()
.mockRejectedValueOnce(new Error('ipc down'))
.mockResolvedValueOnce({
'guide-news': {
channel: 'guide-news',
start: '2026-05-23T10:00:00.000Z',
stop: '2026-05-23T11:00:00.000Z',
title: 'Global News Bulletin',
},
});
const result = await firstValueFrom(
service.getCurrentProgramsForChannels(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
})
);
expect(result.get('guide-news')).toBeNull();
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledTimes(1);
expect(epgBridge.getCurrentProgramsBatch).toHaveBeenCalledWith(
['guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
} finally {
consoleError.mockRestore();
}
});
it('falls back to global EPG metadata for channels missing from playlist-scoped sources', async () => {
settingsStore.getSettings.mockReturnValue({
epgUrl: ['https://global.example.com/guide.xml'],
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
});
epgBridge.supportsChannelMetadata = true;
epgBridge.getChannelMetadata = jest
.fn()
.mockResolvedValueOnce({
'guide-news': {
id: 'guide-news',
displayName: 'Playlist News',
iconUrl: 'https://playlist.example.com/news.png',
},
'guide-sports': null,
})
.mockResolvedValueOnce({
'guide-sports': {
id: 'guide-sports',
displayName: 'Global Sports',
iconUrl: 'https://global.example.com/sports.png',
},
});
const result = await firstValueFrom(
service.getChannelMetadataForChannels(
['guide-news', 'guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
)
);
expect(result.get('guide-news')).toMatchObject({
displayName: 'Playlist News',
});
expect(result.get('guide-sports')).toMatchObject({
displayName: 'Global Sports',
iconUrl: 'https://global.example.com/sports.png',
});
expect(epgBridge.getChannelMetadata).toHaveBeenNthCalledWith(
1,
['guide-news', 'guide-sports'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
expect(epgBridge.getChannelMetadata).toHaveBeenNthCalledWith(
2,
['guide-sports'],
{ sourceUrls: ['https://global.example.com/guide.xml'] }
);
});
});
+427 -48
View File
@@ -2,15 +2,27 @@ import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs';
import { catchError, map, tap, timeout } from 'rxjs/operators';
import {
catchError,
finalize,
map,
shareReplay,
switchMap,
tap,
timeout,
} from 'rxjs/operators';
import {
createDevLogger,
EpgChannelMetadata,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { EpgRuntimeBridgeService } from './epg-runtime-bridge.service';
import {
EpgLookupOptions,
EpgRuntimeBridgeService,
} from './epg-runtime-bridge.service';
import { normalizeEpgPrograms } from './epg-program-normalization.util';
import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
interface CachedProgram {
program: EpgProgram | null;
@@ -33,6 +45,14 @@ export class EpgService {
// Cache for channel programs with 60-second TTL
private programCache = new Map<string, CachedProgram>();
private fetchingCurrentPrograms = new Map<
string,
Observable<EpgProgram | null>
>();
private fetchingCurrentProgramBatches = new Map<
string,
Observable<Map<string, EpgProgram | null>>
>();
private readonly CACHE_TTL = 60000; // 60 seconds
readonly epgAvailable$ = this.epgAvailable.asObservable();
@@ -44,8 +64,8 @@ export class EpgService {
fetchEpg(urls: string[]): void {
if (!this.epgBridge.supportsImport) return;
// Filter out empty URLs and send all URLs at once
const validUrls = urls.filter((url) => url?.trim());
// Filter out empty and duplicate URLs and send all URLs at once.
const validUrls = normalizeEpgUrls(urls);
if (validUrls.length === 0) return;
from(
@@ -59,6 +79,7 @@ export class EpgService {
if (result === null) return;
if (result.success) {
this.clearCache();
this.epgAvailable.next(true);
} else {
this.epgAvailable.next(false);
@@ -117,50 +138,46 @@ export class EpgService {
* @returns Observable of current program or null
*/
getCurrentProgramForChannel(
channelId: string
channelId: string,
options?: EpgLookupOptions
): Observable<EpgProgram | null> {
if (!this.epgBridge.supportsProgramLookup || !channelId) {
return of(null);
}
// Check cache first
const cached = this.programCache.get(channelId);
const now = Date.now();
if (cached && now - cached.timestamp < this.CACHE_TTL) {
return of(cached.program);
const sourceUrls = this.normalizeSourceUrls(options);
if (sourceUrls.length > 0) {
return this.getScopedCurrentProgramForChannel(
channelId,
sourceUrls,
this.getGlobalEpgSourceUrls(sourceUrls)
);
}
const globalSourceUrls = this.getGlobalEpgSourceUrls();
if (globalSourceUrls.length > 0) {
return this.getScopedCurrentProgramForChannel(
channelId,
globalSourceUrls,
[]
);
}
// Check cache first
const cacheKey = this.createProgramCacheKey(channelId);
// Fetch from backend
return from(this.epgBridge.getChannelPrograms(channelId)).pipe(
map((programs) => normalizeEpgPrograms(programs ?? [])),
map((programs: EpgProgram[]) => {
if (!programs.length) {
this.programCache.set(channelId, {
program: null,
timestamp: now,
});
return null;
}
const currentProgram = this.findCurrentProgram(programs);
// Cache the result
this.programCache.set(channelId, {
program: currentProgram,
timestamp: now,
});
return currentProgram;
}),
catchError((err) => {
console.error('EPG get current program error:', err);
this.programCache.set(channelId, {
program: null,
timestamp: now,
});
return of(null);
})
return this.getCachedOrFetchCurrentProgram(cacheKey, () =>
from(this.epgBridge.getChannelPrograms(channelId)).pipe(
map((programs) => normalizeEpgPrograms(programs ?? [])),
map((programs: EpgProgram[]) =>
this.findCurrentProgram(programs)
),
catchError((err) => {
console.error('EPG get current program error:', err);
return of(null);
})
)
);
}
@@ -185,7 +202,8 @@ export class EpgService {
* @returns Observable of Map with channelId -> current program
*/
getCurrentProgramsForChannels(
channelIds: string[]
channelIds: string[],
options?: EpgLookupOptions
): Observable<Map<string, EpgProgram | null>> {
if (!this.epgBridge.supportsProgramLookup) {
return of(new Map());
@@ -195,6 +213,29 @@ export class EpgService {
return of(new Map());
}
const sourceUrls = this.normalizeSourceUrls(options);
if (
sourceUrls.length > 0 &&
this.epgBridge.supportsCurrentProgramBatch
) {
return this.getScopedCurrentProgramsForChannels(
channelIds,
sourceUrls
);
}
const globalSourceUrls = this.getGlobalEpgSourceUrls();
if (
globalSourceUrls.length > 0 &&
this.epgBridge.supportsCurrentProgramBatch
) {
return this.getScopedCurrentProgramsForChannels(
channelIds,
globalSourceUrls,
[]
);
}
const resultMap = new Map<string, EpgProgram | null>();
const channelsToFetch: string[] = [];
const now = Date.now();
@@ -260,31 +301,236 @@ export class EpgService {
);
}
private getScopedCurrentProgramsForChannels(
channelIds: string[],
sourceUrls: string[],
fallbackSourceUrls = this.getGlobalEpgSourceUrls(sourceUrls)
): Observable<Map<string, EpgProgram | null>> {
const normalizedChannelIds = this.normalizeChannelIds(channelIds);
if (normalizedChannelIds.length === 0) {
return of(new Map());
}
const resultMap = new Map<string, EpgProgram | null>();
const channelsToFetch: string[] = [];
normalizedChannelIds.forEach((channelId) => {
const cached = this.getCachedProgram(
this.createProgramCacheKey(channelId, sourceUrls)
);
if (cached) {
resultMap.set(channelId, cached.program);
} else {
channelsToFetch.push(channelId);
}
});
if (channelsToFetch.length === 0) {
return of(resultMap);
}
const batchCacheKey = this.createProgramBatchCacheKey(
channelsToFetch,
sourceUrls,
fallbackSourceUrls
);
const existingRequest =
this.fetchingCurrentProgramBatches.get(batchCacheKey);
const request$ =
existingRequest ??
this.fetchScopedCurrentProgramsBatch(
channelsToFetch,
sourceUrls,
fallbackSourceUrls
).pipe(
tap((fetchedMap) => {
const cacheTimestamp = Date.now();
channelsToFetch.forEach((channelId) => {
this.programCache.set(
this.createProgramCacheKey(channelId, sourceUrls),
{
program: fetchedMap.get(channelId) ?? null,
timestamp: cacheTimestamp,
}
);
});
}),
finalize(() => {
this.fetchingCurrentProgramBatches.delete(batchCacheKey);
}),
shareReplay({ bufferSize: 1, refCount: false })
);
if (!existingRequest) {
this.fetchingCurrentProgramBatches.set(batchCacheKey, request$);
}
return request$.pipe(
map((fetchedMap) => {
const mergedResultMap = new Map(resultMap);
channelsToFetch.forEach((channelId) => {
mergedResultMap.set(
channelId,
fetchedMap.get(channelId) ?? null
);
});
return mergedResultMap;
})
);
}
private fetchScopedCurrentProgramsBatch(
channelIds: string[],
sourceUrls: string[],
fallbackSourceUrls: string[]
): Observable<Map<string, EpgProgram | null>> {
return from(
this.epgBridge.getCurrentProgramsBatch(channelIds, {
sourceUrls,
})
).pipe(
timeout(5000),
switchMap((scopedResult) => {
const resultMap = new Map<string, EpgProgram | null>();
const fallbackChannelIds: string[] = [];
channelIds.forEach((channelId) => {
const program = scopedResult?.[channelId] ?? null;
resultMap.set(channelId, program);
if (!program) {
fallbackChannelIds.push(channelId);
}
});
if (fallbackChannelIds.length === 0) {
return of(resultMap);
}
if (fallbackSourceUrls.length === 0) {
return of(resultMap);
}
return from(
this.epgBridge.getCurrentProgramsBatch(fallbackChannelIds, {
sourceUrls: fallbackSourceUrls,
})
).pipe(
timeout(5000),
map((globalResult) => {
fallbackChannelIds.forEach((channelId) => {
resultMap.set(
channelId,
globalResult?.[channelId] ?? null
);
});
return resultMap;
}),
catchError((err) => {
console.error(
'EPG global fallback current programs error:',
err
);
return of(resultMap);
})
);
}),
catchError((err) => {
console.error('EPG scoped batch current programs error:', err);
return of(this.createNullProgramMap(channelIds));
})
);
}
getChannelMetadataForChannels(
channelIds: string[]
channelIds: string[],
options?: EpgLookupOptions
): Observable<Map<string, EpgChannelMetadata | null>> {
if (!this.epgBridge.supportsChannelMetadata) {
return of(new Map());
}
const normalizedChannelIds = Array.from(
const normalizedChannelIds = this.normalizeChannelIds(channelIds);
if (normalizedChannelIds.length === 0) {
return of(new Map());
}
const sourceUrls = this.normalizeSourceUrls(options);
const globalSourceUrls =
sourceUrls.length > 0
? this.getGlobalEpgSourceUrls(sourceUrls)
: this.getGlobalEpgSourceUrls();
const effectiveSourceUrls =
sourceUrls.length > 0 ? sourceUrls : globalSourceUrls;
return this.getChannelMetadataMapForSourceUrls(
normalizedChannelIds,
effectiveSourceUrls
).pipe(
switchMap((metadataMap) => {
const fallbackChannelIds =
sourceUrls.length > 0 && globalSourceUrls.length > 0
? normalizedChannelIds.filter(
(channelId) => !metadataMap.get(channelId)
)
: [];
if (fallbackChannelIds.length === 0) {
return of(metadataMap);
}
return this.getChannelMetadataMapForSourceUrls(
fallbackChannelIds,
globalSourceUrls
).pipe(
map((globalMetadataMap) => {
fallbackChannelIds.forEach((channelId) => {
metadataMap.set(
channelId,
globalMetadataMap.get(channelId) ?? null
);
});
return metadataMap;
}),
catchError((err) => {
console.error(
'EPG global fallback channel metadata error:',
err
);
return of(metadataMap);
})
);
})
);
}
private normalizeChannelIds(channelIds: string[]): string[] {
return Array.from(
new Set(
channelIds
.map((channelId) => channelId.trim())
.filter((channelId) => channelId.length > 0)
)
);
}
if (normalizedChannelIds.length === 0) {
return of(new Map());
}
private normalizeSourceUrls(options?: EpgLookupOptions): string[] {
return normalizeEpgUrls(options?.sourceUrls ?? []);
}
private getChannelMetadataMapForSourceUrls(
channelIds: string[],
sourceUrls: string[]
): Observable<Map<string, EpgChannelMetadata | null>> {
return from(
this.epgBridge.getChannelMetadata(normalizedChannelIds)
this.epgBridge.getChannelMetadata(
channelIds,
sourceUrls.length > 0 ? { sourceUrls } : undefined
)
).pipe(
map((metadataByChannelId) => {
return new Map<string, EpgChannelMetadata | null>(
normalizedChannelIds.map((channelId) => [
channelIds.map((channelId) => [
channelId,
metadataByChannelId?.[channelId] ?? null,
])
@@ -297,10 +543,143 @@ export class EpgService {
);
}
private createProgramCacheKey(
channelId: string,
sourceUrls: string[] = []
): string {
const normalizedSourceUrls = normalizeEpgUrls(sourceUrls);
if (normalizedSourceUrls.length === 0) {
return channelId;
}
return `source:${channelId}:${JSON.stringify(normalizedSourceUrls)}`;
}
private createProgramBatchCacheKey(
channelIds: string[],
sourceUrls: string[],
fallbackSourceUrls: string[]
): string {
return JSON.stringify({
channelIds: [...channelIds].sort(),
sourceUrls: normalizeEpgUrls(sourceUrls),
fallbackSourceUrls: normalizeEpgUrls(fallbackSourceUrls),
});
}
private getCachedProgram(cacheKey: string): CachedProgram | undefined {
const cached = this.programCache.get(cacheKey);
if (!cached) {
return undefined;
}
if (Date.now() - cached.timestamp >= this.CACHE_TTL) {
this.programCache.delete(cacheKey);
return undefined;
}
return cached;
}
private getCachedOrFetchCurrentProgram(
cacheKey: string,
fetchProgram: () => Observable<EpgProgram | null>
): Observable<EpgProgram | null> {
const cached = this.getCachedProgram(cacheKey);
if (cached) {
return of(cached.program);
}
const existingRequest = this.fetchingCurrentPrograms.get(cacheKey);
if (existingRequest) {
return existingRequest;
}
const request$ = fetchProgram().pipe(
tap((program) => {
this.programCache.set(cacheKey, {
program,
timestamp: Date.now(),
});
}),
finalize(() => {
this.fetchingCurrentPrograms.delete(cacheKey);
}),
shareReplay({ bufferSize: 1, refCount: false })
);
this.fetchingCurrentPrograms.set(cacheKey, request$);
return request$;
}
private getScopedCurrentProgramForChannel(
channelId: string,
sourceUrls: string[],
fallbackSourceUrls: string[]
): Observable<EpgProgram | null> {
const cacheKey = this.createProgramCacheKey(channelId, sourceUrls);
return this.getCachedOrFetchCurrentProgram(cacheKey, () =>
from(
this.epgBridge.getChannelPrograms(channelId, { sourceUrls })
).pipe(
timeout(3000),
map((programs) => normalizeEpgPrograms(programs ?? [])),
switchMap((programs) => {
const currentProgram = this.findCurrentProgram(programs);
if (currentProgram) {
return of(currentProgram);
}
return this.getFallbackCurrentProgramForChannel(
channelId,
fallbackSourceUrls
);
}),
catchError((err) => {
console.error('EPG scoped current program error:', err);
return this.getFallbackCurrentProgramForChannel(
channelId,
fallbackSourceUrls
);
})
)
);
}
private getFallbackCurrentProgramForChannel(
channelId: string,
sourceUrls: string[]
): Observable<EpgProgram | null> {
if (sourceUrls.length === 0) {
return of(null);
}
return this.getScopedCurrentProgramForChannel(
channelId,
sourceUrls,
[]
);
}
private createNullProgramMap(
channelIds: string[]
): Map<string, EpgProgram | null> {
return new Map(channelIds.map((channelId) => [channelId, null]));
}
private getGlobalEpgSourceUrls(excluding: string[] = []): string[] {
const excludedUrls = new Set(excluding);
return normalizeEpgUrls(
this.settingsStore.getSettings().epgUrl ?? []
).filter((url) => !excludedUrls.has(url));
}
/**
* Clears the program cache (useful when EPG is refreshed)
*/
clearCache(): void {
this.programCache.clear();
this.fetchingCurrentPrograms.clear();
this.fetchingCurrentProgramBatches.clear();
}
}
+5 -1
View File
@@ -10,7 +10,11 @@ export const PlaylistActions = createActionGroup({
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(),
'Update Playlist': props<{ playlist: Playlist; playlistId: string }>(),
'Update Playlist': props<{
playlist: Playlist;
playlistId: string;
refreshEpg?: boolean;
}>(),
'Update Many Playlists': props<{ playlists: Playlist[] }>(),
'Parse Playlist': props<{
uploadType: 'FILE' | 'URL' | 'TEXT';
+111 -13
View File
@@ -2,7 +2,10 @@ 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 { resolveM3uCatchupUrl } from '@iptvnator/shared/m3u-utils';
import {
normalizeEpgUrls,
resolveM3uCatchupUrl,
} from '@iptvnator/shared/m3u-utils';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
@@ -18,12 +21,17 @@ import {
tap,
withLatestFrom,
} from 'rxjs';
import { DataService, PlaylistsService } from '@iptvnator/services';
import {
DataService,
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
Channel,
Playlist,
PlaylistMeta,
STORE_KEY,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
@@ -41,6 +49,7 @@ import {
} from './selectors';
import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util';
import { buildExternalPlayerPayload } from './external-player-payload.util';
import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util';
@Injectable({ providedIn: 'any' })
export class PlaylistEffects {
@@ -53,6 +62,8 @@ export class PlaylistEffects {
private storage = inject(StorageMap);
private store = inject(Store);
private translate = inject(TranslateService);
private settingsStore = inject(SettingsStore);
private readonly playlistScopedEpgFetchKeys = new Map<string, string>();
updateFavorites$ = createEffect(
() => {
@@ -222,6 +233,9 @@ export class PlaylistEffects {
ofType(PlaylistActions.loadPlaylists),
switchMap(() =>
this.playlistsService.getAllPlaylists().pipe(
tap((playlists) => {
this.fetchPlaylistScopedEpgForPlaylists(playlists);
}),
map((playlists) =>
PlaylistActions.loadPlaylistsSuccess({
playlists,
@@ -263,6 +277,7 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.removePlaylist),
switchMap(async (action) => {
this.playlistScopedEpgFetchKeys.delete(action.playlistId);
await firstValueFrom(
this.playlistsService.deletePlaylist(action.playlistId)
);
@@ -280,7 +295,13 @@ export class PlaylistEffects {
this.playlistsService.updatePlaylist(action.playlistId, {
...action.playlist,
_id: action.playlistId,
})
}).pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist, {
force: action.refreshEpg === true,
});
})
)
)
);
},
@@ -315,18 +336,16 @@ export class PlaylistEffects {
PlaylistActions.addPlaylist,
PlaylistActions.handleAddingPlaylistByUrl
),
tap((action) => {
if ('isTemporary' in action && action.isTemporary) {
return;
}
this.navigateToPlaylist(action.playlist);
}),
switchMap((action) => {
if ('isTemporary' in action && action.isTemporary) {
return EMPTY;
}
return this.playlistsService.addPlaylist(action.playlist);
return this.playlistsService.addPlaylist(action.playlist).pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist);
this.navigateToPlaylist(action.playlist);
})
);
})
);
},
@@ -338,7 +357,17 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistMeta),
switchMap((action) =>
this.playlistsService.updatePlaylistMeta(action.playlist)
this.playlistsService.updatePlaylistMeta(action.playlist).pipe(
tap(() => {
if (
this.hasPlaylistScopedEpgSourceChange(
action.playlist
)
) {
this.fetchPlaylistScopedEpg(action.playlist);
}
})
)
)
);
},
@@ -376,7 +405,15 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.updateManyPlaylists),
switchMap((action) =>
this.playlistsService.updateManyPlaylists(action.playlists)
this.playlistsService
.updateManyPlaylists(action.playlists)
.pipe(
tap(() => {
action.playlists.forEach((playlist) =>
this.fetchPlaylistScopedEpg(playlist)
);
})
)
)
);
},
@@ -424,4 +461,65 @@ export class PlaylistEffects {
void this.router.navigate(['/workspace', 'playlists', playlist._id]);
}
private fetchPlaylistScopedEpg(
playlist: Pick<
Playlist,
'_id' | 'epgUrls' | 'macAddress' | '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 ?? []);
}
}
@@ -0,0 +1,141 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util';
function createPlaylistMeta(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
return {
_id: 'playlist-1',
title: 'Playlist',
count: 1,
importDate: '2026-06-21T10:00:00.000Z',
autoRefresh: false,
epgUrls: [],
...overrides,
};
}
describe('resolvePlaylistScopedEpgFetchPlan', () => {
it('does not refetch when playlist metadata changes without fetchable EPG URL changes', () => {
const playlist = createPlaylistMeta({
title: 'Renamed playlist',
epgUrls: ['https://playlist.example.com/guide.xml'],
});
expect(
resolvePlaylistScopedEpgFetchPlan(
playlist,
[],
'https://playlist.example.com/guide.xml'
)
).toEqual({
key: 'https://playlist.example.com/guide.xml',
shouldFetch: false,
urls: ['https://playlist.example.com/guide.xml'],
});
});
it('does not fetch playlist EPG URLs that are already configured globally', () => {
const playlist = createPlaylistMeta({
epgUrls: [
'https://global.example.com/guide.xml',
'https://playlist.example.com/guide.xml',
],
});
expect(
resolvePlaylistScopedEpgFetchPlan(playlist, [
' https://global.example.com/guide.xml ',
])
).toEqual({
key: 'https://playlist.example.com/guide.xml',
shouldFetch: true,
urls: ['https://playlist.example.com/guide.xml'],
});
});
it('keeps the previous fetch key when a partial playlist metadata update omits EPG URLs', () => {
expect(
resolvePlaylistScopedEpgFetchPlan(
{},
[],
'https://playlist.example.com/guide.xml'
)
).toEqual({
key: 'https://playlist.example.com/guide.xml',
shouldFetch: false,
urls: [],
});
});
it('does not refetch remaining playlist EPG URLs when one local source is disabled', () => {
const playlist = createPlaylistMeta({
epgUrls: ['https://playlist.example.com/keep.xml'],
});
expect(
resolvePlaylistScopedEpgFetchPlan(
playlist,
[],
[
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/remove.xml',
].join('\n')
)
).toEqual({
key: 'https://playlist.example.com/keep.xml',
shouldFetch: false,
urls: ['https://playlist.example.com/keep.xml'],
});
});
it('fetches only newly added playlist EPG URLs when the local source set expands', () => {
const playlist = createPlaylistMeta({
epgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/new.xml',
],
});
expect(
resolvePlaylistScopedEpgFetchPlan(
playlist,
[],
'https://playlist.example.com/keep.xml'
)
).toEqual({
key: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/new.xml',
].join('\n'),
shouldFetch: true,
urls: ['https://playlist.example.com/new.xml'],
});
});
it('fetches all playlist EPG URLs when refresh is forced for an already fetched source set', () => {
const playlist = createPlaylistMeta({
epgUrls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/other.xml',
],
});
const key = [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/other.xml',
].join('\n');
expect(
resolvePlaylistScopedEpgFetchPlan(playlist, [], key, {
force: true,
})
).toEqual({
key,
shouldFetch: true,
urls: [
'https://playlist.example.com/keep.xml',
'https://playlist.example.com/other.xml',
],
});
});
});
@@ -0,0 +1,47 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { filterPlaylistEpgUrlsForFetch } from '@iptvnator/shared/m3u-utils';
export interface PlaylistScopedEpgFetchPlan {
key: string;
shouldFetch: boolean;
urls: string[];
}
export interface PlaylistScopedEpgFetchOptions {
force?: boolean;
}
export function resolvePlaylistScopedEpgFetchPlan(
playlist: Pick<PlaylistMeta, 'epgUrls' | 'macAddress' | 'serverUrl'>,
globalEpgUrls: readonly string[],
previousKey = '',
options: PlaylistScopedEpgFetchOptions = {}
): PlaylistScopedEpgFetchPlan {
if (playlist.serverUrl || playlist.macAddress) {
return { key: '', shouldFetch: false, urls: [] };
}
if (
!Object.prototype.hasOwnProperty.call(playlist, 'epgUrls') &&
previousKey.length > 0
) {
return { key: previousKey, shouldFetch: false, urls: [] };
}
const urls = filterPlaylistEpgUrlsForFetch(playlist.epgUrls, globalEpgUrls);
const key = urls.join('\n');
const previousUrls = new Set(
previousKey
.split('\n')
.map((url) => url.trim())
.filter((url) => url.length > 0)
);
const newUrls = urls.filter((url) => !previousUrls.has(url));
const urlsToFetch = options.force ? urls : newUrls;
return {
key,
shouldFetch: urlsToFetch.length > 0,
urls: urlsToFetch.length > 0 ? urlsToFetch : urls,
};
}
@@ -1,5 +1,6 @@
import { on } from '@ngrx/store';
import { Channel } from '@iptvnator/shared/interfaces';
import { resolvePlaylistEpgSourceState } from '@iptvnator/shared/m3u-utils';
import { PlaylistActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { PlaylistState } from '../state';
@@ -34,6 +35,19 @@ export const playlistReducers = [
const isActivePlaylist =
state.playlists.selectedId === action.playlistId;
const currentPlaylist = state.playlists.entities[action.playlistId];
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
action.playlist.detectedEpgUrls ??
currentPlaylist?.detectedEpgUrls,
enabledEpgUrls:
action.playlist.epgUrls ?? currentPlaylist?.epgUrls,
manualEpgUrls:
action.playlist.manualEpgUrls ??
currentPlaylist?.manualEpgUrls,
disabledEpgUrls:
action.playlist.disabledEpgUrls ??
currentPlaylist?.disabledEpgUrls,
});
return {
...state,
channels: isActivePlaylist
@@ -49,8 +63,11 @@ export const playlistReducers = [
updateDate: Date.now(),
count: action.playlist.playlist.items.length,
userAgent: action.playlist.userAgent,
favorites:
currentPlaylist?.favorites ?? [],
favorites: currentPlaylist?.favorites ?? [],
epgUrls: epgSourceState.epgUrls,
detectedEpgUrls: epgSourceState.detectedEpgUrls,
manualEpgUrls: epgSourceState.manualEpgUrls,
disabledEpgUrls: epgSourceState.disabledEpgUrls,
autoRefresh:
currentPlaylist?.autoRefresh ??
action.playlist.autoRefresh,
@@ -143,6 +160,16 @@ export const playlistReducers = [
...(p.hiddenGroupTitles != null
? { hiddenGroupTitles: p.hiddenGroupTitles }
: {}),
...(p.epgUrls != null ? { epgUrls: p.epgUrls } : {}),
...(p.detectedEpgUrls != null
? { detectedEpgUrls: p.detectedEpgUrls }
: {}),
...(p.manualEpgUrls != null
? { manualEpgUrls: p.manualEpgUrls }
: {}),
...(p.disabledEpgUrls != null
? { disabledEpgUrls: p.disabledEpgUrls }
: {}),
...(p.updateDate !== undefined
? { updateDate: p.updateDate }
: {}),
@@ -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,
})
);
+50 -2
View File
@@ -5,6 +5,7 @@ import {
aggregateFavoriteChannels,
createFavoritesPlaylist,
createPlaylistObject,
resolvePlaylistEpgSourceState,
} from '@iptvnator/shared/m3u-utils';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import {
@@ -210,6 +211,10 @@ export class PlaylistsService {
playlist: playlist.playlist,
url: playlist.url,
filePath: playlist.filePath,
epgUrls: playlist.epgUrls ?? [],
detectedEpgUrls: playlist.detectedEpgUrls ?? playlist.epgUrls ?? [],
manualEpgUrls: playlist.manualEpgUrls ?? [],
disabledEpgUrls: playlist.disabledEpgUrls ?? [],
userAgent: playlist.userAgent,
referrer: playlist.referrer,
origin: playlist.origin,
@@ -465,6 +470,19 @@ export class PlaylistsService {
updatePlaylist(playlistId: string, updatedPlaylist: Playlist) {
return this.getPlaylistById(playlistId).pipe(
switchMap((currentPlaylist: Playlist) => {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ??
currentPlaylist.detectedEpgUrls,
enabledEpgUrls:
updatedPlaylist.epgUrls ?? currentPlaylist.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ??
currentPlaylist.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ??
currentPlaylist.disabledEpgUrls,
});
const mergedPlaylist: Playlist = {
...currentPlaylist,
...updatedPlaylist,
@@ -475,6 +493,10 @@ export class PlaylistsService {
updateDate: Date.now(),
updateState: PlaylistUpdateState.UPDATED,
favorites: currentPlaylist.favorites,
epgUrls: epgSourceState.epgUrls,
detectedEpgUrls: epgSourceState.detectedEpgUrls,
manualEpgUrls: epgSourceState.manualEpgUrls,
disabledEpgUrls: epgSourceState.disabledEpgUrls,
autoRefresh:
currentPlaylist.autoRefresh ??
updatedPlaylist.autoRefresh,
@@ -515,6 +537,18 @@ export class PlaylistsService {
updatePlaylistMeta(updatedPlaylist: PlaylistMeta) {
return this.getPlaylistById(updatedPlaylist._id).pipe(
switchMap((playlist) => {
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls:
updatedPlaylist.detectedEpgUrls ??
playlist.detectedEpgUrls,
enabledEpgUrls: updatedPlaylist.epgUrls ?? playlist.epgUrls,
manualEpgUrls:
updatedPlaylist.manualEpgUrls ??
playlist.manualEpgUrls,
disabledEpgUrls:
updatedPlaylist.disabledEpgUrls ??
playlist.disabledEpgUrls,
});
const nextPlaylist: Playlist = {
...playlist,
...(updatedPlaylist.title != null
@@ -565,6 +599,21 @@ export class PlaylistsService {
updatedPlaylist.hiddenGroupTitles,
}
: {}),
...(updatedPlaylist.detectedEpgUrls !== undefined
? { detectedEpgUrls: epgSourceState.detectedEpgUrls }
: {}),
...(updatedPlaylist.manualEpgUrls !== undefined
? { manualEpgUrls: epgSourceState.manualEpgUrls }
: {}),
...(updatedPlaylist.disabledEpgUrls !== undefined
? { disabledEpgUrls: epgSourceState.disabledEpgUrls }
: {}),
...(updatedPlaylist.epgUrls !== undefined ||
updatedPlaylist.detectedEpgUrls !== undefined ||
updatedPlaylist.manualEpgUrls !== undefined ||
updatedPlaylist.disabledEpgUrls !== undefined
? { epgUrls: epgSourceState.epgUrls }
: {}),
...(updatedPlaylist.updateDate !== undefined
? { updateDate: updatedPlaylist.updateDate }
: {}),
@@ -659,8 +708,7 @@ export class PlaylistsService {
playlistId: string
): Observable<M3uFavoriteChannel[] | null> {
const electron = this.electronApi;
const getFavoriteChannels =
electron?.dbGetAppPlaylistFavoriteChannels;
const getFavoriteChannels = electron?.dbGetAppPlaylistFavoriteChannels;
if (
!electron ||
!this.isElectronStorageAvailable ||
@@ -129,6 +129,7 @@ describe('RuntimeCapabilitiesService', () => {
onEpgProgress: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
@@ -305,6 +306,7 @@ describe('RuntimeCapabilitiesService', () => {
checkEpgFreshness: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
@@ -320,6 +322,7 @@ describe('RuntimeCapabilitiesService', () => {
checkEpgFreshness: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
@@ -102,7 +102,8 @@ export class RuntimeCapabilitiesService {
get supportsEpgDataManagement(): boolean {
return (
this.hasElectronMethod('forceFetchEpg') &&
this.hasElectronMethod('clearEpgData')
this.hasElectronMethod('clearEpgData') &&
this.hasElectronMethod('clearEpgDataForSource')
);
}
@@ -82,6 +82,9 @@ describe('database schema statements', () => {
expect.arrayContaining([
'ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0',
'ALTER TABLE playlists ADD COLUMN payload TEXT',
'ALTER TABLE playlists ADD COLUMN detected_epg_urls TEXT',
'ALTER TABLE playlists ADD COLUMN manual_epg_urls TEXT',
'ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT',
'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0',
'ALTER TABLE content ADD COLUMN backdrop_url TEXT',
])
@@ -102,10 +105,26 @@ describe('database schema statements', () => {
'CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)',
'CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)',
'CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)',
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)',
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)',
])
);
});
it('creates indexes for migrated EPG program columns only after column migrations run', () => {
const createSchemaSql = createTableStatements.map(compactSql);
expect(createSchemaSql).not.toContain(
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)'
);
expect(createSchemaSql).not.toContain(
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)'
);
expect(columnMigrationStatements.map(compactSql)).toContain(
'ALTER TABLE epg_programs ADD COLUMN source_url TEXT'
);
});
it('does not define duplicate fresh-install schema object names', () => {
const objectNames = [
...createdObjectNames(
@@ -307,4 +326,102 @@ describe('database schema statements', () => {
expect.stringContaining('UPDATE content')
);
});
it('backfills migrated EPG program source URLs after creating scoped EPG indexes', () => {
const callOrder: string[] = [];
const runMigrations = (
__databaseConnectionTestHooks as unknown as {
runMigrations: (sqlite: {
exec: (statement: string) => void;
prepare: (statement: string) => {
all?: () => unknown[];
get?: (...args: unknown[]) => unknown;
run?: (...args: unknown[]) => unknown;
};
transaction: (callback: () => void) => () => void;
}) => void;
}
).runMigrations;
const sqlite = {
exec: jest.fn((statement: string) => {
if (statement.includes('idx_epg_programs_source')) {
callOrder.push('epg-source-index');
}
}),
prepare: jest.fn((statement: string) => {
if (statement.includes('SELECT value FROM app_state')) {
return {
get: (): undefined => undefined,
};
}
if (statement.includes('UPDATE epg_programs')) {
return {
run: () => {
callOrder.push('epg-source-backfill');
},
};
}
if (statement.includes('INSERT INTO app_state')) {
return { run: jest.fn() };
}
return {
all: () => [],
get: (): undefined => undefined,
run: jest.fn(),
};
}),
transaction: jest.fn((callback: () => void) => callback),
};
runMigrations(sqlite);
expect(callOrder).toContain('epg-source-backfill');
expect(callOrder).toContain('epg-source-index');
expect(callOrder.indexOf('epg-source-index')).toBeLessThan(
callOrder.indexOf('epg-source-backfill')
);
});
it('backfills migrated EPG program source URLs in bounded batches', () => {
const { backfillEpgProgramSourceUrls } =
__databaseConnectionTestHooks;
let updateStatement = '';
const backfillRun = jest
.fn()
.mockReturnValueOnce({ changes: 50_000 })
.mockReturnValueOnce({ changes: 12 });
const stateRun = jest.fn();
const prepare = jest.fn((statement: string) => {
if (statement.includes('SELECT value FROM app_state')) {
return {
get: (): undefined => undefined,
};
}
if (statement.includes('UPDATE epg_programs')) {
updateStatement = compactSql(statement);
return {
run: backfillRun,
};
}
if (statement.includes('INSERT INTO app_state')) {
return { run: stateRun };
}
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
});
const transaction = jest.fn((callback: () => void) => callback);
const sqlite = {
prepare,
transaction,
} as unknown as Parameters<typeof backfillEpgProgramSourceUrls>[0];
backfillEpgProgramSourceUrls(sqlite);
expect(updateStatement).toContain('LIMIT 50000');
expect(backfillRun).toHaveBeenCalledTimes(2);
expect(stateRun).toHaveBeenCalledWith(
'migration:epg-program-source-url-backfill:v1'
);
});
});
@@ -28,6 +28,9 @@ const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY =
'migration:xtream-content-added-epoch-seconds:v1';
const CONTENT_TITLE_FTS_MIGRATION_KEY =
'migration:content-title-fts-trigram:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY =
'migration:epg-program-source-url-backfill:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
function readTraceFlag(name: string): boolean {
const value = process.env[name]?.trim().toLowerCase();
@@ -84,6 +87,10 @@ const CREATE_TABLE_STATEMENTS = [
origin TEXT,
referrer TEXT,
filePath TEXT,
epg_urls TEXT,
detected_epg_urls TEXT,
manual_epg_urls TEXT,
disabled_epg_urls TEXT,
autoRefresh INTEGER DEFAULT 0,
macAddress TEXT,
url TEXT,
@@ -210,6 +217,7 @@ const CREATE_TABLE_STATEMENTS = [
icon_url TEXT,
rating TEXT,
episode_num TEXT,
source_url TEXT,
FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE
)`,
// EPG indexes
@@ -304,6 +312,11 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE playlists ADD COLUMN favorites TEXT`,
`ALTER TABLE playlists ADD COLUMN recently_viewed TEXT`,
`ALTER TABLE playlists ADD COLUMN payload TEXT`,
// v1.2.1: Keep M3U-detected EPG URLs available in lightweight playlist metadata
`ALTER TABLE playlists ADD COLUMN epg_urls TEXT`,
`ALTER TABLE playlists ADD COLUMN detected_epg_urls TEXT`,
`ALTER TABLE playlists ADD COLUMN manual_epg_urls TEXT`,
`ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT`,
// v1.2.0 -> v1.3.0: Add position column to favorites for global favorites ordering
`ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0`,
// v1.4.0 -> v1.5.0: Preserve Xtream live metadata required for EPG/catch-up
@@ -313,6 +326,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE content ADD COLUMN direct_source TEXT`,
// v1.5.0 -> v1.6.0: Cinematic backdrop persisted on first detail fetch
`ALTER TABLE content ADD COLUMN backdrop_url TEXT`,
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
];
const INDEX_MIGRATION_STATEMENTS = [
@@ -321,6 +336,9 @@ const INDEX_MIGRATION_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`,
// v1.6.0 -> v1.7.0: Query global favorites in stable display order
`CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)`,
// v1.7.1 -> v1.7.2: Query playlist-scoped EPG by source URL and channel/time
`CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)`,
`CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)`,
];
export const __databaseConnectionTestHooks = {
@@ -329,6 +347,7 @@ export const __databaseConnectionTestHooks = {
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
normalizeXtreamContentAddedEpochs,
ensureContentTitleFts,
backfillEpgProgramSourceUrls,
runMigrations,
} as const;
@@ -594,6 +613,78 @@ function ensureContentTitleFts(sqliteDb: Database.Database): void {
}
}
function backfillEpgProgramSourceUrls(sqliteDb: Database.Database): void {
try {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
if (migrationState?.value === 'done') {
return;
}
const backfillStatement = sqliteDb.prepare(
`UPDATE epg_programs
SET source_url = (
SELECT epg_channels.source_url
FROM epg_channels
WHERE epg_channels.id = epg_programs.channel_id
LIMIT 1
)
WHERE id IN (
SELECT pending_programs.id
FROM epg_programs AS pending_programs
JOIN epg_channels
ON epg_channels.id = pending_programs.channel_id
WHERE pending_programs.source_url IS NULL
AND epg_channels.source_url IS NOT NULL
AND epg_channels.source_url <> ''
LIMIT ${EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE}
)`
);
const markMigrationDoneStatement = sqliteDb.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
);
const executeBackfillBatch = sqliteDb.transaction((): number => {
const result = backfillStatement.run();
return typeof result === 'object' &&
result !== null &&
'changes' in result &&
typeof result.changes === 'number'
? result.changes
: 0;
});
const markMigrationDone = sqliteDb.transaction(() => {
markMigrationDoneStatement.run(
EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY
);
});
let updatedRows = 0;
do {
updatedRows = executeBackfillBatch();
} while (updatedRows === EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE);
markMigrationDone();
} catch (error) {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`EPG program source URL backfill failed (continuing): ${message}`
);
}
}
function runMigrationStatements(
sqliteDb: Database.Database,
statements: string[]
@@ -631,6 +722,7 @@ function runMigrations(sqliteDb: Database.Database): void {
deduplicateXtreamCache(sqliteDb);
normalizeXtreamContentAddedEpochs(sqliteDb);
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
backfillEpgProgramSourceUrls(sqliteDb);
}
export interface DatabaseOptions {
+16 -3
View File
@@ -32,6 +32,10 @@ export const playlists = sqliteTable('playlists', {
origin: text('origin'),
referrer: text('referrer'),
filePath: text('filePath'),
epgUrls: text('epg_urls'),
detectedEpgUrls: text('detected_epg_urls'),
manualEpgUrls: text('manual_epg_urls'),
disabledEpgUrls: text('disabled_epg_urls'),
autoRefresh: integer('autoRefresh', { mode: 'boolean' }).default(false),
macAddress: text('macAddress'),
url: text('url'),
@@ -204,9 +208,11 @@ export const epgPrograms = sqliteTable(
iconUrl: text('icon_url'),
rating: text('rating'),
episodeNum: text('episode_num'),
sourceUrl: text('source_url'),
},
(table) => ({
channelIdx: index('idx_epg_programs_channel').on(table.channelId),
sourceIdx: index('idx_epg_programs_source').on(table.sourceUrl),
startIdx: index('idx_epg_programs_start').on(table.start),
stopIdx: index('idx_epg_programs_stop').on(table.stop),
timeRangeIdx: index('idx_epg_programs_time_range').on(
@@ -214,6 +220,12 @@ export const epgPrograms = sqliteTable(
table.start,
table.stop
),
sourceTimeRangeIdx: index('idx_epg_programs_source_time_range').on(
table.sourceUrl,
table.channelId,
table.start,
table.stop
),
})
);
@@ -256,9 +268,10 @@ export const playbackPositions = sqliteTable(
table.seriesXtreamId
),
updatedIdx: index('playback_positions_updated_idx').on(table.updatedAt),
playlistUpdatedIdx: index(
'playback_positions_playlist_updated_idx'
).on(table.playlistId, sql`${table.updatedAt} DESC`),
playlistUpdatedIdx: index('playback_positions_playlist_updated_idx').on(
table.playlistId,
sql`${table.updatedAt} DESC`
),
})
);
@@ -206,6 +206,10 @@ export interface ElectronBridgeEpgFreshnessResult {
freshUrls: string[];
}
export interface ElectronBridgeEpgLookupOptions {
sourceUrls?: string[];
}
export interface ElectronBridgeEpgProgressStats {
totalChannels: number;
totalPrograms: number;
@@ -509,12 +513,17 @@ export interface ElectronBridgeApi {
urls: string[],
options?: ElectronBridgeTrustOptions
) => Promise<ElectronBridgeEpgFetchResult>;
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
getChannelPrograms: (
channelId: string,
options?: ElectronBridgeEpgLookupOptions
) => Promise<EpgProgram[]>;
getCurrentProgramsBatch: (
channelIds: string[]
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
) => Promise<Record<string, EpgProgram | null>>;
getEpgChannelMetadata: (
channelIds: string[]
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
) => Promise<Record<string, EpgChannelMetadata | null>>;
getEpgChannels: () => Promise<ElectronBridgeEpgChannelListResult>;
getEpgChannelsByRange: (
@@ -526,6 +535,7 @@ export interface ElectronBridgeApi {
options?: ElectronBridgeTrustOptions
) => Promise<ElectronBridgeEpgFetchResult>;
clearEpgData: () => Promise<ElectronBridgeResult>;
clearEpgDataForSource: (sourceUrl: string) => Promise<ElectronBridgeResult>;
checkEpgFreshness: (
urls: string[],
maxAgeHours?: number
@@ -1,8 +1,6 @@
export interface ParsedPlaylist {
header: {
attrs: {
'x-tvg-url': string;
};
attrs: Record<string, string | undefined>;
raw: string;
};
items: ParsedPlaylistItem[];
@@ -12,6 +12,10 @@ export type PlaylistMeta = Pick<
| 'referrer'
| 'origin'
| 'filePath'
| 'epgUrls'
| 'detectedEpgUrls'
| 'manualEpgUrls'
| 'disabledEpgUrls'
| 'updateDate'
| 'updateState'
| 'position'
@@ -33,6 +33,14 @@ export interface Playlist {
referrer?: string;
origin?: string;
filePath?: string;
/** M3U-scoped EPG source URLs enabled for automatic import and lookup. */
epgUrls?: string[];
/** All M3U EPG source URLs detected from the playlist header. */
detectedEpgUrls?: string[];
/** Playlist-local EPG source URLs explicitly enabled by the user. */
manualEpgUrls?: string[];
/** Detected playlist EPG source URLs explicitly disabled by the user. */
disabledEpgUrls?: string[];
autoRefresh: boolean;
updateDate?: number;
updateState?: PlaylistUpdateState;
@@ -1,8 +1,12 @@
import { Channel, Playlist } from '@iptvnator/shared/interfaces';
import {
aggregateFavoriteChannels,
createPlaylistObject,
extractM3uEpgUrls,
filterPlaylistEpgUrlsForFetch,
getExtensionFromUrl,
getStreamExtensionFromUrl,
resolvePlaylistEpgSourceState,
} from './playlist.utils';
function createChannel(id: string, url: string, name = id): Channel {
@@ -105,4 +109,188 @@ describe('playlist utils', () => {
}
);
});
describe('extractM3uEpgUrls', () => {
it('extracts and deduplicates playlist-scoped EPG URLs from supported M3U header attributes', () => {
expect(
extractM3uEpgUrls({
header: {
attrs: {
'x-tvg-url':
' https://example.com/guide.xml, https://example.com/guide.xml https://example.com/extra.xml.gz ',
'url-tvg': 'https://example.com/url-tvg.xml',
},
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml" url-tvg="https://example.com/url-tvg.xml"',
},
})
).toEqual([
'https://example.com/guide.xml',
'https://example.com/extra.xml.gz',
'https://example.com/url-tvg.xml',
]);
});
it('falls back to the raw header for tvg-url variants the parser does not expose as attrs', () => {
expect(
extractM3uEpgUrls({
header: {
attrs: {},
raw: '#EXTM3U tvg-url="https://example.com/raw-guide.xml"',
},
})
).toEqual(['https://example.com/raw-guide.xml']);
});
});
it('stores detected M3U EPG URLs on the created playlist', () => {
const playlist = createPlaylistObject('Playlist with EPG', {
header: {
attrs: {
'x-tvg-url': 'https://example.com/guide.xml',
},
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml"',
},
items: [],
});
expect(playlist.epgUrls).toEqual(['https://example.com/guide.xml']);
expect(playlist.detectedEpgUrls).toEqual([
'https://example.com/guide.xml',
]);
});
it('keeps large detected EPG lists separate from the auto-imported playlist EPG subset', () => {
const playlist = createPlaylistObject('Playlist with global EPG list', {
header: {
attrs: {
'x-tvg-url': [
'https://iptv-org.github.io/epg/guides/us/tvguide.com.epg.xml',
'https://iptv-org.github.io/epg/guides/de/hd-plus.de.epg.xml',
'https://iptv-org.github.io/epg/guides/ua/example.ua.epg.xml',
'https://iptv-org.github.io/epg/guides/ru/tv.yandex.ru.epg.xml',
'https://iptv-org.github.io/epg/guides/fr/programme-tv.net.epg.xml',
'https://iptv-org.github.io/epg/guides/uk/sky.com.epg.xml',
].join(','),
},
raw: '#EXTM3U',
},
items: [
{
name: '1+1',
tvg: {
id: '1Plus1.ua',
name: '1+1',
url: '',
logo: '',
rec: '',
},
group: { title: 'Undefined' },
http: { referrer: '', 'user-agent': '' },
raw: '#EXTINF:-1 tvg-id="1Plus1.ua" tvg-country="RU;UA" tvg-language="Ukrainian",1+1',
url: 'https://example.com/stream.m3u8',
},
],
});
expect(playlist.detectedEpgUrls).toHaveLength(6);
expect(playlist.epgUrls).toEqual([
'https://iptv-org.github.io/epg/guides/ua/example.ua.epg.xml',
'https://iptv-org.github.io/epg/guides/ru/tv.yandex.ru.epg.xml',
]);
});
it('falls back to the first detected EPG URLs when a large list has no region recommendation match', () => {
const epgUrls = [
'https://provider.example.com/epg/source-1.xml',
'https://provider.example.com/epg/source-2.xml',
'https://provider.example.com/epg/source-3.xml',
'https://provider.example.com/epg/source-4.xml',
'https://provider.example.com/epg/source-5.xml',
'https://provider.example.com/epg/source-6.xml',
];
const playlist = createPlaylistObject(
'Playlist with generic EPG list',
{
header: {
attrs: {
'x-tvg-url': epgUrls.join(','),
},
raw: '#EXTM3U',
},
items: [
{
name: 'Generic Channel',
tvg: {
id: 'generic-channel',
name: 'Generic Channel',
url: '',
logo: '',
rec: '',
},
group: { title: 'Undefined' },
http: { referrer: '', 'user-agent': '' },
raw: '#EXTINF:-1 tvg-id="generic-channel",Generic Channel',
url: 'https://example.com/stream.m3u8',
},
],
}
);
expect(playlist.detectedEpgUrls).toEqual(epgUrls);
expect(playlist.epgUrls).toEqual(epgUrls.slice(0, 5));
});
it('keeps disabled playlist EPG sources out while preserving manually enabled URLs', () => {
expect(
resolvePlaylistEpgSourceState({
detectedEpgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/disabled.xml',
],
enabledEpgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/disabled.xml',
],
manualEpgUrls: [
' https://playlist.example.com/manual.xml ',
'https://playlist.example.com/auto.xml',
],
disabledEpgUrls: [
'https://playlist.example.com/disabled.xml',
'',
'https://playlist.example.com/disabled.xml',
],
})
).toEqual({
detectedEpgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/disabled.xml',
],
epgUrls: [
'https://playlist.example.com/auto.xml',
'https://playlist.example.com/manual.xml',
],
manualEpgUrls: [
'https://playlist.example.com/manual.xml',
'https://playlist.example.com/auto.xml',
],
disabledEpgUrls: ['https://playlist.example.com/disabled.xml'],
});
});
it('filters playlist EPG fetch URLs that are already configured globally', () => {
expect(
filterPlaylistEpgUrlsForFetch(
[
'https://global.example.com/guide.xml',
'https://playlist.example.com/local.xml',
' https://playlist.example.com/local.xml ',
],
[
' https://global.example.com/guide.xml ',
'https://global.example.com/other.xml',
]
)
).toEqual(['https://playlist.example.com/local.xml']);
});
});
@@ -65,6 +65,306 @@ export const getFilenameFromUrl = (value: string): string => {
return 'Untitled playlist';
};
const M3U_EPG_HEADER_ATTRS = ['x-tvg-url', 'url-tvg', 'tvg-url'] as const;
const M3U_EPG_URL_PATTERN = /\b(?:https?|file):\/\/[^\s,"']+/gi;
export const M3U_AUTO_IMPORT_EPG_URL_LIMIT = 5;
export const M3U_RECOMMENDED_EPG_URL_LIMIT = 12;
export interface M3uEpgUrlSelection {
detectedEpgUrls: string[];
enabledEpgUrls: string[];
}
export interface PlaylistEpgSourceStateInput {
detectedEpgUrls?: string[];
enabledEpgUrls?: string[];
manualEpgUrls?: string[];
disabledEpgUrls?: string[];
}
export interface PlaylistEpgSourceState {
detectedEpgUrls: string[];
epgUrls: string[];
manualEpgUrls: string[];
disabledEpgUrls: string[];
}
export function normalizeEpgUrls(
urls: readonly string[] | undefined
): string[] {
return Array.from(
new Set(
(urls ?? [])
.map((url) => url.trim())
.filter((url) => url.length > 0)
)
);
}
export function resolvePlaylistEpgSourceState(
input: PlaylistEpgSourceStateInput
): PlaylistEpgSourceState {
const detectedEpgUrls = normalizeEpgUrls(input.detectedEpgUrls);
const manualEpgUrls = normalizeEpgUrls(input.manualEpgUrls);
const disabledEpgUrls = normalizeEpgUrls(input.disabledEpgUrls);
const disabledSet = new Set(disabledEpgUrls);
const epgUrls = normalizeEpgUrls([
...normalizeEpgUrls(input.enabledEpgUrls),
...manualEpgUrls,
]).filter((url) => !disabledSet.has(url));
return {
detectedEpgUrls,
epgUrls,
manualEpgUrls,
disabledEpgUrls,
};
}
export function filterPlaylistEpgUrlsForFetch(
playlistEpgUrls: readonly string[] | undefined,
globalEpgUrls: readonly string[] | undefined
): string[] {
const globalUrlSet = new Set(normalizeEpgUrls(globalEpgUrls));
return normalizeEpgUrls(playlistEpgUrls).filter(
(url) => !globalUrlSet.has(url)
);
}
export function extractM3uEpgUrls(
playlist: Pick<ParsedPlaylist, 'header'> | null | undefined
): string[] {
const header = playlist?.header;
if (!header) {
return [];
}
const candidates: Array<string | undefined> = [];
for (const attr of M3U_EPG_HEADER_ATTRS) {
candidates.push(header.attrs?.[attr]);
candidates.push(extractHeaderAttributeFromRaw(header.raw, attr));
}
const urls: string[] = [];
for (const candidate of candidates) {
urls.push(...extractUrlsFromHeaderValue(candidate));
}
return Array.from(new Set(urls));
}
export function resolveM3uEpgUrlSelection(
playlist: Pick<ParsedPlaylist, 'header' | 'items'> | null | undefined
): M3uEpgUrlSelection {
const detectedEpgUrls = extractM3uEpgUrls(playlist);
if (detectedEpgUrls.length <= M3U_AUTO_IMPORT_EPG_URL_LIMIT) {
return {
detectedEpgUrls,
enabledEpgUrls: detectedEpgUrls,
};
}
const recommendedEpgUrls = selectRecommendedEpgUrls(
detectedEpgUrls,
playlist?.items ?? []
);
return {
detectedEpgUrls,
enabledEpgUrls:
recommendedEpgUrls.length > 0
? recommendedEpgUrls
: detectedEpgUrls.slice(0, M3U_AUTO_IMPORT_EPG_URL_LIMIT),
};
}
function extractHeaderAttributeFromRaw(
raw: string | undefined,
attr: string
): string | undefined {
if (!raw) {
return undefined;
}
const match = raw.match(
new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s]+))`, 'i')
);
return match?.[2] ?? match?.[3] ?? match?.[4];
}
function extractUrlsFromHeaderValue(value: string | undefined): string[] {
if (!value) {
return [];
}
const urls: string[] = [];
const pattern = new RegExp(M3U_EPG_URL_PATTERN);
let match: RegExpExecArray | null;
while ((match = pattern.exec(value)) !== null) {
const url = match[0].trim();
if (url.length > 0) {
urls.push(url);
}
}
return urls;
}
function selectRecommendedEpgUrls(
urls: string[],
items: ParsedPlaylistItem[]
): string[] {
const hints = collectPlaylistEpgRegionHints(items);
const recommendations: string[] = [];
for (const url of urls) {
const guideCodes = extractEpgGuideCodes(url);
const matchesCountry =
guideCodes.country !== undefined &&
hints.countries.has(guideCodes.country);
const matchesLanguageWithoutCountry =
hints.countries.size === 0 &&
guideCodes.language !== undefined &&
hints.languages.has(guideCodes.language);
if (matchesCountry || matchesLanguageWithoutCountry) {
recommendations.push(url);
}
if (recommendations.length >= M3U_RECOMMENDED_EPG_URL_LIMIT) {
break;
}
}
return recommendations;
}
function collectPlaylistEpgRegionHints(items: ParsedPlaylistItem[]): {
countries: Set<string>;
languages: Set<string>;
} {
const countries = new Set<string>();
const languages = new Set<string>();
for (const item of items) {
addDelimitedCodes(
countries,
extractExtinfAttribute(item.raw, 'tvg-country')
);
addDelimitedCodes(countries, extractCountrySuffix(item.tvg?.id));
const languageCode = normalizeLanguageCode(
extractExtinfAttribute(item.raw, 'tvg-language')
);
if (languageCode) {
languages.add(languageCode);
}
}
return { countries, languages };
}
function extractExtinfAttribute(
raw: string | undefined,
attr: string
): string | undefined {
if (!raw) {
return undefined;
}
const match = raw.match(
new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s,]+))`, 'i')
);
return match?.[2] ?? match?.[3] ?? match?.[4];
}
function addDelimitedCodes(target: Set<string>, value: string | undefined) {
if (!value) {
return;
}
for (const token of value.split(/[;,\s]+/)) {
const code = normalizeRegionCode(token);
if (code) {
target.add(code);
}
}
}
function normalizeRegionCode(value: string | undefined): string | undefined {
const normalized = value?.trim().toLowerCase();
return normalized && /^[a-z]{2}$/.test(normalized) ? normalized : undefined;
}
function normalizeLanguageCode(value: string | undefined): string | undefined {
const normalized = value?.trim().toLowerCase();
if (!normalized) {
return undefined;
}
const languageByName: Record<string, string> = {
arabic: 'ar',
english: 'en',
french: 'fr',
german: 'de',
italian: 'it',
polish: 'pl',
portuguese: 'pt',
russian: 'ru',
spanish: 'es',
turkish: 'tr',
ukrainian: 'uk',
};
return normalizeRegionCode(normalized) ?? languageByName[normalized];
}
function extractCountrySuffix(value: string | undefined): string | undefined {
const normalized = value?.trim().toLowerCase();
if (!normalized) {
return undefined;
}
const match = normalized.match(/\.([a-z]{2})$/);
return match?.[1];
}
function extractEpgGuideCodes(url: string): {
country?: string;
language?: string;
} {
let path = '';
try {
path = new URL(url).pathname;
} catch {
path = url;
}
const segments = path
.split('/')
.map((segment) => segment.trim().toLowerCase())
.filter((segment) => segment.length > 0);
const guidesIndex = segments.indexOf('guides');
const guideSegment =
guidesIndex >= 0 ? segments[guidesIndex + 1] : undefined;
if (!guideSegment) {
return {};
}
const [country, language] = guideSegment.split('-');
return {
country: normalizeRegionCode(country),
language: normalizeRegionCode(language),
};
}
/**
* Creates a playlist object
* @param name name of the playlist
@@ -78,6 +378,13 @@ export const createPlaylistObject = (
urlOrPath?: string,
uploadType?: 'URL' | 'FILE' | 'TEXT'
): Playlist => {
const { detectedEpgUrls, enabledEpgUrls } =
resolveM3uEpgUrlSelection(playlist);
const epgSourceState = resolvePlaylistEpgSourceState({
detectedEpgUrls,
enabledEpgUrls,
});
return {
_id: uuidv4(),
filename: name,
@@ -94,6 +401,12 @@ export const createPlaylistObject = (
lastUsage: new Date().toISOString(),
favorites: [],
autoRefresh: false,
...(epgSourceState.epgUrls.length > 0
? { epgUrls: epgSourceState.epgUrls }
: {}),
...(epgSourceState.detectedEpgUrls.length > 0
? { detectedEpgUrls: epgSourceState.detectedEpgUrls }
: {}),
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
};
@@ -65,6 +65,11 @@ describe('ChannelListContainerComponent', () => {
let fixture: ComponentFixture<ChannelListContainerComponent>;
let dispatch: jest.Mock;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
let epgService: {
epgAvailable$: BehaviorSubject<boolean>;
getChannelMetadataForChannels: jest.Mock;
getCurrentProgramsForChannels: jest.Mock;
};
let favoriteChannelIds$: BehaviorSubject<string[]>;
let runtimeCapabilities: { supportsEpg: boolean };
let storageGet: jest.Mock;
@@ -75,6 +80,15 @@ describe('ChannelListContainerComponent', () => {
favoriteChannelIds$ = new BehaviorSubject<string[]>([]);
runtimeCapabilities = { supportsEpg: true };
storageGet = jest.fn().mockReturnValue(of({}));
epgService = {
epgAvailable$: new BehaviorSubject<boolean>(false),
getChannelMetadataForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
getCurrentProgramsForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
};
activePlaylistSignal = signal<PlaylistMeta | null>({
_id: 'playlist-1',
title: 'Playlist One',
@@ -110,14 +124,7 @@ describe('ChannelListContainerComponent', () => {
providers: [
{
provide: EpgService,
useValue: {
getChannelMetadataForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
getCurrentProgramsForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
},
useValue: epgService,
},
{
provide: PlaylistsService,
@@ -217,6 +224,109 @@ describe('ChannelListContainerComponent', () => {
expect(fixture.componentInstance.shouldShowEpg()).toBe(true);
});
it('enables EPG rows and scopes lookups when the active M3U playlist has detected EPG URLs', () => {
runtimeCapabilities.supportsEpg = true;
storageGet.mockReturnValue(of({ epgUrl: [] }));
activePlaylistSignal.set({
_id: 'playlist-1',
title: 'Playlist One',
count: 1,
importDate: '2026-04-11T00:00:00.000Z',
epgUrls: ['https://playlist.example.com/guide.xml'],
} as PlaylistMeta);
fixture.detectChanges();
fixture.componentInstance.channelList = [
createChannel('guide-news', 'https://example.com/news.m3u8'),
];
expect(fixture.componentInstance.shouldShowEpg()).toBe(true);
expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith(
['guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
expect(epgService.getChannelMetadataForChannels).toHaveBeenCalledWith(
['guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
});
it('refreshes visible channel EPG when playlist EPG URLs arrive after channels', () => {
runtimeCapabilities.supportsEpg = true;
storageGet.mockReturnValue(of({ epgUrl: [] }));
fixture.detectChanges();
fixture.componentInstance.channelList = [
createChannel('guide-news', 'https://example.com/news.m3u8'),
];
expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith(
['guide-news'],
undefined
);
epgService.getCurrentProgramsForChannels.mockClear();
epgService.getChannelMetadataForChannels.mockClear();
activePlaylistSignal.set({
_id: 'playlist-1',
title: 'Playlist One',
count: 1,
importDate: '2026-04-11T00:00:00.000Z',
epgUrls: ['https://playlist.example.com/guide.xml'],
} as PlaylistMeta);
fixture.detectChanges();
expect(epgService.getCurrentProgramsForChannels).toHaveBeenCalledWith(
['guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
expect(epgService.getChannelMetadataForChannels).toHaveBeenCalledWith(
['guide-news'],
{ sourceUrls: ['https://playlist.example.com/guide.xml'] }
);
});
it('debounces visible channel EPG row refreshes after EPG imports complete', () => {
jest.useFakeTimers();
try {
runtimeCapabilities.supportsEpg = true;
activePlaylistSignal.set({
_id: 'playlist-1',
title: 'Playlist One',
count: 1,
importDate: '2026-04-11T00:00:00.000Z',
epgUrls: ['https://playlist.example.com/guide.xml'],
} as PlaylistMeta);
fixture.detectChanges();
fixture.componentInstance.channelList = [
createChannel('guide-news', 'https://example.com/news.m3u8'),
];
epgService.getCurrentProgramsForChannels.mockClear();
epgService.epgAvailable$.next(true);
epgService.epgAvailable$.next(true);
jest.advanceTimersByTime(1999);
expect(
epgService.getCurrentProgramsForChannels
).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(
epgService.getCurrentProgramsForChannels
).toHaveBeenCalledTimes(1);
expect(
epgService.getCurrentProgramsForChannels
).toHaveBeenCalledWith(['guide-news'], {
sourceUrls: ['https://playlist.example.com/guide.xml'],
});
} finally {
fixture.destroy();
jest.useRealTimers();
}
});
it('dispatches playlist meta updates when hidden group titles change', () => {
fixture.componentInstance.onHiddenGroupTitlesChanged([
'Movies',
@@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
Input,
input,
@@ -10,6 +11,7 @@ import {
OnInit,
output,
signal,
untracked,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@@ -35,10 +37,12 @@ import {
import {
BehaviorSubject,
combineLatest,
debounceTime,
filter,
forkJoin,
firstValueFrom,
map,
Subscription,
} from 'rxjs';
import {
PlaylistsService,
@@ -55,6 +59,7 @@ import {
Settings,
STORE_KEY,
} from '@iptvnator/shared/interfaces';
import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
import { GroupsViewComponent } from './groups-view/groups-view.component';
@@ -88,6 +93,8 @@ function mapChannelsByFirstUrl(channels: Channel[]): Map<string, Channel> {
return channelsByUrl;
}
const EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS = 2000;
@Component({
selector: 'app-channel-list-container',
templateUrl: './channel-list-container.component.html',
@@ -115,6 +122,10 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
/** Route-aware playlist ID for recent-item mutations */
private readonly resolvedPlaylistId =
this.playlistContext.resolvedPlaylistId;
private readonly activePlaylist = this.playlistContext.activePlaylist;
/** Map of channel ID to current EPG program */
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
@@ -128,9 +139,40 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Interval for global progress updates */
private progressInterval?: number;
private epgAvailabilitySubscription?: Subscription;
/** Whether to show EPG data in channel items */
readonly shouldShowEpg = signal(false);
private readonly globalEpgUrls = signal<string[]>([]);
readonly playlistEpgUrls = computed(() => {
const playlist = this.activePlaylist();
if (!playlist || playlist.serverUrl || playlist.macAddress) {
return [];
}
return normalizeEpgUrls(playlist.epgUrls ?? []);
});
readonly shouldShowEpg = computed(
() =>
this.runtime.supportsEpg &&
(this.globalEpgUrls().length > 0 ||
this.playlistEpgUrls().length > 0)
);
private readonly epgSourceRefreshKey = computed(() => {
if (!this.runtime.supportsEpg) {
return '';
}
const globalUrls = this.globalEpgUrls();
const playlistUrls = this.playlistEpgUrls();
if (globalUrls.length === 0 && playlistUrls.length === 0) {
return '';
}
return JSON.stringify({
globalUrls: Array.from(new Set(globalUrls)).sort(),
playlistUrls: Array.from(new Set(playlistUrls)).sort(),
});
});
readonly openStreamOnDoubleClick = computed(() =>
this.settingsStore.openStreamOnDoubleClick()
);
@@ -182,6 +224,22 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
_channelList: Channel[] = [];
private readonly channelListSignal = signal<Channel[]>([]);
private channelList$ = new BehaviorSubject<Channel[]>([]);
private lastEpgSourceRefreshKey = '';
private readonly epgSourceRefreshEffect = effect(() => {
const refreshKey = this.epgSourceRefreshKey();
if (refreshKey === this.lastEpgSourceRefreshKey) {
return;
}
this.lastEpgSourceRefreshKey = refreshKey;
if (!refreshKey || this._channelList.length === 0) {
return;
}
untracked(() => {
this.fetchEpgForChannels(this._channelList);
});
});
get channelList(): Channel[] {
return this._channelList;
@@ -196,11 +254,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
this.fetchEpgForChannels(safeValue);
}
/** Route-aware playlist ID for recent-item mutations */
private readonly resolvedPlaylistId =
this.playlistContext.resolvedPlaylistId;
private readonly activePlaylist = this.playlistContext.activePlaylist;
readonly hiddenGroupTitles = computed(() => {
const playlist = this.activePlaylist();
@@ -301,13 +354,22 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
Object.keys(settings as Settings).length > 0
) {
const epgUrl = (settings as Settings).epgUrl;
this.shouldShowEpg.set(!!(epgUrl && epgUrl.length > 0));
this.globalEpgUrls.set(normalizeEpgUrls(epgUrl));
}
});
} else {
this.shouldShowEpg.set(false);
this.globalEpgUrls.set([]);
}
this.epgAvailabilitySubscription = this.epgService.epgAvailable$
.pipe(
filter((available) => available),
debounceTime(EPG_AVAILABILITY_REFRESH_DEBOUNCE_MS)
)
.subscribe(() => {
this.fetchEpgForChannels(this._channelList);
});
// Set up EPG refresh interval (every 60 seconds)
this.epgRefreshInterval = window.setInterval(() => {
this.fetchEpgForChannels(this._channelList);
@@ -330,6 +392,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
clearInterval(this.progressInterval);
}
this.epgAvailabilitySubscription?.unsubscribe();
this.channelList$.complete();
}
@@ -351,10 +414,17 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
)
);
const epgLookupOptions = this.getPlaylistEpgLookupOptions();
forkJoin({
epgMap: this.epgService.getCurrentProgramsForChannels(channelIds),
metadataMap:
this.epgService.getChannelMetadataForChannels(channelIds),
epgMap: this.epgService.getCurrentProgramsForChannels(
channelIds,
epgLookupOptions
),
metadataMap: this.epgService.getChannelMetadataForChannels(
channelIds,
epgLookupOptions
),
}).subscribe(({ epgMap, metadataMap }) => {
this.channelEpgMap.set(epgMap);
this.channelIconMap.set(
@@ -371,6 +441,13 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
});
}
private getPlaylistEpgLookupOptions():
| { sourceUrls: string[] }
| undefined {
const sourceUrls = this.playlistEpgUrls();
return sourceUrls.length > 0 ? { sourceUrls } : undefined;
}
/**
* Handles channel selection from any tab
*/