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:
@@ -1,7 +1,44 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import shaka from 'shaka-player'
|
import shaka from 'shaka-player'
|
||||||
|
import ServerDrmVideo from './ServerDrmVideo'
|
||||||
|
|
||||||
export default function DrmVideo({ dashSrc, cookies, mediaId, entityId, entityType, poster, className = '' }) {
|
function isWidevineSupported() {
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
// Safari (desktop & mobile) and all iOS browsers (WebKit-only) lack Widevine
|
||||||
|
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return false
|
||||||
|
if (/iPad|iPhone|iPod/i.test(ua)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DrmVideo({ dashSrc, mpdUrl, cookies, mediaId, entityId, entityType, poster, className = '' }) {
|
||||||
|
if (!isWidevineSupported()) {
|
||||||
|
return (
|
||||||
|
<ServerDrmVideo
|
||||||
|
mpdUrl={mpdUrl}
|
||||||
|
cookies={cookies}
|
||||||
|
mediaId={mediaId}
|
||||||
|
entityType={entityType}
|
||||||
|
entityId={entityId}
|
||||||
|
poster={poster}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShakaPlayer
|
||||||
|
dashSrc={dashSrc}
|
||||||
|
cookies={cookies}
|
||||||
|
mediaId={mediaId}
|
||||||
|
entityId={entityId}
|
||||||
|
entityType={entityType}
|
||||||
|
poster={poster}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShakaPlayer({ dashSrc, cookies, mediaId, entityId, entityType, poster, className = '' }) {
|
||||||
const videoRef = useRef(null)
|
const videoRef = useRef(null)
|
||||||
const playerRef = useRef(null)
|
const playerRef = useRef(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function getDrmDashInfo(item, { entityId, entityType } = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
dashSrc: `/api/drm-hls?url=${encodeURIComponent(drm.manifest.dash)}&cp=${encodeURIComponent(sig['CloudFront-Policy'])}&cs=${encodeURIComponent(sig['CloudFront-Signature'])}&ck=${encodeURIComponent(sig['CloudFront-Key-Pair-Id'])}`,
|
dashSrc: `/api/drm-hls?url=${encodeURIComponent(drm.manifest.dash)}&cp=${encodeURIComponent(sig['CloudFront-Policy'])}&cs=${encodeURIComponent(sig['CloudFront-Signature'])}&ck=${encodeURIComponent(sig['CloudFront-Key-Pair-Id'])}`,
|
||||||
|
mpdUrl: drm.manifest.dash,
|
||||||
cookies: {
|
cookies: {
|
||||||
cp: sig['CloudFront-Policy'],
|
cp: sig['CloudFront-Policy'],
|
||||||
cs: sig['CloudFront-Signature'],
|
cs: sig['CloudFront-Signature'],
|
||||||
@@ -128,6 +129,7 @@ function MediaItem({ item, className = '', entityId, entityType }) {
|
|||||||
>
|
>
|
||||||
<DrmVideo
|
<DrmVideo
|
||||||
dashSrc={drmInfo.dashSrc}
|
dashSrc={drmInfo.dashSrc}
|
||||||
|
mpdUrl={drmInfo.mpdUrl}
|
||||||
cookies={drmInfo.cookies}
|
cookies={drmInfo.cookies}
|
||||||
mediaId={drmInfo.mediaId}
|
mediaId={drmInfo.mediaId}
|
||||||
entityId={drmInfo.entityId}
|
entityId={drmInfo.entityId}
|
||||||
|
|||||||
111
client/src/components/ServerDrmVideo.jsx
Normal file
111
client/src/components/ServerDrmVideo.jsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
export default function ServerDrmVideo({ mpdUrl, cookies, mediaId, entityType, entityId, poster, className = '' }) {
|
||||||
|
const [state, setState] = useState('idle') // idle | processing | ready | error
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const pollRef = useRef(null)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
const triggerDecrypt = useCallback(async () => {
|
||||||
|
setState('processing')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/drm-stream/decrypt', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mpdUrl, cfCookies: cookies, mediaId, entityType, entityId }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setState('error')
|
||||||
|
setError(data.error || `Server error ${res.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'ready') {
|
||||||
|
setState('ready')
|
||||||
|
}
|
||||||
|
// else 'processing' — polling will pick it up
|
||||||
|
} catch (err) {
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
setState('error')
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}, [mpdUrl, cookies, mediaId, entityType, entityId])
|
||||||
|
|
||||||
|
// Start decrypt on mount
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
triggerDecrypt()
|
||||||
|
return () => { mountedRef.current = false }
|
||||||
|
}, [triggerDecrypt])
|
||||||
|
|
||||||
|
// Poll for status while processing
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== 'processing') return
|
||||||
|
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/drm-stream/${mediaId}/status`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (!mountedRef.current) return
|
||||||
|
|
||||||
|
if (data.status === 'ready') {
|
||||||
|
setState('ready')
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
setState('error')
|
||||||
|
setError(data.error || 'Decryption failed')
|
||||||
|
} else if (data.status === 'none') {
|
||||||
|
// Server restarted, re-trigger
|
||||||
|
triggerDecrypt()
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
}
|
||||||
|
}, [state, mediaId, triggerDecrypt])
|
||||||
|
|
||||||
|
if (state === 'ready') {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
src={`/api/drm-stream/${mediaId}`}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
playsInline
|
||||||
|
className={className}
|
||||||
|
poster={poster}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'error') {
|
||||||
|
return (
|
||||||
|
<div className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden flex items-center justify-center ${className}`}>
|
||||||
|
{poster && <img src={poster} alt="" className="w-full h-full object-cover opacity-30" />}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<svg className="w-10 h-10 text-gray-500 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-400 text-xs text-center px-4">{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Processing / idle — show spinner
|
||||||
|
return (
|
||||||
|
<div className={`relative bg-[#1a1a1a] rounded-lg overflow-hidden flex items-center justify-center ${className}`}>
|
||||||
|
{poster && <img src={poster} alt="" className="w-full h-full object-cover opacity-30" />}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-gray-400 animate-spin mb-2" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-400 text-xs">Decrypting video...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,3 +14,4 @@ services:
|
|||||||
- DB_PATH=/data/db/ofapp.db
|
- DB_PATH=/data/db/ofapp.db
|
||||||
- MEDIA_PATH=/data/media
|
- MEDIA_PATH=/data/media
|
||||||
- DOWNLOAD_DELAY=1000
|
- DOWNLOAD_DELAY=1000
|
||||||
|
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
|||||||
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;
|
||||||
@@ -12,6 +12,7 @@ import galleryRouter from './gallery.js';
|
|||||||
import hlsRouter from './hls.js';
|
import hlsRouter from './hls.js';
|
||||||
import settingsRouter from './settings.js';
|
import settingsRouter from './settings.js';
|
||||||
import scrapeRouter from './scrape.js';
|
import scrapeRouter from './scrape.js';
|
||||||
|
import drmStreamRouter from './drm-stream.js';
|
||||||
import { scanMediaFiles } from './gallery.js';
|
import { scanMediaFiles } from './gallery.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -34,6 +35,7 @@ app.use(galleryRouter);
|
|||||||
app.use(hlsRouter);
|
app.use(hlsRouter);
|
||||||
app.use(settingsRouter);
|
app.use(settingsRouter);
|
||||||
app.use(scrapeRouter);
|
app.use(scrapeRouter);
|
||||||
|
app.use(drmStreamRouter);
|
||||||
|
|
||||||
// Serve static client build in production
|
// Serve static client build in production
|
||||||
const clientDist = join(__dirname, '..', 'client', 'dist');
|
const clientDist = join(__dirname, '..', 'client', 'dist');
|
||||||
|
|||||||
Reference in New Issue
Block a user