import { Router } from 'express'; import { existsSync, statSync, createReadStream, readdirSync, unlinkSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { downloadDrmMedia, hasCDM } from './drm-download.js'; const router = Router(); const MEDIA_PATH = process.env.MEDIA_PATH || '/data/media'; const CACHE_DIR = join(MEDIA_PATH, '.drm-cache'); const CACHE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // 4 hours // In-memory job tracking const jobs = new Map(); // mediaId → { status: 'processing' | 'ready' | 'error', error? } // Ensure cache dir exists mkdirSync(CACHE_DIR, { recursive: true }); // Cache cleanup: delete files older than 4 hours function cleanupCache() { try { const files = readdirSync(CACHE_DIR); const now = Date.now(); for (const file of files) { if (!file.endsWith('.mp4')) continue; const filePath = join(CACHE_DIR, file); try { const stat = statSync(filePath); if (now - stat.mtimeMs > CACHE_MAX_AGE_MS) { unlinkSync(filePath); console.log(`[drm-stream] Cleaned up expired cache: ${file}`); } } catch {} } } catch (err) { console.error('[drm-stream] Cache cleanup error:', err.message); } } // Run cleanup on startup and hourly cleanupCache(); setInterval(cleanupCache, 60 * 60 * 1000); // POST /api/drm-stream/decrypt — Start server-side decryption router.post('/api/drm-stream/decrypt', (req, res) => { const { mpdUrl, cfCookies, mediaId, entityType, entityId } = req.body; if (!mediaId || !mpdUrl) { return res.status(400).json({ error: 'Missing mediaId or mpdUrl' }); } if (!hasCDM()) { return res.status(503).json({ error: 'No Widevine CDM available on server' }); } const safeId = String(mediaId).replace(/[^a-zA-Z0-9_-]/g, ''); const cachePath = join(CACHE_DIR, `${safeId}.mp4`); // Already cached if (existsSync(cachePath)) { return res.json({ status: 'ready' }); } // Already processing const existing = jobs.get(safeId); if (existing && existing.status === 'processing') { return res.json({ status: 'processing' }); } // Start decryption job (fire-and-forget) jobs.set(safeId, { status: 'processing' }); console.log(`[drm-stream] Starting decrypt for media ${safeId}`); downloadDrmMedia({ mpdUrl, cfCookies: cfCookies || {}, mediaId, entityType, entityId, outputDir: CACHE_DIR, outputFilename: `${safeId}.mp4`, }) .then(() => { jobs.set(safeId, { status: 'ready' }); console.log(`[drm-stream] Decrypt complete for media ${safeId}`); }) .catch((err) => { jobs.set(safeId, { status: 'error', error: err.message }); console.error(`[drm-stream] Decrypt failed for media ${safeId}:`, err.message); }); return res.json({ status: 'processing' }); }); // GET /api/drm-stream/:mediaId/status — Check decryption status router.get('/api/drm-stream/:mediaId/status', (req, res) => { const safeId = String(req.params.mediaId).replace(/[^a-zA-Z0-9_-]/g, ''); const cachePath = join(CACHE_DIR, `${safeId}.mp4`); // File exists on disk (could be from a previous server session) if (existsSync(cachePath)) { return res.json({ status: 'ready' }); } const job = jobs.get(safeId); if (!job) { return res.json({ status: 'none' }); } return res.json({ status: job.status, error: job.error || undefined }); }); // GET /api/drm-stream/:mediaId — Serve decrypted MP4 with Range support router.get('/api/drm-stream/:mediaId', (req, res) => { const safeId = String(req.params.mediaId).replace(/[^a-zA-Z0-9_-]/g, ''); const cachePath = join(CACHE_DIR, `${safeId}.mp4`); if (!existsSync(cachePath)) { return res.status(404).json({ error: 'Not found' }); } const stat = statSync(cachePath); const fileSize = stat.size; const range = req.headers.range; if (range) { const parts = range.replace(/bytes=/, '').split('-'); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunkSize = end - start + 1; res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunkSize, 'Content-Type': 'video/mp4', }); createReadStream(cachePath, { start, end }).pipe(res); } else { res.writeHead(200, { 'Content-Length': fileSize, 'Content-Type': 'video/mp4', 'Accept-Ranges': 'bytes', }); createReadStream(cachePath).pipe(res); } }); export default router;