Initial commit

This commit is contained in:
Trevor Mears
2025-12-27 14:16:40 -08:00
commit 4f9ec55874
30 changed files with 9148 additions and 0 deletions
+177
View File
@@ -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
};
+155
View File
@@ -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 };
+161
View File
@@ -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 };