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:
147
server/drm-stream.js
Normal file
147
server/drm-stream.js
Normal 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;
|
||||
Reference in New Issue
Block a user