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

@@ -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 } : {}),
};