Instancia transcoder t1: cambios locales
This commit is contained in:
+66
-24
@@ -1,6 +1,14 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/sqlite');
|
||||
const { sources } = require('../db');
|
||||
const { requireAuth } = require('../auth');
|
||||
const {
|
||||
getAccessibleSourceIds,
|
||||
assertCanUseSourceById
|
||||
} = require('../sourceAccess');
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
// Helper to map API item types to DB types and tables
|
||||
function mapItemType(apiType) {
|
||||
@@ -21,27 +29,44 @@ router.get('/hidden', async (req, res) => {
|
||||
const { sourceId } = req.query;
|
||||
const db = getDb();
|
||||
|
||||
let hidden = [];
|
||||
let allowedIds = null;
|
||||
if (req.user.role !== 'admin') {
|
||||
allowedIds = await getAccessibleSourceIds(req, sources);
|
||||
if (allowedIds.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
}
|
||||
|
||||
const resultFormat = (row, itemType) => ({
|
||||
source_id: row.source_id,
|
||||
item_type: itemType,
|
||||
item_id: itemType.includes('category') || itemType === 'group' ? row.category_id : row.item_id
|
||||
});
|
||||
|
||||
// Query Categories
|
||||
let catQuery = `SELECT source_id, category_id, type FROM categories WHERE is_hidden = 1`;
|
||||
let itemQuery = `SELECT source_id, item_id, type FROM playlist_items WHERE is_hidden = 1`;
|
||||
let catParams = [];
|
||||
let itemParams = [];
|
||||
|
||||
const params = [];
|
||||
if (sourceId) {
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
const sid = parseInt(sourceId, 10);
|
||||
catQuery += ` AND source_id = ?`;
|
||||
itemQuery += ` AND source_id = ?`;
|
||||
const sid = parseInt(sourceId);
|
||||
params.push(sid);
|
||||
catParams.push(sid);
|
||||
itemParams.push(sid);
|
||||
} else if (allowedIds) {
|
||||
const ph = allowedIds.map(() => '?').join(',');
|
||||
catQuery += ` AND source_id IN (${ph})`;
|
||||
itemQuery += ` AND source_id IN (${ph})`;
|
||||
catParams = [...allowedIds];
|
||||
itemParams = [...allowedIds];
|
||||
}
|
||||
|
||||
const hiddenCats = db.prepare(catQuery).all(...params);
|
||||
const hiddenItems = db.prepare(itemQuery).all(...params);
|
||||
const hiddenCats = db.prepare(catQuery).all(...catParams);
|
||||
const hiddenItems = db.prepare(itemQuery).all(...itemParams);
|
||||
|
||||
const hidden = [];
|
||||
|
||||
hiddenCats.forEach(row => {
|
||||
let apiType;
|
||||
@@ -75,6 +100,7 @@ router.post('/hide', async (req, res) => {
|
||||
const mapping = mapItemType(itemType);
|
||||
|
||||
if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
|
||||
const db = getDb();
|
||||
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
|
||||
@@ -101,6 +127,7 @@ router.post('/show', async (req, res) => {
|
||||
const mapping = mapItemType(itemType);
|
||||
|
||||
if (!mapping) return res.status(400).json({ error: 'Invalid item type' });
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
|
||||
const db = getDb();
|
||||
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
|
||||
@@ -127,6 +154,8 @@ router.get('/hidden/check', async (req, res) => {
|
||||
const mapping = mapItemType(itemType);
|
||||
if (!mapping) return res.json({ hidden: false });
|
||||
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
|
||||
const db = getDb();
|
||||
const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id';
|
||||
|
||||
@@ -148,13 +177,16 @@ router.post('/hide/bulk', async (req, res) => {
|
||||
const { items } = req.body;
|
||||
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
|
||||
|
||||
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
|
||||
for (const sid of uniqueIds) {
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prepare statements once
|
||||
const hideCat = db.prepare('UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||
const hideItem = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND item_id = ?');
|
||||
|
||||
// Cascading statements (hide all children of a category)
|
||||
const hideCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||
|
||||
const runBulk = db.transaction((list) => {
|
||||
@@ -162,12 +194,9 @@ router.post('/hide/bulk', async (req, res) => {
|
||||
const mapping = mapItemType(item.itemType);
|
||||
if (mapping) {
|
||||
if (mapping.table === 'categories') {
|
||||
// Hide the category
|
||||
hideCat.run(item.sourceId, mapping.type, item.itemId);
|
||||
// Cascade to children
|
||||
hideCatChildren.run(item.sourceId, mapping.type, item.itemId);
|
||||
} else {
|
||||
// Hide individual item
|
||||
hideItem.run(item.sourceId, mapping.type, item.itemId);
|
||||
}
|
||||
}
|
||||
@@ -191,13 +220,16 @@ router.post('/show/bulk', async (req, res) => {
|
||||
const { items } = req.body;
|
||||
if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' });
|
||||
|
||||
const uniqueIds = [...new Set(items.map(i => i.sourceId))];
|
||||
for (const sid of uniqueIds) {
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sid))) return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prepare statements once
|
||||
const showCat = db.prepare('UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||
const showItem = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND item_id = ?');
|
||||
|
||||
// Cascading statements (show all children of a category)
|
||||
const showCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
|
||||
|
||||
const runBulk = db.transaction((list) => {
|
||||
@@ -205,12 +237,9 @@ router.post('/show/bulk', async (req, res) => {
|
||||
const mapping = mapItemType(item.itemType);
|
||||
if (mapping) {
|
||||
if (mapping.table === 'categories') {
|
||||
// Show the category
|
||||
showCat.run(item.sourceId, mapping.type, item.itemId);
|
||||
// Cascade to children
|
||||
showCatChildren.run(item.sourceId, mapping.type, item.itemId);
|
||||
} else {
|
||||
// Show individual item
|
||||
showItem.run(item.sourceId, mapping.type, item.itemId);
|
||||
}
|
||||
}
|
||||
@@ -234,14 +263,15 @@ router.post('/show/all', async (req, res) => {
|
||||
const { sourceId, contentType } = req.body;
|
||||
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
|
||||
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
|
||||
const db = getDb();
|
||||
let catCount = 0;
|
||||
let itemCount = 0;
|
||||
|
||||
// Determine which types to update based on contentType
|
||||
const types = contentType === 'movies' ? ['movie']
|
||||
: contentType === 'series' ? ['series']
|
||||
: ['live']; // default to channels
|
||||
: ['live'];
|
||||
|
||||
for (const type of types) {
|
||||
const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type);
|
||||
@@ -264,14 +294,15 @@ router.post('/hide/all', async (req, res) => {
|
||||
const { sourceId, contentType } = req.body;
|
||||
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
|
||||
|
||||
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
|
||||
|
||||
const db = getDb();
|
||||
let catCount = 0;
|
||||
let itemCount = 0;
|
||||
|
||||
// Determine which types to update based on contentType
|
||||
const types = contentType === 'movies' ? ['movie']
|
||||
: contentType === 'series' ? ['series']
|
||||
: ['live']; // default to channels
|
||||
: ['live'];
|
||||
|
||||
for (const type of types) {
|
||||
const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type);
|
||||
@@ -296,6 +327,18 @@ router.get('/recent', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Valid type (movie or series) is required' });
|
||||
}
|
||||
|
||||
let accessibleFilter = '';
|
||||
const params = [type];
|
||||
if (req.user.role !== 'admin') {
|
||||
const ids = await getAccessibleSourceIds(req, sources);
|
||||
if (ids.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
accessibleFilter = ` AND p.source_id IN (${ids.map(() => '?').join(',')})`;
|
||||
params.push(...ids);
|
||||
}
|
||||
params.push(parseInt(limit, 10));
|
||||
|
||||
const db = getDb();
|
||||
const recentItems = db.prepare(`
|
||||
SELECT * FROM playlist_items p
|
||||
@@ -308,11 +351,11 @@ router.get('/recent', async (req, res) => {
|
||||
AND c.type = p.type
|
||||
AND c.is_hidden = 1
|
||||
)
|
||||
${accessibleFilter}
|
||||
ORDER BY p.added_at DESC
|
||||
LIMIT ?
|
||||
`).all(type, parseInt(limit));
|
||||
`).all(...params);
|
||||
|
||||
// Parse JSON data for each item
|
||||
const formatted = recentItems.map(item => ({
|
||||
...item,
|
||||
data: JSON.parse(item.data)
|
||||
@@ -326,4 +369,3 @@ router.get('/recent', async (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
+44
-21
@@ -170,32 +170,55 @@ router.get('/', async (req, res) => {
|
||||
|
||||
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
|
||||
|
||||
try {
|
||||
const probeResult = await probeStream(url, ffprobePath, ua);
|
||||
const analysis = analyzeProbeResult(probeResult, url);
|
||||
// Retry probe up to 3 times on 5XX errors (provider slot not released yet)
|
||||
const MAX_PROBE_RETRIES = 3;
|
||||
const PROBE_RETRY_DELAY_MS = 2500;
|
||||
let lastErr = null;
|
||||
|
||||
// Cache result
|
||||
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
|
||||
for (let attempt = 1; attempt <= MAX_PROBE_RETRIES; attempt++) {
|
||||
try {
|
||||
const probeResult = await probeStream(url, ffprobePath, ua);
|
||||
const analysis = analyzeProbeResult(probeResult, url);
|
||||
|
||||
console.log(`[Probe] Result: video=${analysis.video}, audio=${analysis.audio}, ` +
|
||||
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
|
||||
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
|
||||
// Cache successful result
|
||||
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
|
||||
|
||||
res.json(analysis);
|
||||
} catch (err) {
|
||||
console.error('[Probe] Failed:', err.message);
|
||||
console.log(`[Probe] Result (attempt ${attempt}): video=${analysis.video}, audio=${analysis.audio}, ` +
|
||||
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
|
||||
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
|
||||
|
||||
// On error, assume transcode needed to be safe
|
||||
res.json({
|
||||
video: 'unknown',
|
||||
audio: 'unknown',
|
||||
container: 'unknown',
|
||||
compatible: false,
|
||||
needsRemux: false,
|
||||
needsTranscode: true,
|
||||
error: err.message
|
||||
});
|
||||
return res.json(analysis);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const is5xx = /5[0-9]{2}|Server Error/i.test(err.message);
|
||||
if (is5xx && attempt < MAX_PROBE_RETRIES) {
|
||||
console.warn(`[Probe] 5XX from provider (attempt ${attempt}/${MAX_PROBE_RETRIES}), retrying in ${PROBE_RETRY_DELAY_MS}ms...`);
|
||||
await new Promise(r => setTimeout(r, PROBE_RETRY_DELAY_MS));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[Probe] Failed after retries:', lastErr.message);
|
||||
|
||||
// If there's a stale cached result, use it rather than assuming needsTranscode
|
||||
const stale = probeCache.get(cacheKey);
|
||||
if (stale) {
|
||||
console.warn('[Probe] Using stale cached result as fallback');
|
||||
return res.json({ ...stale.result, stale: true });
|
||||
}
|
||||
|
||||
// Final fallback: assume transcode needed
|
||||
res.json({
|
||||
video: 'unknown',
|
||||
audio: 'unknown',
|
||||
container: 'unknown',
|
||||
compatible: false,
|
||||
needsRemux: false,
|
||||
needsTranscode: true,
|
||||
error: lastErr.message
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+45
-18
@@ -12,6 +12,35 @@ const https = require('https');
|
||||
const { spawn } = require('child_process');
|
||||
const ffmpegPath = require('ffmpeg-static');
|
||||
const { Readable } = require('stream');
|
||||
const auth = require('../auth');
|
||||
const { canUserAccessSource } = require('../sourceAccess');
|
||||
|
||||
// Rutas que el navegador pide sin Bearer (HLS, <img>, etc.) deben quedar fuera de JWT
|
||||
router.use((req, res, next) => {
|
||||
const p = req.path || (req.url || '').split('?')[0];
|
||||
if (p === '/stream' || p === '/image') return next();
|
||||
return auth.requireAuth(req, res, next);
|
||||
});
|
||||
|
||||
router.param('sourceId', async (req, res, next, id) => {
|
||||
try {
|
||||
const sid = parseInt(id, 10);
|
||||
if (Number.isNaN(sid)) {
|
||||
return res.status(400).json({ error: 'Invalid source id' });
|
||||
}
|
||||
const source = await sources.getById(sid);
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, source)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
req.grantedSource = source;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Default cache max age in hours
|
||||
const DEFAULT_MAX_AGE_HOURS = 24;
|
||||
@@ -83,8 +112,8 @@ function getStreamsFromDb(sourceId, type, categoryId = null, includeHidden = fal
|
||||
// Login / Authenticate
|
||||
router.get('/xtream/:sourceId', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source || source.type !== 'xtream') return res.status(404).send('Source not found');
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'xtream') return res.status(404).send('Source not found');
|
||||
|
||||
// Proxy auth check to upstream to ensure credentials are still valid
|
||||
|
||||
@@ -185,8 +214,7 @@ router.get('/xtream/:sourceId/series', async (req, res) => {
|
||||
// Proxy series info request
|
||||
router.get('/xtream/:sourceId/series_info', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source) return res.status(404).send('Source not found');
|
||||
const source = req.grantedSource;
|
||||
|
||||
const seriesId = req.query.series_id;
|
||||
if (!seriesId) return res.status(400).send('series_id required');
|
||||
@@ -207,8 +235,7 @@ router.get('/xtream/:sourceId/series_info', async (req, res) => {
|
||||
// VOD Info
|
||||
router.get('/xtream/:sourceId/vod_info', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source) return res.status(404).send('Source not found');
|
||||
const source = req.grantedSource;
|
||||
|
||||
const vodId = req.query.vod_id;
|
||||
if (!vodId) return res.status(400).send('vod_id required');
|
||||
@@ -230,14 +257,14 @@ router.get('/xtream/:sourceId/vod_info', async (req, res) => {
|
||||
// Returns the direct stream URL for a given stream ID
|
||||
router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source || source.type !== 'xtream') {
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'xtream') {
|
||||
return res.status(404).json({ error: 'Xtream source not found' });
|
||||
}
|
||||
|
||||
const streamId = req.params.streamId;
|
||||
const type = req.params.type || 'live';
|
||||
const container = req.query.container || 'm3u8';
|
||||
const container = req.query.container || 'ts';
|
||||
|
||||
// Construct the Xtream stream URL
|
||||
// Format: http://server:port/live/username/password/streamId.container (for live)
|
||||
@@ -393,8 +420,8 @@ router.delete('/cache/:sourceId', (req, res) => {
|
||||
router.get('/xtream/:sourceId/:action', async (req, res) => {
|
||||
try {
|
||||
const sourceId = req.params.sourceId;
|
||||
const source = await sources.getById(sourceId);
|
||||
if (!source || source.type !== 'xtream') {
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'xtream') {
|
||||
return res.status(404).json({ error: 'Xtream source not found' });
|
||||
}
|
||||
|
||||
@@ -478,14 +505,14 @@ router.get('/xtream/:sourceId/:action', async (req, res) => {
|
||||
*/
|
||||
router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source || source.type !== 'xtream') {
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'xtream') {
|
||||
return res.status(404).json({ error: 'Xtream source not found' });
|
||||
}
|
||||
|
||||
const api = xtreamApi.createFromSource(source);
|
||||
const { streamId, type = 'live' } = req.params;
|
||||
const { container = 'm3u8' } = req.query;
|
||||
const { container = 'ts' } = req.query;
|
||||
|
||||
const url = api.buildStreamUrl(streamId, type, container);
|
||||
res.json({ url });
|
||||
@@ -505,8 +532,8 @@ router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
|
||||
router.get('/epg/:sourceId', async (req, res) => {
|
||||
try {
|
||||
const sourceId = req.params.sourceId;
|
||||
const source = await sources.getById(sourceId);
|
||||
if (!source || (source.type !== 'epg' && source.type !== 'xtream')) {
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'epg' && source.type !== 'xtream') {
|
||||
return res.status(404).json({ error: 'Valid EPG source not found' });
|
||||
}
|
||||
|
||||
@@ -567,8 +594,8 @@ router.delete('/epg/:sourceId/cache', (req, res) => {
|
||||
*/
|
||||
router.post('/epg/:sourceId/channels', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.sourceId);
|
||||
if (!source || source.type !== 'epg') {
|
||||
const source = req.grantedSource;
|
||||
if (source.type !== 'epg') {
|
||||
return res.status(404).json({ error: 'EPG source not found' });
|
||||
}
|
||||
|
||||
|
||||
+137
-97
@@ -5,29 +5,84 @@ const { getDb } = require('../db/sqlite');
|
||||
const xtreamApi = require('../services/xtreamApi');
|
||||
const syncService = require('../services/syncService');
|
||||
const m3uParser = require('../services/m3uParser');
|
||||
const { requireAuth, requireAdmin } = require('../auth');
|
||||
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
|
||||
|
||||
// Get all sources
|
||||
router.use(requireAuth);
|
||||
|
||||
function sanitizeSourceList(list) {
|
||||
return list.map((s) => ({
|
||||
...s,
|
||||
password: s.password ? '••••••••' : null
|
||||
}));
|
||||
}
|
||||
|
||||
function parseOwnerId(body) {
|
||||
if (body.ownerId === undefined || body.ownerId === '') return null;
|
||||
const n = parseInt(body.ownerId, 10);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
// Estimate by URL (must stay before /:id routes)
|
||||
const M3U_LARGE_THRESHOLD = 50000;
|
||||
|
||||
router.post('/estimate', async (req, res) => {
|
||||
try {
|
||||
const { url, type } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
if (type !== 'm3u') {
|
||||
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
|
||||
}
|
||||
|
||||
const count = await m3uParser.countEntries(url);
|
||||
|
||||
res.json({
|
||||
count,
|
||||
needsWarning: count > M3U_LARGE_THRESHOLD,
|
||||
threshold: M3U_LARGE_THRESHOLD
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error estimating M3U size:', err);
|
||||
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Global sync — admin only
|
||||
router.post('/sync-all', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
syncService.syncAll().catch(console.error);
|
||||
res.json({ success: true, message: 'Global sync started' });
|
||||
} catch (err) {
|
||||
console.error('Error starting global sync:', err);
|
||||
res.status(500).json({ error: 'Failed to start global sync' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all sources (filtered by access)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const allSources = await sources.getAll();
|
||||
// Don't expose passwords in list view
|
||||
const sanitized = allSources.map(s => ({
|
||||
...s,
|
||||
password: s.password ? '••••••••' : null
|
||||
}));
|
||||
res.json(sanitized);
|
||||
const filtered = allSources.filter((s) => canUserAccessSource(req.user, s));
|
||||
res.json(sanitizeSourceList(filtered));
|
||||
} catch (err) {
|
||||
console.error('Error getting sources:', err);
|
||||
res.status(500).json({ error: 'Failed to get sources' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get sync status for all sources
|
||||
// Get sync status (filtered for non-admins)
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
const { getDb } = require('../db/sqlite');
|
||||
const db = getDb();
|
||||
const statuses = db.prepare('SELECT * FROM sync_status').all();
|
||||
let statuses = db.prepare('SELECT * FROM sync_status').all();
|
||||
if (req.user.role !== 'admin') {
|
||||
const allowed = new Set(await getAccessibleSourceIds(req, sources));
|
||||
statuses = statuses.filter((row) => allowed.has(row.source_id));
|
||||
}
|
||||
res.json(statuses);
|
||||
} catch (err) {
|
||||
console.error('Error getting sync status:', err);
|
||||
@@ -39,13 +94,42 @@ router.get('/status', async (req, res) => {
|
||||
router.get('/type/:type', async (req, res) => {
|
||||
try {
|
||||
const typeSources = await sources.getByType(req.params.type);
|
||||
res.json(typeSources);
|
||||
const filtered = typeSources.filter((s) => canUserAccessSource(req.user, s));
|
||||
res.json(sanitizeSourceList(filtered));
|
||||
} catch (err) {
|
||||
console.error('Error getting sources by type:', err);
|
||||
res.status(500).json({ error: 'Failed to get sources' });
|
||||
}
|
||||
});
|
||||
|
||||
// Estimate by source ID (before bare GET /:id — same base path length)
|
||||
router.get('/:id/estimate', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.id);
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, source)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
if (source.type !== 'm3u') {
|
||||
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
|
||||
}
|
||||
|
||||
const count = await m3uParser.countEntries(source.url);
|
||||
|
||||
res.json({
|
||||
count,
|
||||
needsWarning: count > M3U_LARGE_THRESHOLD,
|
||||
threshold: M3U_LARGE_THRESHOLD
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error estimating M3U size:', err);
|
||||
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single source
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
@@ -53,6 +137,9 @@ router.get('/:id', async (req, res) => {
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, source)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
res.json(source);
|
||||
} catch (err) {
|
||||
console.error('Error getting source:', err);
|
||||
@@ -73,8 +160,14 @@ router.post('/', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Invalid source type' });
|
||||
}
|
||||
|
||||
const source = await sources.create({ type, name, url, username, password });
|
||||
// Trigger Sync
|
||||
let ownerId = null;
|
||||
if (req.user.role === 'admin') {
|
||||
ownerId = parseOwnerId(req.body);
|
||||
} else {
|
||||
ownerId = req.user.id;
|
||||
}
|
||||
|
||||
const source = await sources.create({ type, name, url, username, password, ownerId });
|
||||
syncService.syncSource(source.id).catch(console.error);
|
||||
res.status(201).json(source);
|
||||
} catch (err) {
|
||||
@@ -90,16 +183,23 @@ router.put('/:id', async (req, res) => {
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, existing)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
const { name, url, username, password } = req.body;
|
||||
const updated = await sources.update(req.params.id, {
|
||||
const updates = {
|
||||
name: name || existing.name,
|
||||
url: url || existing.url,
|
||||
username: username !== undefined ? username : existing.username,
|
||||
password: password !== undefined ? password : existing.password
|
||||
});
|
||||
// Trigger Sync (if critical fields changed? safely just trigger it)
|
||||
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
|
||||
};
|
||||
if (req.user.role === 'admin' && req.body.ownerId !== undefined) {
|
||||
updates.ownerId = parseOwnerId(req.body);
|
||||
}
|
||||
|
||||
const updated = await sources.update(req.params.id, updates);
|
||||
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
console.error('Error updating source:', err);
|
||||
@@ -110,13 +210,15 @@ router.put('/:id', async (req, res) => {
|
||||
// Delete source
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const sourceId = parseInt(req.params.id);
|
||||
const sourceId = parseInt(req.params.id, 10);
|
||||
const existing = await sources.getById(sourceId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, existing)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
// Cascade delete: Clean up SQLite data for this source
|
||||
const db = getDb();
|
||||
const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?');
|
||||
const deleteItems = db.prepare('DELETE FROM playlist_items WHERE source_id = ?');
|
||||
@@ -130,7 +232,6 @@ router.delete('/:id', async (req, res) => {
|
||||
|
||||
console.log(`[Source] Cascade delete for source ${sourceId}: ${catResult.changes} categories, ${itemResult.changes} items, ${epgResult.changes} EPG programs`);
|
||||
|
||||
// Delete source config and related hidden items (favorites handled by db.js)
|
||||
await sources.delete(sourceId);
|
||||
|
||||
res.json({ success: true });
|
||||
@@ -143,14 +244,21 @@ router.delete('/:id', async (req, res) => {
|
||||
// Toggle source enabled/disabled
|
||||
router.post('/:id/toggle', async (req, res) => {
|
||||
try {
|
||||
const existing = await sources.getById(req.params.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, existing)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
const updated = await sources.toggleEnabled(req.params.id);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
|
||||
// If enabled, trigger sync
|
||||
if (updated.enabled) {
|
||||
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
|
||||
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
@@ -163,11 +271,13 @@ router.post('/:id/toggle', async (req, res) => {
|
||||
// Manual Sync
|
||||
router.post('/:id/sync', async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const source = await sources.getById(id);
|
||||
if (!source) return res.status(404).json({ error: 'Source not found' });
|
||||
if (!canUserAccessSource(req.user, source)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
// Trigger sync (async)
|
||||
syncService.syncSource(id).catch(console.error);
|
||||
|
||||
res.json({ success: true, message: 'Sync started' });
|
||||
@@ -184,6 +294,9 @@ router.post('/:id/test', async (req, res) => {
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (!canUserAccessSource(req.user, source)) {
|
||||
return res.status(403).json({ error: 'No access to this source' });
|
||||
}
|
||||
|
||||
if (source.type === 'xtream') {
|
||||
const result = await xtreamApi.authenticate(source.url, source.username, source.password);
|
||||
@@ -205,77 +318,4 @@ router.post('/:id/test', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Estimate M3U playlist size (for large playlist warning)
|
||||
const M3U_LARGE_THRESHOLD = 50000;
|
||||
|
||||
// Estimate by URL (for new sources before creation)
|
||||
router.post('/estimate', async (req, res) => {
|
||||
try {
|
||||
const { url, type } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
// Only M3U sources need estimation
|
||||
if (type !== 'm3u') {
|
||||
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
|
||||
}
|
||||
|
||||
console.log(`[Sources] Estimating M3U size for URL...`);
|
||||
const count = await m3uParser.countEntries(url);
|
||||
console.log(`[Sources] M3U estimate: ${count} entries`);
|
||||
|
||||
res.json({
|
||||
count,
|
||||
needsWarning: count > M3U_LARGE_THRESHOLD,
|
||||
threshold: M3U_LARGE_THRESHOLD
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error estimating M3U size:', err);
|
||||
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Estimate by source ID (for existing sources)
|
||||
router.get('/:id/estimate', async (req, res) => {
|
||||
try {
|
||||
const source = await sources.getById(req.params.id);
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
|
||||
// Only M3U sources need estimation
|
||||
if (source.type !== 'm3u') {
|
||||
return res.json({ count: 0, needsWarning: false, threshold: M3U_LARGE_THRESHOLD });
|
||||
}
|
||||
|
||||
console.log(`[Sources] Estimating M3U size for ${source.name}...`);
|
||||
const count = await m3uParser.countEntries(source.url);
|
||||
console.log(`[Sources] M3U estimate: ${count} entries`);
|
||||
|
||||
res.json({
|
||||
count,
|
||||
needsWarning: count > M3U_LARGE_THRESHOLD,
|
||||
threshold: M3U_LARGE_THRESHOLD
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error estimating M3U size:', err);
|
||||
res.status(500).json({ error: 'Failed to estimate playlist size', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Global Sync - sync all enabled sources
|
||||
router.post('/sync-all', async (req, res) => {
|
||||
try {
|
||||
// Trigger global sync (async - don't wait for completion)
|
||||
syncService.syncAll().catch(console.error);
|
||||
res.json({ success: true, message: 'Global sync started' });
|
||||
} catch (err) {
|
||||
console.error('Error starting global sync:', err);
|
||||
res.status(500).json({ error: 'Failed to start global sync' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
+134
-26
@@ -1,10 +1,45 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
const { spawn, execFile } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const db = require('../db');
|
||||
const transcodeSession = require('../services/transcodeSession');
|
||||
const streamInput = require('../utils/streamInput');
|
||||
|
||||
/**
|
||||
* Probe a stream URL with FFprobe to get its total duration in seconds.
|
||||
* VOD pesado usa probes mayores y timeout más alto (MKV Jellyfin/remux).
|
||||
*/
|
||||
function probeDuration(url, ffprobePath, userAgent) {
|
||||
return new Promise((resolve) => {
|
||||
const sizing = streamInput.probeSizingForUrl(url);
|
||||
const referer = streamInput.deriveRefererFromUrl(url);
|
||||
const args = [
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_entries', 'format=duration',
|
||||
'-user_agent', userAgent || 'Mozilla/5.0',
|
||||
'-probesize', sizing.probesize,
|
||||
'-analyzeduration', sizing.analyzeduration,
|
||||
];
|
||||
if (referer) {
|
||||
args.push('-referer', referer);
|
||||
}
|
||||
args.push(url);
|
||||
const timeoutMs = sizing.heavy ? 45000 : 8000;
|
||||
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: timeoutMs }, (err, stdout) => {
|
||||
if (err) { resolve(0); return; }
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
const dur = parseFloat(data?.format?.duration);
|
||||
resolve(Number.isFinite(dur) && dur > 0 ? Math.round(dur) : 0);
|
||||
} catch { resolve(0); }
|
||||
});
|
||||
// Ensure the process is killed if execFile timeout fires
|
||||
proc.on('error', () => resolve(0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcode Routes
|
||||
@@ -40,38 +75,78 @@ router.post('/session', async (req, res) => {
|
||||
const userAgent = db.getUserAgent(settings);
|
||||
|
||||
try {
|
||||
const session = await transcodeSession.createSession(url, {
|
||||
ffmpegPath,
|
||||
userAgent,
|
||||
seekOffset: seekOffset || 0,
|
||||
hwEncoder: settings.hwEncoder || 'software',
|
||||
maxResolution: settings.maxResolution || '1080p',
|
||||
quality: settings.quality || 'medium',
|
||||
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
|
||||
// Upscaling options
|
||||
upscaleEnabled: settings.upscaleEnabled || false,
|
||||
upscaleMethod: settings.upscaleMethod || 'hardware',
|
||||
upscaleTarget: settings.upscaleTarget || '1080p',
|
||||
videoMode: videoMode, // 'copy' or 'encode'
|
||||
videoCodec: videoCodec, // 'h264', 'hevc', etc.
|
||||
audioCodec: audioCodec, // 'aac', 'ac3', etc.
|
||||
audioChannels: audioChannels // number of channels (2=stereo)
|
||||
});
|
||||
const ffprobePath = (ffmpegPath || 'ffmpeg').replace(/ffmpeg$/, 'ffprobe');
|
||||
|
||||
await session.start();
|
||||
// Probe duration and start session in parallel (don't block on duration)
|
||||
const [session, durationSec] = await Promise.all([
|
||||
transcodeSession.createSession(url, {
|
||||
ffmpegPath,
|
||||
userAgent,
|
||||
seekOffset: seekOffset || 0,
|
||||
hwEncoder: settings.hwEncoder || 'software',
|
||||
maxResolution: settings.maxResolution || '1080p',
|
||||
quality: settings.quality || 'medium',
|
||||
audioMixPreset: settings.audioMixPreset || 'auto',
|
||||
upscaleEnabled: settings.upscaleEnabled || false,
|
||||
upscaleMethod: settings.upscaleMethod || 'hardware',
|
||||
upscaleTarget: settings.upscaleTarget || '1080p',
|
||||
videoMode: videoMode,
|
||||
videoCodec: videoCodec,
|
||||
audioCodec: audioCodec,
|
||||
audioChannels: audioChannels
|
||||
}).then(async s => { await s.start(); return s; }),
|
||||
probeDuration(url, ffprobePath, userAgent)
|
||||
]);
|
||||
|
||||
// Wait for playlist to be ready (first segments generated)
|
||||
const ready = await session.waitForPlaylist(15000);
|
||||
// VOD pesado (4K HEVC/remux) puede tardar >15 s en generar el primer segmento
|
||||
const waitTimeout = streamInput.isHeavyVodUrl(url) ? 60000 : 15000;
|
||||
let ready = await session.waitForPlaylist(waitTimeout);
|
||||
|
||||
// Fallback a software si el encoder HW falla inmediatamente (error rápido)
|
||||
if (!ready && session.status === 'error' && (settings.hwEncoder || 'software') !== 'software') {
|
||||
console.warn(`[Transcode] HW encoder (${settings.hwEncoder}) falló, reintentando con software libx264...`);
|
||||
await transcodeSession.removeSession(session.id);
|
||||
const swSession = await transcodeSession.createSession(url, {
|
||||
ffmpegPath,
|
||||
userAgent,
|
||||
seekOffset: seekOffset || 0,
|
||||
hwEncoder: 'software',
|
||||
maxResolution: settings.maxResolution || '1080p',
|
||||
quality: settings.quality || 'medium',
|
||||
audioMixPreset: settings.audioMixPreset || 'auto',
|
||||
upscaleEnabled: false,
|
||||
videoMode: videoMode,
|
||||
videoCodec: videoCodec,
|
||||
audioCodec: audioCodec,
|
||||
audioChannels: audioChannels
|
||||
});
|
||||
await swSession.start();
|
||||
ready = await swSession.waitForPlaylist(waitTimeout);
|
||||
if (ready) {
|
||||
console.log(`[Transcode] Software fallback OK → session ${swSession.id}`);
|
||||
return res.json({
|
||||
sessionId: swSession.id,
|
||||
playlistUrl: `/api/transcode/${swSession.id}/stream.m3u8`,
|
||||
status: swSession.status,
|
||||
durationSec: durationSec || 0
|
||||
});
|
||||
}
|
||||
await transcodeSession.removeSession(swSession.id);
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
await transcodeSession.removeSession(session.id);
|
||||
return res.status(500).json({ error: 'Transcoding failed to start', reason: 'Playlist not generated in time' });
|
||||
}
|
||||
|
||||
console.log(`[Transcode] Session ${session.id} ready. Source duration: ${durationSec}s`);
|
||||
|
||||
res.json({
|
||||
sessionId: session.id,
|
||||
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
|
||||
status: session.status
|
||||
status: session.status,
|
||||
durationSec: durationSec || 0
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
@@ -144,6 +219,30 @@ router.delete('/:sessionId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Heartbeat — keeps a session alive while the player is active.
|
||||
* POST /api/transcode/:sessionId/heartbeat
|
||||
*/
|
||||
router.post('/:sessionId/heartbeat', (req, res) => {
|
||||
const session = transcodeSession.getSession(req.params.sessionId);
|
||||
if (!session) return res.status(404).json({ error: 'Session not found' });
|
||||
session.lastAccess = Date.now();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Stop session via POST (for sendBeacon on page close)
|
||||
* POST /api/transcode/:sessionId/stop
|
||||
*/
|
||||
router.post('/:sessionId/stop', async (req, res) => {
|
||||
try {
|
||||
await transcodeSession.removeSession(req.params.sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to remove session', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* List all active sessions (for debugging)
|
||||
* GET /api/transcode/sessions
|
||||
@@ -175,6 +274,9 @@ router.get('/', async (req, res) => {
|
||||
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
|
||||
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
||||
|
||||
const sizing = streamInput.probeSizingForUrl(url);
|
||||
const referer = streamInput.deriveRefererFromUrl(url);
|
||||
|
||||
// FFmpeg arguments for transcoding
|
||||
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
|
||||
// Also works for live streams with ad stitching (Pluto TV, etc.)
|
||||
@@ -182,9 +284,13 @@ router.get('/', async (req, res) => {
|
||||
'-hide_banner',
|
||||
'-loglevel', 'warning',
|
||||
'-user_agent', userAgent,
|
||||
// Faster startup - reduced probe/analyze for quicker first bytes
|
||||
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
||||
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
|
||||
];
|
||||
if (referer) {
|
||||
args.push('-referer', referer);
|
||||
}
|
||||
args.push(
|
||||
'-probesize', sizing.probesize,
|
||||
'-analyzeduration', sizing.analyzeduration,
|
||||
// Error resilience: generate timestamps, discard corrupt packets
|
||||
'-fflags', '+genpts+discardcorrupt+nobuffer',
|
||||
// Ignore errors in stream and continue
|
||||
@@ -197,7 +303,9 @@ router.get('/', async (req, res) => {
|
||||
'-reconnect_delay_max', '3',
|
||||
// Prevent Range/HEAD requests that some providers reject with 405
|
||||
'-seekable', '0',
|
||||
'-i', url,
|
||||
'-i', url
|
||||
);
|
||||
args.push(
|
||||
// Map only first video and audio stream (avoid subtitle streams causing issues)
|
||||
'-map', '0:v:0',
|
||||
'-map', '0:a:0?', // ? makes audio optional if not present
|
||||
@@ -218,7 +326,7 @@ router.get('/', async (req, res) => {
|
||||
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
|
||||
'-flush_packets', '1', // Send data immediately
|
||||
'-' // Output to stdout
|
||||
];
|
||||
);
|
||||
|
||||
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user