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>
148 lines
4.4 KiB
JavaScript
148 lines
4.4 KiB
JavaScript
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;
|