Initial commit
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* M3U Playlist Parser
|
||||
* Parses EXTM3U format playlists and extracts channel information
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a simple stable ID from name and group
|
||||
* @param {string} name - Channel name
|
||||
* @param {string} group - Group title
|
||||
* @returns {string} Stable ID
|
||||
*/
|
||||
function generateStableId(name, group) {
|
||||
const str = `${name || 'unknown'}:${group || 'unknown'}`;
|
||||
// Simple hash function
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return `m3u_${Math.abs(hash).toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse M3U playlist content
|
||||
* @param {string} content - Raw M3U playlist content
|
||||
* @returns {{ channels: Array, groups: Array }}
|
||||
*/
|
||||
function parse(content) {
|
||||
const lines = content.split('\n').map(line => line.trim());
|
||||
const channels = [];
|
||||
const groupsSet = new Set();
|
||||
|
||||
// Verify it's a valid M3U file
|
||||
if (!lines[0] || !lines[0].startsWith('#EXTM3U')) {
|
||||
throw new Error('Invalid M3U format: missing #EXTM3U header');
|
||||
}
|
||||
|
||||
let currentInfo = null;
|
||||
let currentGroup = null;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('#EXTINF:')) {
|
||||
// Parse EXTINF line
|
||||
currentInfo = parseExtinf(line);
|
||||
if (currentInfo.groupTitle) {
|
||||
groupsSet.add(currentInfo.groupTitle);
|
||||
currentGroup = currentInfo.groupTitle;
|
||||
}
|
||||
} else if (line.startsWith('#EXTGRP:')) {
|
||||
// Parse EXTGRP line (alternative group specification)
|
||||
currentGroup = line.substring(8).trim();
|
||||
groupsSet.add(currentGroup);
|
||||
if (currentInfo) {
|
||||
currentInfo.groupTitle = currentGroup;
|
||||
}
|
||||
} else if (line && !line.startsWith('#')) {
|
||||
// This is a stream URL
|
||||
if (currentInfo) {
|
||||
const groupTitle = currentInfo.groupTitle || currentGroup || 'Uncategorized';
|
||||
// Generate a stable ID: use tvgId if present, otherwise hash name+group
|
||||
const stableId = currentInfo.tvgId || generateStableId(currentInfo.name, groupTitle);
|
||||
|
||||
channels.push({
|
||||
...currentInfo,
|
||||
id: stableId,
|
||||
url: line,
|
||||
groupTitle: groupTitle
|
||||
});
|
||||
currentInfo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert groups to array of objects
|
||||
const groups = Array.from(groupsSet).map((name, index) => ({
|
||||
id: `group_${index}`,
|
||||
name,
|
||||
channelCount: channels.filter(c => c.groupTitle === name).length
|
||||
}));
|
||||
|
||||
return { channels, groups };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse EXTINF line and extract attributes
|
||||
* @param {string} line - EXTINF line
|
||||
* @returns {Object} Parsed channel info
|
||||
*/
|
||||
function parseExtinf(line) {
|
||||
const info = {
|
||||
duration: -1,
|
||||
tvgId: null,
|
||||
tvgName: null,
|
||||
tvgLogo: null,
|
||||
groupTitle: null,
|
||||
name: null
|
||||
};
|
||||
|
||||
// Extract duration and rest
|
||||
const match = line.match(/#EXTINF:(-?\d+\.?\d*)\s*(.*)/);
|
||||
if (!match) return info;
|
||||
|
||||
info.duration = parseFloat(match[1]);
|
||||
const rest = match[2];
|
||||
|
||||
// Extract attributes using regex
|
||||
const attrPatterns = {
|
||||
tvgId: /tvg-id="([^"]*)"/i,
|
||||
tvgName: /tvg-name="([^"]*)"/i,
|
||||
tvgLogo: /tvg-logo="([^"]*)"/i,
|
||||
groupTitle: /group-title="([^"]*)"/i
|
||||
};
|
||||
|
||||
for (const [key, pattern] of Object.entries(attrPatterns)) {
|
||||
const attrMatch = rest.match(pattern);
|
||||
if (attrMatch) {
|
||||
info[key] = attrMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract channel name (after the comma)
|
||||
const commaIndex = rest.lastIndexOf(',');
|
||||
if (commaIndex !== -1) {
|
||||
info.name = rest.substring(commaIndex + 1).trim();
|
||||
} else {
|
||||
// Fallback: use tvg-name or the whole rest
|
||||
info.name = info.tvgName || rest.trim();
|
||||
}
|
||||
|
||||
// Generate ID if not present
|
||||
if (!info.tvgId) {
|
||||
info.tvgId = info.name ? info.name.toLowerCase().replace(/\s+/g, '_') : `channel_${Date.now()}`;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse M3U from URL
|
||||
* @param {string} url - M3U playlist URL
|
||||
* @returns {Promise<{ channels: Array, groups: Array }>}
|
||||
*/
|
||||
async function fetchAndParse(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch M3U: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const content = await response.text();
|
||||
return parse(content);
|
||||
}
|
||||
|
||||
module.exports = { parse, parseExtinf, fetchAndParse };
|
||||
Reference in New Issue
Block a user