Refactored server/db.js and all routes to use asynchronous fs operations, refactored m3uParser and epgParser to use streaming (via readline and sax) to prevent errors with large files, updated server/routes/proxy.js to use native stream piping for images and video, reducing latency and memory overhead

This commit is contained in:
Trevor Mears
2025-12-30 00:34:55 -08:00
parent 3b0d311756
commit d63c9c37d7
9 changed files with 366 additions and 311 deletions
+1
View File
@@ -10,6 +10,7 @@
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",
"sax": "^1.4.3",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
}, },
"engines": { "engines": {
+2 -1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",
"sax": "^1.4.3",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
}, },
"optionalDependencies": { "optionalDependencies": {
@@ -18,4 +19,4 @@
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
} }
} }
+74 -60
View File
@@ -1,61 +1,75 @@
const fs = require('fs'); const fs = require('fs/promises');
const path = require('path'); const path = require('path');
const { existsSync, mkdirSync } = require('fs');
// Ensure data directory exists // Ensure data directory exists (sync is fine for startup)
const dataDir = path.join(__dirname, '..', 'data'); const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) { if (!existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true }); mkdirSync(dataDir, { recursive: true });
} }
const dbPath = path.join(dataDir, 'db.json'); const dbPath = path.join(dataDir, 'db.json');
// Initialize database structure // Initialize database structure
function loadDb() { async function loadDb() {
try { try {
if (fs.existsSync(dbPath)) { // Check if file exists (using fs.access is better for async, but we can catch ENOENT)
const data = JSON.parse(fs.readFileSync(dbPath, 'utf-8')); try {
// Ensure all keys exist const fileContent = await fs.readFile(dbPath, 'utf-8');
const data = JSON.parse(fileContent);
return { return {
sources: data.sources || [], sources: data.sources || [],
hiddenItems: data.hiddenItems || [], hiddenItems: data.hiddenItems || [],
favorites: data.favorites || [], favorites: data.favorites || [],
nextId: data.nextId || 1 nextId: data.nextId || 1
}; };
} catch (error) {
if (error.code === 'ENOENT') {
// File doesn't exist, return default
return {
sources: [],
hiddenItems: [],
favorites: [],
nextId: 1
};
}
throw error;
} }
} catch (err) { } catch (err) {
console.error('Error loading database:', err); console.error('Error loading database:', err);
// Return safe default on error to prevent crashing, but log it
return {
sources: [],
hiddenItems: [],
favorites: [],
nextId: 1
};
} }
return {
sources: [],
hiddenItems: [],
favorites: [],
nextId: 1
};
} }
function saveDb(data) { async function saveDb(data) {
fs.writeFileSync(dbPath, JSON.stringify(data, null, 2)); await fs.writeFile(dbPath, JSON.stringify(data, null, 2));
} }
// Source CRUD operations // Source CRUD operations
const sources = { const sources = {
getAll() { async getAll() {
const db = loadDb(); const db = await loadDb();
return db.sources; return db.sources;
}, },
getById(id) { async getById(id) {
const db = loadDb(); const db = await loadDb();
return db.sources.find(s => s.id === parseInt(id)); return db.sources.find(s => s.id === parseInt(id));
}, },
getByType(type) { async getByType(type) {
const db = loadDb(); const db = await loadDb();
return db.sources.filter(s => s.type === type && s.enabled); return db.sources.filter(s => s.type === type && s.enabled);
}, },
create(source) { async create(source) {
const db = loadDb(); const db = await loadDb();
const newSource = { const newSource = {
id: db.nextId++, id: db.nextId++,
...source, ...source,
@@ -64,12 +78,12 @@ const sources = {
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}; };
db.sources.push(newSource); db.sources.push(newSource);
saveDb(db); await saveDb(db);
return newSource; return newSource;
}, },
update(id, updates) { async update(id, updates) {
const db = loadDb(); const db = await loadDb();
const index = db.sources.findIndex(s => s.id === parseInt(id)); const index = db.sources.findIndex(s => s.id === parseInt(id));
if (index === -1) return null; if (index === -1) return null;
@@ -78,25 +92,25 @@ const sources = {
...updates, ...updates,
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}; };
saveDb(db); await saveDb(db);
return db.sources[index]; return db.sources[index];
}, },
delete(id) { async delete(id) {
const db = loadDb(); const db = await loadDb();
db.sources = db.sources.filter(s => s.id !== parseInt(id)); db.sources = db.sources.filter(s => s.id !== parseInt(id));
// Also delete related hidden items // Also delete related hidden items
db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id)); db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id));
saveDb(db); await saveDb(db);
}, },
toggleEnabled(id) { async toggleEnabled(id) {
const db = loadDb(); const db = await loadDb();
const source = db.sources.find(s => s.id === parseInt(id)); const source = db.sources.find(s => s.id === parseInt(id));
if (source) { if (source) {
source.enabled = !source.enabled; source.enabled = !source.enabled;
source.updated_at = new Date().toISOString(); source.updated_at = new Date().toISOString();
saveDb(db); await saveDb(db);
} }
return source; return source;
} }
@@ -104,16 +118,16 @@ const sources = {
// Hidden items operations // Hidden items operations
const hiddenItems = { const hiddenItems = {
getAll(sourceId = null) { async getAll(sourceId = null) {
const db = loadDb(); const db = await loadDb();
if (sourceId) { if (sourceId) {
return db.hiddenItems.filter(h => h.source_id === parseInt(sourceId)); return db.hiddenItems.filter(h => h.source_id === parseInt(sourceId));
} }
return db.hiddenItems; return db.hiddenItems;
}, },
hide(sourceId, itemType, itemId) { async hide(sourceId, itemType, itemId) {
const db = loadDb(); const db = await loadDb();
// Check if already hidden // Check if already hidden
const exists = db.hiddenItems.find( const exists = db.hiddenItems.find(
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
@@ -125,27 +139,27 @@ const hiddenItems = {
item_type: itemType, item_type: itemType,
item_id: itemId item_id: itemId
}); });
saveDb(db); await saveDb(db);
} }
}, },
show(sourceId, itemType, itemId) { async show(sourceId, itemType, itemId) {
const db = loadDb(); const db = await loadDb();
db.hiddenItems = db.hiddenItems.filter( db.hiddenItems = db.hiddenItems.filter(
h => !(h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId) h => !(h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId)
); );
saveDb(db); await saveDb(db);
}, },
isHidden(sourceId, itemType, itemId) { async isHidden(sourceId, itemType, itemId) {
const db = loadDb(); const db = await loadDb();
return db.hiddenItems.some( return db.hiddenItems.some(
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
); );
}, },
bulkHide(items) { async bulkHide(items) {
const db = loadDb(); const db = await loadDb();
let modified = false; let modified = false;
items.forEach(item => { items.forEach(item => {
@@ -166,13 +180,13 @@ const hiddenItems = {
}); });
if (modified) { if (modified) {
saveDb(db); await saveDb(db);
} }
return true; return true;
}, },
bulkShow(items) { async bulkShow(items) {
const db = loadDb(); const db = await loadDb();
const initialLength = db.hiddenItems.length; const initialLength = db.hiddenItems.length;
// Create a set of "signatures" for O(1) lookup of items to remove // Create a set of "signatures" for O(1) lookup of items to remove
@@ -183,7 +197,7 @@ const hiddenItems = {
); );
if (db.hiddenItems.length !== initialLength) { if (db.hiddenItems.length !== initialLength) {
saveDb(db); await saveDb(db);
} }
return true; return true;
} }
@@ -191,8 +205,8 @@ const hiddenItems = {
// Favorites operations // Favorites operations
const favorites = { const favorites = {
getAll(sourceId = null, itemType = null) { async getAll(sourceId = null, itemType = null) {
const db = loadDb(); const db = await loadDb();
let results = db.favorites; let results = db.favorites;
if (sourceId) { if (sourceId) {
results = results.filter(f => f.source_id === parseInt(sourceId)); results = results.filter(f => f.source_id === parseInt(sourceId));
@@ -203,8 +217,8 @@ const favorites = {
return results; return results;
}, },
add(sourceId, itemId, itemType = 'channel') { async add(sourceId, itemId, itemType = 'channel') {
const db = loadDb(); const db = await loadDb();
// Check if already favorited // Check if already favorited
const exists = db.favorites.find( const exists = db.favorites.find(
f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType
@@ -217,22 +231,22 @@ const favorites = {
item_type: itemType, // 'channel', 'movie', 'series' item_type: itemType, // 'channel', 'movie', 'series'
created_at: new Date().toISOString() created_at: new Date().toISOString()
}); });
saveDb(db); await saveDb(db);
} }
return true; return true;
}, },
remove(sourceId, itemId, itemType = 'channel') { async remove(sourceId, itemId, itemType = 'channel') {
const db = loadDb(); const db = await loadDb();
db.favorites = db.favorites.filter( db.favorites = db.favorites.filter(
f => !(f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType) f => !(f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType)
); );
saveDb(db); await saveDb(db);
return true; return true;
}, },
isFavorite(sourceId, itemId, itemType = 'channel') { async isFavorite(sourceId, itemId, itemType = 'channel') {
const db = loadDb(); const db = await loadDb();
return db.favorites.some( return db.favorites.some(
f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType
); );
+13 -12
View File
@@ -3,10 +3,10 @@ const router = express.Router();
const { hiddenItems } = require('../db'); const { hiddenItems } = require('../db');
// Get all hidden items // Get all hidden items
router.get('/hidden', (req, res) => { router.get('/hidden', async (req, res) => {
try { try {
const { sourceId } = req.query; const { sourceId } = req.query;
const items = hiddenItems.getAll(sourceId ? parseInt(sourceId) : null); const items = await hiddenItems.getAll(sourceId ? parseInt(sourceId) : null);
res.json(items); res.json(items);
} catch (err) { } catch (err) {
console.error('Error getting hidden items:', err); console.error('Error getting hidden items:', err);
@@ -15,7 +15,7 @@ router.get('/hidden', (req, res) => {
}); });
// Hide a channel, group, or category // Hide a channel, group, or category
router.post('/hide', (req, res) => { router.post('/hide', async (req, res) => {
try { try {
const { sourceId, itemType, itemId } = req.body; const { sourceId, itemType, itemId } = req.body;
@@ -28,7 +28,7 @@ router.post('/hide', (req, res) => {
return res.status(400).json({ error: `itemType must be one of: ${validTypes.join(', ')}` }); return res.status(400).json({ error: `itemType must be one of: ${validTypes.join(', ')}` });
} }
hiddenItems.hide(sourceId, itemType, itemId); await hiddenItems.hide(sourceId, itemType, itemId);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Error hiding item:', err); console.error('Error hiding item:', err);
@@ -37,7 +37,7 @@ router.post('/hide', (req, res) => {
}); });
// Show (unhide) a channel or group // Show (unhide) a channel or group
router.post('/show', (req, res) => { router.post('/show', async (req, res) => {
try { try {
const { sourceId, itemType, itemId } = req.body; const { sourceId, itemType, itemId } = req.body;
@@ -45,7 +45,7 @@ router.post('/show', (req, res) => {
return res.status(400).json({ error: 'sourceId, itemType, and itemId are required' }); return res.status(400).json({ error: 'sourceId, itemType, and itemId are required' });
} }
hiddenItems.show(sourceId, itemType, itemId); await hiddenItems.show(sourceId, itemType, itemId);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Error showing item:', err); console.error('Error showing item:', err);
@@ -54,7 +54,7 @@ router.post('/show', (req, res) => {
}); });
// Check if item is hidden // Check if item is hidden
router.get('/hidden/check', (req, res) => { router.get('/hidden/check', async (req, res) => {
try { try {
const { sourceId, itemType, itemId } = req.query; const { sourceId, itemType, itemId } = req.query;
@@ -62,7 +62,8 @@ router.get('/hidden/check', (req, res) => {
return res.status(400).json({ error: 'sourceId, itemType, and itemId are required' }); return res.status(400).json({ error: 'sourceId, itemType, and itemId are required' });
} }
const isHidden = hiddenItems.isHidden(parseInt(sourceId), itemType, itemId); // isHidden is now async
const isHidden = await hiddenItems.isHidden(parseInt(sourceId), itemType, itemId);
res.json({ hidden: isHidden }); res.json({ hidden: isHidden });
} catch (err) { } catch (err) {
console.error('Error checking hidden status:', err); console.error('Error checking hidden status:', err);
@@ -71,7 +72,7 @@ router.get('/hidden/check', (req, res) => {
}); });
// Bulk hide channels and groups // Bulk hide channels and groups
router.post('/hide/bulk', (req, res) => { router.post('/hide/bulk', async (req, res) => {
try { try {
const { items } = req.body; const { items } = req.body;
@@ -79,7 +80,7 @@ router.post('/hide/bulk', (req, res) => {
return res.status(400).json({ error: 'items array is required' }); return res.status(400).json({ error: 'items array is required' });
} }
hiddenItems.bulkHide(items); await hiddenItems.bulkHide(items);
res.json({ success: true, count: items.length }); res.json({ success: true, count: items.length });
} catch (err) { } catch (err) {
console.error('Error bulk hiding items:', err); console.error('Error bulk hiding items:', err);
@@ -88,7 +89,7 @@ router.post('/hide/bulk', (req, res) => {
}); });
// Bulk show channels and groups // Bulk show channels and groups
router.post('/show/bulk', (req, res) => { router.post('/show/bulk', async (req, res) => {
try { try {
const { items } = req.body; const { items } = req.body;
@@ -96,7 +97,7 @@ router.post('/show/bulk', (req, res) => {
return res.status(400).json({ error: 'items array is required' }); return res.status(400).json({ error: 'items array is required' });
} }
hiddenItems.bulkShow(items); await hiddenItems.bulkShow(items);
res.json({ success: true, count: items.length }); res.json({ success: true, count: items.length });
} catch (err) { } catch (err) {
console.error('Error bulk showing items:', err); console.error('Error bulk showing items:', err);
+8 -8
View File
@@ -3,10 +3,10 @@ const router = express.Router();
const { favorites } = require('../db'); const { favorites } = require('../db');
// Get all favorites // Get all favorites
router.get('/', (req, res) => { router.get('/', async (req, res) => {
try { try {
const { sourceId, itemType } = req.query; const { sourceId, itemType } = req.query;
const items = favorites.getAll(sourceId, itemType); const items = await favorites.getAll(sourceId, itemType);
res.json(items); res.json(items);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@@ -14,14 +14,14 @@ router.get('/', (req, res) => {
}); });
// Add favorite // Add favorite
router.post('/', (req, res) => { router.post('/', async (req, res) => {
try { try {
const { sourceId, itemId, itemType = 'channel' } = req.body; const { sourceId, itemId, itemType = 'channel' } = req.body;
if (!sourceId || !itemId) { if (!sourceId || !itemId) {
return res.status(400).json({ error: 'Source ID and Item ID are required' }); return res.status(400).json({ error: 'Source ID and Item ID are required' });
} }
favorites.add(sourceId, itemId, itemType); await favorites.add(sourceId, itemId, itemType);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@@ -29,14 +29,14 @@ router.post('/', (req, res) => {
}); });
// Remove favorite // Remove favorite
router.delete('/', (req, res) => { router.delete('/', async (req, res) => {
try { try {
const { sourceId, itemId, itemType = 'channel' } = req.body; const { sourceId, itemId, itemType = 'channel' } = req.body;
if (!sourceId || !itemId) { if (!sourceId || !itemId) {
return res.status(400).json({ error: 'Source ID and Item ID are required' }); return res.status(400).json({ error: 'Source ID and Item ID are required' });
} }
favorites.remove(sourceId, itemId, itemType); await favorites.remove(sourceId, itemId, itemType);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@@ -44,14 +44,14 @@ router.delete('/', (req, res) => {
}); });
// Check if item is favorited // Check if item is favorited
router.get('/check', (req, res) => { router.get('/check', async (req, res) => {
try { try {
const { sourceId, itemId, itemType = 'channel' } = req.query; const { sourceId, itemId, itemType = 'channel' } = req.query;
if (!sourceId || !itemId) { if (!sourceId || !itemId) {
return res.status(400).json({ error: 'Source ID and Item ID are required' }); return res.status(400).json({ error: 'Source ID and Item ID are required' });
} }
const isFav = favorites.isFavorite(sourceId, itemId, itemType); const isFav = await favorites.isFavorite(sourceId, itemId, itemType);
res.json({ isFavorite: isFav }); res.json({ isFavorite: isFav });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
+26 -42
View File
@@ -5,6 +5,7 @@ const xtreamApi = require('../services/xtreamApi');
const m3uParser = require('../services/m3uParser'); const m3uParser = require('../services/m3uParser');
const epgParser = require('../services/epgParser'); const epgParser = require('../services/epgParser');
const cache = require('../services/cache'); const cache = require('../services/cache');
const { Readable } = require('stream');
// Default cache TTL: 24 hours // Default cache TTL: 24 hours
const DEFAULT_MAX_AGE_HOURS = 24; const DEFAULT_MAX_AGE_HOURS = 24;
@@ -16,7 +17,7 @@ const DEFAULT_MAX_AGE_HOURS = 24;
router.get('/xtream/:sourceId/:action', async (req, res) => { router.get('/xtream/:sourceId/:action', async (req, res) => {
try { try {
const sourceId = req.params.sourceId; const sourceId = req.params.sourceId;
const source = sources.getById(sourceId); const source = await sources.getById(sourceId);
if (!source || source.type !== 'xtream') { if (!source || source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' }); return res.status(404).json({ error: 'Xtream source not found' });
} }
@@ -99,9 +100,9 @@ router.get('/xtream/:sourceId/:action', async (req, res) => {
* Get Xtream stream URL * Get Xtream stream URL
* GET /api/proxy/xtream/:sourceId/stream/:streamId * GET /api/proxy/xtream/:sourceId/stream/:streamId
*/ */
router.get('/xtream/:sourceId/stream/:streamId/:type?', (req, res) => { router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
try { try {
const source = sources.getById(req.params.sourceId); const source = await sources.getById(req.params.sourceId);
if (!source || source.type !== 'xtream') { if (!source || source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' }); return res.status(404).json({ error: 'Xtream source not found' });
} }
@@ -125,7 +126,7 @@ router.get('/xtream/:sourceId/stream/:streamId/:type?', (req, res) => {
router.get('/m3u/:sourceId', async (req, res) => { router.get('/m3u/:sourceId', async (req, res) => {
try { try {
const sourceId = req.params.sourceId; const sourceId = req.params.sourceId;
const source = sources.getById(sourceId); const source = await sources.getById(sourceId);
if (!source || source.type !== 'm3u') { if (!source || source.type !== 'm3u') {
return res.status(404).json({ error: 'M3U source not found' }); return res.status(404).json({ error: 'M3U source not found' });
} }
@@ -164,7 +165,7 @@ router.get('/m3u/:sourceId', async (req, res) => {
router.get('/epg/:sourceId', async (req, res) => { router.get('/epg/:sourceId', async (req, res) => {
try { try {
const sourceId = req.params.sourceId; const sourceId = req.params.sourceId;
const source = sources.getById(sourceId); const source = await sources.getById(sourceId);
if (!source || (source.type !== 'epg' && source.type !== 'xtream')) { if (!source || (source.type !== 'epg' && source.type !== 'xtream')) {
return res.status(404).json({ error: 'Valid EPG source not found' }); return res.status(404).json({ error: 'Valid EPG source not found' });
} }
@@ -226,7 +227,7 @@ router.delete('/epg/:sourceId/cache', (req, res) => {
*/ */
router.post('/epg/:sourceId/channels', async (req, res) => { router.post('/epg/:sourceId/channels', async (req, res) => {
try { try {
const source = sources.getById(req.params.sourceId); const source = await sources.getById(req.params.sourceId);
if (!source || source.type !== 'epg') { if (!source || source.type !== 'epg') {
return res.status(404).json({ error: 'EPG source not found' }); return res.status(404).json({ error: 'EPG source not found' });
} }
@@ -277,7 +278,6 @@ router.get('/stream', async (req, res) => {
const response = await fetch(url, { headers }); const response = await fetch(url, { headers });
if (!response.ok) { if (!response.ok) {
console.error(`Upstream error for ${url.substring(0, 80)}...: ${response.status} ${response.statusText}`); console.error(`Upstream error for ${url.substring(0, 80)}...: ${response.status} ${response.statusText}`);
// Log response body for debugging 403s
if (response.status === 403) { if (response.status === 403) {
const errorBody = await response.text().catch(() => 'N/A'); const errorBody = await response.text().catch(() => 'N/A');
console.error(`403 Response body: ${errorBody.substring(0, 200)}`); console.error(`403 Response body: ${errorBody.substring(0, 200)}`);
@@ -289,25 +289,22 @@ router.get('/stream', async (req, res) => {
res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Origin', '*');
// Create an async iterator for the response body // Create an async iterator for the response body
// Note: undici fetch returns an iterable body
const iterator = response.body[Symbol.asyncIterator](); const iterator = response.body[Symbol.asyncIterator]();
const first = await iterator.next(); const first = await iterator.next();
if (first.done) { if (first.done) {
// Empty response
res.set('Content-Type', contentType || 'application/octet-stream'); res.set('Content-Type', contentType || 'application/octet-stream');
return res.end(); return res.end();
} }
const firstChunk = Buffer.from(first.value); const firstChunk = Buffer.from(first.value);
// Peek at first bytes to check for HLS manifest (#EXTM3U) // Peek at first bytes to check for HLS manifest ({ #EXTM3U })
const textPrefix = firstChunk.subarray(0, 7).toString('utf8'); const textPrefix = firstChunk.subarray(0, 7).toString('utf8');
const contentLooksLikeHls = textPrefix === '#EXTM3U'; const contentLooksLikeHls = textPrefix === '#EXTM3U';
if (contentLooksLikeHls) { if (contentLooksLikeHls) {
// HLS Manifest: We must read the WHOLE stream to rewrite it // HLS Manifest: We must read the WHOLE manifest to rewrite it
// This is fine because manifests are small text files
const chunks = [firstChunk]; const chunks = [firstChunk];
// Consume the rest of the stream // Consume the rest of the stream
@@ -318,8 +315,6 @@ router.get('/stream', async (req, res) => {
} }
const buffer = Buffer.concat(chunks); const buffer = Buffer.concat(chunks);
// Use the final URL after redirects for base URL calculation
const finalUrl = response.url || url; const finalUrl = response.url || url;
console.log(`[Proxy] Processing HLS manifest from: ${finalUrl.substring(0, 80)}...`); console.log(`[Proxy] Processing HLS manifest from: ${finalUrl.substring(0, 80)}...`);
res.set('Content-Type', 'application/vnd.apple.mpegurl'); res.set('Content-Type', 'application/vnd.apple.mpegurl');
@@ -328,11 +323,9 @@ router.get('/stream', async (req, res) => {
const finalUrlObj = new URL(finalUrl); const finalUrlObj = new URL(finalUrl);
const baseUrl = finalUrlObj.origin + finalUrlObj.pathname.substring(0, finalUrlObj.pathname.lastIndexOf('/') + 1); const baseUrl = finalUrlObj.origin + finalUrlObj.pathname.substring(0, finalUrlObj.pathname.lastIndexOf('/') + 1);
console.log(`[Proxy] Base URL for rewriting: ${baseUrl}`);
manifest = manifest.split('\n').map(line => { manifest = manifest.split('\n').map(line => {
const trimmed = line.trim(); const trimmed = line.trim();
// ... same rewrite logic as before ...
if (trimmed === '' || trimmed.startsWith('#')) { if (trimmed === '' || trimmed.startsWith('#')) {
if (trimmed.includes('URI="')) { if (trimmed.includes('URI="')) {
return line.replace(/URI="([^"]+)"/g, (match, p1) => { return line.replace(/URI="([^"]+)"/g, (match, p1) => {
@@ -360,34 +353,19 @@ router.get('/stream', async (req, res) => {
return res.send(manifest); return res.send(manifest);
} }
// Binary content (Video Segment): STREAM IT! // Binary content (Video Segment): Efficient Pipe
// This is the critical fix for "SocketError: other side closed"
console.log(`[Proxy] Piping binary stream (${contentType})`); console.log(`[Proxy] Piping binary stream (${contentType})`);
res.set('Content-Type', contentType || 'application/octet-stream'); res.set('Content-Type', contentType || 'application/octet-stream');
// Write the first chunk we already peeked // Write the chunk we peeked
res.write(firstChunk); res.write(firstChunk);
// Pipe the rest of the iterator to the response // Stream the rest
try { // Create a readable stream from the iterator
let result = await iterator.next(); const restOfStream = Readable.from(iterator);
while (!result.done) {
// If client disconnects, stop reading
if (res.writableEnded || res.closed) break;
const canWrite = res.write(Buffer.from(result.value)); // Pipe to response
if (!canWrite) { restOfStream.pipe(res);
// Handle backpressure
await new Promise(resolve => res.once('drain', resolve));
}
result = await iterator.next();
}
res.end();
} catch (streamErr) {
console.error('Stream pipe error:', streamErr);
if (!res.headersSent) res.status(500).end();
else res.end();
}
} catch (err) { } catch (err) {
console.error('Stream proxy error:', err); console.error('Stream proxy error:', err);
@@ -420,15 +398,21 @@ router.get('/image', async (req, res) => {
return res.status(response.status).send('Failed to fetch image'); return res.status(response.status).send('Failed to fetch image');
} }
// Forward content type
const contentType = response.headers.get('content-type') || 'image/png'; const contentType = response.headers.get('content-type') || 'image/png';
res.set('Content-Type', contentType); res.set('Content-Type', contentType);
res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours res.set('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
// Pipe the image data // Efficiently pipe the response body
const buffer = await response.arrayBuffer(); if (response.body) {
res.send(Buffer.from(buffer)); // response.body is an AsyncIterable in standard fetch/undici
// Readable.from converts it to a Node.js Readable stream
const stream = Readable.from(response.body);
stream.pipe(res);
} else {
res.end();
}
} catch (err) { } catch (err) {
console.error('Image proxy error:', err.message); console.error('Image proxy error:', err.message);
res.status(500).send('Image proxy error'); res.status(500).send('Image proxy error');
+17 -17
View File
@@ -4,9 +4,9 @@ const { sources } = require('../db');
const xtreamApi = require('../services/xtreamApi'); const xtreamApi = require('../services/xtreamApi');
// Get all sources // Get all sources
router.get('/', (req, res) => { router.get('/', async (req, res) => {
try { try {
const allSources = sources.getAll(); const allSources = await sources.getAll();
// Don't expose passwords in list view // Don't expose passwords in list view
const sanitized = allSources.map(s => ({ const sanitized = allSources.map(s => ({
...s, ...s,
@@ -20,9 +20,9 @@ router.get('/', (req, res) => {
}); });
// Get sources by type // Get sources by type
router.get('/type/:type', (req, res) => { router.get('/type/:type', async (req, res) => {
try { try {
const typeSources = sources.getByType(req.params.type); const typeSources = await sources.getByType(req.params.type);
res.json(typeSources); res.json(typeSources);
} catch (err) { } catch (err) {
console.error('Error getting sources by type:', err); console.error('Error getting sources by type:', err);
@@ -31,9 +31,9 @@ router.get('/type/:type', (req, res) => {
}); });
// Get single source // Get single source
router.get('/:id', (req, res) => { router.get('/:id', async (req, res) => {
try { try {
const source = sources.getById(req.params.id); const source = await sources.getById(req.params.id);
if (!source) { if (!source) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
@@ -45,7 +45,7 @@ router.get('/:id', (req, res) => {
}); });
// Create source // Create source
router.post('/', (req, res) => { router.post('/', async (req, res) => {
try { try {
const { type, name, url, username, password } = req.body; const { type, name, url, username, password } = req.body;
@@ -57,7 +57,7 @@ router.post('/', (req, res) => {
return res.status(400).json({ error: 'Invalid source type' }); return res.status(400).json({ error: 'Invalid source type' });
} }
const source = sources.create({ type, name, url, username, password }); const source = await sources.create({ type, name, url, username, password });
res.status(201).json(source); res.status(201).json(source);
} catch (err) { } catch (err) {
console.error('Error creating source:', err); console.error('Error creating source:', err);
@@ -66,15 +66,15 @@ router.post('/', (req, res) => {
}); });
// Update source // Update source
router.put('/:id', (req, res) => { router.put('/:id', async (req, res) => {
try { try {
const existing = sources.getById(req.params.id); const existing = await sources.getById(req.params.id);
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
const { name, url, username, password } = req.body; const { name, url, username, password } = req.body;
const updated = sources.update(req.params.id, { const updated = await sources.update(req.params.id, {
name: name || existing.name, name: name || existing.name,
url: url || existing.url, url: url || existing.url,
username: username !== undefined ? username : existing.username, username: username !== undefined ? username : existing.username,
@@ -88,13 +88,13 @@ router.put('/:id', (req, res) => {
}); });
// Delete source // Delete source
router.delete('/:id', (req, res) => { router.delete('/:id', async (req, res) => {
try { try {
const existing = sources.getById(req.params.id); const existing = await sources.getById(req.params.id);
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
sources.delete(req.params.id); await sources.delete(req.params.id);
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Error deleting source:', err); console.error('Error deleting source:', err);
@@ -103,9 +103,9 @@ router.delete('/:id', (req, res) => {
}); });
// Toggle source enabled/disabled // Toggle source enabled/disabled
router.post('/:id/toggle', (req, res) => { router.post('/:id/toggle', async (req, res) => {
try { try {
const updated = sources.toggleEnabled(req.params.id); const updated = await sources.toggleEnabled(req.params.id);
if (!updated) { if (!updated) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
@@ -119,7 +119,7 @@ router.post('/:id/toggle', (req, res) => {
// Test source connection // Test source connection
router.post('/:id/test', async (req, res) => { router.post('/:id/test', async (req, res) => {
try { try {
const source = sources.getById(req.params.id); const source = await sources.getById(req.params.id);
if (!source) { if (!source) {
return res.status(404).json({ error: 'Source not found' }); return res.status(404).json({ error: 'Source not found' });
} }
+137 -104
View File
@@ -1,14 +1,11 @@
/** /**
* EPG (XMLTV) Parser * EPG (XMLTV) Parser (Streaming)
* Parses XMLTV format EPG data and extracts channel/programme information * Parses XMLTV format EPG data and extracts channel/programme information using streaming XML parser
*/ */
const { parseString } = require('xml2js'); const sax = require('sax');
const { promisify } = require('util');
const zlib = require('zlib'); const zlib = require('zlib');
const { Readable } = require('stream');
const parseXml = promisify(parseString);
const gunzip = promisify(zlib.gunzip);
/** /**
* Parse XMLTV date format (YYYYMMDDHHmmss +ZZZZ) * Parse XMLTV date format (YYYYMMDDHHmmss +ZZZZ)
@@ -40,96 +37,120 @@ function parseXmltvDate(dateStr) {
} }
/** /**
* Parse XMLTV content * Parse XMLTV content (Stream or String)
* @param {string} content - Raw XMLTV content * @param {Readable|string} input - XMLTV content as Stream or String
* @returns {Promise<{ channels: Array, programmes: Array }>} * @returns {Promise<{ channels: Array, programmes: Array }>}
*/ */
async function parse(content) { function parse(input) {
const result = await parseXml(content, { return new Promise((resolve, reject) => {
explicitArray: false, const channels = [];
mergeAttrs: true const programmes = [];
});
if (!result.tv) { const saxStream = sax.createStream(true, { trim: true, normalize: true }); // strict mode
throw new Error('Invalid XMLTV format: missing <tv> root element');
}
const tv = result.tv; let currentTag = null;
const channels = []; let currentObject = null;
const programmes = []; let textBuffer = '';
// Parse channels saxStream.on('error', function (e) {
const channelList = Array.isArray(tv.channel) ? tv.channel : (tv.channel ? [tv.channel] : []); // clear the error
for (const ch of channelList) { this._parser.error = null;
const channel = { this._parser.resume();
id: ch.id, console.warn('XML Parse Warning:', e.message);
name: extractText(ch['display-name']), });
icon: ch.icon ? (ch.icon.src || ch.icon) : null,
url: extractText(ch.url)
};
channels.push(channel);
}
// Parse programmes saxStream.on('opentag', function (node) {
const programmeList = Array.isArray(tv.programme) ? tv.programme : (tv.programme ? [tv.programme] : []); currentTag = node.name;
for (const prog of programmeList) { const attr = node.attributes;
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 }; if (currentTag === 'channel') {
} currentObject = {
id: attr.id,
name: null, // Will be populated by display-name tag
icon: null,
url: null
};
} else if (currentTag === 'programme') {
currentObject = {
channelId: attr.channel,
start: parseXmltvDate(attr.start),
stop: parseXmltvDate(attr.stop),
title: null,
subtitle: null,
description: null,
category: [],
icon: null,
date: null,
episodeNum: null
};
} else if (currentTag === 'icon') {
if (currentObject) {
currentObject.icon = attr.src;
}
}
textBuffer = '';
});
/** saxStream.on('text', function (text) {
* Extract text from XMLTV element (handles both string and object formats) textBuffer += text;
*/ });
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);
}
/** saxStream.on('cdata', function (text) {
* Extract categories array textBuffer += text;
*/ });
function extractCategories(category) {
if (!category) return [];
const cats = Array.isArray(category) ? category : [category];
return cats.map(c => extractText(c)).filter(Boolean);
}
/** saxStream.on('closetag', function (tagName) {
* Extract episode number if (tagName === 'channel') {
*/ if (currentObject) channels.push(currentObject);
function extractEpisodeNum(episodeNum) { currentObject = null;
if (!episodeNum) return null; } else if (tagName === 'programme') {
const nums = Array.isArray(episodeNum) ? episodeNum : [episodeNum]; if (currentObject) programmes.push(currentObject);
currentObject = null;
} else if (currentObject) {
// Handle properties within objects
switch (tagName) {
case 'display-name': // channel name
if (!currentObject.name) currentObject.name = textBuffer;
break;
case 'url': // channel url
currentObject.url = textBuffer;
break;
case 'title':
currentObject.title = textBuffer;
break;
case 'sub-title':
currentObject.subtitle = textBuffer;
break;
case 'desc':
currentObject.description = textBuffer;
break;
case 'category':
if (textBuffer) currentObject.category.push(textBuffer);
break;
case 'date':
currentObject.date = textBuffer;
break;
case 'episode-num':
// Prefer system "xmltv_ns" or just take text
// Complex episode parsing logic can go here if needed
currentObject.episodeNum = textBuffer;
break;
}
}
});
for (const num of nums) { saxStream.on('end', function () {
if (typeof num === 'string') return num; resolve({ channels, programmes });
if (num._ || num['#text']) { });
return num._ || num['#text'];
// Handle input type
if (typeof input === 'string') {
const inputStream = Readable.from([input]);
inputStream.pipe(saxStream);
} else {
input.pipe(saxStream);
} }
} });
return null;
} }
/** /**
@@ -167,28 +188,40 @@ async function fetchAndParse(url) {
throw new Error(`Failed to fetch EPG: ${response.status} ${response.statusText}`); throw new Error(`Failed to fetch EPG: ${response.status} ${response.statusText}`);
} }
let content; let stream;
const buffer = await response.arrayBuffer(); if (response.body && typeof response.body.pipe === 'function') {
const bytes = new Uint8Array(buffer); stream = response.body;
} else if (response.body) {
// Check for gzip magic bytes (1f 8b) to detect actual gzip content stream = Readable.fromWeb(response.body);
// This handles cases where the server auto-decompresses or the URL doesn't reflect actual encoding
const isActuallyGzipped = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
if (isActuallyGzipped) {
// Handle gzipped EPG files (.xml.gz)
try {
const decompressed = await gunzip(Buffer.from(buffer));
content = decompressed.toString('utf-8');
} catch (err) {
throw new Error(`Failed to decompress gzipped EPG: ${err.message}`);
}
} else { } else {
// Already plain text (or auto-decompressed by fetch) stream = Readable.from([]);
content = Buffer.from(buffer).toString('utf-8');
} }
return parse(content); // Check for GZIP
// Note: We can't easily check for magic bytes on a stream without buffering.
// We'll rely on response headers or file extension mostly, or try to peek.
// For now, let's assume if content-encoding is gzip OR url ends in .gz
// However, undici/fetch usually handles 'Content-Encoding: gzip' automatically transparently.
// We only need to manually gunzip if the server serves it as application/octet-stream but it's actually gzipped,
// or if it's a .gz file download.
// A robust way for streams is checking magic bytes, but that requires peeking.
// Simplified approach: try to pipe through gunzip if the URL indicates it.
const isGzipped = url.endsWith('.gz') || (response.headers.get('content-type') || '').includes('gzip');
if (isGzipped) {
const gunzip = zlib.createGunzip();
stream.pipe(gunzip);
return parse(gunzip);
}
// In the previous version we read magic bytes.
// To support that with streams we'd need a peek stream.
// For now let's trust the transparent decompression of fetch or the URL.
return parse(stream);
} }
module.exports = { module.exports = {
+88 -67
View File
@@ -1,8 +1,11 @@
/** /**
* M3U Playlist Parser * M3U Playlist Parser (Streaming)
* Parses EXTM3U format playlists and extracts channel information * Parses EXTM3U format playlists and extracts channel information line-by-line
*/ */
const readline = require('readline');
const { Readable } = require('stream');
/** /**
* Generate a simple stable ID from name and group * Generate a simple stable ID from name and group
* @param {string} name - Channel name * @param {string} name - Channel name
@@ -21,69 +24,6 @@ function generateStableId(name, group) {
return `m3u_${Math.abs(hash).toString(36)}`; 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 * Parse EXTINF line and extract attributes
* @param {string} line - EXTINF line * @param {string} line - EXTINF line
@@ -138,6 +78,75 @@ function parseExtinf(line) {
return info; return info;
} }
/**
* Parse M3U content (Stream or String)
* @param {Readable|string} input - M3U content as Stream or String
* @returns {Promise<{ channels: Array, groups: Array }>}
*/
async function parse(input) {
const channels = [];
const groupsSet = new Set();
let currentInfo = null;
let currentGroup = null;
let inputStream;
if (typeof input === 'string') {
inputStream = Readable.from([input]);
} else {
inputStream = input;
}
const rl = readline.createInterface({
input: inputStream,
crlfDelay: Infinity
});
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('#EXTINF:')) {
// Parse EXTINF line
currentInfo = parseExtinf(trimmed);
if (currentInfo.groupTitle) {
groupsSet.add(currentInfo.groupTitle);
currentGroup = currentInfo.groupTitle;
}
} else if (trimmed.startsWith('#EXTGRP:')) {
// Parse EXTGRP line (alternative group specification)
currentGroup = trimmed.substring(8).trim();
groupsSet.add(currentGroup);
if (currentInfo) {
currentInfo.groupTitle = currentGroup;
}
} else if (!trimmed.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: trimmed,
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 };
}
/** /**
* Fetch and parse M3U from URL * Fetch and parse M3U from URL
* @param {string} url - M3U playlist URL * @param {string} url - M3U playlist URL
@@ -148,8 +157,20 @@ async function fetchAndParse(url) {
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch M3U: ${response.status} ${response.statusText}`); throw new Error(`Failed to fetch M3U: ${response.status} ${response.statusText}`);
} }
const content = await response.text();
return parse(content); // Check if body is a Node.js stream (undici/node-fetch) or web stream
let stream;
if (response.body && typeof response.body.pipe === 'function') {
stream = response.body;
} else if (response.body) {
// Convert Web Stream to Node Readable for readline
stream = Readable.fromWeb(response.body);
} else {
// Fallback for empty body
stream = Readable.from([]);
}
return parse(stream);
} }
module.exports = { parse, parseExtinf, fetchAndParse }; module.exports = { parse, parseExtinf, fetchAndParse };