Initial commit
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* EPG (XMLTV) Parser
|
||||
* Parses XMLTV format EPG data and extracts channel/programme information
|
||||
*/
|
||||
|
||||
const { parseString } = require('xml2js');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const parseXml = promisify(parseString);
|
||||
|
||||
/**
|
||||
* Parse XMLTV date format (YYYYMMDDHHmmss +ZZZZ)
|
||||
* @param {string} dateStr - XMLTV format date string
|
||||
* @returns {Date}
|
||||
*/
|
||||
function parseXmltvDate(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
|
||||
// Format: 20231225120000 +0000
|
||||
const match = dateStr.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/);
|
||||
if (!match) {
|
||||
// Try ISO format fallback
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute, second, tz] = match;
|
||||
let isoStr = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
|
||||
|
||||
if (tz) {
|
||||
const tzHours = tz.substring(0, 3);
|
||||
const tzMins = tz.substring(3);
|
||||
isoStr += `${tzHours}:${tzMins}`;
|
||||
} else {
|
||||
isoStr += 'Z';
|
||||
}
|
||||
|
||||
return new Date(isoStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XMLTV content
|
||||
* @param {string} content - Raw XMLTV content
|
||||
* @returns {Promise<{ channels: Array, programmes: Array }>}
|
||||
*/
|
||||
async function parse(content) {
|
||||
const result = await parseXml(content, {
|
||||
explicitArray: false,
|
||||
mergeAttrs: true
|
||||
});
|
||||
|
||||
if (!result.tv) {
|
||||
throw new Error('Invalid XMLTV format: missing <tv> root element');
|
||||
}
|
||||
|
||||
const tv = result.tv;
|
||||
const channels = [];
|
||||
const programmes = [];
|
||||
|
||||
// Parse channels
|
||||
const channelList = Array.isArray(tv.channel) ? tv.channel : (tv.channel ? [tv.channel] : []);
|
||||
for (const ch of channelList) {
|
||||
const channel = {
|
||||
id: ch.id,
|
||||
name: extractText(ch['display-name']),
|
||||
icon: ch.icon ? (ch.icon.src || ch.icon) : null,
|
||||
url: extractText(ch.url)
|
||||
};
|
||||
channels.push(channel);
|
||||
}
|
||||
|
||||
// Parse programmes
|
||||
const programmeList = Array.isArray(tv.programme) ? tv.programme : (tv.programme ? [tv.programme] : []);
|
||||
for (const prog of programmeList) {
|
||||
const programme = {
|
||||
channelId: prog.channel,
|
||||
start: parseXmltvDate(prog.start),
|
||||
stop: parseXmltvDate(prog.stop),
|
||||
title: extractText(prog.title),
|
||||
subtitle: extractText(prog['sub-title']),
|
||||
description: extractText(prog.desc),
|
||||
category: extractCategories(prog.category),
|
||||
icon: prog.icon ? (prog.icon.src || prog.icon) : null,
|
||||
date: extractText(prog.date),
|
||||
episodeNum: extractEpisodeNum(prog['episode-num'])
|
||||
};
|
||||
programmes.push(programme);
|
||||
}
|
||||
|
||||
return { channels, programmes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from XMLTV element (handles both string and object formats)
|
||||
*/
|
||||
function extractText(element) {
|
||||
if (!element) return null;
|
||||
if (typeof element === 'string') return element;
|
||||
if (Array.isArray(element)) {
|
||||
// Prefer English or first item
|
||||
const en = element.find(e => e.lang === 'en' || !e.lang);
|
||||
return extractText(en || element[0]);
|
||||
}
|
||||
if (element._) return element._;
|
||||
if (element['#text']) return element['#text'];
|
||||
return String(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract categories array
|
||||
*/
|
||||
function extractCategories(category) {
|
||||
if (!category) return [];
|
||||
const cats = Array.isArray(category) ? category : [category];
|
||||
return cats.map(c => extractText(c)).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract episode number
|
||||
*/
|
||||
function extractEpisodeNum(episodeNum) {
|
||||
if (!episodeNum) return null;
|
||||
const nums = Array.isArray(episodeNum) ? episodeNum : [episodeNum];
|
||||
|
||||
for (const num of nums) {
|
||||
if (typeof num === 'string') return num;
|
||||
if (num._ || num['#text']) {
|
||||
return num._ || num['#text'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get programmes for a specific channel
|
||||
*/
|
||||
function getProgrammesForChannel(programmes, channelId) {
|
||||
return programmes.filter(p => p.channelId === channelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current and upcoming programmes for a channel
|
||||
*/
|
||||
function getCurrentAndUpcoming(programmes, channelId, count = 5) {
|
||||
const now = new Date();
|
||||
const channelProgrammes = getProgrammesForChannel(programmes, channelId);
|
||||
|
||||
// Sort by start time
|
||||
channelProgrammes.sort((a, b) => a.start - b.start);
|
||||
|
||||
// Find current and upcoming
|
||||
const current = channelProgrammes.find(p => p.start <= now && p.stop > now);
|
||||
const upcoming = channelProgrammes
|
||||
.filter(p => p.start > now)
|
||||
.slice(0, count);
|
||||
|
||||
return { current, upcoming };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse XMLTV from URL
|
||||
*/
|
||||
async function fetchAndParse(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch EPG: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const content = await response.text();
|
||||
return parse(content);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parse,
|
||||
parseXmltvDate,
|
||||
fetchAndParse,
|
||||
getProgrammesForChannel,
|
||||
getCurrentAndUpcoming
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Xtream Codes API v2 Client
|
||||
* Handles authentication and API calls to Xtream servers
|
||||
*/
|
||||
|
||||
class XtreamApi {
|
||||
constructor(baseUrl, username, password) {
|
||||
// Clean up base URL
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build API URL with authentication
|
||||
*/
|
||||
buildApiUrl(action, params = {}) {
|
||||
const url = new URL(`${this.baseUrl}/player_api.php`);
|
||||
url.searchParams.set('username', this.username);
|
||||
url.searchParams.set('password', this.password);
|
||||
if (action) {
|
||||
url.searchParams.set('action', action);
|
||||
}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API request
|
||||
*/
|
||||
async request(action, params = {}) {
|
||||
const url = this.buildApiUrl(action, params);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Xtream API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and get server/user info
|
||||
*/
|
||||
async authenticate() {
|
||||
const data = await this.request(null);
|
||||
if (!data.user_info) {
|
||||
throw new Error('Invalid credentials or server response');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get live channel categories
|
||||
*/
|
||||
async getLiveCategories() {
|
||||
return this.request('get_live_categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get live streams, optionally filtered by category
|
||||
*/
|
||||
async getLiveStreams(categoryId = null) {
|
||||
return this.request('get_live_streams', { category_id: categoryId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VOD categories
|
||||
*/
|
||||
async getVodCategories() {
|
||||
return this.request('get_vod_categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VOD streams, optionally filtered by category
|
||||
*/
|
||||
async getVodStreams(categoryId = null) {
|
||||
return this.request('get_vod_streams', { category_id: categoryId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VOD info
|
||||
*/
|
||||
async getVodInfo(vodId) {
|
||||
return this.request('get_vod_info', { vod_id: vodId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get series categories
|
||||
*/
|
||||
async getSeriesCategories() {
|
||||
return this.request('get_series_categories');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get series, optionally filtered by category
|
||||
*/
|
||||
async getSeries(categoryId = null) {
|
||||
return this.request('get_series', { category_id: categoryId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get series info
|
||||
*/
|
||||
async getSeriesInfo(seriesId) {
|
||||
return this.request('get_series_info', { series_id: seriesId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short EPG for a stream
|
||||
*/
|
||||
async getShortEpg(streamId, limit = 10) {
|
||||
return this.request('get_short_epg', { stream_id: streamId, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full EPG for a stream
|
||||
*/
|
||||
async getSimpleDateTable(streamId) {
|
||||
return this.request('get_simple_data_table', { stream_id: streamId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build stream URL for playback
|
||||
*/
|
||||
buildStreamUrl(streamId, type = 'live', container = 'ts') {
|
||||
const typeMap = {
|
||||
live: 'live',
|
||||
vod: 'movie',
|
||||
series: 'series'
|
||||
};
|
||||
const streamType = typeMap[type] || 'live';
|
||||
return `${this.baseUrl}/${streamType}/${this.username}/${this.password}/${streamId}.${container}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get XMLTV EPG URL
|
||||
*/
|
||||
getXmltvUrl() {
|
||||
return `${this.baseUrl}/xmltv.php?username=${this.username}&password=${this.password}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create API instance from source
|
||||
*/
|
||||
function createFromSource(source) {
|
||||
return new XtreamApi(source.url, source.username, source.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static authenticate for testing
|
||||
*/
|
||||
async function authenticate(url, username, password) {
|
||||
const api = new XtreamApi(url, username, password);
|
||||
return api.authenticate();
|
||||
}
|
||||
|
||||
module.exports = { XtreamApi, createFromSource, authenticate };
|
||||
Reference in New Issue
Block a user