Add Safari DRM video playback via server-side decryption

Safari/iOS browsers lack Widevine EME support, so DRM videos couldn't play.
This adds a server-side decrypt-and-stream pipeline that reuses the existing
downloadDrmMedia() code to decrypt videos on demand, cache them, and serve
plain MP4s with Range support for native <video> playback.

- server/drm-stream.js: new router with decrypt, status, and serve endpoints
- client/src/components/ServerDrmVideo.jsx: decrypt→poll→play lifecycle component
- DrmVideo.jsx: Widevine detection, falls back to ServerDrmVideo for Safari
- MediaGrid.jsx: passes raw mpdUrl through to DrmVideo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-16 12:30:26 -06:00
parent 1e5f54f60b
commit faa7dbf4d3
6 changed files with 301 additions and 1 deletions

147
server/drm-stream.js Normal file
View File

@@ -0,0 +1,147 @@
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;