This commit is contained in:
+276
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user