286 lines
13 KiB
JavaScript
286 lines
13 KiB
JavaScript
function parseM3U(content, sourceOrigin = null) {
|
|
const lines = content.split(/\r\n?|\n/).map(line => line.trim()).filter(Boolean);
|
|
const parsedChannels = [];
|
|
let currentChannel = null;
|
|
const seenGroups = new Set();
|
|
const orderedGroups = [];
|
|
|
|
if (lines.length > 0 && !lines[0].startsWith('#EXTM3U')) {
|
|
}
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.startsWith('#EXTINF:')) {
|
|
if (currentChannel && !currentChannel.url) {
|
|
}
|
|
currentChannel = {
|
|
name: `Canal ${parsedChannels.length + 1}`,
|
|
url: null,
|
|
attributes: {},
|
|
kodiProps: {},
|
|
vlcOptions: {},
|
|
extHttp: {},
|
|
effectiveEpgId: null,
|
|
sourceOrigin: sourceOrigin
|
|
};
|
|
try {
|
|
const extinfMatch = line.match(/^#EXTINF:(-?\d*(?:\.\d+)?)([^,]*),(.*)$/);
|
|
if (extinfMatch) {
|
|
currentChannel.attributes.duration = extinfMatch[1];
|
|
|
|
const attrString = extinfMatch[2].trim();
|
|
const channelName = extinfMatch[3].trim();
|
|
currentChannel.name = channelName || `Canal ${parsedChannels.length + 1}`;
|
|
|
|
const attributeMatchRegex = /([a-zA-Z0-9_-]+)=("([^"]*)"|'([^']*)'|([^"\s',]+))/g;
|
|
let attrMatch;
|
|
while ((attrMatch = attributeMatchRegex.exec(attrString)) !== null) {
|
|
const attrName = attrMatch[1].toLowerCase();
|
|
const attrValue = attrMatch[3] || attrMatch[4] || attrMatch[5] || '';
|
|
currentChannel.attributes[attrName] = attrValue.trim();
|
|
}
|
|
currentChannel['tvg-id'] = currentChannel.attributes['tvg-id'] || '';
|
|
currentChannel['tvg-name'] = currentChannel.attributes['tvg-name'] || '';
|
|
currentChannel['tvg-logo'] = currentChannel.attributes['tvg-logo'] || '';
|
|
currentChannel['group-title'] = currentChannel.attributes['group-title'] || '';
|
|
currentChannel.attributes['ch-number'] = currentChannel.attributes['ch-number'] || currentChannel.attributes['tvg-chno'] || '';
|
|
|
|
if (currentChannel.attributes['source-origin']) {
|
|
currentChannel.sourceOrigin = currentChannel.attributes['source-origin'];
|
|
}
|
|
|
|
const groupTitle = currentChannel['group-title'];
|
|
if (groupTitle && groupTitle.trim() !== '' && !seenGroups.has(groupTitle)) {
|
|
seenGroups.add(groupTitle); orderedGroups.push(groupTitle);
|
|
}
|
|
|
|
} else {
|
|
const commaIndex = line.indexOf(',');
|
|
if (commaIndex !== -1) {
|
|
currentChannel.name = line.substring(commaIndex + 1).trim() || `Canal ${parsedChannels.length + 1}`;
|
|
currentChannel.attributes.duration = line.substring("#EXTINF:".length, commaIndex).trim();
|
|
} else { currentChannel = null; }
|
|
}
|
|
} catch (e) { console.warn("Error parsing #EXTINF line:", line, e); currentChannel = null; }
|
|
|
|
} else if (currentChannel && line.startsWith('#KODIPROP:')) {
|
|
const propMatch = line.match(/^#KODIPROP:([^=]+)=(.*)$/);
|
|
if (propMatch && propMatch[1] && typeof propMatch[2] === 'string') {
|
|
currentChannel.kodiProps[propMatch[1].trim()] = propMatch[2].trim();
|
|
}
|
|
} else if (currentChannel && line.startsWith('#EXTVLCOPT:')) {
|
|
const propMatch = line.match(/^#EXTVLCOPT:([^=]+)=(.*)$/);
|
|
if (propMatch && propMatch[1] && typeof propMatch[2] === 'string') {
|
|
const key = propMatch[1].trim();
|
|
let value = propMatch[2].trim();
|
|
if (key === 'http-user-agent' && value.includes('&Referer=')) {
|
|
const parts = value.split('&Referer=');
|
|
currentChannel.vlcOptions['http-user-agent'] = parts[0];
|
|
if (parts.length > 1 && parts[1]) {
|
|
currentChannel.vlcOptions['http-referrer'] = parts[1];
|
|
}
|
|
} else if (key === 'http-user-agent' && value.includes('&referer=')) {
|
|
const parts = value.split('&referer=');
|
|
currentChannel.vlcOptions['http-user-agent'] = parts[0];
|
|
if (parts.length > 1 && parts[1]) {
|
|
currentChannel.vlcOptions['http-referrer'] = parts[1];
|
|
}
|
|
}
|
|
else {
|
|
currentChannel.vlcOptions[key] = value;
|
|
}
|
|
}
|
|
} else if (currentChannel && line.startsWith('#EXTHTTP:')) {
|
|
try {
|
|
const httpJson = line.substring('#EXTHTTP:'.length).trim();
|
|
if (httpJson) { currentChannel.extHttp = JSON.parse(httpJson); }
|
|
} catch (e) { console.warn("Error parsing #EXTHTTP JSON:", line, e); }
|
|
} else if (currentChannel && line.startsWith('#EXTGRP:')) {
|
|
const groupName = line.substring('#EXTGRP:'.length).trim();
|
|
if (!currentChannel['group-title'] && groupName) {
|
|
currentChannel['group-title'] = groupName;
|
|
if (!seenGroups.has(groupName)) { seenGroups.add(groupName); orderedGroups.push(groupName); }
|
|
} else if (groupName && groupName.trim() !== '' && !seenGroups.has(groupName)) {
|
|
seenGroups.add(groupName); orderedGroups.push(groupName);
|
|
}
|
|
} else if (!line.startsWith('#') && currentChannel && !currentChannel.url) {
|
|
const url = line.trim();
|
|
if (url) {
|
|
currentChannel.url = url;
|
|
|
|
if (!currentChannel.attributes['source-origin']) {
|
|
if (url.includes('atres-live.atresmedia.com')) {
|
|
currentChannel.sourceOrigin = 'Atresplayer';
|
|
} else if (url.includes('orangetv.orange.es')) {
|
|
currentChannel.sourceOrigin = 'OrangeTV';
|
|
} else if (url.toLowerCase().includes('dazn')) {
|
|
currentChannel.sourceOrigin = 'DAZN';
|
|
} else if (url.toLowerCase().includes('telefonica.com') || url.toLowerCase().includes('movistarplus.es')) {
|
|
currentChannel.sourceOrigin = 'Movistar+';
|
|
}
|
|
}
|
|
if (currentChannel.attributes['source-origin']) {
|
|
currentChannel.sourceOrigin = currentChannel.attributes['source-origin'];
|
|
}
|
|
|
|
|
|
parsedChannels.push(currentChannel);
|
|
currentChannel = null;
|
|
} else {
|
|
currentChannel = null;
|
|
}
|
|
} else if (!line.startsWith('#') && !currentChannel) {
|
|
}
|
|
}
|
|
if (currentChannel && !currentChannel.url) {
|
|
}
|
|
|
|
const finalOrderedGroups = Array.from(new Set(orderedGroups));
|
|
return { channels: parsedChannels, groupOrder: finalOrderedGroups };
|
|
}
|
|
|
|
function normalizeStringForComparison(str) {
|
|
if (typeof str !== 'string') return '';
|
|
return str.toLowerCase()
|
|
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[hd|sd|fhd|uhd|4k|8k|(\(\d+p\))|[,.:;\-_\s()\[\]&+'!¡¿?]/g, '')
|
|
.replace(/\s+/g, '');
|
|
}
|
|
|
|
function getStringSimilarity(str1, str2) {
|
|
const s1 = normalizeStringForComparison(str1);
|
|
const s2 = normalizeStringForComparison(str2);
|
|
if (s1 === s2) return 1.0;
|
|
if (s1.length < 2 || s2.length < 2) return 0.0;
|
|
|
|
const profile1 = {};
|
|
for (let i = 0; i < s1.length - 1; i++) {
|
|
const bigram = s1.substring(i, i + 2);
|
|
profile1[bigram] = (profile1[bigram] || 0) + 1;
|
|
}
|
|
const profile2 = {};
|
|
for (let i = 0; i < s2.length - 1; i++) {
|
|
const bigram = s2.substring(i, i + 2);
|
|
profile2[bigram] = (profile2[bigram] || 0) + 1;
|
|
}
|
|
const union = new Set([...Object.keys(profile1), ...Object.keys(profile2)]);
|
|
let intersectionSize = 0;
|
|
for (const bigram of union) {
|
|
if (profile1[bigram] && profile2[bigram]) {
|
|
intersectionSize += Math.min(profile1[bigram], profile2[bigram]);
|
|
}
|
|
}
|
|
return (2.0 * intersectionSize) / (s1.length - 1 + s2.length - 1);
|
|
}
|
|
|
|
function base64ToHex(base64) {
|
|
try {
|
|
const b64Str = String(base64 || '');
|
|
const binary = atob(b64Str.replace(/-/g, '+').replace(/_/g, '/'));
|
|
let hex = '';
|
|
for (let i = 0; i < binary.length; i++) {
|
|
const byte = binary.charCodeAt(i).toString(16).padStart(2, '0');
|
|
hex += byte;
|
|
}
|
|
return hex.toLowerCase();
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseClearKey(keyString) {
|
|
if (!keyString || typeof keyString !== 'string') {
|
|
return null;
|
|
}
|
|
keyString = keyString.trim();
|
|
const clearKeys = {};
|
|
try {
|
|
if (keyString.startsWith('{') && keyString.endsWith('}')) {
|
|
try {
|
|
const parsed = JSON.parse(keyString);
|
|
if (parsed.keys && Array.isArray(parsed.keys)) {
|
|
for (const keyObj of parsed.keys) {
|
|
if (keyObj.kty !== 'oct') { continue; }
|
|
if (!keyObj.k || !keyObj.kid) { continue; }
|
|
const kidHex = base64ToHex(keyObj.kid);
|
|
const keyHex = base64ToHex(keyObj.k);
|
|
if (kidHex && keyHex && /^[0-9a-f]{32}$/.test(kidHex) && /^[0-9a-f]{32}$/.test(keyHex)) {
|
|
clearKeys[kidHex] = keyHex;
|
|
}
|
|
}
|
|
} else {
|
|
for (const kid_orig in parsed) {
|
|
if (Object.prototype.hasOwnProperty.call(parsed, kid_orig)) {
|
|
const key_orig = parsed[kid_orig];
|
|
if (typeof kid_orig !== 'string' || typeof key_orig !== 'string') {
|
|
continue;
|
|
}
|
|
let kidHexStr, keyHexStr;
|
|
|
|
if (!/^[0-9a-fA-F]{32}$/.test(kid_orig)) {
|
|
const converted = base64ToHex(kid_orig);
|
|
kidHexStr = converted ? converted : '';
|
|
} else {
|
|
kidHexStr = kid_orig.toLowerCase();
|
|
}
|
|
if (!/^[0-9a-fA-F]{32}$/.test(key_orig)) {
|
|
const converted = base64ToHex(key_orig);
|
|
keyHexStr = converted ? converted : '';
|
|
} else {
|
|
keyHexStr = key_orig.toLowerCase();
|
|
}
|
|
|
|
if (/^[0-9a-f]{32}$/.test(kidHexStr) && /^[0-9a-f]{32}$/.test(keyHexStr)) {
|
|
clearKeys[kidHexStr] = keyHexStr;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (jsonParseError) {
|
|
const compactObjectMatch = keyString.match(/^\{([0-9a-fA-F]{32}):([0-9a-fA-F]{32})\}$/);
|
|
if (compactObjectMatch) {
|
|
clearKeys[compactObjectMatch[1].toLowerCase()] = compactObjectMatch[2].toLowerCase();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(clearKeys).length === 0) {
|
|
const simpleHexMatch = keyString.match(/^([0-9a-fA-F]{32}):([0-9a-fA-F]{32})$/);
|
|
if (simpleHexMatch) {
|
|
clearKeys[simpleHexMatch[1].toLowerCase()] = simpleHexMatch[2].toLowerCase();
|
|
return clearKeys;
|
|
}
|
|
const simpleBase64Match = keyString.match(/^([A-Za-z0-9+/_-]+={0,2}):([A-Za-z0-9+/_-]+={0,2})$/);
|
|
if (simpleBase64Match) {
|
|
const kidHex = base64ToHex(simpleBase64Match[1]);
|
|
const keyHex = base64ToHex(simpleBase64Match[2]);
|
|
if (kidHex && keyHex && /^[0-9a-f]{32}$/.test(kidHex) && /^[0-9a-f]{32}$/.test(keyHex)) {
|
|
clearKeys[kidHex] = keyHex;
|
|
return clearKeys;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(clearKeys).length === 0) {
|
|
return null;
|
|
}
|
|
return clearKeys;
|
|
} catch (e) {
|
|
console.error("Error parsing clearkey string:", e, keyString);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function safeParseInt(value, defaultValue = 0) {
|
|
const parsed = parseInt(value, 10);
|
|
return isNaN(parsed) ? defaultValue : parsed;
|
|
}
|
|
|
|
function detectMimeType(url) {
|
|
if (typeof url !== 'string') return '';
|
|
const u = url.toLowerCase();
|
|
const urlWithoutQuery = u.split('?')[0];
|
|
if (urlWithoutQuery.endsWith('.m3u8')) return 'application/x-mpegURL';
|
|
if (urlWithoutQuery.endsWith('.mpd')) return 'application/dash+xml';
|
|
return '';
|
|
} |