import express from 'express'; import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { ProbeCache } from './lib/probe.js'; import { Transcoder, MODE_LABEL } from './lib/transcode.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // CONFIG=path/to/other.json lets you run a second instance (e.g. for tests) without touching config.json. const configPath = path.resolve(__dirname, process.env.CONFIG || 'config.json'); const config = JSON.parse(await fs.readFile(configPath, 'utf8')); // Environment overrides (used by the Docker image: /videos, /cache). if (process.env.PORT) config.port = Number(process.env.PORT); if (process.env.HOST) config.host = process.env.HOST; if (process.env.VIDEO_DIR) config.videoDir = process.env.VIDEO_DIR; if (process.env.CACHE_DIR) config.cacheDir = process.env.CACHE_DIR; const VIDEO_DIR = path.resolve(__dirname, config.videoDir); const CACHE_DIR = path.resolve(__dirname, config.cacheDir); const EXT = new Set((config.extensions || ['.mp4']).map(e => e.toLowerCase())); const QUALITY_IDS = new Set(config.qualities.map(q => q.id)); const probeCache = new ProbeCache(path.join(CACHE_DIR, 'probe-cache.json')); const isSafeName = (n) => typeof n === 'string' && n.length > 0 && n !== '.' && n !== '..' && !n.includes('/') && !n.includes('\\') && path.basename(n) === n; async function resolveSource(name) { if (!isSafeName(name)) throw Object.assign(new Error('檔名不合法'), { status: 400 }); const file = path.join(VIDEO_DIR, name); let stat; try { stat = await fs.stat(file); } catch { throw Object.assign(new Error('找不到影片'), { status: 404 }); } const info = await probeCache.get(file, stat); return { file, stat, info }; } const transcoder = new Transcoder({ cacheDir: CACHE_DIR, qualities: config.qualities, resolveSource }); /** Run async fn over items with bounded concurrency, preserving order. */ async function mapLimit(items, limit, fn) { const out = new Array(items.length); let i = 0; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); } })); return out; } async function listVideos() { let entries; try { entries = await fs.readdir(VIDEO_DIR, { withFileTypes: true }); } catch (e) { throw Object.assign(new Error(`無法讀取資料夾 ${VIDEO_DIR}:${e.message}`), { status: 500 }); } const names = entries .filter(e => e.isFile() && EXT.has(path.extname(e.name).toLowerCase())) .map(e => e.name) .sort((a, b) => a.localeCompare(b, 'zh-Hant', { numeric: true })); return mapLimit(names, 4, describeVideo); } async function describeVideo(name) { const file = path.join(VIDEO_DIR, name); const stat = await fs.stat(file); let info = null, error = null; try { info = await probeCache.get(file, stat); } catch (e) { error = e.message; } const variants = {}; if (info?.video) { for (const q of config.qualities) { if (q.width >= info.video.width) continue; // would not shrink anything const height = Math.round((q.width * info.video.height) / info.video.width / 2) * 2; variants[q.id] = { label: q.label, width: q.width, height, bitrate: q.bitrate, ...(await transcoder.variantStatus(name, q.id, stat)), }; } } return { name, size: stat.size, mtime: stat.mtimeMs, info, error, variants }; } const app = express(); app.disable('x-powered-by'); app.use(express.json()); // Access log for media requests (set LOG_STREAM=0 to silence). if (process.env.LOG_STREAM !== '0') { app.use('/stream', (req, res, next) => { const t0 = Date.now(); res.on('close', () => { const sent = res.socket ? res.socket.bytesWritten : 0; console.log(`[stream] ${res.statusCode} ${decodeURIComponent(req.url)} range=${req.headers.range || '-'} ` + `${(res.getHeader('content-length') || '?')}B ${Date.now() - t0}ms${res.writableFinished ? '' : ' (aborted)'}`); }); next(); }); } app.get('/api/config', (req, res) => { res.json({ videoDir: VIDEO_DIR, cacheDir: CACHE_DIR, qualities: config.qualities, encoder: { modes: transcoder.modes, label: MODE_LABEL[transcoder.modes[0]] }, }); }); app.get('/api/videos', async (req, res) => { res.json(await listVideos()); }); app.get('/api/jobs', (req, res) => res.json(transcoder.list())); /** * Body: { name, quality: "1920" } | { name, qualities: ["2560","1920"] } | { name, all: true } * `all` = every applicable quality that is not ready yet, produced in one pass. */ app.post('/api/transcode', async (req, res) => { const { name, quality, qualities, all } = req.body || {}; if (!isSafeName(name)) return res.status(400).json({ error: '檔名不合法' }); let qids; if (all) { const v = await describeVideo(name); qids = Object.entries(v.variants).filter(([, s]) => !s.ready).map(([id]) => id); if (!qids.length) return res.status(409).json({ error: '所有畫質都已經轉檔完成' }); } else { qids = Array.isArray(qualities) ? qualities.map(String) : [String(quality)]; if (!qids.length || qids.some(q => !QUALITY_IDS.has(q))) return res.status(400).json({ error: '未知的畫質' }); } res.json(await transcoder.enqueue(name, qids)); }); app.delete('/api/jobs/finished', (req, res) => { transcoder.clearFinished(); res.json({ ok: true }); }); app.delete('/api/jobs/:id', (req, res) => res.json(transcoder.cancel(req.params.id))); app.delete('/api/variant', async (req, res) => { const { name, quality } = req.query; if (!isSafeName(name)) return res.status(400).json({ error: '檔名不合法' }); if (!QUALITY_IDS.has(String(quality))) return res.status(400).json({ error: '未知的畫質' }); await transcoder.deleteVariant(name, String(quality)); res.json({ ok: true }); }); // ---- Server-sent events: job progress pushed to every open page ---- const sseClients = new Set(); let sseTimer = null; function broadcast(type, data) { const payload = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; for (const c of sseClients) c.write(payload); } transcoder.on('update', () => { if (sseTimer) return; // throttle progress spam to ~4/s sseTimer = setTimeout(() => { sseTimer = null; broadcast('jobs', transcoder.list()); }, 250); }); transcoder.on('finished', job => broadcast('finished', job)); app.get('/api/events', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); res.write('retry: 2000\n\n'); res.write(`event: jobs\ndata: ${JSON.stringify(transcoder.list())}\n\n`); sseClients.add(res); const ping = setInterval(() => res.write(': ping\n\n'), 20000); req.on('close', () => { clearInterval(ping); sseClients.delete(res); }); }); // ---- Media streaming with HTTP Range support ---- const MIME = { '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/mp4', '.webm': 'video/webm' }; app.get('/stream/:name', async (req, res) => { const name = req.params.name; if (!isSafeName(name)) return res.status(400).end(); const q = String(req.query.q || 'original'); let file; if (q === 'original') file = path.join(VIDEO_DIR, name); else if (QUALITY_IDS.has(q)) file = transcoder.variantPath(name, q); else return res.status(400).end(); try { await fs.access(file); } catch { return res.status(404).end(); } res.sendFile(file, { acceptRanges: true, cacheControl: false, etag: false, lastModified: true, dotfiles: 'allow', headers: { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-cache', }, }, err => { // Client aborts (seeking, closing the tab) surface here; nothing to do. if (err && !res.headersSent && err.code !== 'ECONNABORTED') res.status(err.status || 500).end(); }); }); app.use('/vendor/three', express.static(path.join(__dirname, 'node_modules/three/build'), { maxAge: '1d' })); app.use(express.static(path.join(__dirname, 'public'))); app.use((err, req, res, next) => { if (res.headersSent) return next(err); const status = err.status || 500; if (status >= 500) console.error(err); res.status(status).json({ error: err.message || 'server error' }); }); // ---- Start ---- await probeCache.load(); const modes = await transcoder.init(); const server = app.listen(config.port, config.host, () => { console.log(`360 Player`); console.log(` 影片資料夾 : ${VIDEO_DIR}`); console.log(` 轉檔快取 : ${CACHE_DIR}`); console.log(` 轉檔引擎 : ${MODE_LABEL[modes[0]]} (備援: ${modes.slice(1).map(m => MODE_LABEL[m]).join(' → ') || '無'})`); console.log(` 網址 : http://localhost:${config.port}`); for (const [ifname, addrs] of Object.entries(os.networkInterfaces())) { for (const a of addrs) { if (a.family === 'IPv4' && !a.internal) console.log(` http://${a.address}:${config.port} (${ifname})`); } } }); server.keepAliveTimeout = 65000; // Graceful stop (docker stop / Ctrl+C): kill the running ffmpeg so no half-written .part survives. for (const sig of ['SIGTERM', 'SIGINT']) { process.on(sig, () => { console.log(`收到 ${sig},關閉中…`); if (transcoder.running) transcoder.cancel(transcoder.running.id); server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 3000).unref(); }); }