231 lines
8.1 KiB
JavaScript
231 lines
8.1 KiB
JavaScript
const express = require('express');
|
|
require('dotenv').config();
|
|
const path = require('path');
|
|
const passport = require('passport');
|
|
const syncService = require('./services/syncService');
|
|
|
|
// Initialize database
|
|
require('./db');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For, etc.)
|
|
// Required for correct protocol detection behind reverse proxies (nginx, Caddy, etc.)
|
|
app.set('trust proxy', true);
|
|
|
|
// Middleware
|
|
app.use(express.json({ limit: '50mb' }));
|
|
|
|
// Initialize Passport
|
|
const session = require('express-session');
|
|
app.use(session({
|
|
secret: process.env.JWT_SECRET || 'keyboard cat',
|
|
resave: false,
|
|
saveUninitialized: true
|
|
}));
|
|
app.use(passport.initialize());
|
|
app.use(passport.session());
|
|
|
|
// Serve static files with no-cache for JS/CSS so browser always loads the latest version
|
|
app.use(express.static(path.join(__dirname, '..', 'public'), {
|
|
setHeaders(res, filePath) {
|
|
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
|
|
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
|
}
|
|
}
|
|
}));
|
|
|
|
// FFMPEG Configuration (optional - for transcoding support)
|
|
// Priority: 1. System FFmpeg (better Docker DNS support), 2. ffmpeg-static npm package
|
|
const { execSync } = require('child_process');
|
|
|
|
function findFFmpeg() {
|
|
// Try system FFmpeg first (better Docker compatibility)
|
|
try {
|
|
execSync('ffmpeg -version', { stdio: 'ignore' });
|
|
console.log('FFmpeg binary configured at: ffmpeg (system)');
|
|
return 'ffmpeg';
|
|
} catch (e) {
|
|
// System FFmpeg not found, try ffmpeg-static
|
|
}
|
|
|
|
// Try ffmpeg-static npm package
|
|
try {
|
|
let ffmpegPath = require('ffmpeg-static');
|
|
// In packaged Electron apps, ffmpeg-static returns path inside .asar archive
|
|
// but the binary is actually unpacked to app.asar.unpacked
|
|
if (ffmpegPath && ffmpegPath.includes('app.asar')) {
|
|
ffmpegPath = ffmpegPath.replace('app.asar', 'app.asar.unpacked');
|
|
}
|
|
console.log('FFmpeg binary configured at:', ffmpegPath);
|
|
return ffmpegPath;
|
|
} catch (err) {
|
|
console.warn('FFmpeg not available - transcoding/remuxing will be disabled.');
|
|
console.warn('Install FFmpeg via your package manager or npm install ffmpeg-static');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function findFFprobe() {
|
|
// Try system ffprobe first
|
|
try {
|
|
execSync('ffprobe -version', { stdio: 'ignore' });
|
|
console.log('FFprobe binary configured at: ffprobe (system)');
|
|
return 'ffprobe';
|
|
} catch (e) {
|
|
// Not found in system
|
|
}
|
|
|
|
// Try @ffprobe-installer/ffprobe package
|
|
try {
|
|
const ffprobePath = require('@ffprobe-installer/ffprobe').path;
|
|
if (ffprobePath) {
|
|
console.log('FFprobe binary configured at:', ffprobePath);
|
|
return ffprobePath;
|
|
}
|
|
} catch (err) {
|
|
// Package not available
|
|
}
|
|
|
|
console.warn('FFprobe not available - auto transcode will fallback to always transcode');
|
|
return null;
|
|
}
|
|
|
|
app.locals.ffmpegPath = findFFmpeg();
|
|
app.locals.ffprobePath = findFFprobe();
|
|
|
|
// Dynamic services loader - collects exports from files in ./services
|
|
const fs = require('fs');
|
|
const services = {};
|
|
try {
|
|
const servicesDir = path.join(__dirname, 'services');
|
|
const serviceFiles = fs.readdirSync(servicesDir).filter(f => f.endsWith('.js'));
|
|
for (const file of serviceFiles) {
|
|
const name = file.replace(/\.js$/, '');
|
|
try {
|
|
services[name] = require(path.join(servicesDir, file));
|
|
} catch (e) {
|
|
console.warn(`Failed to load service ${file}:`, e.message);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('No services directory found or failed to read services:', e.message);
|
|
}
|
|
|
|
// Freeze services object to prevent plugins from mutating shared state
|
|
Object.freeze(services);
|
|
|
|
// Plugin loader: loads any .js file inside server/plugins and calls the
|
|
// exported function with (app, services).
|
|
// Supports both function exports and object exports with lifecycle hooks.
|
|
const loadedPlugins = [];
|
|
|
|
async function loadPlugins() {
|
|
try {
|
|
const pluginsDir = path.join(__dirname, 'plugins');
|
|
if (fs.existsSync(pluginsDir)) {
|
|
// Sort plugin files alphabetically for deterministic load order
|
|
const pluginFiles = fs.readdirSync(pluginsDir)
|
|
.filter(f => f.endsWith('.js'))
|
|
.sort();
|
|
|
|
for (const file of pluginFiles) {
|
|
const pluginPath = path.join(pluginsDir, file);
|
|
try {
|
|
const plugin = require(pluginPath);
|
|
|
|
// Support both function exports and object exports with lifecycle hooks
|
|
if (typeof plugin === 'function') {
|
|
// Direct function export (sync or async)
|
|
await plugin(app, services);
|
|
loadedPlugins.push({ name: file, plugin: null });
|
|
console.log(`✓ Loaded plugin: ${file}`);
|
|
} else if (plugin && typeof plugin.init === 'function') {
|
|
// Object export with init/shutdown lifecycle
|
|
await plugin.init(app, services);
|
|
loadedPlugins.push({ name: file, plugin });
|
|
console.log(`✓ Loaded plugin: ${file} (with lifecycle hooks)`);
|
|
} else {
|
|
console.warn(`⚠ Plugin ${file} does not export a function or object with init(), skipping.`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`✗ Failed to load plugin ${file}:`, err);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('Plugin loader failed:', err.message);
|
|
}
|
|
}
|
|
|
|
// Graceful shutdown handler for plugins with shutdown hooks
|
|
process.on('SIGTERM', async () => {
|
|
console.log('SIGTERM received, shutting down plugins...');
|
|
for (const { name, plugin } of loadedPlugins) {
|
|
if (plugin && typeof plugin.shutdown === 'function') {
|
|
try {
|
|
await plugin.shutdown();
|
|
console.log(`✓ Shutdown plugin: ${name}`);
|
|
} catch (err) {
|
|
console.error(`✗ Error shutting down plugin ${name}:`, err);
|
|
}
|
|
}
|
|
}
|
|
process.exit(0);
|
|
});
|
|
|
|
// API Routes
|
|
app.use('/api/auth', require('./routes/auth'));
|
|
app.use('/api/sources', require('./routes/sources'));
|
|
app.use('/api/proxy', require('./routes/proxy'));
|
|
app.use('/api/channels', require('./routes/channels'));
|
|
app.use('/api/favorites', require('./routes/favorites'));
|
|
app.use('/api/transcode', require('./routes/transcode'));
|
|
app.use('/api/remux', require('./routes/remux'));
|
|
app.use('/api/probe', require('./routes/probe'));
|
|
app.use('/api/subtitle', require('./routes/subtitle'));
|
|
app.use('/api/settings', require('./routes/settings'));
|
|
app.use('/api/history', require('./routes/history'));
|
|
|
|
// Version endpoint
|
|
app.get('/api/version', (req, res) => {
|
|
const pkg = require('../package.json');
|
|
res.json({ version: pkg.version });
|
|
});
|
|
|
|
// SPA fallback - serve index.html for all non-API routes
|
|
app.get('*', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
|
});
|
|
|
|
// Error handling
|
|
app.use((err, req, res, next) => {
|
|
console.error('Server error:', err);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
});
|
|
|
|
app.listen(PORT, async () => {
|
|
console.log(`KiraTV server running on http://localhost:${PORT}`);
|
|
|
|
// Load plugins
|
|
await loadPlugins().catch(err => {
|
|
console.error('Plugin initialization failed:', err);
|
|
});
|
|
|
|
// Trigger background sync with delay to allow server to settle
|
|
setTimeout(async () => {
|
|
await syncService.syncAll().catch(console.error);
|
|
// Start the server-side sync timer after initial sync
|
|
await syncService.startSyncTimer().catch(console.error);
|
|
|
|
// Detect hardware acceleration capabilities
|
|
try {
|
|
const hwDetect = require('./services/hwDetect');
|
|
await hwDetect.detect();
|
|
} catch (err) {
|
|
console.warn('Hardware detection failed:', err.message);
|
|
}
|
|
}, 5000);
|
|
});
|