mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
feat(epg): support playlist-scoped sources
Add playlist-scoped EPG source support for M3U playlists.
This commit is contained in:
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 } : {}),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user