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:
Generated
+1
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user