This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user