Proyecto KiraTV
Build and Publish Container Image / build-and-push (push) Has been cancelled

This commit is contained in:
root
2026-05-09 19:31:07 +00:00
commit 6f6e3161ec
76 changed files with 30802 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const passport = require('passport');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { Strategy: LocalStrategy } = require('passport-local');
/**
* Authentication and Authorization Module
* Handles user authentication, session management, and role-based access control
* Using Passport.js with JWT tokens
*/
// JWT Secret - In production, use environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production';
const JWT_EXPIRY = '24h';
/**
* Hash password using bcrypt
*/
async function hashPassword(password) {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
/**
* Verify password against hash
*/
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
/**
* Generate JWT token
*/
function generateToken(user) {
return jwt.sign(
{
id: user.id,
username: user.username,
role: user.role
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRY }
);
}
/**
* Verify JWT token
*/
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (err) {
return null;
}
}
/**
* Configure Passport Local Strategy for username/password authentication
*/
function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
passport.use(new LocalStrategy(
async (username, password, done) => {
try {
const user = await getUserByUsername(username);
if (!user) {
return done(null, false, { message: 'Invalid credentials' });
}
const isValid = await verifyUserPassword(password, user.passwordHash);
if (!isValid) {
return done(null, false, { message: 'Invalid credentials' });
}
return done(null, user);
} catch (err) {
return done(err);
}
}
));
}
/**
* Configure Passport JWT Strategy for token-based authentication
*/
function configureJwtStrategy(getUserById) {
const options = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: JWT_SECRET
};
passport.use(new JwtStrategy(options, async (payload, done) => {
try {
const user = await getUserById(payload.id);
if (!user) {
return done(null, false);
}
return done(null, {
id: Number(user.id),
username: user.username,
role: user.role
});
} catch (err) {
return done(err, false);
}
}));
}
/**
* Configure Passport session serialization
* Required for OIDC flow which uses sessions
*/
function configureSessionSerialization(getUserById) {
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await getUserById(id);
done(null, user);
} catch (err) {
done(err, null);
}
});
}
/**
* Configure Passport OpenID Connect Strategy
*/
function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) {
console.warn('OIDC configuration missing - SSO disabled');
return;
}
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
passport.use(new OpenIDConnectStrategy({
issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock
authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`,
tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`,
userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`,
clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id',
clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret',
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
scope: ['openid', 'profile', 'email']
},
async (...args) => {
// The done callback is always the last argument
const done = args[args.length - 1];
// Map known arguments
// Standard: issuer, sub, profile, accessToken, refreshToken, done
// Some versions: issuer, sub, profile, accessToken, refreshToken, params, done
let issuer, sub, profile;
if (args.length === 3) {
// Scenario: (issuer, profile, done)
const arg0 = args[0];
const arg1 = args[1];
if (typeof arg1 === 'object' && arg1.id) {
issuer = arg0;
profile = arg1;
sub = profile.id;
} else if (typeof arg0 === 'string' && typeof arg1 === 'string') {
issuer = arg0;
sub = arg1;
profile = { id: sub, displayName: 'Unknown' };
}
} else if (args.length >= 4) {
// Assume standard: iss, sub, profile...
issuer = args[0];
sub = args[1];
profile = args[2];
}
if (!sub && profile && profile.id) sub = profile.id;
if (!sub) {
return done(new Error('Could not identify OIDC Subject (sub) from arguments'));
}
try {
// 1. Try to find by OIDC ID (sub)
let user = await findUserByOidcId(sub);
// 2. If not found, try to match by email
// Extract email - handle both profile.emails[] (Google) and profile.email (others)
const email = profile.emails?.[0]?.value || profile.email || profile._json?.email;
if (!user && email) {
user = await findUserByEmail(email);
// If found by email but no OIDC ID, link them
if (user && !user.oidcId) {
// We don't have a direct update method for specific fields without full user object in this context
// Ideally we'd update the user here. For now, we'll just log in.
// Future: Update user with oidcId
}
}
// 3. If still not found, create new user (JIT Provisioning)
if (!user) {
const username = profile.username || profile.displayName || (email ? email.split('@')[0] : `user_${sub.substring(0, 8)}`);
user = await createUser({
username: username,
role: 'viewer', // Default role for SSO users
oidcId: sub,
email: email || null
});
}
return done(null, user);
} catch (err) {
return done(err);
}
}));
}
/**
* Middleware: Require authentication using Passport JWT
*/
const requireAuth = passport.authenticate('jwt', { session: false });
/**
* True if user has admin role (case-insensitive, tolerates stray whitespace).
*/
function isAdminRole(user) {
return !!(user && String(user.role || '').trim().toLowerCase() === 'admin');
}
/**
* Middleware: Require admin role
*/
function requireAdmin(req, res, next) {
if (!isAdminRole(req.user)) {
return res.status(403).json({ error: 'Forbidden - Admin access required' });
}
next();
}
/**
* Middleware: Check for specific role
*/
function requireRole(role) {
return (req, res, next) => {
if (!req.user || req.user.role !== role) {
return res.status(403).json({ error: `Forbidden - ${role} access required` });
}
next();
};
}
module.exports = {
passport,
hashPassword,
verifyPassword,
generateToken,
verifyToken,
configureLocalStrategy,
configureJwtStrategy,
configureSessionSerialization,
configureOidcStrategy,
requireAuth,
requireAdmin,
requireRole,
isAdminRole
};
+268
View File
@@ -0,0 +1,268 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const passport = require('passport');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { Strategy: LocalStrategy } = require('passport-local');
/**
* Authentication and Authorization Module
* Handles user authentication, session management, and role-based access control
* Using Passport.js with JWT tokens
*/
// JWT Secret - In production, use environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production';
const JWT_EXPIRY = '24h';
/**
* Hash password using bcrypt
*/
async function hashPassword(password) {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
/**
* Verify password against hash
*/
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
/**
* Generate JWT token
*/
function generateToken(user) {
return jwt.sign(
{
id: user.id,
username: user.username,
role: user.role
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRY }
);
}
/**
* Verify JWT token
*/
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (err) {
return null;
}
}
/**
* Configure Passport Local Strategy for username/password authentication
*/
function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
passport.use(new LocalStrategy(
async (username, password, done) => {
try {
const user = await getUserByUsername(username);
if (!user) {
return done(null, false, { message: 'Invalid credentials' });
}
const isValid = await verifyUserPassword(password, user.passwordHash);
if (!isValid) {
return done(null, false, { message: 'Invalid credentials' });
}
return done(null, user);
} catch (err) {
return done(err);
}
}
));
}
/**
* Configure Passport JWT Strategy for token-based authentication
*/
function configureJwtStrategy(getUserById) {
const options = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: JWT_SECRET
};
passport.use(new JwtStrategy(options, async (payload, done) => {
try {
const user = await getUserById(payload.id);
if (!user) {
return done(null, false);
}
return done(null, {
id: user.id,
username: user.username,
role: user.role
});
} catch (err) {
return done(err, false);
}
}));
}
/**
* Configure Passport session serialization
* Required for OIDC flow which uses sessions
*/
function configureSessionSerialization(getUserById) {
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await getUserById(id);
done(null, user);
} catch (err) {
done(err, null);
}
});
}
/**
* Configure Passport OpenID Connect Strategy
*/
function configureOidcStrategy(findUserByOidcId, findUserByEmail, createUser) {
if (!process.env.OIDC_ISSUER_URL || !process.env.OIDC_CLIENT_ID || !process.env.OIDC_CLIENT_SECRET) {
console.warn('OIDC configuration missing - SSO disabled');
return;
}
const { Strategy: OpenIDConnectStrategy } = require('passport-openidconnect');
passport.use(new OpenIDConnectStrategy({
issuer: process.env.OIDC_ISSUER_URL || 'https://mock-issuer.com', // Dummy default for mock
authorizationURL: process.env.OIDC_AUTH_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/auth`,
tokenURL: process.env.OIDC_TOKEN_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/token`,
userInfoURL: process.env.OIDC_USERINFO_URL || `${process.env.OIDC_ISSUER_URL}/protocol/openid-connect/userinfo`,
clientID: process.env.OIDC_CLIENT_ID || 'mock-client-id',
clientSecret: process.env.OIDC_CLIENT_SECRET || 'mock-secret',
callbackURL: process.env.OIDC_CALLBACK_URL || '/api/auth/oidc/callback',
scope: ['openid', 'profile', 'email']
},
async (...args) => {
// The done callback is always the last argument
const done = args[args.length - 1];
// Map known arguments
// Standard: issuer, sub, profile, accessToken, refreshToken, done
// Some versions: issuer, sub, profile, accessToken, refreshToken, params, done
let issuer, sub, profile;
if (args.length === 3) {
// Scenario: (issuer, profile, done)
const arg0 = args[0];
const arg1 = args[1];
if (typeof arg1 === 'object' && arg1.id) {
issuer = arg0;
profile = arg1;
sub = profile.id;
} else if (typeof arg0 === 'string' && typeof arg1 === 'string') {
issuer = arg0;
sub = arg1;
profile = { id: sub, displayName: 'Unknown' };
}
} else if (args.length >= 4) {
// Assume standard: iss, sub, profile...
issuer = args[0];
sub = args[1];
profile = args[2];
}
if (!sub && profile && profile.id) sub = profile.id;
if (!sub) {
return done(new Error('Could not identify OIDC Subject (sub) from arguments'));
}
try {
// 1. Try to find by OIDC ID (sub)
let user = await findUserByOidcId(sub);
// 2. If not found, try to match by email
// Extract email - handle both profile.emails[] (Google) and profile.email (others)
const email = profile.emails?.[0]?.value || profile.email || profile._json?.email;
if (!user && email) {
user = await findUserByEmail(email);
// If found by email but no OIDC ID, link them
if (user && !user.oidcId) {
// We don't have a direct update method for specific fields without full user object in this context
// Ideally we'd update the user here. For now, we'll just log in.
// Future: Update user with oidcId
}
}
// 3. If still not found, create new user (JIT Provisioning)
if (!user) {
const username = profile.username || profile.displayName || (email ? email.split('@')[0] : `user_${sub.substring(0, 8)}`);
user = await createUser({
username: username,
role: 'viewer', // Default role for SSO users
oidcId: sub,
email: email || null
});
}
return done(null, user);
} catch (err) {
return done(err);
}
}));
}
/**
* Middleware: Require authentication using Passport JWT
*/
const requireAuth = passport.authenticate('jwt', { session: false });
/**
* Middleware: Require admin role
*/
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden - Admin access required' });
}
next();
}
/**
* Middleware: Check for specific role
*/
function requireRole(role) {
return (req, res, next) => {
if (!req.user || req.user.role !== role) {
return res.status(403).json({ error: `Forbidden - ${role} access required` });
}
next();
};
}
module.exports = {
passport,
hashPassword,
verifyPassword,
generateToken,
verifyToken,
configureLocalStrategy,
configureJwtStrategy,
configureSessionSerialization,
configureOidcStrategy,
requireAuth,
requireAdmin,
requireRole
};
+469
View File
@@ -0,0 +1,469 @@
const fs = require('fs/promises');
const path = require('path');
const { existsSync, mkdirSync } = require('fs');
// Ensure data directory exists (sync is fine for startup)
const dataDir = path.join(__dirname, '..', 'data');
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'db.json');
// Initialize database structure
async function loadDb() {
try {
// Check if file exists (using fs.access is better for async, but we can catch ENOENT)
try {
const fileContent = await fs.readFile(dbPath, 'utf-8');
const data = JSON.parse(fileContent);
return {
sources: data.sources || [],
hiddenItems: data.hiddenItems || [],
favorites: data.favorites || [],
settings: data.settings || getDefaultSettings(),
users: data.users || [],
nextId: data.nextId || 1
};
} catch (error) {
if (error.code === 'ENOENT') {
// File doesn't exist, return default
return {
sources: [],
hiddenItems: [],
favorites: [],
settings: getDefaultSettings(),
users: [],
nextId: 1
};
}
throw error;
}
} catch (err) {
console.error('Error loading database:', err);
// Return safe default on error to prevent crashing, but log it
return {
sources: [],
hiddenItems: [],
favorites: [],
settings: getDefaultSettings(),
users: [],
nextId: 1
};
}
}
// Default settings
function getDefaultSettings() {
return {
arrowKeysChangeChannel: true,
overlayDuration: 5,
defaultVolume: 80,
rememberVolume: true,
lastVolume: 80,
autoPlayNextEpisode: false,
forceProxy: false,
forceTranscode: false, // Force Audio Transcode
forceVideoTranscode: false, // Force Video Transcode
forceRemux: false,
autoTranscode: true,
// Dispatcharr (y muchos Xtream) sirven MPEG-TS aunque la extensión sea .m3u8; Hls.js falla. Usar ts.
streamFormat: 'ts',
epgRefreshInterval: '24',
// User-Agent settings
userAgentPreset: 'chrome', // chrome | vlc | tivimate | custom
userAgentCustom: '', // Custom UA string when preset is 'custom'
// Transcoding settings
hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software
maxResolution: '1080p', // 4k | 1080p | 720p | 480p
quality: 'medium', // high | medium | low
audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough
// Probe cache settings
probeCacheTTL: 300, // 5 minutes for URL probe cache
seriesProbeCacheDays: 7, // 7 days for series episode probe cache
// Upscaling settings
upscaleEnabled: false,
upscaleMethod: 'hardware', // hardware | software
upscaleTarget: '1080p' // 1080p | 4k | 720p
};
}
// User-Agent presets
const USER_AGENT_PRESETS = {
chrome: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
vlc: 'VLC/3.0.20 LibVLC/3.0.20',
tivimate: 'TiviMate/4.7.0',
};
function getUserAgent(settings) {
if (settings.userAgentPreset === 'custom' && settings.userAgentCustom) {
return settings.userAgentCustom;
}
return USER_AGENT_PRESETS[settings.userAgentPreset] || USER_AGENT_PRESETS.chrome;
}
// Write lock to prevent concurrent writes from corrupting db.json
let writeQueue = Promise.resolve();
const tmpPath = dbPath + '.tmp';
async function saveDb(data) {
// Queue this write operation - each write waits for the previous one
writeQueue = writeQueue.then(async () => {
try {
const jsonString = JSON.stringify(data, null, 2);
// Atomic write: write to temp file, then rename
// Rename is atomic on most filesystems, preventing corruption on crash
await fs.writeFile(tmpPath, jsonString);
await fs.rename(tmpPath, dbPath);
} catch (err) {
console.error('Error writing database:', err);
// Clean up temp file if it exists
try { await fs.unlink(tmpPath); } catch { /* ignore */ }
throw err;
}
}).catch(err => {
console.error('Database write failed:', err);
});
return writeQueue;
}
// Source CRUD operations
const sources = {
async getAll() {
const db = await loadDb();
return db.sources;
},
async getById(id) {
const db = await loadDb();
return db.sources.find(s => s.id === parseInt(id));
},
async getByType(type) {
const db = await loadDb();
return db.sources.filter(s => s.type === type && s.enabled);
},
async create(source) {
const db = await loadDb();
const newSource = {
id: db.nextId++,
...source,
enabled: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
db.sources.push(newSource);
await saveDb(db);
return newSource;
},
async update(id, updates) {
const db = await loadDb();
const index = db.sources.findIndex(s => s.id === parseInt(id));
if (index === -1) return null;
db.sources[index] = {
...db.sources[index],
...updates,
updated_at: new Date().toISOString()
};
await saveDb(db);
return db.sources[index];
},
async delete(id) {
const db = await loadDb();
db.sources = db.sources.filter(s => s.id !== parseInt(id));
// Also delete related hidden items and favorites
db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id));
db.favorites = db.favorites.filter(f => f.source_id !== parseInt(id));
await saveDb(db);
},
async toggleEnabled(id) {
const db = await loadDb();
const source = db.sources.find(s => s.id === parseInt(id));
if (source) {
source.enabled = !source.enabled;
source.updated_at = new Date().toISOString();
await saveDb(db);
}
return source;
}
};
// Hidden items operations
const hiddenItems = {
async getAll(sourceId = null) {
const db = await loadDb();
if (sourceId) {
return db.hiddenItems.filter(h => h.source_id === parseInt(sourceId));
}
return db.hiddenItems;
},
async hide(sourceId, itemType, itemId) {
const db = await loadDb();
// Check if already hidden
const exists = db.hiddenItems.find(
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
);
if (!exists) {
db.hiddenItems.push({
id: db.nextId++,
source_id: parseInt(sourceId),
item_type: itemType,
item_id: itemId
});
await saveDb(db);
}
},
async show(sourceId, itemType, itemId) {
const db = await loadDb();
db.hiddenItems = db.hiddenItems.filter(
h => !(h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId)
);
await saveDb(db);
},
async isHidden(sourceId, itemType, itemId) {
const db = await loadDb();
return db.hiddenItems.some(
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
);
},
async bulkHide(items) {
const db = await loadDb();
let modified = false;
items.forEach(item => {
const { sourceId, itemType, itemId } = item;
const exists = db.hiddenItems.find(
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
);
if (!exists) {
db.hiddenItems.push({
id: db.nextId++,
source_id: parseInt(sourceId),
item_type: itemType,
item_id: itemId
});
modified = true;
}
});
if (modified) {
await saveDb(db);
}
return true;
},
async bulkShow(items) {
const db = await loadDb();
const initialLength = db.hiddenItems.length;
// Create a set of "signatures" for O(1) lookup of items to remove
const toRemove = new Set(items.map(i => `${i.sourceId}:${i.itemType}:${i.itemId}`));
db.hiddenItems = db.hiddenItems.filter(h =>
!toRemove.has(`${h.source_id}:${h.item_type}:${h.item_id}`)
);
if (db.hiddenItems.length !== initialLength) {
await saveDb(db);
}
return true;
}
};
// Favorites operations
const favorites = {
async getAll(sourceId = null, itemType = null) {
const db = await loadDb();
let results = db.favorites;
if (sourceId) {
results = results.filter(f => f.source_id === parseInt(sourceId));
}
if (itemType) {
results = results.filter(f => f.item_type === itemType);
}
return results;
},
async add(sourceId, itemId, itemType = 'channel') {
const db = await loadDb();
// Check if already favorited
const exists = db.favorites.find(
f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType
);
if (!exists) {
db.favorites.push({
id: db.nextId++,
source_id: parseInt(sourceId),
item_id: String(itemId),
item_type: itemType, // 'channel', 'movie', 'series'
created_at: new Date().toISOString()
});
await saveDb(db);
}
return true;
},
async remove(sourceId, itemId, itemType = 'channel') {
const db = await loadDb();
db.favorites = db.favorites.filter(
f => !(f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType)
);
await saveDb(db);
return true;
},
async isFavorite(sourceId, itemId, itemType = 'channel') {
const db = await loadDb();
return db.favorites.some(
f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType
);
}
};
// Settings operations
const settings = {
async get() {
const db = await loadDb();
return { ...getDefaultSettings(), ...db.settings };
},
async update(newSettings) {
const db = await loadDb();
db.settings = { ...db.settings, ...newSettings };
await saveDb(db);
return db.settings;
},
async reset() {
const db = await loadDb();
db.settings = getDefaultSettings();
await saveDb(db);
return db.settings;
}
};
// User operations
const users = {
async getAll() {
const db = await loadDb();
return db.users || [];
},
async getById(id) {
const db = await loadDb();
return db.users?.find(u => u.id === parseInt(id));
},
async getByUsername(username) {
const db = await loadDb();
return db.users?.find(u => u.username === username);
},
async getByOidcId(oidcId) {
const db = await loadDb();
return db.users?.find(u => u.oidcId === oidcId);
},
async getByEmail(email) {
const db = await loadDb();
return db.users?.find(u => u.email === email);
},
async create(userData) {
const db = await loadDb();
if (!db.users) {
db.users = [];
}
// Check if username already exists
if (db.users.some(u => u.username === userData.username)) {
throw new Error('Username already exists');
}
const newUser = {
id: db.nextId++,
username: userData.username,
// For OIDC users, passwordHash is optional
passwordHash: userData.passwordHash || null,
role: userData.role || 'viewer',
oidcId: userData.oidcId || null,
email: userData.email || null,
createdAt: new Date().toISOString()
};
db.users.push(newUser);
await saveDb(db);
// Return user without password hash
const { passwordHash, ...userWithoutPassword } = newUser;
return userWithoutPassword;
},
async update(id, updates) {
const db = await loadDb();
const userIndex = db.users?.findIndex(u => u.id === parseInt(id));
if (userIndex === -1 || userIndex === undefined) {
throw new Error('User not found');
}
// Check if username is being changed and if it already exists
if (updates.username && updates.username !== db.users[userIndex].username) {
if (db.users.some(u => u.username === updates.username)) {
throw new Error('Username already exists');
}
}
db.users[userIndex] = {
...db.users[userIndex],
...updates,
updatedAt: new Date().toISOString()
};
await saveDb(db);
// Return user without password hash
const { passwordHash, ...userWithoutPassword } = db.users[userIndex];
return userWithoutPassword;
},
async delete(id) {
const db = await loadDb();
const userIndex = db.users?.findIndex(u => u.id === parseInt(id));
if (userIndex === -1 || userIndex === undefined) {
throw new Error('User not found');
}
// Prevent deleting the last admin
const user = db.users[userIndex];
if (user.role === 'admin') {
const adminCount = db.users.filter(u => u.role === 'admin').length;
if (adminCount <= 1) {
throw new Error('Cannot delete the last admin user');
}
}
db.users.splice(userIndex, 1);
await saveDb(db);
return true;
},
async count() {
const db = await loadDb();
return db.users?.length || 0;
}
};
module.exports = { loadDb, saveDb, sources, hiddenItems, favorites, settings, users, getDefaultSettings, getUserAgent, USER_AGENT_PRESETS };
+216
View File
@@ -0,0 +1,216 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dataDir = path.join(__dirname, '..', '..', 'data');
const dbPath = path.join(dataDir, 'content.db');
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
let db;
function getDb() {
if (!db) {
console.log('[SQLite] Opening database at', dbPath);
db = new Database(dbPath);
// Optimize performance
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
initSchema();
}
return db;
}
function initSchema() {
if (!db) throw new Error('Database not initialized');
// Categories (Groups)
db.exec(`
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY, -- Composite key: sourceId:categoryId
source_id INTEGER NOT NULL,
category_id TEXT NOT NULL,
type TEXT NOT NULL, -- 'live', 'movie', 'series'
name TEXT NOT NULL,
parent_id TEXT, -- For nested categories
is_hidden INTEGER DEFAULT 0,
data JSON -- Extra provider data
);
CREATE INDEX IF NOT EXISTS idx_categories_source_type ON categories(source_id, type);
`);
// Playlist Items (Channels, Movies, Series, Episodes)
db.exec(`
CREATE TABLE IF NOT EXISTS playlist_items (
id TEXT PRIMARY KEY, -- Composite key: sourceId:itemId
source_id INTEGER NOT NULL,
item_id TEXT NOT NULL, -- Original ID from provider
type TEXT NOT NULL, -- 'live', 'movie', 'series', 'episode'
name TEXT NOT NULL,
category_id TEXT, -- maps to categories.category_id (not our composite id)
parent_id TEXT, -- For episodes -> series_id
-- Common Media Fields
stream_icon TEXT,
stream_url TEXT, -- Direct link if available
container_extension TEXT,
-- VOD/Series Specific
rating REAL,
year TEXT,
added_at TEXT,
-- App State
is_hidden INTEGER DEFAULT 0,
is_favorite INTEGER DEFAULT 0,
data JSON -- Full original JSON object
);
CREATE INDEX IF NOT EXISTS idx_items_source_type ON playlist_items(source_id, type);
CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id);
`);
// EPG Programs
// Optimized for range queries
db.exec(`
CREATE TABLE IF NOT EXISTS epg_programs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT NOT NULL, -- matches playlist_items.id if possible, or mapping key
source_id INTEGER NOT NULL,
start_time INTEGER NOT NULL, -- Unix timestamp (ms)
end_time INTEGER NOT NULL, -- Unix timestamp (ms)
title TEXT,
description TEXT,
data JSON
);
CREATE INDEX IF NOT EXISTS idx_epg_channel_time ON epg_programs(channel_id, start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_epg_cleanup ON epg_programs(end_time); -- For deleting old programs
`);
// Sync Status
db.exec(`
CREATE TABLE IF NOT EXISTS sync_status (
source_id INTEGER NOT NULL,
type TEXT NOT NULL, -- 'live', 'vod', 'series', 'epg'
last_sync INTEGER NOT NULL,
status TEXT, -- 'success', 'error', 'syncing'
error TEXT,
PRIMARY KEY (source_id, type)
);
`);
// User Favorites (per-user)
db.exec(`
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
source_id INTEGER NOT NULL,
item_id TEXT NOT NULL,
item_type TEXT NOT NULL, -- 'channel', 'movie', 'series'
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, source_id, item_id, item_type)
);
CREATE INDEX IF NOT EXISTS idx_favorites_user ON favorites(user_id);
CREATE INDEX IF NOT EXISTS idx_favorites_user_type ON favorites(user_id, item_type);
`);
// Watch History (per-user)
db.exec(`
CREATE TABLE IF NOT EXISTS watch_history (
id TEXT PRIMARY KEY, -- Composite key: user_id:item_id
user_id INTEGER NOT NULL,
source_id INTEGER, -- Source ID for Xtream/M3U
item_type TEXT NOT NULL, -- 'movie', 'episode'
item_id TEXT NOT NULL, -- The original item ID (stream_id or composite)
parent_id TEXT, -- For episodes (series ID)
progress INTEGER DEFAULT 0, -- Current position in seconds
duration INTEGER DEFAULT 0, -- Total duration in seconds
updated_at INTEGER NOT NULL, -- Timestamp
data JSON -- Snapshot of item data (title, poster, etc)
);
CREATE INDEX IF NOT EXISTS idx_history_user_updated ON watch_history(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id);
`);
// Migration: Add source_id column if missing (for existing databases)
try {
db.exec(`ALTER TABLE watch_history ADD COLUMN source_id INTEGER`);
console.log('[SQLite] Added source_id column to watch_history');
} catch (e) {
// Column already exists, ignore
}
console.log('[SQLite] Schema initialized');
}
// ============================================================
// Favorites CRUD Operations
// ============================================================
const favorites = {
getAll(userId, sourceId = null, itemType = null) {
const db = getDb();
let sql = 'SELECT * FROM favorites WHERE user_id = ?';
const params = [userId];
if (sourceId) {
sql += ' AND source_id = ?';
params.push(sourceId);
}
if (itemType) {
sql += ' AND item_type = ?';
params.push(itemType);
}
sql += ' ORDER BY created_at DESC';
return db.prepare(sql).all(...params);
},
add(userId, sourceId, itemId, itemType = 'channel') {
const db = getDb();
const stmt = db.prepare(`
INSERT OR IGNORE INTO favorites (user_id, source_id, item_id, item_type)
VALUES (?, ?, ?, ?)
`);
const result = stmt.run(userId, sourceId, itemId, itemType);
return result.changes > 0;
},
remove(userId, sourceId, itemId, itemType = 'channel') {
const db = getDb();
const stmt = db.prepare(`
DELETE FROM favorites
WHERE user_id = ? AND source_id = ? AND item_id = ? AND item_type = ?
`);
const result = stmt.run(userId, sourceId, itemId, itemType);
return result.changes > 0;
},
isFavorite(userId, sourceId, itemId, itemType = 'channel') {
const db = getDb();
const row = db.prepare(`
SELECT 1 FROM favorites
WHERE user_id = ? AND source_id = ? AND item_id = ? AND item_type = ?
`).get(userId, sourceId, itemId, itemType);
return !!row;
},
// Get all favorites for a user, grouped by type (for bulk checks)
getAllAsSet(userId) {
const db = getDb();
const rows = db.prepare('SELECT source_id, item_id, item_type FROM favorites WHERE user_id = ?').all(userId);
const set = new Set();
for (const row of rows) {
set.add(`${row.source_id}:${row.item_id}:${row.item_type}`);
}
return set;
}
};
module.exports = {
getDb,
initSchema,
favorites
};
+230
View File
@@ -0,0 +1,230 @@
const express = require('express');
require('dotenv').config();
const path = require('path');
const passport = require('passport');
const syncService = require('./services/syncService');
// Initialize database
require('./db');
const app = express();
const PORT = process.env.PORT || 3000;
// Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For, etc.)
// Required for correct protocol detection behind reverse proxies (nginx, Caddy, etc.)
app.set('trust proxy', true);
// Middleware
app.use(express.json({ limit: '50mb' }));
// Initialize Passport
const session = require('express-session');
app.use(session({
secret: process.env.JWT_SECRET || 'keyboard cat',
resave: false,
saveUninitialized: true
}));
app.use(passport.initialize());
app.use(passport.session());
// Serve static files with no-cache for JS/CSS so browser always loads the latest version
app.use(express.static(path.join(__dirname, '..', 'public'), {
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
}
}
}));
// FFMPEG Configuration (optional - for transcoding support)
// Priority: 1. System FFmpeg (better Docker DNS support), 2. ffmpeg-static npm package
const { execSync } = require('child_process');
function findFFmpeg() {
// Try system FFmpeg first (better Docker compatibility)
try {
execSync('ffmpeg -version', { stdio: 'ignore' });
console.log('FFmpeg binary configured at: ffmpeg (system)');
return 'ffmpeg';
} catch (e) {
// System FFmpeg not found, try ffmpeg-static
}
// Try ffmpeg-static npm package
try {
let ffmpegPath = require('ffmpeg-static');
// In packaged Electron apps, ffmpeg-static returns path inside .asar archive
// but the binary is actually unpacked to app.asar.unpacked
if (ffmpegPath && ffmpegPath.includes('app.asar')) {
ffmpegPath = ffmpegPath.replace('app.asar', 'app.asar.unpacked');
}
console.log('FFmpeg binary configured at:', ffmpegPath);
return ffmpegPath;
} catch (err) {
console.warn('FFmpeg not available - transcoding/remuxing will be disabled.');
console.warn('Install FFmpeg via your package manager or npm install ffmpeg-static');
return null;
}
}
function findFFprobe() {
// Try system ffprobe first
try {
execSync('ffprobe -version', { stdio: 'ignore' });
console.log('FFprobe binary configured at: ffprobe (system)');
return 'ffprobe';
} catch (e) {
// Not found in system
}
// Try @ffprobe-installer/ffprobe package
try {
const ffprobePath = require('@ffprobe-installer/ffprobe').path;
if (ffprobePath) {
console.log('FFprobe binary configured at:', ffprobePath);
return ffprobePath;
}
} catch (err) {
// Package not available
}
console.warn('FFprobe not available - auto transcode will fallback to always transcode');
return null;
}
app.locals.ffmpegPath = findFFmpeg();
app.locals.ffprobePath = findFFprobe();
// Dynamic services loader - collects exports from files in ./services
const fs = require('fs');
const services = {};
try {
const servicesDir = path.join(__dirname, 'services');
const serviceFiles = fs.readdirSync(servicesDir).filter(f => f.endsWith('.js'));
for (const file of serviceFiles) {
const name = file.replace(/\.js$/, '');
try {
services[name] = require(path.join(servicesDir, file));
} catch (e) {
console.warn(`Failed to load service ${file}:`, e.message);
}
}
} catch (e) {
console.warn('No services directory found or failed to read services:', e.message);
}
// Freeze services object to prevent plugins from mutating shared state
Object.freeze(services);
// Plugin loader: loads any .js file inside server/plugins and calls the
// exported function with (app, services).
// Supports both function exports and object exports with lifecycle hooks.
const loadedPlugins = [];
async function loadPlugins() {
try {
const pluginsDir = path.join(__dirname, 'plugins');
if (fs.existsSync(pluginsDir)) {
// Sort plugin files alphabetically for deterministic load order
const pluginFiles = fs.readdirSync(pluginsDir)
.filter(f => f.endsWith('.js'))
.sort();
for (const file of pluginFiles) {
const pluginPath = path.join(pluginsDir, file);
try {
const plugin = require(pluginPath);
// Support both function exports and object exports with lifecycle hooks
if (typeof plugin === 'function') {
// Direct function export (sync or async)
await plugin(app, services);
loadedPlugins.push({ name: file, plugin: null });
console.log(`✓ Loaded plugin: ${file}`);
} else if (plugin && typeof plugin.init === 'function') {
// Object export with init/shutdown lifecycle
await plugin.init(app, services);
loadedPlugins.push({ name: file, plugin });
console.log(`✓ Loaded plugin: ${file} (with lifecycle hooks)`);
} else {
console.warn(`⚠ Plugin ${file} does not export a function or object with init(), skipping.`);
}
} catch (err) {
console.error(`✗ Failed to load plugin ${file}:`, err);
}
}
}
} catch (err) {
console.warn('Plugin loader failed:', err.message);
}
}
// Graceful shutdown handler for plugins with shutdown hooks
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down plugins...');
for (const { name, plugin } of loadedPlugins) {
if (plugin && typeof plugin.shutdown === 'function') {
try {
await plugin.shutdown();
console.log(`✓ Shutdown plugin: ${name}`);
} catch (err) {
console.error(`✗ Error shutting down plugin ${name}:`, err);
}
}
}
process.exit(0);
});
// API Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/sources', require('./routes/sources'));
app.use('/api/proxy', require('./routes/proxy'));
app.use('/api/channels', require('./routes/channels'));
app.use('/api/favorites', require('./routes/favorites'));
app.use('/api/transcode', require('./routes/transcode'));
app.use('/api/remux', require('./routes/remux'));
app.use('/api/probe', require('./routes/probe'));
app.use('/api/subtitle', require('./routes/subtitle'));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/history', require('./routes/history'));
// Version endpoint
app.get('/api/version', (req, res) => {
const pkg = require('../package.json');
res.json({ version: pkg.version });
});
// SPA fallback - serve index.html for all non-API routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
});
// Error handling
app.use((err, req, res, next) => {
console.error('Server error:', err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(PORT, async () => {
console.log(`KiraTV server running on http://localhost:${PORT}`);
// Load plugins
await loadPlugins().catch(err => {
console.error('Plugin initialization failed:', err);
});
// Trigger background sync with delay to allow server to settle
setTimeout(async () => {
await syncService.syncAll().catch(console.error);
// Start the server-side sync timer after initial sync
await syncService.startSyncTimer().catch(console.error);
// Detect hardware acceleration capabilities
try {
const hwDetect = require('./services/hwDetect');
await hwDetect.detect();
} catch (err) {
console.warn('Hardware detection failed:', err.message);
}
}, 5000);
});
+163
View File
@@ -0,0 +1,163 @@
# nodecast-tv Plugin System
This directory allows you to extend the functionality of **nodecast-tv** without modifying the core source code. The server automatically detects and loads any `.js` file placed in this folder at startup.
---
## How It Works
The plugin loader in `server/index.js` scans this directory at startup and loads plugins in **alphabetical order** (sorted by filename). Each plugin file should export either:
- A function (sync or async) that will be called during initialization
- An object with `init()` and optionally `shutdown()` methods for lifecycle management
When the server starts, it calls your plugin's initialization code and passes:
- `app` - The Express application instance
- `services` - A **frozen** (read-only) object containing all internal services
---
## Plugin Patterns
### Pattern 1: Simple Function Export
The simplest plugin pattern - just export a function:
```javascript
/**
* @param {Object} app - The Express application instance
* @param {Object} services - Frozen object containing all internal services
*/
module.exports = function(app, services) {
// Register routes, middleware, etc.
app.get('/api/my-route', (req, res) => {
res.json({ message: 'Hello from plugin!' });
});
};
```
### Pattern 2: Async Function Export
For plugins that need to perform async initialization (database connections, API calls, etc.):
```javascript
module.exports = async function(app, services) {
// Async initialization
await someAsyncSetup();
// Access services
if (services.syncService) {
console.log('Sync service available');
}
// Register routes
app.get('/api/my-route', (req, res) => {
res.json({ status: 'ready' });
});
};
```
### Pattern 3: Lifecycle Hooks (Advanced)
For plugins that need cleanup on shutdown:
```javascript
module.exports = {
/**
* Called during server startup
*/
init: async (app, services) => {
// Setup code
this.interval = setInterval(() => {
console.log('Background task running...');
}, 60000);
app.get('/api/status', (req, res) => {
res.json({ uptime: process.uptime() });
});
},
/**
* Called on SIGTERM (graceful shutdown)
*/
shutdown: async () => {
// Cleanup code
if (this.interval) {
clearInterval(this.interval);
}
console.log('Plugin cleaned up');
}
};
```
---
## Security & Permissions
> [!WARNING]
> Plugins run with **full Node.js permissions** and have access to the Express app and all services. Only install plugins from trusted sources.
**Important notes:**
- The `services` object is **frozen** - you cannot modify or delete services
- Plugins can still mutate properties of service objects (e.g., `services.cache.set()`)
- Plugins can register routes, middleware, and access the file system
- This is appropriate for self-hosted applications where you control what plugins are installed
---
## Available Services
The `services` object contains all modules from `server/services/`:
| Service | Description |
|---------|-------------|
| `cache` | Caching utilities |
| `epgParser` | EPG/XMLTV parsing |
| `hwDetect` | Hardware acceleration detection |
| `m3uParser` | M3U playlist parsing |
| `m3uXtreamAdapter` | Xtream API adapter |
| `syncService` | Channel/EPG synchronization |
| `transcodeSession` | Transcoding session management |
| `xtreamApi` | Xtream API client |
**Example:**
```javascript
module.exports = async function(app, services) {
// Use the EPG parser service
const epgData = await services.epgParser.fetchAndParse('http://example.com/epg.xml');
console.log(`Loaded ${epgData.programmes.length} programmes`);
};
```
---
## Load Order
Plugins are loaded in **alphabetical order** by filename. If you need specific ordering:
```
plugins/
01-database.js # Loads first
02-api-client.js # Loads second
99-cleanup.js # Loads last
```
---
## Testing Your Plugin
1. Place your `.js` file in `server/plugins/`
2. Restart the NodeCast TV server
3. Check the console for plugin loading messages:
- `✓ Loaded plugin: your-plugin.js` - Success
- `⚠ Plugin your-plugin.js does not export...` - Wrong export format
- `✗ Failed to load plugin your-plugin.js` - Error during initialization
---
## Example Use Cases
- **Custom scrapers** - Add support for new streaming sources
- **Notification systems** - Send alerts when new content is available
- **Analytics** - Track viewing patterns
- **Custom APIs** - Expose additional endpoints for integrations
- **Middleware** - Add authentication, logging, or rate limiting
+25
View File
@@ -0,0 +1,25 @@
/**
* Example plugin demonstrating async initialization and service access
* This plugin registers a test route at /api/hello
*/
module.exports = async function (app, services) {
console.log("Plugin 'Hello' activated!");
// Example: Access loaded services
if (services.syncService) {
console.log(" - syncService is available");
}
// Simulate async initialization (e.g., database connection, API setup)
await new Promise(resolve => setTimeout(resolve, 100));
// Register a test route accessible at http://localhost:3000/api/hello
app.get('/api/hello', (req, res) => {
res.json({
message: "The plugin system is working!",
availableServices: Object.keys(services)
});
});
console.log(" - Registered route: GET /api/hello");
};
+294
View File
@@ -0,0 +1,294 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const auth = require('../auth');
// Configure Passport strategies
auth.configureLocalStrategy(
async (username) => await db.users.getByUsername(username),
async (password, hash) => await auth.verifyPassword(password, hash)
);
auth.configureJwtStrategy(
async (id) => await db.users.getById(id)
);
// Configure Passport session serialization (required for OIDC)
auth.configureSessionSerialization(
async (id) => await db.users.getById(id)
);
// Configure OIDC Strategy
auth.configureOidcStrategy(
async (oidcId) => await db.users.getByOidcId(oidcId),
async (email) => await db.users.getByEmail(email),
async (userData) => await db.users.create(userData)
);
/**
* Start OIDC Login
* GET /api/auth/oidc/login
*/
router.get('/oidc/login', auth.passport.authenticate('openidconnect'));
/**
* OIDC Callback
* GET /api/auth/oidc/callback
*/
router.get('/oidc/callback',
auth.passport.authenticate('openidconnect', { session: false, failureRedirect: '/login.html?error=SSO+Failed' }),
(req, res) => {
// Successful authentication
const token = auth.generateToken(req.user);
// Redirect to hompage with token
res.redirect(`/?token=${token}`);
}
);
/**
* Check if initial setup is required
* GET /api/auth/setup-required
*/
router.get('/setup-required', async (req, res) => {
try {
const userCount = await db.users.count();
res.json({ setupRequired: userCount === 0 });
} catch (err) {
console.error('Error in /setup-required:', err);
res.status(500).json({ error: 'Server error' });
}
});
/**
* Initial setup - Create admin user
* POST /api/auth/setup
*/
router.post('/setup', async (req, res) => {
try {
const userCount = await db.users.count();
// Check if setup already done
if (userCount > 0) {
return res.status(400).json({ error: 'Setup already completed' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
// Create admin user
const passwordHash = await auth.hashPassword(password);
const adminUser = await db.users.create({
username,
passwordHash,
role: 'admin'
});
// Generate token for immediate login
const token = auth.generateToken(adminUser);
res.status(201).json({
message: 'Admin user created successfully',
token,
user: adminUser
});
} catch (err) {
console.error('Error in /setup:', err);
res.status(500).json({ error: err.message || 'Server error' });
}
});
/**
* Login with Passport Local Strategy
* POST /api/auth/login
*/
router.post('/login', (req, res, next) => {
auth.passport.authenticate('local', { session: false }, (err, user, info) => {
if (err) {
console.error('Login error:', err);
return res.status(500).json({ error: 'Server error' });
}
if (!user) {
return res.status(401).json({ error: info?.message || 'Invalid credentials' });
}
// Generate JWT token
const token = auth.generateToken(user);
res.json({
token,
user: {
id: user.id,
username: user.username,
role: user.role
}
});
})(req, res, next);
});
/**
* Logout (client-side handles token removal)
* POST /api/auth/logout
*/
router.post('/logout', (req, res) => {
// With JWT, logout is handled client-side by removing the token
// This endpoint exists for consistency and future server-side token blacklisting
res.json({ success: true, message: 'Logged out successfully' });
});
/**
* Get current user
* GET /api/auth/me
*/
router.get('/me', auth.requireAuth, async (req, res) => {
try {
const user = await db.users.getById(req.user.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
id: user.id,
username: user.username,
role: user.role
});
} catch (err) {
console.error('Error in /me:', err);
res.status(500).json({ error: 'Server error' });
}
});
/**
* Get all users (admin only)
* GET /api/auth/users
*/
router.get('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const allUsers = await db.users.getAll();
// Remove password hashes
const users = allUsers.map(u => {
const { passwordHash, ...userWithoutPassword } = u;
return userWithoutPassword;
});
res.json(users);
} catch (err) {
console.error('Error fetching users:', err);
res.status(500).json({ error: 'Server error' });
}
});
/**
* Create a new user (admin only)
* POST /api/auth/users
*/
router.post('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const { username, password, role } = req.body;
if (!username || !password || !role) {
return res.status(400).json({ error: 'Username, password, and role are required' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
if (!['admin', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' });
}
const passwordHash = await auth.hashPassword(password);
const newUser = await db.users.create({
username,
passwordHash,
role
});
res.status(201).json(newUser);
} catch (err) {
console.error('Error creating user:', err);
res.status(500).json({ error: err.message || 'Server error' });
}
});
/**
* Update a user (admin only)
* PUT /api/auth/users/:id
*/
router.put('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const { id } = req.params;
const { username, password, role } = req.body;
const updates = {};
if (username) {
updates.username = username;
}
if (password) {
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
updates.passwordHash = await auth.hashPassword(password);
}
if (role) {
if (!['admin', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' });
}
// Prevent removing admin role from the last admin
const user = await db.users.getById(id);
if (user && user.role === 'admin' && role !== 'admin') {
const allUsers = await db.users.getAll();
const adminCount = allUsers.filter(u => u.role === 'admin').length;
if (adminCount <= 1) {
return res.status(400).json({ error: 'Cannot remove admin role from the last admin user' });
}
}
updates.role = role;
}
const updatedUser = await db.users.update(id, updates);
res.json(updatedUser);
} catch (err) {
console.error('Error updating user:', err);
res.status(500).json({ error: err.message || 'Server error' });
}
});
/**
* Delete a user (admin only)
* DELETE /api/auth/users/:id
*/
router.delete('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const { id } = req.params;
// Prevent deleting yourself
if (parseInt(id) === req.user.id) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
await db.users.delete(id);
res.json({ success: true, message: 'User deleted successfully' });
} catch (err) {
console.error('Error deleting user:', err);
res.status(500).json({ error: err.message || 'Server error' });
}
});
module.exports = router;
+371
View File
@@ -0,0 +1,371 @@
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) {
switch (apiType) {
case 'channel': return { table: 'playlist_items', type: 'live' };
case 'group': return { table: 'categories', type: 'live' };
case 'vod_category': return { table: 'categories', type: 'movie' };
case 'series_category': return { table: 'categories', type: 'series' };
case 'movie': return { table: 'playlist_items', type: 'movie' };
case 'series': return { table: 'playlist_items', type: 'series' };
default: return null;
}
}
// Get all hidden items (formatted like db.json for frontend compatibility)
router.get('/hidden', async (req, res) => {
try {
const { sourceId } = req.query;
const db = getDb();
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
});
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 = [];
if (sourceId) {
if (!(await assertCanUseSourceById(req, res, sources, sourceId))) return;
const sid = parseInt(sourceId, 10);
catQuery += ` AND source_id = ?`;
itemQuery += ` AND source_id = ?`;
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(...catParams);
const hiddenItems = db.prepare(itemQuery).all(...itemParams);
const hidden = [];
hiddenCats.forEach(row => {
let apiType;
if (row.type === 'live') apiType = 'group';
else if (row.type === 'movie') apiType = 'vod_category';
else if (row.type === 'series') apiType = 'series_category';
if (apiType) hidden.push(resultFormat(row, apiType));
});
hiddenItems.forEach(row => {
let apiType;
if (row.type === 'live') apiType = 'channel';
else if (row.type === 'movie') apiType = 'movie';
else if (row.type === 'series') apiType = 'series';
if (apiType) hidden.push(resultFormat(row, apiType));
});
res.json(hidden);
} catch (err) {
console.error('Error getting hidden items:', err);
res.status(500).json({ error: 'Failed to get hidden items' });
}
});
// Hide item
router.post('/hide', async (req, res) => {
try {
const { sourceId, itemType, itemId } = req.body;
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';
const stmt = db.prepare(`
UPDATE ${mapping.table}
SET is_hidden = 1
WHERE source_id = ? AND type = ? AND ${idCol} = ?
`);
stmt.run(sourceId, mapping.type, itemId);
res.json({ success: true });
} catch (err) {
console.error('Error hiding item:', err);
res.status(500).json({ error: 'Failed to hide item' });
}
});
// Show item
router.post('/show', async (req, res) => {
try {
const { sourceId, itemType, itemId } = req.body;
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';
const stmt = db.prepare(`
UPDATE ${mapping.table}
SET is_hidden = 0
WHERE source_id = ? AND type = ? AND ${idCol} = ?
`);
stmt.run(sourceId, mapping.type, itemId);
res.json({ success: true });
} catch (err) {
console.error('Error showing item:', err);
res.status(500).json({ error: 'Failed to show item' });
}
});
// Check hidden status
router.get('/hidden/check', async (req, res) => {
try {
const { sourceId, itemType, itemId } = req.query;
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';
const row = db.prepare(`
SELECT is_hidden FROM ${mapping.table}
WHERE source_id = ? AND type = ? AND ${idCol} = ?
`).get(sourceId, mapping.type, itemId);
res.json({ hidden: !!(row && row.is_hidden) });
} catch (err) {
console.error('Error checking hidden:', err);
res.status(500).json({ error: 'Failed to check status' });
}
});
// Bulk Hide
router.post('/hide/bulk', async (req, res) => {
try {
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();
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 = ?');
const hideCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ? AND category_id = ?');
const runBulk = db.transaction((list) => {
for (const item of list) {
const mapping = mapItemType(item.itemType);
if (mapping) {
if (mapping.table === 'categories') {
hideCat.run(item.sourceId, mapping.type, item.itemId);
hideCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else {
hideItem.run(item.sourceId, mapping.type, item.itemId);
}
}
}
});
runBulk(items);
res.json({ success: true, count: items.length });
} catch (err) {
if (err.code === 'SQLITE_BUSY') {
return res.status(503).json({ error: 'Database is busy, please try again' });
}
console.error('Error bulk hide:', err);
res.status(500).json({ error: 'Failed' });
}
});
// Bulk Show
router.post('/show/bulk', async (req, res) => {
try {
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();
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 = ?');
const showCatChildren = db.prepare('UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ? AND category_id = ?');
const runBulk = db.transaction((list) => {
for (const item of list) {
const mapping = mapItemType(item.itemType);
if (mapping) {
if (mapping.table === 'categories') {
showCat.run(item.sourceId, mapping.type, item.itemId);
showCatChildren.run(item.sourceId, mapping.type, item.itemId);
} else {
showItem.run(item.sourceId, mapping.type, item.itemId);
}
}
}
});
runBulk(items);
res.json({ success: true, count: items.length });
} catch (err) {
if (err.code === 'SQLITE_BUSY') {
return res.status(503).json({ error: 'Database is busy, please try again' });
}
console.error('Error bulk show:', err);
res.status(500).json({ error: 'Failed' });
}
});
// Show ALL items for a source (single SQL statement - much faster than bulk)
router.post('/show/all', async (req, res) => {
try {
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;
const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series']
: ['live'];
for (const type of types) {
const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type);
const itemResult = db.prepare(`UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type);
catCount += catResult.changes;
itemCount += itemResult.changes;
}
console.log(`[Channels] Show all for source ${sourceId} (${contentType}): ${catCount} categories, ${itemCount} items`);
res.json({ success: true, categoriesUpdated: catCount, itemsUpdated: itemCount });
} catch (err) {
console.error('Error show all:', err);
res.status(500).json({ error: 'Failed to show all' });
}
});
// Hide ALL items for a source (single SQL statement - much faster than bulk)
router.post('/hide/all', async (req, res) => {
try {
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;
const types = contentType === 'movies' ? ['movie']
: contentType === 'series' ? ['series']
: ['live'];
for (const type of types) {
const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type);
const itemResult = db.prepare(`UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type);
catCount += catResult.changes;
itemCount += itemResult.changes;
}
console.log(`[Channels] Hide all for source ${sourceId} (${contentType}): ${catCount} categories, ${itemCount} items`);
res.json({ success: true, categoriesUpdated: catCount, itemsUpdated: itemCount });
} catch (err) {
console.error('Error hide all:', err);
res.status(500).json({ error: 'Failed to hide all' });
}
});
// Get recent movies or series
router.get('/recent', async (req, res) => {
try {
const { type, limit = 12 } = req.query;
if (!type || (type !== 'movie' && type !== 'series')) {
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
WHERE p.type = ?
AND p.is_hidden = 0
AND NOT EXISTS (
SELECT 1 FROM categories c
WHERE c.source_id = p.source_id
AND c.category_id = p.category_id
AND c.type = p.type
AND c.is_hidden = 1
)
${accessibleFilter}
ORDER BY p.added_at DESC
LIMIT ?
`).all(...params);
const formatted = recentItems.map(item => ({
...item,
data: JSON.parse(item.data)
}));
res.json(formatted);
} catch (err) {
console.error(`Error getting recent ${req.query.type}:`, err);
res.status(500).json({ error: 'Failed to get recent items' });
}
});
module.exports = router;
+66
View File
@@ -0,0 +1,66 @@
const express = require('express');
const router = express.Router();
const { favorites } = require('../db/sqlite');
const { requireAuth } = require('../auth');
// All favorites routes require authentication
router.use(requireAuth);
// Get all favorites for current user
router.get('/', async (req, res) => {
try {
const { sourceId, itemType } = req.query;
const items = favorites.getAll(req.user.id, sourceId || null, itemType || null);
res.json(items);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Add favorite for current user
router.post('/', async (req, res) => {
try {
const { sourceId, itemId, itemType = 'channel' } = req.body;
if (!sourceId || !itemId) {
return res.status(400).json({ error: 'Source ID and Item ID are required' });
}
favorites.add(req.user.id, sourceId, itemId, itemType);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Remove favorite for current user
router.delete('/', async (req, res) => {
try {
const { sourceId, itemId, itemType = 'channel' } = req.body;
if (!sourceId || !itemId) {
return res.status(400).json({ error: 'Source ID and Item ID are required' });
}
favorites.remove(req.user.id, sourceId, itemId, itemType);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Check if item is favorited by current user
router.get('/check', async (req, res) => {
try {
const { sourceId, itemId, itemType = 'channel' } = req.query;
if (!sourceId || !itemId) {
return res.status(400).json({ error: 'Source ID and Item ID are required' });
}
const isFav = favorites.isFavorite(req.user.id, sourceId, itemId, itemType);
res.json({ isFavorite: isFav });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+112
View File
@@ -0,0 +1,112 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/sqlite');
const { requireAuth } = require('../auth');
// Middleware to ensure authentication
router.use(requireAuth);
/**
* GET /api/history
* Returns the watch history for the authenticated user
*/
router.get('/', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const limit = parseInt(req.query.limit) || 20;
const rows = db.prepare(`
SELECT * FROM watch_history
WHERE user_id = ?
ORDER BY updated_at DESC
LIMIT ?
`).all(userId, limit);
const history = rows.map(row => ({
...row,
data: JSON.parse(row.data || '{}')
}));
res.json(history);
} catch (err) {
console.error('[History] Error fetching history:', err);
res.status(500).json({ error: 'Failed to fetch history' });
}
});
/**
* POST /api/history
* Saves/updates watch progress for an item
*/
router.post('/', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const { id, type, parentId, progress, duration, data, sourceId } = req.body;
if (!id || !type) {
return res.status(400).json({ error: 'Missing required fields (id, type)' });
}
const compositeId = `${userId}:${id}`;
const timestamp = Date.now();
const stmt = db.prepare(`
INSERT INTO watch_history (id, user_id, source_id, item_type, item_id, parent_id, progress, duration, updated_at, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
source_id = excluded.source_id,
progress = excluded.progress,
duration = excluded.duration,
updated_at = excluded.updated_at,
data = excluded.data
`);
stmt.run(
compositeId,
userId,
sourceId || null,
type,
id.toString(),
parentId ? parentId.toString() : null,
progress || 0,
duration || 0,
timestamp,
JSON.stringify(data || {})
);
res.json({ success: true, timestamp });
} catch (err) {
console.error('[History] Error saving progress:', err);
res.status(500).json({ error: 'Failed to save progress' });
}
});
/**
* DELETE /api/history/:itemId
* Removes an item from the user's watch history
*/
router.delete('/:itemId', (req, res) => {
try {
const db = getDb();
const userId = req.user.id;
const itemId = req.params.itemId;
const compositeId = `${userId}:${itemId}`;
const stmt = db.prepare('DELETE FROM watch_history WHERE id = ? AND user_id = ?');
const result = stmt.run(compositeId, userId);
if (result.changes === 0) {
return res.status(404).json({ error: 'Item not found in history' });
}
res.json({ success: true });
} catch (err) {
console.error('[History] Error deleting history item:', err);
res.status(500).json({ error: 'Failed to delete history item' });
}
});
module.exports = router;
+224
View File
@@ -0,0 +1,224 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
/**
* Probe endpoint - detects stream codecs and container
* GET /api/probe?url=...
*
* Returns:
* {
* video: "h264",
* audio: "aac",
* container: "mpegts",
* compatible: true,
* needsRemux: false,
* needsTranscode: false
* }
*/
// Probe cache (URL → result)
const probeCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Browser-compatible codecs
const BROWSER_VIDEO_CODECS = ['h264', 'avc', 'avc1'];
const BROWSER_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis'];
/**
* Probe stream with ffprobe
*/
function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
return new Promise((resolve, reject) => {
const args = [
'-v', 'error',
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'-print_format', 'json',
'-show_streams',
'-show_format',
'-probesize', '5000000',
'-analyzeduration', '5000000',
url
];
const proc = spawn(ffprobePath, args);
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
proc.kill('SIGKILL');
reject(new Error('Probe timeout'));
}, timeout);
proc.stdout.on('data', (data) => { stdout += data; });
proc.stderr.on('data', (data) => { stderr += data; });
proc.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) {
reject(new Error(`ffprobe exited with code ${code}: ${stderr}`));
return;
}
try {
const result = JSON.parse(stdout);
resolve(result);
} catch (e) {
reject(new Error('Failed to parse ffprobe output'));
}
});
proc.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}
/**
* Analyze probe result and determine compatibility
*/
function analyzeProbeResult(probeResult, url) {
const streams = probeResult.streams || [];
const format = probeResult.format || {};
const videoStream = streams.find(s => s.codec_type === 'video');
const audioStream = streams.find(s => s.codec_type === 'audio');
const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown';
const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown';
const container = format.format_name?.toLowerCase() || 'unknown';
// Check codec compatibility
const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c));
const audioOk = BROWSER_AUDIO_CODECS.some(c => audioCodec.includes(c));
// Browser-safe containers
// Note: We exclude 'webm' because ffprobe reports MKV as "matroska,webm",
// and H.264/AAC in MKV/WebM is not universally supported. Best to remux to MP4.
const BROWSER_CONTAINERS = ['hls', 'mp4', 'mov'];
const containerOk = BROWSER_CONTAINERS.some(c => container.includes(c));
// Check if it's a raw TS stream (not HLS)
const isRawTs = (container.includes('mpegts') || url.endsWith('.ts')) && !url.includes('.m3u8');
// Extract subtitle tracks
const subtitles = streams
.filter(s => s.codec_type === 'subtitle' && s.codec_name !== 'timed_id3' && s.codec_name !== 'bin_data')
.map(s => ({
index: s.index,
language: s.tags?.language || 'und',
title: s.tags?.title || s.tags?.language || `Track ${s.index}`,
codec: s.codec_name
}));
// Determine what processing is needed
// 4. MKV files often cause OOM/decoding issues in browser fMP4 remux,
// so we force them to "needsTranscode" which uses HLS (more robust).
// The frontend will still use "copy" mode if codecs are compatible.
const isMkv = container.includes('matroska') || container.includes('webm') || url.endsWith('.mkv');
// 1. Incompatible audio/video OR MKV -> Transcode (or HLS Copy)
const needsTranscode = !audioOk || !videoOk || isMkv;
// 2. Compatible audio/video but incompatible container (non-MKV) -> Remux (fMP4 pipe)
const needsRemux = !needsTranscode && (!containerOk || isRawTs);
const compatible = !needsTranscode && !needsRemux;
return {
video: videoCodec,
audio: audioCodec,
width: videoStream?.width || 0,
height: videoStream?.height || 0,
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy
container: container,
compatible: compatible,
needsRemux: needsRemux,
needsTranscode: needsTranscode,
subtitles: subtitles
};
}
router.get('/', async (req, res) => {
const { url, ua } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const ffprobePath = req.app.locals.ffprobePath;
const cacheKey = `${url}${ua ? `|${ua}` : ''}`;
if (!ffprobePath) {
// No ffprobe available - assume needs transcoding to be safe
console.log('[Probe] FFprobe not available, assuming transcode needed');
return res.json({
video: 'unknown',
audio: 'unknown',
container: 'unknown',
compatible: false,
needsRemux: false,
needsTranscode: true
});
}
// Check cache
const cached = probeCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`);
return res.json(cached.result);
}
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
// 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;
for (let attempt = 1; attempt <= MAX_PROBE_RETRIES; attempt++) {
try {
const probeResult = await probeStream(url, ffprobePath, ua);
const analysis = analyzeProbeResult(probeResult, url);
// Cache successful result
probeCache.set(cacheKey, { result: analysis, timestamp: Date.now() });
console.log(`[Probe] Result (attempt ${attempt}): video=${analysis.video}, audio=${analysis.audio}, ` +
`container=${analysis.container}, compatible=${analysis.compatible}, ` +
`needsRemux=${analysis.needsRemux}, needsTranscode=${analysis.needsTranscode}`);
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;
+852
View File
@@ -0,0 +1,852 @@
const express = require('express');
const router = express.Router();
const { sources } = require('../db');
const { getDb } = require('../db/sqlite'); // Import SQLite
const xtreamApi = require('../services/xtreamApi');
const epgParser = require('../services/epgParser');
const cache = require('../services/cache');
const path = require('path');
const fs = require('fs');
const http = require('http');
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');
// Las rutas de proxy (categorías, streams, EPG, imágenes, HLS, etc.) son accedidas
// directamente por el reproductor y los navegadores sin header Authorization.
// El control de acceso a fuentes se hace en /api/sources (CRUD).
// Por eso NO aplicamos requireAuth globalmente aquí.
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)) {
// Solo bloqueamos si hay usuario autenticado y no tiene acceso
// (sin token, req.user es undefined → dejamos pasar, el reproductor no lleva Bearer)
if (req.user) {
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;
// Helper to get formatted category list from DB
function getCategoriesFromDb(sourceId, type, includeHidden = false) {
const db = getDb();
let query = `
SELECT category_id, name as category_name, parent_id
FROM categories
WHERE source_id = ? AND type = ?
`;
if (!includeHidden) {
query += ` AND is_hidden = 0`;
}
query += ` ORDER BY name ASC`;
const cats = db.prepare(query).all(sourceId, type);
return cats;
}
// Helper to get formatted streams from DB
function getStreamsFromDb(sourceId, type, categoryId = null, includeHidden = false) {
const db = getDb();
let query = `
SELECT item_id, name, stream_icon, added_at, rating, container_extension, year, category_id, data
FROM playlist_items
WHERE source_id = ? AND type = ?
`;
if (!includeHidden) {
query += ` AND is_hidden = 0`;
}
const params = [sourceId, type];
if (categoryId) {
query += ` AND category_id = ?`;
params.push(categoryId);
}
// Default sorting
// query += ` ORDER BY name ASC`; // Sorting usually handled by client
const items = db.prepare(query).all(...params);
// Map to Xtream format
return items.map(item => {
const data = JSON.parse(item.data || '{}');
// Override with our local fields if needed, or just return the mixed object
// We should ensure critical fields are present
return {
...data,
stream_id: item.item_id, // ensure ID matches what client expects
series_id: type === 'series' ? item.item_id : undefined,
name: item.name,
stream_icon: item.stream_icon,
cover: item.stream_icon, // series/vod often use cover
added: item.added_at,
rating: item.rating,
container_extension: item.container_extension,
category_id: item.category_id,
// Normalize EPG channel ID: Xtream uses epg_channel_id, M3U uses tvgId
epg_channel_id: data.epg_channel_id || data.tvgId || null
};
});
}
// --- Xtream Codes Proxy API --- //
// Login / Authenticate
router.get('/xtream/:sourceId', async (req, res) => {
try {
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
const cached = cache.get('xtream', source.id, 'auth', 300000);
if (cached) return res.json(cached);
const api = xtreamApi.createFromSource(source);
const data = await api.authenticate();
cache.set('xtream', source.id, 'auth', data);
res.json(data);
} catch (err) {
res.status(502).json({ error: 'Upstream error', details: err.message });
}
});
// Live Categories
router.get('/xtream/:sourceId/live_categories', async (req, res) => {
try {
const sourceId = parseInt(req.params.sourceId);
const includeHidden = req.query.includeHidden === 'true';
const cats = getCategoriesFromDb(sourceId, 'live', includeHidden);
res.json(cats);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Live Streams
router.get('/xtream/:sourceId/live_streams', async (req, res) => {
try {
const sourceId = parseInt(req.params.sourceId);
const categoryId = req.query.category_id;
const includeHidden = req.query.includeHidden === 'true';
const streams = getStreamsFromDb(sourceId, 'live', categoryId, includeHidden);
res.json(streams);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// VOD Categories
router.get('/xtream/:sourceId/vod_categories', async (req, res) => {
try {
const sourceId = parseInt(req.params.sourceId);
const includeHidden = req.query.includeHidden === 'true';
const cats = getCategoriesFromDb(sourceId, 'movie', includeHidden);
res.json(cats);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// VOD Streams
router.get('/xtream/:sourceId/vod_streams', async (req, res) => {
try {
const sourceId = parseInt(req.params.sourceId);
const categoryId = req.query.category_id;
const includeHidden = req.query.includeHidden === 'true';
const streams = getStreamsFromDb(sourceId, 'movie', categoryId, includeHidden);
res.json(streams);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Series Categories
router.get('/xtream/:sourceId/series_categories', async (req, res) => {
try {
const sourceId = parseInt(req.params.sourceId);
const includeHidden = req.query.includeHidden === 'true';
const cats = getCategoriesFromDb(sourceId, 'series', includeHidden);
res.json(cats);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Series
router.get('/xtream/:sourceId/series', async (req, res) => {
try {
const sourceId = parseInt(req.params.sourceId);
const categoryId = req.query.category_id;
const includeHidden = req.query.includeHidden === 'true';
const streams = getStreamsFromDb(sourceId, 'series', categoryId, includeHidden);
res.json(streams);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Series Info (Episodes)
// Proxy series info request
router.get('/xtream/:sourceId/series_info', async (req, res) => {
try {
const source = req.grantedSource;
const seriesId = req.query.series_id;
if (!seriesId) return res.status(400).send('series_id required');
const cacheKey = `series_info_${seriesId}`;
const cached = cache.get('xtream', source.id, cacheKey, 3600000);
if (cached) return res.json(cached);
const api = xtreamApi.createFromSource(source);
const data = await api.getSeriesInfo(seriesId);
cache.set('xtream', source.id, cacheKey, data);
res.json(data);
} catch (err) {
res.status(502).json({ error: 'Upstream error', details: err.message });
}
});
// VOD Info
router.get('/xtream/:sourceId/vod_info', async (req, res) => {
try {
const source = req.grantedSource;
const vodId = req.query.vod_id;
if (!vodId) return res.status(400).send('vod_id required');
const cacheKey = `vod_info_${vodId}`;
const cached = cache.get('xtream', source.id, cacheKey, 3600000);
if (cached) return res.json(cached);
const api = xtreamApi.createFromSource(source);
const data = await api.getVodInfo(vodId);
cache.set('xtream', source.id, cacheKey, data);
res.json(data);
} catch (err) {
res.status(502).json({ error: 'Upstream error', details: err.message });
}
});
// Get Stream URL for playback
// Returns the direct stream URL for a given stream ID
router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => {
try {
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 || 'ts';
// Construct the Xtream stream URL
// Format: http://server:port/live/username/password/streamId.container (for live)
// Format: http://server:port/movie/username/password/streamId.container (for movie)
// Format: http://server:port/series/username/password/streamId.container (for series)
let streamUrl;
const baseUrl = source.url.replace(/\/$/, ''); // Remove trailing slash
if (type === 'live') {
streamUrl = `${baseUrl}/live/${source.username}/${source.password}/${streamId}.${container}`;
} else if (type === 'movie') {
streamUrl = `${baseUrl}/movie/${source.username}/${source.password}/${streamId}.${container}`;
} else if (type === 'series') {
streamUrl = `${baseUrl}/series/${source.username}/${source.password}/${streamId}.${container}`;
} else {
return res.status(400).json({ error: 'Invalid stream type' });
}
res.json({ url: streamUrl });
} catch (err) {
console.error('Error getting stream URL:', err);
res.status(500).json({ error: 'Failed to get stream URL' });
}
});
// --- Other Proxy Routes --- //
// M3U Playlist
// (For M3U sources, we now have data in DB. We can reconstruct M3U or return JSON)
// Frontend ChannelList.js for M3U sources calls `API.proxy.m3u.get(sourceId)`
// which points here. It expects { channels, groups }.
router.get('/m3u/:sourceId', async (req, res) => {
try {
const sourceId = parseInt(req.params.sourceId);
const includeHidden = req.query.includeHidden === 'true';
// Fetch from DB
const channels = getStreamsFromDb(sourceId, 'live', null, includeHidden);
const groups = getCategoriesFromDb(sourceId, 'live', includeHidden);
// Format for frontend helper
// ChannelList expects:
// {
// channels: [ { id, name, groupTitle, url, tvgLogo, ... } ],
// groups: [ { id, name, channelCount } ]
// }
// Note: DB `live` items from M3U sync have `category_id` as their group name usually.
const reformattedChannels = channels.map(c => ({
...c,
id: c.stream_id,
groupTitle: c.category_id || 'Uncategorized',
url: c.stream_url || c.url,
tvgLogo: c.stream_icon
}));
const reformattedGroups = groups.map(g => ({
id: g.category_id,
name: g.category_name,
channelCount: 0 // Frontend calculates this or we can
}));
// Add implicit groups check?
// The frontend M3U parser generates groups from the channels if explicit groups missing.
// Our SyncService `saveCategories` handles explicit groups.
res.json({ channels: reformattedChannels, groups: reformattedGroups });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// EPG
router.get('/epg/:sourceId', async (req, res) => {
try {
const sourceId = parseInt(req.params.sourceId);
const db = getDb();
// Time window: 24 hours ago to 24 hours from now
// This prevents returning millions of rows and crashing the server/browser
const windowStart = Date.now() - (24 * 60 * 60 * 1000); // -24 hours
const windowEnd = Date.now() + (24 * 60 * 60 * 1000); // +24 hours
// Fetch programs within the time window
let programsQuery = `
SELECT channel_id as channelId, start_time, end_time, title, description, data
FROM epg_programs
WHERE source_id = ? AND end_time > ? AND start_time < ?
`;
const params = [sourceId, windowStart, windowEnd];
const programs = db.prepare(programsQuery).all(...params);
const formattedPrograms = programs.map(p => ({
channelId: p.channelId,
start: new Date(p.start_time).toISOString(), // EpgGuide parse this back
stop: new Date(p.end_time).toISOString(),
title: p.title,
description: p.description
}));
// Fetch EPG channels from playlist_items (type='epg_channel')
let epgChannels = [];
// Try getting stored channels first
const storedChannels = db.prepare(`
SELECT item_id as id, name, stream_icon as icon, data
FROM playlist_items
WHERE source_id = ? AND type = 'epg_channel'
`).all(sourceId);
if (storedChannels.length > 0) {
epgChannels = storedChannels;
} else {
// Fallback: Build from unique channelIds in programmes (Legacy behavior)
const uniqueChannelIds = [...new Set(programs.map(p => p.channelId))];
epgChannels = uniqueChannelIds.map(id => ({
id: id,
name: id // Use channelId as name (fallback)
}));
}
res.json({
channels: epgChannels,
programmes: formattedPrograms
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Clear cache (kept for compatibility)
router.delete('/cache/:sourceId', (req, res) => {
const sourceId = req.params.sourceId;
cache.clearSource(sourceId);
res.json({ success: true });
});
/**
* Proxy Xtream API calls
* GET /api/proxy/xtream/:sourceId/:action
*/
router.get('/xtream/:sourceId/:action', async (req, res) => {
try {
const sourceId = req.params.sourceId;
const source = req.grantedSource;
if (source.type !== 'xtream') {
return res.status(404).json({ error: 'Xtream source not found' });
}
const { action } = req.params;
const { category_id, stream_id, vod_id, series_id, limit, refresh, maxAge } = req.query;
const forceRefresh = refresh === '1';
const maxAgeHours = parseInt(maxAge) || DEFAULT_MAX_AGE_HOURS;
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
// Actions that should be cached
const cacheableActions = [
'live_categories', 'live_streams',
'vod_categories', 'vod_streams',
'series_categories', 'series'
];
// Build cache key (include category_id if present)
const cacheKey = category_id ? `${action}_${category_id}` : action;
// Check cache for cacheable actions
if (!forceRefresh && cacheableActions.includes(action)) {
const cached = cache.get('xtream', sourceId, cacheKey, maxAgeMs);
if (cached) {
return res.json(cached);
}
}
// Fetch fresh data
const api = xtreamApi.createFromSource(source);
let data;
switch (action) {
case 'auth':
data = await api.authenticate();
break;
case 'live_categories':
data = await api.getLiveCategories();
break;
case 'live_streams':
data = await api.getLiveStreams(category_id);
break;
case 'vod_categories':
data = await api.getVodCategories();
break;
case 'vod_streams':
data = await api.getVodStreams(category_id);
break;
case 'vod_info':
data = await api.getVodInfo(vod_id);
break;
case 'series_categories':
data = await api.getSeriesCategories();
break;
case 'series':
data = await api.getSeries(category_id);
break;
case 'series_info':
data = await api.getSeriesInfo(series_id);
break;
case 'short_epg':
data = await api.getShortEpg(stream_id, limit);
break;
default:
return res.status(400).json({ error: 'Unknown action' });
}
// Cache the result for cacheable actions
if (cacheableActions.includes(action)) {
cache.set('xtream', sourceId, cacheKey, data);
}
res.json(data);
} catch (err) {
console.error('Xtream proxy error:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Get Xtream stream URL
* GET /api/proxy/xtream/:sourceId/stream/:streamId
*/
router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => {
try {
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 = 'ts' } = req.query;
const url = api.buildStreamUrl(streamId, type, container);
res.json({ url });
} catch (err) {
console.error('Stream URL error:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Fetch and parse EPG (with file-based caching)
* GET /api/proxy/epg/:sourceId
* Query params:
* - refresh=1 Force refresh, bypass cache
* - maxAge=N Max cache age in hours (default 24)
*/
router.get('/epg/:sourceId', async (req, res) => {
try {
const sourceId = req.params.sourceId;
const source = req.grantedSource;
if (source.type !== 'epg' && source.type !== 'xtream') {
return res.status(404).json({ error: 'Valid EPG source not found' });
}
const forceRefresh = req.query.refresh === '1';
const maxAgeHours = parseInt(req.query.maxAge) || DEFAULT_MAX_AGE_HOURS;
const maxAgeMs = maxAgeHours * 60 * 60 * 1000;
// Check file cache (unless force refresh)
if (!forceRefresh) {
const cached = cache.get('epg', sourceId, 'data', maxAgeMs);
if (cached) {
return res.json(cached);
}
}
// Fetch fresh data
let url = source.url;
if (source.type === 'xtream') {
const api = xtreamApi.createFromSource(source);
url = api.getXmltvUrl();
}
const data = await epgParser.fetchAndParse(url);
// Store in file cache
cache.set('epg', sourceId, 'data', data);
res.json(data);
} catch (err) {
console.error('EPG proxy error:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Clear cache for a source
* DELETE /api/proxy/cache/:sourceId
*/
router.delete('/cache/:sourceId', (req, res) => {
const sourceId = req.params.sourceId;
cache.clearSource(sourceId);
res.json({ success: true });
});
/**
* Clear EPG cache for a source (legacy endpoint, calls clearSource)
* DELETE /api/proxy/epg/:sourceId/cache
*/
router.delete('/epg/:sourceId/cache', (req, res) => {
const sourceId = req.params.sourceId;
cache.clear('epg', sourceId, 'data');
res.json({ success: true });
});
/**
* Get EPG for specific channels
* POST /api/proxy/epg/:sourceId/channels
*/
router.post('/epg/:sourceId/channels', async (req, res) => {
try {
const source = req.grantedSource;
if (source.type !== 'epg') {
return res.status(404).json({ error: 'EPG source not found' });
}
const { channelIds } = req.body;
if (!channelIds || !Array.isArray(channelIds)) {
return res.status(400).json({ error: 'channelIds array required' });
}
const data = await epgParser.fetchAndParse(source.url);
// Filter programmes for requested channels
const result = {};
for (const channelId of channelIds) {
result[channelId] = epgParser.getCurrentAndUpcoming(data.programmes, channelId);
}
res.json(result);
} catch (err) {
console.error('EPG channels error:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Proxy stream for playback
* This handles CORS for streams that don't allow cross-origin
* Supports HTTP Range requests for video seeking
*/
router.get('/stream', async (req, res) => {
const maxRetries = 2;
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
let { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL required' });
}
// Forward some headers to be more "transparent" back to the origin
// Pluto TV uses multiple domains for content delivery
const plutoDomains = ['pluto.tv', 'pluto.io', 'plutotv.net', 'siloh.pluto.tv', 'service-stitcher'];
const isPluto = plutoDomains.some(domain => url.includes(domain));
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
// Using https and matching the origin of the request
'Origin': isPluto ? 'https://pluto.tv' : new URL(url).origin,
'Referer': isPluto ? 'https://pluto.tv/' : new URL(url).origin + '/'
};
// Forward Range header for video seeking support
const rangeHeader = req.get('range');
if (rangeHeader) {
headers['Range'] = rangeHeader;
}
const response = await fetch(url, { headers });
// Retry on 5xx errors (transient upstream issues)
if (response.status >= 500 && attempt < maxRetries) {
console.log(`[Proxy] Upstream 5xx error (attempt ${attempt}/${maxRetries}), retrying in 500ms...`);
await new Promise(r => setTimeout(r, 500));
continue;
}
if (!response.ok) {
console.error(`Upstream error for ${url.substring(0, 80)}...: ${response.status} ${response.statusText}`);
if (response.status === 403) {
const errorBody = await response.text().catch(() => 'N/A');
console.error(`403 Response body: ${errorBody.substring(0, 200)}`);
}
return res.status(response.status).send(`Failed to fetch stream: ${response.statusText}`);
}
const contentType = response.headers.get('content-type') || '';
res.set('Access-Control-Allow-Origin', '*');
// Forward range-related headers for video seeking support
const contentLength = response.headers.get('content-length');
const contentRange = response.headers.get('content-range');
const acceptRanges = response.headers.get('accept-ranges');
if (contentLength) {
res.set('Content-Length', contentLength);
}
if (contentRange) {
res.set('Content-Range', contentRange);
}
if (acceptRanges) {
res.set('Accept-Ranges', acceptRanges);
} else if (contentLength && !contentRange) {
// If server supports content-length but didn't explicitly state accept-ranges,
// we can safely assume it supports byte ranges
res.set('Accept-Ranges', 'bytes');
}
// Set status code (206 for partial content when range request was made)
res.status(response.status);
// Create an async iterator for the response body
const iterator = response.body[Symbol.asyncIterator]();
const first = await iterator.next();
if (first.done) {
res.set('Content-Type', contentType || 'application/octet-stream');
return res.end();
}
const firstChunk = Buffer.from(first.value);
// Peek at first bytes to check for HLS manifest ({ #EXTM3U })
const textPrefix = firstChunk.subarray(0, 7).toString('utf8');
const contentLooksLikeHls = textPrefix === '#EXTM3U';
if (contentLooksLikeHls) {
// HLS Manifest: We must read the WHOLE manifest to rewrite it
const chunks = [firstChunk];
// Consume the rest of the stream
let result = await iterator.next();
while (!result.done) {
chunks.push(Buffer.from(result.value));
result = await iterator.next();
}
const buffer = Buffer.concat(chunks);
const finalUrl = response.url || url;
console.log(`[Proxy] Processing HLS manifest from: ${finalUrl.substring(0, 80)}...`);
res.set('Content-Type', 'application/vnd.apple.mpegurl');
let manifest = buffer.toString('utf-8');
const finalUrlObj = new URL(finalUrl);
const baseUrl = finalUrlObj.origin + finalUrlObj.pathname.substring(0, finalUrlObj.pathname.lastIndexOf('/') + 1);
manifest = manifest.split('\n').map(line => {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) {
// Handle both URI="..." and URI='...' formats
if (trimmed.includes('URI=')) {
// Replace both double and single quoted URIs
return line.replace(/URI=["']([^"']+)["']/g, (match, p1) => {
try {
const absoluteUrl = new URL(p1, baseUrl).href;
return `URI="${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}"`;
} catch (e) {
return match;
}
});
}
return line;
}
// Stream URL handling
try {
let absoluteUrl;
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
absoluteUrl = trimmed;
} else {
absoluteUrl = new URL(trimmed, baseUrl).href;
}
return `${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}`;
} catch (e) { return line; }
}).join('\n');
return res.send(manifest);
}
// Binary content (Video Segment or Key): Collect and send
console.log(`[Proxy] Serving binary content (${contentType})`);
res.set('Content-Type', contentType || 'application/octet-stream');
// For small files (like encryption keys), collect all data and send at once
// This ensures proper Content-Length and response completion
const chunks = [firstChunk];
let result = await iterator.next();
while (!result.done) {
chunks.push(Buffer.from(result.value));
result = await iterator.next();
}
const fullContent = Buffer.concat(chunks);
// Set Content-Length for proper client handling
res.set('Content-Length', fullContent.length);
res.send(fullContent);
return; // Success - exit the retry loop
} catch (err) {
lastError = err;
console.error(`Stream proxy error (attempt ${attempt}/${maxRetries}):`, err.message);
if (attempt < maxRetries) {
console.log('[Proxy] Retrying after error...');
await new Promise(r => setTimeout(r, 500));
continue;
}
}
}
// All retries failed
if (!res.headersSent) {
res.status(500).json({ error: lastError?.message || 'Stream proxy failed after retries' });
}
});
/**
* Proxy images (channel logos, posters)
* Fixes mixed content errors when loading HTTP images on HTTPS pages
* GET /api/proxy/image?url=...
*/
router.get('/image', async (req, res) => {
try {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL required' });
}
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'image/*,*/*;q=0.8'
}
});
if (!response.ok) {
return res.status(response.status).send('Failed to fetch image');
}
const contentType = response.headers.get('content-type') || 'image/png';
res.set('Content-Type', contentType);
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
// Efficiently pipe the response body
if (response.body) {
// 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) {
console.error('Image proxy error:', err.message);
res.status(500).send('Image proxy error');
}
});
module.exports = router;
+124
View File
@@ -0,0 +1,124 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
const db = require('../db');
/**
* Remux stream (container conversion only)
* GET /api/remux?url=...
*
* Remuxes MPEG-TS to fragmented MP4 for browser playback.
* This is a lightweight operation - no video/audio re-encoding.
* Use this for raw .ts streams that browsers can't play directly.
*
* Note: This does NOT fix Dolby/AC3 audio issues - use /api/transcode for that.
*/
router.get('/', async (req, res) => {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
// Get User-Agent from settings
const settings = await db.settings.get();
const userAgent = db.getUserAgent(settings);
console.log(`[Remux] Starting remux for: ${url}`);
console.log(`[Remux] Using User-Agent: ${settings.userAgentPreset}`);
// FFmpeg arguments for pure remux (no encoding)
// Very lightweight - just changes container from TS to fragmented MP4
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-user_agent', userAgent,
'-user_agent', userAgent,
// Standard probe size to handle complex containers (MKV) correctly
'-probesize', '5000000',
'-analyzeduration', '5000000',
// Error resilience: discard corrupt packets, generate timestamps, ignore DTS, no buffering
'-fflags', '+genpts+discardcorrupt+igndts+nobuffer',
// Ignore errors in stream and continue
'-err_detect', 'ignore_err',
// Limit max demux delay to prevent buffering issues with bad timestamps
'-max_delay', '5000000',
// Reconnect settings for network drops
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
// Prevent Range/HEAD requests that some providers reject with 405
'-seekable', '0',
'-i', url,
// STRICT MAPPING: Only map video and audio, ignore subtitles/data/attachments
// This prevents remux failure when source container has incompatible subtitle tracks (e.g. MKV -> MP4)
'-map', '0:v',
'-map', '0:a',
// Drop subtitles (-sn) and data (-dn) explicitly
'-sn', '-dn',
// Copy streams without re-encoding
'-c', 'copy',
// Ensure extradata is correctly extracted/converted (fixes Annex B -> AVCC issues in Firefox)
'-bsf:v', 'dump_extra',
// NOTE: We intentionally do NOT use -bsf:a aac_adtstoasc here
// That filter only works for AAC audio and breaks AC3/EAC3/MP3.
// If AAC audio from MPEG-TS fails in MP4, use /api/transcode instead.
// Handle timestamp discontinuities at output
'-fps_mode', 'passthrough',
'-max_muxing_queue_size', '1024',
// Fragmented MP4 for streaming (browser-compatible)
'-f', 'mp4',
'-movflags', 'frag_keyframe+empty_moov+default_base_moof',
'-' // Output to stdout
];
console.log(`[Remux] Full command: ${ffmpegPath} ${args.join(' ')}`);
let ffmpeg;
try {
ffmpeg = spawn(ffmpegPath, args);
} catch (spawnErr) {
console.error('[Remux] Failed to spawn FFmpeg:', spawnErr);
return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message });
}
// Set headers for fragmented MP4
res.setHeader('Content-Type', 'video/mp4');
res.setHeader('Access-Control-Allow-Origin', '*');
// Pipe stdout to response
ffmpeg.stdout.pipe(res);
// Log stderr (useful for debugging)
ffmpeg.stderr.on('data', (data) => {
const msg = data.toString();
// Only log warnings/errors, not progress
if (msg.includes('Warning') || msg.includes('Error') || msg.includes('error')) {
console.log(`[Remux FFmpeg] ${msg}`);
}
});
// Cleanup on client disconnect
req.on('close', () => {
console.log('[Remux] Client disconnected, killing FFmpeg process');
ffmpeg.kill('SIGKILL');
});
// Handle process exit
ffmpeg.on('exit', (code) => {
if (code !== null && code !== 0 && code !== 255) {
console.error(`[Remux] FFmpeg exited with code ${code}`);
}
});
// Handle spawn errors
ffmpeg.on('error', (err) => {
console.error('[Remux] Failed to spawn FFmpeg:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Remux failed to start' });
}
});
});
module.exports = router;
+111
View File
@@ -0,0 +1,111 @@
const express = require('express');
const router = express.Router();
const { settings, getDefaultSettings } = require('../db');
const syncService = require('../services/syncService');
/**
* Get all settings
* GET /api/settings
*/
router.get('/', async (req, res) => {
try {
const currentSettings = await settings.get();
res.json(currentSettings);
} catch (err) {
console.error('Error getting settings:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Update settings (partial update)
* PUT /api/settings
*/
router.put('/', async (req, res) => {
try {
const updates = req.body;
const updatedSettings = await settings.update(updates);
// If sync interval changed, restart the server-side sync timer
if (updates.epgRefreshInterval !== undefined) {
syncService.restartSyncTimer().catch(console.error);
}
res.json(updatedSettings);
} catch (err) {
console.error('Error updating settings:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Reset settings to defaults
* DELETE /api/settings
*/
router.delete('/', async (req, res) => {
try {
const defaultSettings = await settings.reset();
res.json(defaultSettings);
} catch (err) {
console.error('Error resetting settings:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Get default settings (for reference)
* GET /api/settings/defaults
*/
router.get('/defaults', (req, res) => {
res.json(getDefaultSettings());
});
/**
* Get sync status (last sync time)
* GET /api/settings/sync-status
*/
router.get('/sync-status', (req, res) => {
const lastSyncTime = syncService.getLastSyncTime();
res.json({
lastSyncTime: lastSyncTime ? lastSyncTime.toISOString() : null
});
});
/**
* Get hardware capabilities (GPU acceleration support)
* GET /api/settings/hw-info
*/
router.get('/hw-info', async (req, res) => {
try {
const hwDetect = require('../services/hwDetect');
let capabilities = hwDetect.getCapabilities();
// If not yet detected, run detection now
if (!capabilities) {
capabilities = await hwDetect.detect();
}
res.json(capabilities);
} catch (err) {
console.error('Error getting hardware info:', err);
res.status(500).json({ error: err.message });
}
});
/**
* Refresh hardware detection (re-probe GPUs)
* POST /api/settings/hw-info/refresh
*/
router.post('/hw-info/refresh', async (req, res) => {
try {
const hwDetect = require('../services/hwDetect');
const capabilities = await hwDetect.refresh();
res.json(capabilities);
} catch (err) {
console.error('Error refreshing hardware info:', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+323
View File
@@ -0,0 +1,323 @@
const express = require('express');
const router = express.Router();
const { sources } = require('../db');
const { getDb } = require('../db/sqlite');
const xtreamApi = require('../services/xtreamApi');
const syncService = require('../services/syncService');
const m3uParser = require('../services/m3uParser');
const { requireAuth, requireAdmin, isAdminRole } = require('../auth');
const { canUserAccessSource, getAccessibleSourceIds } = require('../sourceAccess');
router.use(requireAuth);
function sanitizeSourceList(list) {
return list.map((s) => ({
...s,
password: s.password ? '••••••••' : null
}));
}
function parseOwnerId(body) {
const raw = body.ownerId;
if (raw === undefined || raw === null || raw === '') return null;
const n = parseInt(raw, 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();
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 (filtered for non-admins)
router.get('/status', async (req, res) => {
try {
const db = getDb();
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);
res.status(500).json({ error: 'Failed to get sync status' });
}
});
// Get sources by type
router.get('/type/:type', async (req, res) => {
try {
const typeSources = await sources.getByType(req.params.type);
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 {
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' });
}
res.json(source);
} catch (err) {
console.error('Error getting source:', err);
res.status(500).json({ error: 'Failed to get source' });
}
});
// Create source
router.post('/', async (req, res) => {
try {
const { type, name, url, username, password } = req.body;
if (!type || !name || !url) {
return res.status(400).json({ error: 'Type, name, and URL are required' });
}
if (!['xtream', 'm3u', 'epg'].includes(type)) {
return res.status(400).json({ error: 'Invalid source type' });
}
let ownerId = null;
if (isAdminRole(req.user)) {
ownerId = parseOwnerId(req.body);
} else {
const uid = req.user.id != null ? Number(req.user.id) : NaN;
ownerId = Number.isNaN(uid) ? null : uid;
}
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) {
console.error('Error creating source:', err);
res.status(500).json({ error: 'Failed to create source' });
}
});
// Update source
router.put('/:id', 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 { name, url, username, password } = req.body;
const updates = {
name: name || existing.name,
url: url || existing.url,
username: username !== undefined ? username : existing.username,
password: password !== undefined ? password : existing.password
};
if (isAdminRole(req.user) && 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);
res.status(500).json({ error: 'Failed to update source' });
}
});
// Delete source
router.delete('/:id', async (req, res) => {
try {
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' });
}
const db = getDb();
const deleteCategories = db.prepare('DELETE FROM categories WHERE source_id = ?');
const deleteItems = db.prepare('DELETE FROM playlist_items WHERE source_id = ?');
const deleteEpg = db.prepare('DELETE FROM epg_programs WHERE source_id = ?');
const deleteSyncStatus = db.prepare('DELETE FROM sync_status WHERE source_id = ?');
const catResult = deleteCategories.run(sourceId);
const itemResult = deleteItems.run(sourceId);
const epgResult = deleteEpg.run(sourceId);
deleteSyncStatus.run(sourceId);
console.log(`[Source] Cascade delete for source ${sourceId}: ${catResult.changes} categories, ${itemResult.changes} items, ${epgResult.changes} EPG programs`);
await sources.delete(sourceId);
res.json({ success: true });
} catch (err) {
console.error('Error deleting source:', err);
res.status(500).json({ error: 'Failed to delete source' });
}
});
// 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 (updated.enabled) {
syncService.syncSource(parseInt(req.params.id, 10)).catch(console.error);
}
res.json(updated);
} catch (err) {
console.error('Error toggling source:', err);
res.status(500).json({ error: 'Failed to toggle source' });
}
});
// Manual Sync
router.post('/:id/sync', async (req, res) => {
try {
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' });
}
syncService.syncSource(id).catch(console.error);
res.json({ success: true, message: 'Sync started' });
} catch (err) {
console.error('Error starting sync:', err);
res.status(500).json({ error: 'Failed to start sync' });
}
});
// Test source connection
router.post('/:id/test', 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 === 'xtream') {
const result = await xtreamApi.authenticate(source.url, source.username, source.password);
res.json({ success: true, data: result });
} else if (source.type === 'm3u') {
const response = await fetch(source.url);
const text = await response.text();
const isValid = text.includes('#EXTM3U');
res.json({ success: isValid, message: isValid ? 'Valid M3U playlist' : 'Invalid M3U format' });
} else if (source.type === 'epg') {
const response = await fetch(source.url);
const text = await response.text();
const isValid = text.includes('<tv') || text.includes('<?xml');
res.json({ success: isValid, message: isValid ? 'Valid EPG XML' : 'Invalid EPG format' });
}
} catch (err) {
console.error('Error testing source:', err);
res.json({ success: false, error: err.message });
}
});
module.exports = router;
+58
View File
@@ -0,0 +1,58 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
/**
* Subtitle extraction endpoint
* GET /api/subtitle?url=...&index=...
*
* Extracts a specific subtitle track and converts it to WebVTT on the fly.
*/
router.get('/', (req, res) => {
const { url, index } = req.query;
if (!url || index === undefined) {
return res.status(400).json({ error: 'URL and index parameters are required' });
}
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
// console.log(`[Subtitle] Extracting track ${index} from: ${url}`);
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'-probesize', '5000000',
'-analyzeduration', '5000000',
'-i', url,
'-map', `0:${index}`,
'-c:s', 'webvtt',
'-f', 'webvtt',
'-'
];
const ffmpeg = spawn(ffmpegPath, args);
res.setHeader('Content-Type', 'text/vtt');
res.setHeader('Access-Control-Allow-Origin', '*');
// Pipe stdout to response
ffmpeg.stdout.pipe(res);
ffmpeg.stderr.on('data', (data) => {
// console.error(`[Subtitle FFmpeg] ${data}`);
});
req.on('close', () => {
ffmpeg.kill('SIGKILL');
});
ffmpeg.on('error', (err) => {
console.error('[Subtitle] Failed to spawn FFmpeg:', err);
if (!res.headersSent) {
res.status(500).send('Subtitle extraction failed');
}
});
});
module.exports = router;
+331
View File
@@ -0,0 +1,331 @@
const express = require('express');
const router = express.Router();
const { spawn, execFile } = require('child_process');
const path = require('path');
const fs = require('fs').promises;
const db = require('../db');
const transcodeSession = require('../services/transcodeSession');
/**
* Probe a stream URL with FFprobe to get its total duration in seconds.
* Times out quickly (8 s) so it doesn't block session creation for long.
*/
function probeDuration(url, ffprobePath, userAgent) {
return new Promise((resolve) => {
const args = [
'-v', 'quiet',
'-print_format', 'json',
'-show_entries', 'format=duration',
'-user_agent', userAgent || 'Mozilla/5.0',
'-probesize', '3000000',
'-analyzeduration', '2000000',
url
];
const proc = execFile(ffprobePath || 'ffprobe', args, { timeout: 8000 }, (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
*
* Direct streaming (backward compatible):
* GET /api/transcode?url=...
*
* HLS session-based (new, supports seeking):
* POST /api/transcode/session - Create new session
* GET /api/transcode/:id/stream.m3u8 - Get HLS playlist
* GET /api/transcode/:id/:segment.ts - Get segment file
* DELETE /api/transcode/:id - Stop and cleanup session
* GET /api/transcode/sessions - List all sessions (debug)
*/
// Start session cleanup interval
transcodeSession.startCleanupInterval();
/**
* Create a new transcode session
* POST /api/transcode/session
* Body: { url: string, seekOffset?: number }
*/
router.post('/session', async (req, res) => {
const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
const settings = await db.settings.get();
const userAgent = db.getUserAgent(settings);
try {
const ffprobePath = (ffmpegPath || 'ffmpeg').replace(/ffmpeg$/, 'ffprobe');
// 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);
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,
durationSec: durationSec || 0
});
} catch (err) {
console.error('[Transcode] Session creation failed:', err);
res.status(500).json({ error: 'Failed to create session', details: err.message });
}
});
/**
* Get HLS playlist for a session
* GET /api/transcode/:sessionId/stream.m3u8
*/
router.get('/:sessionId/stream.m3u8', async (req, res) => {
const { sessionId } = req.params;
const session = transcodeSession.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
const playlist = await session.getPlaylist();
if (!playlist) {
return res.status(404).json({ error: 'Playlist not ready' });
}
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Cache-Control', 'no-cache');
res.send(playlist);
});
/**
* Get a segment file for a session
* GET /api/transcode/:sessionId/:segment.ts
*/
router.get('/:sessionId/:segment', async (req, res) => {
const { sessionId, segment } = req.params;
// Only handle .ts files
if (!segment.endsWith('.ts')) {
return res.status(404).json({ error: 'Invalid segment' });
}
const session = transcodeSession.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
const segmentPath = await session.getSegment(segment);
if (!segmentPath) {
return res.status(404).json({ error: 'Segment not found' });
}
res.setHeader('Content-Type', 'video/MP2T');
res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache forever (immutable)
res.sendFile(segmentPath);
});
/**
* Stop and cleanup a session
* DELETE /api/transcode/:sessionId
*/
router.delete('/:sessionId', async (req, res) => {
const { sessionId } = req.params;
try {
await transcodeSession.removeSession(sessionId);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to remove session', details: err.message });
}
});
/**
* 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
*/
router.get('/sessions', (req, res) => {
res.json(transcodeSession.getAllSessions());
});
/**
* Direct transcode stream (backward compatible, no seeking)
* GET /api/transcode?url=...
*
* Transcodes audio to AAC for browser compatibility while passing video through.
* This fixes playback issues with Dolby/AC3/EAC3 audio that browsers can't decode.
*/
router.get('/', async (req, res) => {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
// Get User-Agent from settings
const settings = await db.settings.get();
const userAgent = db.getUserAgent(settings);
console.log(`[Transcode] Starting transcoding for: ${url}`);
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
// 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.)
const args = [
'-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)
// Error resilience: generate timestamps, discard corrupt packets
'-fflags', '+genpts+discardcorrupt+nobuffer',
// Ignore errors in stream and continue
'-err_detect', 'ignore_err',
// Limit max demux delay to prevent buffering issues
'-max_delay', '2000000',
// Reconnect settings for network drops (useful for live streams)
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '3',
// Prevent Range/HEAD requests that some providers reject with 405
'-seekable', '0',
'-i', url,
// 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
// Video: passthrough (no re-encoding = fast!)
'-c:v', 'copy',
// Audio: Transcode to browser-compatible AAC
'-c:a', 'aac',
'-ar', '48000',
'-b:a', '192k',
// Handle async audio/video using async filter
'-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0',
// Timestamp handling
'-fps_mode', 'passthrough',
'-async', '1',
'-max_muxing_queue_size', '2048',
// Fragmented MP4 for streaming (browser-compatible)
'-f', 'mp4',
'-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(' ')}`);
let ffmpeg;
try {
ffmpeg = spawn(ffmpegPath, args);
} catch (spawnErr) {
console.error('[Transcode] Failed to spawn FFmpeg:', spawnErr);
return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message });
}
// Collect stderr for error reporting
let stderrBuffer = '';
// Set headers for fragmented MP4
res.setHeader('Content-Type', 'video/mp4');
res.setHeader('Access-Control-Allow-Origin', '*');
// Pipe stdout to response
ffmpeg.stdout.pipe(res);
// Log stderr (useful for debugging transcoding failures)
ffmpeg.stderr.on('data', (data) => {
const msg = data.toString();
stderrBuffer += msg;
console.log(`[FFmpeg] ${msg}`);
});
// Cleanup on client disconnect
req.on('close', () => {
console.log('[Transcode] Client disconnected, killing FFmpeg process');
ffmpeg.kill('SIGKILL');
});
// Handle process exit
ffmpeg.on('exit', (code) => {
if (code !== null && code !== 0 && code !== 255) { // 255 is often returned on kill
console.error(`[Transcode] FFmpeg exited with code ${code}`);
}
});
// Handle spawn errors
ffmpeg.on('error', (err) => {
console.error('[Transcode] Failed to spawn FFmpeg:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Transcoding failed to start' });
}
});
});
module.exports = router;
+186
View File
@@ -0,0 +1,186 @@
const express = require('express');
const router = express.Router();
const db = require('../db');
const auth = require('../auth');
/**
* Get all users (admin only)
* GET /api/users
*/
router.get('/', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const data = await db.loadDb();
const users = (data.users || []).map(u => ({
id: u.id,
username: u.username,
role: u.role,
createdAt: u.createdAt
}));
res.json(users);
} catch (err) {
res.status(500).json({ error: 'Server error' });
}
});
/**
* Create user (admin only)
* POST /api/users
*/
router.post('/', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const { username, password, role } = req.body;
if (!username || !password || !role) {
return res.status(400).json({ error: 'Username, password, and role required' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
if (!['admin', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Role must be admin or viewer' });
}
const data = await db.loadDb();
// Check if username exists
if (data.users?.some(u => u.username === username)) {
return res.status(400).json({ error: 'Username already exists' });
}
// Create user
const passwordHash = await auth.hashPassword(password);
const newUser = {
id: data.nextUserId || (data.users?.length || 0) + 1,
username,
passwordHash,
role,
createdAt: new Date().toISOString()
};
data.users = data.users || [];
data.users.push(newUser);
data.nextUserId = newUser.id + 1;
await db.saveDb(data);
res.json({
id: newUser.id,
username: newUser.username,
role: newUser.role,
createdAt: newUser.createdAt
});
} catch (err) {
console.error('Create user error:', err);
res.status(500).json({ error: 'Server error' });
}
});
/**
* Update user (admin only)
* PUT /api/users/:id
*/
router.put('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const userId = parseInt(req.params.id);
const { username, password, role } = req.body;
const data = await db.loadDb();
const userIndex = data.users?.findIndex(u => u.id === userId);
if (userIndex === -1 || userIndex === undefined) {
return res.status(404).json({ error: 'User not found' });
}
const user = data.users[userIndex];
// Update username if provided
if (username && username !== user.username) {
// Check if new username exists
if (data.users.some(u => u.username === username && u.id !== userId)) {
return res.status(400).json({ error: 'Username already exists' });
}
user.username = username;
}
// Update password if provided
if (password) {
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
user.passwordHash = await auth.hashPassword(password);
}
// Update role if provided
if (role) {
if (!['admin', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Role must be admin or viewer' });
}
// Prevent removing last admin
if (user.role === 'admin' && role !== 'admin') {
const adminCount = data.users.filter(u => u.role === 'admin').length;
if (adminCount <= 1) {
return res.status(400).json({ error: 'Cannot remove last admin user' });
}
}
user.role = role;
}
await db.saveDb(data);
res.json({
id: user.id,
username: user.username,
role: user.role,
createdAt: user.createdAt
});
} catch (err) {
console.error('Update user error:', err);
res.status(500).json({ error: 'Server error' });
}
});
/**
* Delete user (admin only)
* DELETE /api/users/:id
*/
router.delete('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
try {
const userId = parseInt(req.params.id);
const data = await db.loadDb();
const userIndex = data.users?.findIndex(u => u.id === userId);
if (userIndex === -1 || userIndex === undefined) {
return res.status(404).json({ error: 'User not found' });
}
const user = data.users[userIndex];
// Prevent deleting yourself
if (user.id === req.session.userId) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
// Prevent deleting last admin
if (user.role === 'admin') {
const adminCount = data.users.filter(u => u.role === 'admin').length;
if (adminCount <= 1) {
return res.status(400).json({ error: 'Cannot delete last admin user' });
}
}
data.users.splice(userIndex, 1);
await db.saveDb(data);
res.json({ success: true });
} catch (err) {
console.error('Delete user error:', err);
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;
+151
View File
@@ -0,0 +1,151 @@
/**
* File-based Cache Service
* Stores cached data as JSON files in data/cache/
*/
const fs = require('fs');
const path = require('path');
// Cache directory
const cacheDir = path.join(__dirname, '..', '..', 'data', 'cache');
// Ensure cache directories exist
function ensureCacheDir(type, sourceId) {
const dir = path.join(cacheDir, type, String(sourceId));
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
}
// Get cache file path
function getCachePath(type, sourceId, key) {
const dir = ensureCacheDir(type, sourceId);
// Sanitize key for filename
const safeKey = String(key || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
return path.join(dir, `${safeKey}.json`);
}
/**
* Get cached data if not expired
* @param {string} type - Cache type (epg, m3u, xtream)
* @param {number|string} sourceId - Source ID
* @param {string} key - Cache key (e.g., action name)
* @param {number} maxAgeMs - Maximum age in milliseconds
* @returns {any|null} - Cached data or null if expired/missing
*/
function get(type, sourceId, key, maxAgeMs) {
try {
const cachePath = getCachePath(type, sourceId, key);
if (!fs.existsSync(cachePath)) {
return null;
}
const cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
const age = Date.now() - cached.timestamp;
if (age > maxAgeMs) {
return null; // Expired
}
return cached.data;
} catch (err) {
console.warn(`Cache read error for ${type}/${sourceId}/${key}:`, err.message);
return null;
}
}
/**
* Store data in cache
* @param {string} type - Cache type
* @param {number|string} sourceId - Source ID
* @param {string} key - Cache key
* @param {any} data - Data to cache
*/
function set(type, sourceId, key, data) {
try {
const cachePath = getCachePath(type, sourceId, key);
const cached = {
timestamp: Date.now(),
data: data
};
fs.writeFileSync(cachePath, JSON.stringify(cached));
} catch (err) {
console.error(`Cache write error for ${type}/${sourceId}/${key}:`, err.message);
}
}
/**
* Clear specific cache entry
*/
function clear(type, sourceId, key) {
try {
const cachePath = getCachePath(type, sourceId, key);
if (fs.existsSync(cachePath)) {
fs.unlinkSync(cachePath);
}
} catch (err) {
console.warn(`Cache clear error:`, err.message);
}
}
/**
* Clear all cache for a source
*/
function clearSource(sourceId) {
try {
const types = ['epg', 'm3u', 'xtream'];
for (const type of types) {
const dir = path.join(cacheDir, type, String(sourceId));
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true });
}
}
} catch (err) {
console.warn(`Cache clear source error:`, err.message);
}
}
/**
* Clear all cache
*/
function clearAll() {
try {
if (fs.existsSync(cacheDir)) {
fs.rmSync(cacheDir, { recursive: true });
}
} catch (err) {
console.warn(`Cache clear all error:`, err.message);
}
}
/**
* Get cache info for debugging
*/
function getInfo(type, sourceId, key) {
try {
const cachePath = getCachePath(type, sourceId, key);
if (!fs.existsSync(cachePath)) {
return null;
}
const cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
const stats = fs.statSync(cachePath);
return {
timestamp: cached.timestamp,
age: Date.now() - cached.timestamp,
size: stats.size
};
} catch (err) {
return null;
}
}
module.exports = {
get,
set,
clear,
clearSource,
clearAll,
getInfo
};
+448
View File
@@ -0,0 +1,448 @@
/**
* EPG (XMLTV) Parser (Streaming)
* Parses XMLTV format EPG data and extracts channel/programme information using streaming XML parser
*/
const sax = require('sax');
const zlib = require('zlib');
const { Readable } = require('stream');
/**
* Parse XMLTV date format (YYYYMMDDHHmmss +ZZZZ)
* @param {string} dateStr - XMLTV format date string
* @returns {Date}
*/
function parseXmltvDate(dateStr) {
if (!dateStr) return null;
// Format: 20231225120000 +0000
const match = dateStr.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?$/);
if (!match) {
// Try ISO format fallback
return new Date(dateStr);
}
const [, year, month, day, hour, minute, second, tz] = match;
let isoStr = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
if (tz) {
const tzHours = tz.substring(0, 3);
const tzMins = tz.substring(3);
isoStr += `${tzHours}:${tzMins}`;
} else {
isoStr += 'Z';
}
return new Date(isoStr);
}
/**
* Parse XMLTV content (Stream or String)
* @param {Readable|string} input - XMLTV content as Stream or String
* @returns {Promise<{ channels: Array, programmes: Array }>}
*/
function parse(input) {
return new Promise((resolve, reject) => {
const channels = [];
const programmes = [];
const saxStream = sax.createStream(true, { trim: true, normalize: true }); // strict mode
let currentTag = null;
let currentObject = null;
let textBuffer = '';
saxStream.on('error', function (e) {
// clear the error
this._parser.error = null;
this._parser.resume();
console.warn('XML Parse Warning:', e.message);
});
saxStream.on('opentag', function (node) {
currentTag = node.name;
const attr = node.attributes;
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) {
textBuffer += text;
});
saxStream.on('cdata', function (text) {
textBuffer += text;
});
saxStream.on('closetag', function (tagName) {
if (tagName === 'channel') {
if (currentObject) channels.push(currentObject);
currentObject = null;
} else if (tagName === 'programme') {
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) 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;
}
}
});
saxStream.on('end', function () {
resolve({ channels, programmes });
});
// Handle input type
if (typeof input === 'string') {
const inputStream = Readable.from([input]);
inputStream.pipe(saxStream);
} else {
input.pipe(saxStream);
}
});
}
/**
* Get programmes for a specific channel
*/
function getProgrammesForChannel(programmes, channelId) {
return programmes.filter(p => p.channelId === channelId);
}
/**
* Get current and upcoming programmes for a channel
*/
function getCurrentAndUpcoming(programmes, channelId, count = 5) {
const now = new Date();
const channelProgrammes = getProgrammesForChannel(programmes, channelId);
// Sort by start time
channelProgrammes.sort((a, b) => a.start - b.start);
// Find current and upcoming
const current = channelProgrammes.find(p => p.start <= now && p.stop > now);
const upcoming = channelProgrammes
.filter(p => p.start > now)
.slice(0, count);
return { current, upcoming };
}
/**
* Fetch and parse XMLTV from URL
*/
async function fetchAndParse(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch EPG: ${response.status} ${response.statusText}`);
}
let stream;
if (response.body && typeof response.body.pipe === 'function') {
stream = response.body;
} else if (response.body) {
stream = Readable.fromWeb(response.body);
} else {
stream = Readable.from([]);
}
// 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);
}
/**
* Streaming EPG parser that yields batches of programmes (memory-efficient)
* Channels are collected and returned with the first batch, then programmes are yielded in batches.
*
* @param {string} url - XMLTV URL
* @param {number} batchSize - Number of programmes per batch (default: 1000)
* @yields {{ channels: Array|null, programmes: Array, isLast: boolean }}
*/
async function* fetchAndParseStreaming(url, batchSize = 1000) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch EPG: ${response.status} ${response.statusText}`);
}
let stream;
if (response.body && typeof response.body.pipe === 'function') {
stream = response.body;
} else if (response.body) {
stream = Readable.fromWeb(response.body);
} else {
stream = Readable.from([]);
}
const isGzipped = url.endsWith('.gz') || (response.headers.get('content-type') || '').includes('gzip');
if (isGzipped) {
const gunzip = zlib.createGunzip();
stream.pipe(gunzip);
stream = gunzip;
}
// Use async iterator pattern with SAX
yield* parseStreaming(stream, batchSize);
}
/**
* Parse XMLTV as streaming async generator
* @param {Readable} input - XMLTV stream
* @param {number} batchSize - Number of programmes per batch
* @yields {{ channels: Array|null, programmes: Array, isLast: boolean }}
*/
async function* parseStreaming(input, batchSize = 1000) {
const channels = [];
let programmeBatch = [];
let channelsYielded = false;
// We need to convert SAX events to an async iterator
// This requires collecting events and yielding when batch is full
const saxStream = sax.createStream(true, { trim: true, normalize: true });
let currentTag = null;
let currentObject = null;
let textBuffer = '';
let resolveNext = null;
let pendingBatch = null;
let ended = false;
let error = null;
saxStream.on('error', function (e) {
this._parser.error = null;
this._parser.resume();
console.warn('XML Parse Warning:', e.message);
});
saxStream.on('opentag', function (node) {
currentTag = node.name;
const attr = node.attributes;
if (currentTag === 'channel') {
currentObject = {
id: attr.id,
name: null,
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) {
textBuffer += text;
});
saxStream.on('cdata', function (text) {
textBuffer += text;
});
saxStream.on('closetag', function (tagName) {
if (tagName === 'channel') {
if (currentObject) channels.push(currentObject);
currentObject = null;
} else if (tagName === 'programme') {
if (currentObject) {
programmeBatch.push(currentObject);
// Check if we should yield a batch
if (programmeBatch.length >= batchSize) {
const batch = {
channels: !channelsYielded ? channels : null,
programmes: programmeBatch,
isLast: false
};
channelsYielded = true;
programmeBatch = [];
if (resolveNext) {
resolveNext(batch);
resolveNext = null;
} else {
pendingBatch = batch;
}
}
}
currentObject = null;
} else if (currentObject) {
switch (tagName) {
case 'display-name':
if (!currentObject.name) currentObject.name = textBuffer;
break;
case '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) currentObject.category.push(textBuffer);
break;
case 'date':
currentObject.date = textBuffer;
break;
case 'episode-num':
currentObject.episodeNum = textBuffer;
break;
}
}
});
saxStream.on('end', function () {
ended = true;
// Yield final batch
const batch = {
channels: !channelsYielded ? channels : null,
programmes: programmeBatch,
isLast: true
};
if (resolveNext) {
resolveNext(batch);
resolveNext = null;
} else {
pendingBatch = batch;
}
});
saxStream.on('error', function (e) {
error = e;
if (resolveNext) {
resolveNext(null);
}
});
// Start piping
input.pipe(saxStream);
// Yield batches as they become available
while (!ended || pendingBatch) {
if (pendingBatch) {
const batch = pendingBatch;
pendingBatch = null;
yield batch;
if (batch.isLast) break;
} else if (!ended) {
// Wait for next batch
const batch = await new Promise(resolve => {
resolveNext = resolve;
});
if (batch) {
yield batch;
if (batch.isLast) break;
}
}
}
if (error) {
throw error;
}
}
module.exports = {
parse,
parseXmltvDate,
fetchAndParse,
fetchAndParseStreaming,
parseStreaming,
getProgrammesForChannel,
getCurrentAndUpcoming
};
+283
View File
@@ -0,0 +1,283 @@
/**
* Hardware Detection Service
*
* Detects available hardware acceleration capabilities:
* - NVIDIA GPU (NVENC/NVDEC via nvidia-smi)
* - VAAPI (Linux integrated GPU acceleration)
* - QuickSync (Intel GPU acceleration)
*
* Results are cached at startup and exposed via API.
*/
const { execSync, exec } = require('child_process');
const os = require('os');
// Cache detection results
let hwCapabilities = null;
/**
* NVIDIA GPU compute capability requirements for codec support
* Source: https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new
*/
const NVDEC_MIN_COMPUTE = {
h264: 3.0, // Kepler+
hevc: 5.0, // Maxwell GM206+
av1: 8.0, // Ada Lovelace+
};
/**
* Detect NVIDIA GPU and its capabilities
*/
async function detectNvidia() {
try {
// Query GPU info via nvidia-smi
const result = execSync(
'nvidia-smi --query-gpu=name,compute_cap --format=csv,noheader',
{ timeout: 5000, encoding: 'utf-8', windowsHide: true }
);
const lines = result.trim().split('\n');
if (lines.length === 0 || !lines[0]) {
return { available: false };
}
// Parse "NVIDIA GeForce RTX 3080, 8.6"
const parts = lines[0].split(',');
if (parts.length < 2) {
return { available: false };
}
const gpuName = parts[0].trim();
const computeCap = parseFloat(parts[1].trim());
// Determine supported codecs based on compute capability
const supportedCodecs = [];
for (const [codec, minCap] of Object.entries(NVDEC_MIN_COMPUTE)) {
if (computeCap >= minCap) {
supportedCodecs.push(codec);
}
}
console.log(`[HwDetect] NVIDIA GPU detected: ${gpuName} (compute ${computeCap})`);
console.log(`[HwDetect] NVDEC supported codecs: ${supportedCodecs.join(', ')}`);
return {
available: true,
name: gpuName,
computeCap,
supportedCodecs,
encoder: 'h264_nvenc',
decoder: 'h264_cuvid'
};
} catch (err) {
// nvidia-smi not found or no GPU
console.log('[HwDetect] No NVIDIA GPU detected');
return { available: false };
}
}
/**
* Detect VAAPI support (Linux only)
* Checks for /dev/dri/renderD* devices
*/
async function detectVAAPI() {
if (os.platform() !== 'linux') {
return { available: false, reason: 'VAAPI is Linux-only' };
}
try {
// Check for render nodes
const result = execSync('ls /dev/dri/renderD* 2>/dev/null', {
timeout: 2000,
encoding: 'utf-8',
shell: true
});
const devices = result.trim().split('\n').filter(d => d);
if (devices.length === 0) {
return { available: false, reason: 'No render devices found' };
}
// Use first available device
const device = devices[0];
console.log(`[HwDetect] VAAPI device found: ${device}`);
return {
available: true,
device,
encoder: 'h264_vaapi',
decoder: 'vaapi'
};
} catch (err) {
console.log('[HwDetect] VAAPI not available');
return { available: false };
}
}
/**
* Detect Intel QuickSync support
* Checks if FFmpeg can use QSV
*/
async function detectQuickSync() {
try {
// Check if Intel GPU exists
let hasIntelGpu = false;
if (os.platform() === 'win32') {
// Windows: Check via WMIC
const result = execSync(
'wmic path win32_VideoController get name',
{ timeout: 5000, encoding: 'utf-8', windowsHide: true }
);
hasIntelGpu = result.toLowerCase().includes('intel');
} else if (os.platform() === 'linux') {
// Linux: Check lspci
try {
const result = execSync('lspci | grep -i "vga\\|display" | grep -i intel', {
timeout: 5000,
encoding: 'utf-8',
shell: true
});
hasIntelGpu = result.trim().length > 0;
} catch {
hasIntelGpu = false;
}
}
if (!hasIntelGpu) {
return { available: false, reason: 'No Intel GPU found' };
}
console.log('[HwDetect] Intel GPU detected, QSV may be available');
return {
available: true,
encoder: 'h264_qsv',
decoder: 'h264_qsv'
};
} catch (err) {
console.log('[HwDetect] QuickSync not available');
return { available: false };
}
}
/**
* Detect AMD AMF support (Windows only)
* Linux AMD uses VAAPI (detected separately)
*/
async function detectAMF() {
if (os.platform() !== 'win32') {
// On Linux, AMD GPUs use VAAPI which is detected separately
return { available: false, reason: 'AMF is Windows-only (Linux uses VAAPI)' };
}
try {
// Windows: Check via WMIC for AMD/Radeon
const result = execSync(
'wmic path win32_VideoController get name',
{ timeout: 5000, encoding: 'utf-8', windowsHide: true }
);
const lowerResult = result.toLowerCase();
const hasAmdGpu = lowerResult.includes('amd') || lowerResult.includes('radeon');
if (!hasAmdGpu) {
return { available: false, reason: 'No AMD GPU found' };
}
// Extract GPU name for display
const lines = result.trim().split('\n').filter(l => l.trim());
let gpuName = 'AMD GPU';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.toLowerCase().includes('amd') || trimmed.toLowerCase().includes('radeon')) {
gpuName = trimmed;
break;
}
}
console.log(`[HwDetect] AMD GPU detected: ${gpuName}`);
return {
available: true,
name: gpuName,
encoder: 'h264_amf',
decoder: 'h264' // AMF has limited decode support, often uses software
};
} catch (err) {
console.log('[HwDetect] AMF not available');
return { available: false };
}
}
/**
* Detect all hardware capabilities
* Results are cached for performance
*/
async function detect() {
if (hwCapabilities !== null) {
return hwCapabilities;
}
console.log('[HwDetect] Probing hardware acceleration capabilities...');
const [nvidia, vaapi, qsv, amf] = await Promise.all([
detectNvidia(),
detectVAAPI(),
detectQuickSync(),
detectAMF()
]);
// Determine recommended encoder
// On Linux: prefer VAAPI over QSV — VAAPI is more reliable in LXC containers
// (QSV may fail to initialize the MFX device even when /dev/dri is passed through)
let recommended = 'software';
if (nvidia.available) {
recommended = 'nvenc';
} else if (amf.available) {
recommended = 'amf';
} else if (vaapi.available) {
recommended = 'vaapi';
} else if (qsv.available) {
recommended = 'qsv';
}
hwCapabilities = {
nvidia,
amf,
vaapi,
qsv,
recommended,
platform: os.platform(),
detectedAt: new Date().toISOString()
};
console.log(`[HwDetect] Recommended encoder: ${recommended}`);
return hwCapabilities;
}
/**
* Get cached capabilities (or detect if not cached)
*/
function getCapabilities() {
return hwCapabilities;
}
/**
* Force re-detection (clears cache)
*/
async function refresh() {
hwCapabilities = null;
return detect();
}
module.exports = {
detect,
getCapabilities,
refresh,
detectNvidia,
detectAMF,
detectVAAPI,
detectQuickSync
};
+316
View File
@@ -0,0 +1,316 @@
/**
* M3U Playlist Parser (Streaming)
* 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
* @param {string} name - Channel name
* @param {string} group - Group title
* @returns {string} Stable ID
*/
function generateStableId(name, group) {
const str = `${name || 'unknown'}:${group || 'unknown'}`;
// Simple hash function
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return `m3u_${Math.abs(hash).toString(36)}`;
}
/**
* Parse EXTINF line and extract attributes
* @param {string} line - EXTINF line
* @returns {Object} Parsed channel info
*/
function parseExtinf(line) {
const info = {
duration: -1,
tvgId: null,
tvgName: null,
tvgLogo: null,
groupTitle: null,
name: null
};
// Extract duration and rest
const match = line.match(/#EXTINF:(-?\d+\.?\d*)\s*(.*)/);
if (!match) return info;
info.duration = parseFloat(match[1]);
const rest = match[2];
// Extract attributes using regex
const attrPatterns = {
tvgId: /tvg-id="([^"]*)"/i,
tvgName: /tvg-name="([^"]*)"/i,
tvgLogo: /tvg-logo="([^"]*)"/i,
groupTitle: /group-title="([^"]*)"/i
};
for (const [key, pattern] of Object.entries(attrPatterns)) {
const attrMatch = rest.match(pattern);
if (attrMatch) {
info[key] = attrMatch[1];
}
}
// Extract channel name (after the comma)
const commaIndex = rest.lastIndexOf(',');
if (commaIndex !== -1) {
info.name = rest.substring(commaIndex + 1).trim();
} else {
// Fallback: use tvg-name or the whole rest
info.name = info.tvgName || rest.trim();
}
// Generate ID if not present
if (!info.tvgId) {
info.tvgId = info.name ? info.name.toLowerCase().replace(/\s+/g, '_') : `channel_${Date.now()}`;
}
return info;
}
/**
* 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 lines;
if (typeof input === 'string') {
// Handle string input directly
lines = input.split(/\r?\n/);
} else {
// Handle stream input
const rl = readline.createInterface({
input: input,
crlfDelay: Infinity
});
lines = rl;
}
for await (const line of lines) {
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
* @param {string} url - M3U playlist URL
* @returns {Promise<{ channels: Array, groups: Array }>}
*/
async function fetchAndParse(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch M3U: ${response.status} ${response.statusText}`);
}
// 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);
}
/**
* Parse M3U content as a streaming async generator (memory-efficient)
* Yields batches of channels to avoid loading entire playlist into memory.
*
* @param {Readable|string} input - M3U content as Stream or String
* @param {number} batchSize - Number of channels per batch (default: 500)
* @yields {{ channels: Array, groups: Set, isLast: boolean }}
*/
async function* parseStreaming(input, batchSize = 500) {
const groupsSet = new Set();
let currentInfo = null;
let currentGroup = null;
let batch = [];
let lines;
if (typeof input === 'string') {
lines = input.split(/\r?\n/);
} else {
const rl = readline.createInterface({
input: input,
crlfDelay: Infinity
});
lines = rl;
}
for await (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('#EXTINF:')) {
currentInfo = parseExtinf(trimmed);
if (currentInfo.groupTitle) {
groupsSet.add(currentInfo.groupTitle);
currentGroup = currentInfo.groupTitle;
}
} else if (trimmed.startsWith('#EXTGRP:')) {
currentGroup = trimmed.substring(8).trim();
groupsSet.add(currentGroup);
if (currentInfo) {
currentInfo.groupTitle = currentGroup;
}
} else if (!trimmed.startsWith('#')) {
if (currentInfo) {
const groupTitle = currentInfo.groupTitle || currentGroup || 'Uncategorized';
const stableId = currentInfo.tvgId || generateStableId(currentInfo.name, groupTitle);
batch.push({
...currentInfo,
id: stableId,
url: trimmed,
groupTitle: groupTitle
});
currentInfo = null;
// Yield batch when full
if (batch.length >= batchSize) {
yield { channels: batch, groups: groupsSet, isLast: false };
batch = [];
}
}
}
}
// Yield remaining channels
if (batch.length > 0) {
yield { channels: batch, groups: groupsSet, isLast: true };
} else {
// Yield empty final batch with isLast=true so caller knows we're done
yield { channels: [], groups: groupsSet, isLast: true };
}
}
/**
* Fetch and parse M3U from URL as streaming async generator (memory-efficient)
* @param {string} url - M3U playlist URL
* @param {number} batchSize - Number of channels per batch
* @yields {{ channels: Array, groups: Set, isLast: boolean }}
*/
async function* fetchAndParseStreaming(url, batchSize = 500) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch M3U: ${response.status} ${response.statusText}`);
}
let stream;
if (response.body && typeof response.body.pipe === 'function') {
stream = response.body;
} else if (response.body) {
stream = Readable.fromWeb(response.body);
} else {
stream = Readable.from([]);
}
yield* parseStreaming(stream, batchSize);
}
/**
* Fast count of entries in an M3U playlist (for size estimation)
* Streams the file and counts #EXTINF lines without full parsing
* @param {string} url - URL of the M3U playlist
* @returns {Promise<number>} Number of entries
*/
async function countEntries(url) {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch playlist: ${response.status}`);
}
let stream;
if (response.body && typeof response.body.pipe === 'function') {
stream = response.body;
} else if (response.body) {
stream = Readable.fromWeb(response.body);
} else {
return 0;
}
return new Promise((resolve, reject) => {
let count = 0;
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
rl.on('line', (line) => {
if (line.startsWith('#EXTINF:')) {
count++;
}
});
rl.on('close', () => resolve(count));
rl.on('error', reject);
});
}
module.exports = { parse, parseExtinf, fetchAndParse, parseStreaming, fetchAndParseStreaming, countEntries };
+133
View File
@@ -0,0 +1,133 @@
/**
* M3U Xtream Adapter
*
* Makes M3U sources respond to Xtream-style API methods.
* Queries data from SQLite (already synced during source refresh).
*/
const { getDb } = require('../db/sqlite');
class M3uXtreamAdapter {
constructor(sourceId) {
this.sourceId = sourceId;
}
/**
* Get live categories (groups) for this M3U source.
* Returns Xtream-compatible format: [{ category_id, category_name, parent_id }]
*/
getLiveCategories(includeHidden = false) {
const db = getDb();
// M3U stores group name in category_id field of playlist_items
// We need to aggregate unique groups with counts
let query = `
SELECT
category_id,
category_id as category_name,
NULL as parent_id,
COUNT(*) as channel_count
FROM playlist_items
WHERE source_id = ? AND type = 'live'
${!includeHidden ? 'AND is_hidden = 0' : ''}
GROUP BY category_id
ORDER BY category_id ASC
`;
const rows = db.prepare(query).all(this.sourceId);
return rows.map(row => ({
category_id: row.category_id || 'Uncategorized',
category_name: row.category_id || 'Uncategorized',
parent_id: null,
// Bonus: include count for lazy-loading UI
channel_count: row.channel_count
}));
}
/**
* Get live streams (channels), optionally filtered by category.
* Returns Xtream-compatible format: [{ stream_id, name, stream_icon, category_id, ... }]
*/
getLiveStreams(categoryId = null, includeHidden = false) {
const db = getDb();
let query = `
SELECT
item_id as stream_id,
name,
stream_icon,
stream_url,
category_id,
added_at,
data
FROM playlist_items
WHERE source_id = ? AND type = 'live'
${!includeHidden ? 'AND is_hidden = 0' : ''}
`;
const params = [this.sourceId];
if (categoryId) {
query += ` AND category_id = ?`;
params.push(categoryId);
}
query += ` ORDER BY name ASC`;
const rows = db.prepare(query).all(...params);
return rows.map(row => {
// Parse any extra data stored as JSON
let extra = {};
if (row.data) {
try { extra = JSON.parse(row.data); } catch (e) { }
}
return {
stream_id: row.stream_id,
name: row.name,
stream_icon: row.stream_icon,
category_id: row.category_id,
added: row.added_at,
// M3U-specific: direct stream URL (Xtream builds URLs from credentials)
stream_url: row.stream_url,
// Include extra fields from parser (tvgId, etc.)
epg_channel_id: extra.tvgId || null,
...extra
};
});
}
/**
* Build stream URL for playback.
* For M3U, we return the stored URL directly (no credential building).
*/
buildStreamUrl(streamId, type = 'live', container = 'ts') {
const db = getDb();
const row = db.prepare(`
SELECT stream_url FROM playlist_items
WHERE source_id = ? AND item_id = ?
`).get(this.sourceId, streamId);
return row?.stream_url || null;
}
/**
* Get XMLTV EPG URL - M3U sources don't have built-in EPG URLs.
* Returns null; EPG must be configured as a separate source.
*/
getXmltvUrl() {
return null;
}
}
/**
* Factory function to create adapter from source ID
*/
function createFromSourceId(sourceId) {
return new M3uXtreamAdapter(sourceId);
}
module.exports = { M3uXtreamAdapter, createFromSourceId };
+574
View File
@@ -0,0 +1,574 @@
const { getDb } = require('../db/sqlite');
const { sources, settings } = require('../db'); // For source config and settings
const xtreamApi = require('./xtreamApi');
const m3uParser = require('./m3uParser');
const epgParser = require('./epgParser');
// Sync tracking
const activeSyncs = new Set(); // sourceId
class SyncService {
constructor() {
this.lastSyncTime = null; // Track when global sync last completed
this._syncTimer = null; // Server-side sync timer
this._currentInterval = null;
}
/**
* Get when the last global sync completed
*/
getLastSyncTime() {
return this.lastSyncTime;
}
/**
* Start the server-side sync timer based on settings
* Should be called once on server startup after initial sync
*/
async startSyncTimer() {
// Get interval from settings
const currentSettings = await settings.get();
const intervalHours = parseInt(currentSettings.epgRefreshInterval) || 24;
// If interval is 0, don't start timer (manual only mode)
if (intervalHours <= 0) {
console.log('[Sync] Auto-sync disabled (manual only mode)');
this.stopSyncTimer();
this._currentInterval = 0;
return;
}
const intervalMs = intervalHours * 60 * 60 * 1000;
// Don't restart if interval hasn't changed and timer exists
if (this._currentInterval === intervalHours && this._syncTimer) {
console.log(`[Sync] Timer already running for ${intervalHours} hours, not restarting`);
return;
}
// Clear existing timer
this.stopSyncTimer();
const nextSyncTime = new Date(Date.now() + intervalMs);
console.log(`[Sync] Starting server-side sync timer: every ${intervalHours} hours`);
console.log(`[Sync] Next scheduled sync at: ${nextSyncTime.toLocaleString()}`);
this._syncTimer = setInterval(async () => {
console.log('[Sync] Scheduled sync triggered');
await this.syncAll();
// Log next sync time
const next = new Date(Date.now() + intervalMs);
console.log(`[Sync] Next scheduled sync at: ${next.toLocaleString()}`);
}, intervalMs);
this._currentInterval = intervalHours;
}
/**
* Stop the server-side sync timer
*/
stopSyncTimer() {
if (this._syncTimer) {
clearInterval(this._syncTimer);
this._syncTimer = null;
}
}
/**
* Restart the sync timer with updated settings
* Called when sync interval setting changes
*/
async restartSyncTimer() {
await this.startSyncTimer();
}
/**
* Sync all enabled sources
*/
async syncAll() {
console.log('[Sync] Starting global sync...');
try {
const allSources = await sources.getAll();
for (const source of allSources) {
if (source.enabled) {
// Run sequentially to not overload
await this.syncSource(source.id);
}
}
this.lastSyncTime = new Date();
console.log('[Sync] Global sync completed at', this.lastSyncTime.toISOString());
} catch (err) {
console.error('[Sync] Global sync failed:', err);
}
}
/**
* Start sync for a source
*/
async syncSource(sourceId) {
if (activeSyncs.has(sourceId)) {
console.log(`[Sync] Source ${sourceId} is already syncing`);
return;
}
activeSyncs.add(sourceId);
try {
const db = getDb();
const source = await sources.getById(sourceId);
if (!source) {
throw new Error(`Source ${sourceId} not found`);
}
console.log(`[Sync] Starting sync for source ${source.name} (ID: ${sourceId})`);
if (!source.enabled) {
console.log(`[Sync] Skipping disabled source ${source.name}`);
activeSyncs.delete(sourceId);
return;
}
// Update status
this.updateSyncStatus(sourceId, 'all', 'syncing');
if (source.type === 'xtream') {
await this.syncXtream(source);
} else if (source.type === 'm3u') {
await this.syncM3u(source);
} else if (source.type === 'epg') {
await this.syncEpg(source);
}
this.updateSyncStatus(sourceId, 'all', 'success');
console.log(`[Sync] Completed sync for source ${source.name}`);
} catch (err) {
console.error(`[Sync] Failed sync for source ${sourceId}:`, err);
this.updateSyncStatus(sourceId, 'all', 'error', err.message);
} finally {
activeSyncs.delete(sourceId);
}
}
/**
* Update sync status in DB
*/
updateSyncStatus(sourceId, type, status, error = null) {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO sync_status (source_id, type, last_sync, status, error)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(source_id, type) DO UPDATE SET
last_sync = excluded.last_sync,
status = excluded.status,
error = excluded.error
`);
stmt.run(sourceId, type, Date.now(), status, error);
}
/**
* Xtream Sync Logic
*/
async syncXtream(source) {
const api = xtreamApi.createFromSource(source);
const db = getDb();
// 1. Live Categories
console.log(`[Sync] Fetching Live Categories for ${source.name}`);
const liveCats = await api.getLiveCategories();
await this.saveCategories(source.id, 'live', liveCats);
// 2. Live Streams
console.log(`[Sync] Fetching Live Streams for ${source.name}`);
const liveStreams = await api.getLiveStreams();
await this.saveStreams(source.id, 'live', liveStreams);
// 3. VOD Categories
console.log(`[Sync] Fetching VOD Categories for ${source.name}`);
const vodCats = await api.getVodCategories();
await this.saveCategories(source.id, 'movie', vodCats);
// 4. VOD Streams
console.log(`[Sync] Fetching VOD Streams for ${source.name}`);
const vodStreams = await api.getVodStreams();
await this.saveStreams(source.id, 'movie', vodStreams);
// 5. Series Categories
console.log(`[Sync] Fetching Series Categories for ${source.name}`);
const seriesCats = await api.getSeriesCategories();
await this.saveCategories(source.id, 'series', seriesCats);
// 6. Series
console.log(`[Sync] Fetching Series for ${source.name}`);
const series = await api.getSeries();
await this.saveStreams(source.id, 'series', series);
// 7. EPG (Xmltv)
// Try to fetch XMLTV if available
console.log(`[Sync] Fetching EPG for ${source.name}`);
try {
const xmltvUrl = api.getXmltvUrl();
await this.syncEpgFromUrl(source.id, xmltvUrl);
} catch (e) {
console.warn('[Sync] XMLTV fetch failed, skipping EPG sync for now:', e.message);
}
}
/**
* Batch save categories
*/
async saveCategories(sourceId, type, categories) {
if (!categories || categories.length === 0) return;
console.log(`[Sync] Saving ${categories.length} ${type} categories for source ${sourceId}...`);
const db = getDb();
const stmt = db.prepare(`
INSERT INTO categories (id, source_id, category_id, type, name, parent_id, data)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
data = excluded.data
`);
const insertBatch = db.transaction((batch) => {
for (const cat of batch) {
const catId = cat.category_id; // standard xtream field
const name = cat.category_name;
const id = `${sourceId}:${catId}`;
stmt.run(id, sourceId, String(catId), type, name, cat.parent_id || null, JSON.stringify(cat));
}
});
// Reduced batch size for better event loop interleaving
const BATCH_SIZE = 100;
for (let i = 0; i < categories.length; i += BATCH_SIZE) {
insertBatch(categories.slice(i, i + BATCH_SIZE));
// Yield to event loop between batches to allow other requests
await new Promise(resolve => setImmediate(resolve));
}
console.log(`[Sync] Saved ${categories.length} ${type} categories`);
}
/**
* Batch save streams (channels, vod, series)
* Also purges stale entries that no longer exist in the source (unless skipPurge is true)
* @param {number} sourceId - Source ID
* @param {string} type - Type of items (live, movie, series)
* @param {Array} items - Items to save
* @param {Object} options - Options { skipPurge: boolean }
* @returns {Set} Set of synced IDs (for external purge if skipPurge was true)
*/
async saveStreams(sourceId, type, items, options = {}) {
if (!items || items.length === 0) return new Set();
const db = getDb();
const { skipPurge = false } = options;
// Collect all IDs we're syncing
const syncedIds = new Set();
const stmt = db.prepare(`
INSERT INTO playlist_items (
id, source_id, item_id, type, name, category_id,
stream_icon, stream_url, container_extension,
rating, year, added_at, data
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
category_id = excluded.category_id,
stream_icon = excluded.stream_icon,
container_extension = excluded.container_extension,
data = excluded.data
`);
const insertBatch = db.transaction((batch) => {
for (const item of batch) {
// Map fields based on type
let itemId, name, catId, icon, container;
let rating = null, year = null, added = null;
if (type === 'live') {
itemId = item.stream_id;
name = item.name || `Channel ${item.stream_id}`;
catId = item.category_id;
icon = item.stream_icon;
added = item.added;
} else if (type === 'movie') {
itemId = item.stream_id;
name = item.name || `Movie ${item.stream_id}`;
catId = item.category_id;
icon = item.stream_icon; // or cover
container = item.container_extension;
rating = item.rating;
added = item.added;
} else if (type === 'series') {
itemId = item.series_id;
name = item.name || `Series ${item.series_id}`;
catId = item.category_id;
icon = item.cover;
rating = item.rating;
year = item.releaseDate;
added = item.last_modified;
}
const id = `${sourceId}:${itemId}`;
syncedIds.add(id);
stmt.run(
id,
sourceId,
String(itemId),
type,
name,
String(catId),
icon,
null, // Direct URL not stored for Xtream usually, built on fly
container,
rating,
year,
added,
JSON.stringify(item)
);
}
});
// Reduced batch size for better event loop interleaving
const BATCH_SIZE = 100;
for (let i = 0; i < items.length; i += BATCH_SIZE) {
insertBatch(items.slice(i, i + BATCH_SIZE));
// Yield to event loop between batches to allow other requests
await new Promise(resolve => setImmediate(resolve));
}
// Purge stale entries (skip if doing batch sync like M3U)
if (!skipPurge && syncedIds.size > 0) {
await this.purgeStaleItems(sourceId, type, syncedIds);
}
console.log(`[Sync] Saved ${items.length} ${type} items`);
return syncedIds;
}
/**
* Purge stale items that are no longer in the source
* @param {number} sourceId - Source ID
* @param {string} type - Type of items (live, movie, series)
* @param {Set} syncedIds - Set of IDs that should be kept
*/
async purgeStaleItems(sourceId, type, syncedIds) {
if (!syncedIds || syncedIds.size === 0) return;
const db = getDb();
db.exec('CREATE TEMP TABLE IF NOT EXISTS synced_ids (id TEXT PRIMARY KEY)');
db.exec('DELETE FROM synced_ids');
const insertTemp = db.prepare('INSERT OR IGNORE INTO synced_ids (id) VALUES (?)');
const insertTempBatch = db.transaction((ids) => {
for (const id of ids) {
insertTemp.run(id);
}
});
insertTempBatch([...syncedIds]);
const deleteStmt = db.prepare(`
DELETE FROM playlist_items
WHERE source_id = ? AND type = ?
AND id NOT IN (SELECT id FROM synced_ids)
`);
const deleted = deleteStmt.run(sourceId, type);
if (deleted.changes > 0) {
console.log(`[Sync] Purged ${deleted.changes} stale ${type} items`);
}
}
/**
* Sync EPG from URL (Streaming - Memory Efficient)
* Processes EPG files in batches to avoid OOM on large EPG data
*/
async syncEpgFromUrl(sourceId, url) {
console.log(`[Sync] Fetching EPG from: ${url.substring(0, 60)}...`);
// Temporary memory logging for verification
const logMemory = () => {
const used = process.memoryUsage();
console.log(`[Sync] Memory: ${Math.round(used.heapUsed / 1024 / 1024)}MB heap`);
};
logMemory();
const db = getDb();
let allChannels = [];
let totalProgrammes = 0;
let batchCount = 0;
// Clear old programmes first
db.prepare('DELETE FROM epg_programs WHERE source_id = ?').run(sourceId);
const programmeStmt = db.prepare(`
INSERT INTO epg_programs (channel_id, source_id, start_time, end_time, title, description, data)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertProgrammes = db.transaction((progs) => {
for (const p of progs) {
programmeStmt.run(
p.channelId,
sourceId,
p.start ? p.start.getTime() : 0,
p.stop ? p.stop.getTime() : 0,
p.title,
p.description || p.desc,
JSON.stringify(p)
);
}
});
// Stream and process in batches (default 1000 programmes per batch)
for await (const batch of epgParser.fetchAndParseStreaming(url)) {
batchCount++;
// Collect channels from first batch
if (batch.channels) {
allChannels = batch.channels;
}
// Save this batch of programmes immediately
if (batch.programmes.length > 0) {
insertProgrammes(batch.programmes);
totalProgrammes += batch.programmes.length;
}
// Log progress every 10 batches
if (batchCount % 10 === 0) {
console.log(`[Sync] Processed ${totalProgrammes} programmes so far...`);
logMemory();
}
// Yield to event loop
await new Promise(resolve => setImmediate(resolve));
}
console.log(`[Sync] EPG Parsed: ${allChannels.length} channels, ${totalProgrammes} programmes`);
logMemory();
// Save EPG Channels
if (allChannels.length > 0) {
const channelStmt = db.prepare(`
INSERT INTO playlist_items (
id, source_id, item_id, type, name, stream_icon,
stream_url, category_id, data
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
stream_icon = excluded.stream_icon,
data = excluded.data
`);
const insertChannels = db.transaction((chanList) => {
for (const ch of chanList) {
const id = `${sourceId}:${ch.id}`;
channelStmt.run(
id,
sourceId,
ch.id,
'epg_channel',
ch.name,
ch.icon || null,
null,
null,
JSON.stringify(ch)
);
}
});
insertChannels(allChannels);
console.log(`[Sync] Saved ${allChannels.length} EPG channels`);
}
console.log(`[Sync] Saved ${totalProgrammes} programmes`);
}
/**
* M3U Sync Logic (Streaming - Memory Efficient)
* Processes M3U files in batches to avoid OOM on large playlists
*/
async syncM3u(source) {
console.log(`[Sync] Fetching M3U playlist for ${source.name}`);
// Temporary memory logging for verification
const logMemory = () => {
const used = process.memoryUsage();
console.log(`[Sync] Memory: ${Math.round(used.heapUsed / 1024 / 1024)}MB heap`);
};
logMemory();
const allGroups = new Set();
const allSyncedIds = new Set(); // Collect IDs across all batches
let totalChannels = 0;
let batchCount = 0;
// Stream and process in batches (default 500 channels per batch)
for await (const batch of m3uParser.fetchAndParseStreaming(source.url)) {
batchCount++;
// Map M3U channel format to our schema
const playlistItems = batch.channels.map(ch => ({
stream_id: ch.id,
name: ch.name,
category_id: ch.groupTitle || 'Uncategorized',
stream_icon: ch.tvgLogo,
stream_url: ch.url,
tvgId: ch.tvgId || null,
}));
// Save this batch immediately (skip purge - we'll do it at the end)
if (playlistItems.length > 0) {
const batchIds = await this.saveStreams(source.id, 'live', playlistItems, { skipPurge: true });
batchIds.forEach(id => allSyncedIds.add(id));
totalChannels += playlistItems.length;
}
// Collect groups for category creation at the end
batch.groups.forEach(g => allGroups.add(g));
// Log progress every 10 batches
if (batchCount % 10 === 0) {
console.log(`[Sync] Processed ${totalChannels} channels so far...`);
logMemory();
}
}
console.log(`[Sync] M3U Parsed: ${totalChannels} channels, ${allGroups.size} groups`);
logMemory();
// Purge stale items after all batches are complete
if (allSyncedIds.size > 0) {
await this.purgeStaleItems(source.id, 'live', allSyncedIds);
}
// Save Categories (Groups) at the end
const categories = Array.from(allGroups).map(name => ({
category_id: name,
category_name: name,
parent_id: null
}));
await this.saveCategories(source.id, 'live', categories);
console.log(`[Sync] M3U sync complete for ${source.name}`);
}
/**
* EPG Source Sync Logic
*/
async syncEpg(source) {
console.log(`[Sync] Fetching standalone EPG for ${source.name}`);
await this.syncEpgFromUrl(source.id, source.url);
}
}
module.exports = new SyncService();
+781
View File
@@ -0,0 +1,781 @@
/**
* Transcode Session Service
*
* Manages HLS transcoding sessions with segment caching for VOD seeking.
* Each session transcodes a source URL to HLS segments on disk.
*
* Key features:
* - Session-based transcoding with unique IDs
* - HLS segment output for seeking support
* - Segment caching for fast access
* - Session persistence for recovery after restart
* - Automatic cleanup of stale sessions
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const EventEmitter = require('events');
const hwDetect = require('./hwDetect');
// Session storage
const sessions = new Map();
// Cache directory for transcoded segments
const CACHE_DIR = path.join(process.cwd(), 'transcode-cache');
// Session settings
const SESSION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes idle timeout (kills orphaned sessions quickly)
const SEGMENT_DURATION = 4; // seconds per HLS segment
const CLEANUP_INTERVAL_MS = 60 * 1000; // Check every 60 seconds
/**
* Generate a unique session ID
*/
function generateSessionId() {
return crypto.randomBytes(8).toString('hex');
}
/**
* Ensure cache directory exists
*/
async function ensureCacheDir() {
try {
await fs.mkdir(CACHE_DIR, { recursive: true });
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
/**
* TranscodeSession class
* Manages a single transcoding session from source URL to HLS segments
*/
class TranscodeSession extends EventEmitter {
constructor(url, options = {}) {
super();
this.id = generateSessionId();
this.url = url;
this.dir = path.join(CACHE_DIR, this.id);
this.playlistPath = path.join(this.dir, 'stream.m3u8');
this.process = null;
this.segments = new Map(); // segment index -> { ready: boolean, path: string }
this.status = 'pending'; // pending | starting | running | stopped | error
this.error = null;
this.startTime = Date.now();
this.lastAccess = Date.now();
this.options = {
ffmpegPath: options.ffmpegPath || 'ffmpeg',
userAgent: options.userAgent || 'Mozilla/5.0',
seekOffset: options.seekOffset || 0,
hwEncoder: options.hwEncoder || 'software',
maxResolution: options.maxResolution || '1080p',
quality: options.quality || 'medium',
// Upscaling options
upscaleEnabled: options.upscaleEnabled || false,
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
upscaleTarget: options.upscaleTarget || '1080p',
...options
};
}
/**
* Start the transcoding process
*/
async start() {
if (this.status === 'running') {
return;
}
this.status = 'starting';
console.log(`[TranscodeSession ${this.id}] Starting session for: ${this.url}`);
// Create session directory
try {
await fs.mkdir(this.dir, { recursive: true });
} catch (err) {
this.status = 'error';
this.error = err.message;
throw err;
}
// Build FFmpeg arguments for HLS output
const args = this.buildFFmpegArgs();
console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`);
try {
this.process = spawn(this.options.ffmpegPath, args, {
cwd: this.dir,
windowsHide: true
});
this.status = 'running';
// Handle stdout (should be empty for file output)
this.process.stdout.on('data', (data) => {
console.log(`[TranscodeSession ${this.id}] stdout: ${data}`);
});
// Handle stderr (FFmpeg progress/errors)
let stderrBuffer = '';
this.process.stderr.on('data', (data) => {
stderrBuffer += data.toString();
// Log periodically to avoid spam
const lines = stderrBuffer.split('\n');
if (lines.length > 1) {
lines.slice(0, -1).forEach(line => {
if (line.trim()) {
console.log(`[FFmpeg ${this.id}] ${line}`);
}
});
stderrBuffer = lines[lines.length - 1];
}
});
// Handle process exit
this.process.on('exit', (code) => {
if (code === 0 || code === null) {
console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`);
this.status = 'stopped';
} else if (code !== 255) { // 255 is often from SIGKILL
console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`);
this.status = 'error';
this.error = `FFmpeg exited with code ${code}`;
}
this.process = null;
this.emit('exit', code);
});
// Handle spawn errors
this.process.on('error', (err) => {
console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err);
this.status = 'error';
this.error = err.message;
this.emit('error', err);
});
// Save session metadata
await this.persist();
} catch (err) {
this.status = 'error';
this.error = err.message;
throw err;
}
}
/**
* Build FFmpeg arguments for HLS output with optional GPU encoding
*/
buildFFmpegArgs() {
const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
const videoMode = this.options.videoMode || 'encode';
// Resolve 'auto' encoder to detected hardware, fallback to software
let encoder = this.options.hwEncoder || 'software';
if (encoder === 'auto') {
const hwCaps = hwDetect.getCapabilities();
encoder = hwCaps?.recommended || 'software';
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
}
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-user_agent', this.options.userAgent,
];
// Add hardware acceleration input options based on encoder (only if encoding)
if (videoMode === 'encode') {
this.addHwAccelInputArgs(args, encoder);
}
// Input options (common)
args.push(
'-probesize', '5000000',
'-analyzeduration', '5000000',
'-fflags', '+genpts+discardcorrupt',
'-err_detect', 'ignore_err',
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '3'
);
args.push('-i', this.url);
// Add seek offset if specified (as output option to avoid Range requests)
if (this.options.seekOffset > 0) {
args.push('-ss', String(this.options.seekOffset));
}
// Map streams
args.push('-map', '0:v:0');
args.push('-map', '0:a:0?');
// Add video encoder and filters based on selected encoder OR copy
if (videoMode === 'copy') {
args.push('-c:v', 'copy');
// Critical for MKV/MP4 -> TS copy: Convert bitstream from AVCC/HVCC to Annex B
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
args.push('-bsf:v', 'hevc_mp4toannexb');
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
// Keep Annex-B conversion and force parameter sets to be repeated.
// This helps players recover on streams where SPS/PPS appear sporadically,
// which often manifests as "audio OK, black video".
args.push('-bsf:v', 'h264_mp4toannexb,dump_extra');
} else {
// Fallback (e.g. unknown codec), try strict extraction
args.push('-bsf:v', 'dump_extra');
}
} else {
this.addVideoEncoderArgs(args, encoder);
}
// Audio: Apply mix preset
const audioCodec = this.options.audioCodec?.toLowerCase() || 'unknown';
const audioChannels = this.options.audioChannels || 0;
const audioMixPreset = this.options.audioMixPreset || 'auto';
const isStereoAac = audioCodec.includes('aac') && audioChannels === 2;
// Define pan filter presets for 5.1 -> Stereo downmix
const AUDIO_MIX_FILTERS = {
// ITU-R BS.775 Standard: Mathematically balanced, transparent
itu: 'pan=stereo|FL=FL+0.707*FC+0.707*BL+0.5*LFE|FR=FR+0.707*FC+0.707*BR+0.5*LFE',
// Night Mode: Heavy dialogue boost, reduced bass/surrounds for quiet viewing
night: 'pan=stereo|FL=0.5*FL+1.2*FC+0.3*BL+0.1*LFE|FR=0.5*FR+1.2*FC+0.3*BR+0.1*LFE',
// Cinematic: Wide soundstage, immersive (original "dialogue boost" mix)
cinematic: 'pan=stereo|FL=FC+0.80*FL+0.60*BL+0.5*LFE|FR=FC+0.80*FR+0.60*BR+0.5*LFE'
};
if (audioMixPreset === 'passthrough') {
// Passthrough: Always copy audio, no processing
console.log(`[TranscodeSession ${this.id}] Audio: Passthrough (copy)`);
args.push('-c:a', 'copy');
} else if (audioMixPreset === 'auto' && isStereoAac) {
// Auto + Stereo AAC source: Smart copy
console.log(`[TranscodeSession ${this.id}] Audio: Auto (Smart Copy) - Source is Stereo AAC`);
args.push('-c:a', 'copy');
} else {
// Transcode to AAC with selected mix preset (default to ITU for 'auto')
const mixPreset = (audioMixPreset === 'auto') ? 'itu' : audioMixPreset;
const panFilter = AUDIO_MIX_FILTERS[mixPreset] || AUDIO_MIX_FILTERS.itu;
console.log(`[TranscodeSession ${this.id}] Audio: ${mixPreset.toUpperCase()} mix (${audioCodec} ${audioChannels}ch -> Stereo AAC)`);
args.push(
'-c:a', 'aac',
'-ar', '48000',
'-b:a', '192k',
'-af', `${panFilter},aresample=async=1`
);
}
// HLS output options
args.push(
'-f', 'hls',
'-hls_time', String(SEGMENT_DURATION),
'-hls_list_size', '0', // Keep all segments in playlist
'-hls_flags', 'independent_segments+append_list',
'-hls_segment_type', 'mpegts',
'-hls_segment_filename', path.join(this.dir, 'seg%04d.ts'),
this.playlistPath
);
return args;
}
/**
* Add hardware acceleration input arguments
*/
addHwAccelInputArgs(args, encoder) {
switch (encoder) {
case 'nvenc':
// NVIDIA CUDA/NVDEC hardware decoding
args.push(
'-hwaccel', 'cuda',
'-hwaccel_output_format', 'cuda'
);
break;
case 'vaapi':
// VAAPI hardware decoding (Linux)
args.push(
'-hwaccel', 'vaapi',
'-hwaccel_device', '/dev/dri/renderD128',
'-hwaccel_output_format', 'vaapi'
);
break;
case 'qsv':
// Intel QuickSync hardware decoding
args.push(
'-hwaccel', 'qsv',
'-hwaccel_output_format', 'qsv'
);
break;
case 'amf':
// AMD AMF (no hwaccel input, AMF is encode-only)
// Decode on CPU, encode on GPU
break;
case 'software':
case 'auto':
default:
// No hardware acceleration for input
break;
}
}
/**
* Add video encoder arguments based on selected encoder
*/
addVideoEncoderArgs(args, encoder) {
const resolution = this.getTargetHeight();
const quality = this.options.quality || 'medium';
// Quality presets mapping
const qualityPresets = {
'high': { nvenc: 18, vaapi: 18, qsv: 18, amf: 18, software: 18 },
'medium': { nvenc: 24, vaapi: 24, qsv: 24, amf: 24, software: 23 },
'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 }
};
const qp = qualityPresets[quality] || qualityPresets.medium;
switch (encoder) {
case 'nvenc':
this.addNvencEncoderArgs(args, resolution, qp.nvenc);
break;
case 'amf':
this.addAmfEncoderArgs(args, resolution, qp.amf);
break;
case 'vaapi':
this.addVaapiEncoderArgs(args, resolution, qp.vaapi);
break;
case 'qsv':
this.addQsvEncoderArgs(args, resolution, qp.qsv);
break;
case 'software':
case 'auto':
default:
this.addSoftwareEncoderArgs(args, resolution, qp.software);
break;
}
}
/**
* Get target height based on maxResolution or upscaleTarget setting
* When upscaling is enabled, uses the upscaleTarget resolution.
* Otherwise, uses maxResolution to cap the output.
*/
getTargetHeight() {
const resolutionMap = {
'4k': 2160,
'1080p': 1080,
'720p': 720,
'480p': 480
};
// When upscaling is enabled, use the upscale target resolution
if (this.options.upscaleEnabled) {
const target = resolutionMap[this.options.upscaleTarget] || 1080;
console.log(`[TranscodeSession ${this.id}] Upscale target height: ${target}p`);
return target;
}
// Otherwise, use max resolution as the cap
return resolutionMap[this.options.maxResolution] || 1080;
}
/**
* Build scale filter string based on encoder and upscaling settings
* @param {string} encoder - The encoder being used
* @param {number} height - Target height
*/
buildScaleFilter(encoder, height) {
const useUpscale = this.options.upscaleEnabled;
const upscaleMethod = this.options.upscaleMethod || 'hardware';
// Log upscaling status
if (useUpscale) {
console.log(`[TranscodeSession ${this.id}] Upscaling: ${upscaleMethod} method to ${height}p`);
}
// Hardware scaling filters (for both upscale and downscale)
if (upscaleMethod === 'hardware' || !useUpscale) {
switch (encoder) {
case 'nvenc':
// NVIDIA CUDA scaling with Lanczos
// Force nv12 (8-bit) output to handle 10-bit inputs (fixes "10 bit encode not supported")
return `scale_cuda=-2:${height}:interp_algo=lanczos:format=nv12`;
case 'vaapi':
return `scale_vaapi=w=-2:h=${height}:format=nv12`;
case 'qsv':
return `scale_qsv=w=-2:h=${height}:format=nv12`;
case 'amf':
// AMF uses CPU decode, so use software scale
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
case 'software':
default:
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
}
}
// Software Lanczos scaling (high quality, slower)
return `scale=-2:${height}:flags=lanczos`;
}
/**
* NVIDIA NVENC encoder arguments
*/
addNvencEncoderArgs(args, height, qp) {
// Video filter for scaling on GPU
args.push('-vf', this.buildScaleFilter('nvenc', height));
// NVENC encoder with quality settings
// Using portable options that work across FFmpeg builds
args.push(
'-c:v', 'h264_nvenc',
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
'-rc', 'constqp', // Constant QP mode
'-qp', String(qp),
'-bf', '3' // B-frames for better compression
);
}
/**
* AMD AMF encoder arguments
*/
addAmfEncoderArgs(args, height, qp) {
// CPU decoding + software scale + AMF encode
args.push('-vf', this.buildScaleFilter('amf', height));
args.push(
'-c:v', 'h264_amf',
'-quality', 'quality', // Quality preset
'-rc', 'cqp', // Constant QP
'-qp_i', String(qp),
'-qp_p', String(qp + 2),
'-qp_b', String(qp + 4),
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
);
}
/**
* VAAPI encoder arguments (Linux)
*/
addVaapiEncoderArgs(args, height, qp) {
// VAAPI filter chain:
// 1. scale_vaapi to resize on GPU
// 2. Ensure output format is nv12 for maximum encoder compatibility
// The format is handled automatically when using -hwaccel_output_format vaapi
args.push('-vf', this.buildScaleFilter('vaapi', height));
// VAAPI encoder with quality setting
// Note: -global_quality is the portable way to set quality for VAAPI
args.push(
'-c:v', 'h264_vaapi',
'-profile:v', 'main', // Use main profile for compatibility
'-global_quality', String(qp),
'-bf', '3',
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
);
}
/**
* Intel QuickSync encoder arguments
*/
addQsvEncoderArgs(args, height, qp) {
// Scale on QSV
args.push('-vf', this.buildScaleFilter('qsv', height));
args.push(
'-c:v', 'h264_qsv',
'-preset', 'medium',
'-global_quality', String(qp),
'-look_ahead', '1',
'-look_ahead_depth', '40',
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
);
}
/**
* Software encoder arguments (fallback)
*/
addSoftwareEncoderArgs(args, height, crf) {
// Software scaling (use Lanczos for upscaling if enabled)
args.push('-vf', this.buildScaleFilter('software', height));
args.push(
'-c:v', 'libx264',
'-preset', 'veryfast', // Fast for real-time
'-crf', String(crf),
'-profile:v', 'high',
'-level', '4.1',
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility (fixes 10-bit input errors)
);
}
/**
* Stop the transcoding process
*/
stop() {
if (this.process) {
console.log(`[TranscodeSession ${this.id}] Stopping FFmpeg process`);
this.process.kill('SIGTERM');
// Force kill after 2 seconds if still running
setTimeout(() => {
if (this.process) {
this.process.kill('SIGKILL');
}
}, 2000);
}
this.status = 'stopped';
}
/**
* Update last access time (prevents cleanup)
*/
touch() {
this.lastAccess = Date.now();
}
/**
* Check if playlist exists and is ready
*/
async isPlaylistReady() {
try {
await fs.access(this.playlistPath);
const content = await fs.readFile(this.playlistPath, 'utf8');
// Check if playlist has at least one segment
return content.includes('.ts');
} catch {
return false;
}
}
/**
* Wait for playlist to be ready (with timeout)
* Fast-fails if FFmpeg exited with an error before the timeout.
*/
async waitForPlaylist(timeoutMs = 10000) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
if (await this.isPlaylistReady()) {
return true;
}
// Fast-fail: FFmpeg already exited with an error — no point waiting
if (this.status === 'error' && !this.process) {
console.log(`[TranscodeSession ${this.id}] Fast-fail: FFmpeg exited with error, aborting wait`);
return false;
}
await new Promise(resolve => setTimeout(resolve, 200));
}
return false;
}
/**
* Get the HLS playlist content
*/
async getPlaylist() {
this.touch();
try {
return await fs.readFile(this.playlistPath, 'utf8');
} catch (err) {
return null;
}
}
/**
* Get a specific segment
*/
async getSegment(segmentName) {
this.touch();
const segmentPath = path.join(this.dir, segmentName);
try {
await fs.access(segmentPath);
return segmentPath;
} catch {
return null;
}
}
/**
* Save session metadata to disk for recovery
*/
async persist() {
const metadata = {
id: this.id,
url: this.url,
status: this.status,
startTime: this.startTime,
lastAccess: this.lastAccess,
options: this.options,
seekOffset: this.options.seekOffset
};
const metaPath = path.join(this.dir, 'session.json');
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
}
/**
* Restore a session from disk metadata
*/
static async restore(sessionDir) {
const metaPath = path.join(sessionDir, 'session.json');
try {
const data = await fs.readFile(metaPath, 'utf8');
const metadata = JSON.parse(data);
const session = new TranscodeSession(metadata.url, metadata.options);
session.id = metadata.id;
session.dir = sessionDir;
session.playlistPath = path.join(sessionDir, 'stream.m3u8');
session.startTime = metadata.startTime;
session.lastAccess = metadata.lastAccess;
session.status = 'stopped'; // Not running after restart
return session;
} catch (err) {
console.error(`Failed to restore session from ${sessionDir}:`, err.message);
return null;
}
}
/**
* Delete session directory and all segments
*/
async cleanup() {
this.stop();
try {
await fs.rm(this.dir, { recursive: true, force: true });
console.log(`[TranscodeSession ${this.id}] Cleaned up session directory`);
} catch (err) {
console.error(`[TranscodeSession ${this.id}] Failed to cleanup:`, err.message);
}
}
}
/**
* Session Manager
*/
/**
* Create a new transcode session
*/
async function createSession(url, options = {}) {
await ensureCacheDir();
const session = new TranscodeSession(url, options);
sessions.set(session.id, session);
return session;
}
/**
* Get an existing session by ID
*/
function getSession(sessionId) {
const session = sessions.get(sessionId);
if (session) {
session.touch();
}
return session;
}
/**
* Get or create a session for a URL (reuses existing if still valid)
*/
async function getOrCreateSession(url, options = {}) {
// Check for existing session with same URL
for (const session of sessions.values()) {
if (session.url === url && session.status === 'running') {
session.touch();
return session;
}
}
// Create new session
return createSession(url, options);
}
/**
* Stop and remove a session
*/
async function removeSession(sessionId) {
const session = sessions.get(sessionId);
if (session) {
await session.cleanup();
sessions.delete(sessionId);
}
}
/**
* Cleanup stale sessions (idle for too long)
*/
async function cleanupStaleSessions() {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
console.log(`[TranscodeSession] Cleaning up stale session ${id}`);
await removeSession(id);
}
}
}
/**
* Recover sessions from disk after server restart
*/
async function recoverSessions() {
try {
await fs.access(CACHE_DIR);
const dirs = await fs.readdir(CACHE_DIR, { withFileTypes: true });
for (const dirent of dirs) {
if (dirent.isDirectory()) {
const sessionDir = path.join(CACHE_DIR, dirent.name);
const session = await TranscodeSession.restore(sessionDir);
if (session) {
sessions.set(session.id, session);
console.log(`[TranscodeSession] Recovered session ${session.id}`);
}
}
}
} catch (err) {
// Cache dir doesn't exist yet, that's fine
if (err.code !== 'ENOENT') {
console.error('[TranscodeSession] Error recovering sessions:', err.message);
}
}
}
/**
* Start cleanup interval
*/
let cleanupInterval = null;
function startCleanupInterval() {
if (!cleanupInterval) {
cleanupInterval = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
cleanupInterval.unref(); // Don't prevent process exit
}
}
/**
* Get all active sessions (for debugging/monitoring)
*/
function getAllSessions() {
return Array.from(sessions.values()).map(s => ({
id: s.id,
url: s.url,
status: s.status,
startTime: s.startTime,
lastAccess: s.lastAccess,
idleMs: Date.now() - s.lastAccess
}));
}
module.exports = {
TranscodeSession,
createSession,
getSession,
getOrCreateSession,
removeSession,
cleanupStaleSessions,
recoverSessions,
startCleanupInterval,
getAllSessions,
CACHE_DIR,
SEGMENT_DURATION
};
+161
View File
@@ -0,0 +1,161 @@
/**
* Xtream Codes API v2 Client
* Handles authentication and API calls to Xtream servers
*/
class XtreamApi {
constructor(baseUrl, username, password) {
// Clean up base URL
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.username = username;
this.password = password;
}
/**
* Build API URL with authentication
*/
buildApiUrl(action, params = {}) {
const url = new URL(`${this.baseUrl}/player_api.php`);
url.searchParams.set('username', this.username);
url.searchParams.set('password', this.password);
if (action) {
url.searchParams.set('action', action);
}
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, value);
}
}
return url.toString();
}
/**
* Make API request
*/
async request(action, params = {}) {
const url = this.buildApiUrl(action, params);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Xtream API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
/**
* Authenticate and get server/user info
*/
async authenticate() {
const data = await this.request(null);
if (!data.user_info) {
throw new Error('Invalid credentials or server response');
}
return data;
}
/**
* Get live channel categories
*/
async getLiveCategories() {
return this.request('get_live_categories');
}
/**
* Get live streams, optionally filtered by category
*/
async getLiveStreams(categoryId = null) {
return this.request('get_live_streams', { category_id: categoryId });
}
/**
* Get VOD categories
*/
async getVodCategories() {
return this.request('get_vod_categories');
}
/**
* Get VOD streams, optionally filtered by category
*/
async getVodStreams(categoryId = null) {
return this.request('get_vod_streams', { category_id: categoryId });
}
/**
* Get VOD info
*/
async getVodInfo(vodId) {
return this.request('get_vod_info', { vod_id: vodId });
}
/**
* Get series categories
*/
async getSeriesCategories() {
return this.request('get_series_categories');
}
/**
* Get series, optionally filtered by category
*/
async getSeries(categoryId = null) {
return this.request('get_series', { category_id: categoryId });
}
/**
* Get series info
*/
async getSeriesInfo(seriesId) {
return this.request('get_series_info', { series_id: seriesId });
}
/**
* Get short EPG for a stream
*/
async getShortEpg(streamId, limit = 10) {
return this.request('get_short_epg', { stream_id: streamId, limit });
}
/**
* Get full EPG for a stream
*/
async getSimpleDateTable(streamId) {
return this.request('get_simple_data_table', { stream_id: streamId });
}
/**
* Build stream URL for playback
*/
buildStreamUrl(streamId, type = 'live', container = 'ts') {
const typeMap = {
live: 'live',
vod: 'movie',
series: 'series'
};
const streamType = typeMap[type] || 'live';
return `${this.baseUrl}/${streamType}/${this.username}/${this.password}/${streamId}.${container}`;
}
/**
* Get XMLTV EPG URL
*/
getXmltvUrl() {
return `${this.baseUrl}/xmltv.php?username=${this.username}&password=${this.password}`;
}
}
/**
* Factory function to create API instance from source
*/
function createFromSource(source) {
return new XtreamApi(source.url, source.username, source.password);
}
/**
* Static authenticate for testing
*/
async function authenticate(url, username, password) {
const api = new XtreamApi(url, username, password);
return api.authenticate();
}
module.exports = { XtreamApi, createFromSource, authenticate };
+46
View File
@@ -0,0 +1,46 @@
const { isAdminRole } = require('./auth');
/**
* Who may use a source for playback and API access.
* ownerId null/undefined: legacy shared — any authenticated user may use it.
* ownerId N: only that user id (and admins) may use it.
*/
function canUserAccessSource(user, source) {
if (!user || !source) return false;
if (isAdminRole(user)) return true;
const oid = source.ownerId;
if (oid == null || oid === undefined) return true;
const uid = user.id != null ? Number(user.id) : NaN;
const oidN = Number(oid);
return !Number.isNaN(uid) && !Number.isNaN(oidN) && oidN === uid;
}
async function getAccessibleSourceIds(req, sourcesApi) {
const all = await sourcesApi.getAll();
return all.filter((s) => canUserAccessSource(req.user, s)).map((s) => s.id);
}
/** Returns false after sending res if missing, forbidden, or invalid. */
async function assertCanUseSourceById(req, res, sourcesApi, rawSourceId) {
const sid = typeof rawSourceId === 'number' ? rawSourceId : parseInt(rawSourceId, 10);
if (Number.isNaN(sid)) {
res.status(400).json({ error: 'Invalid source id' });
return false;
}
const source = await sourcesApi.getById(sid);
if (!source) {
res.status(404).json({ error: 'Source not found' });
return false;
}
if (!canUserAccessSource(req.user, source)) {
res.status(403).json({ error: 'No access to this source' });
return false;
}
return true;
}
module.exports = {
canUserAccessSource,
getAccessibleSourceIds,
assertCanUseSourceById
};