diff --git a/client/src/components/DrmVideo.jsx b/client/src/components/DrmVideo.jsx
index b607f08..2b94bf3 100644
--- a/client/src/components/DrmVideo.jsx
+++ b/client/src/components/DrmVideo.jsx
@@ -1,7 +1,44 @@
import { useEffect, useRef, useState } from 'react'
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 (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+function ShakaPlayer({ dashSrc, cookies, mediaId, entityId, entityType, poster, className = '' }) {
const videoRef = useRef(null)
const playerRef = useRef(null)
const [error, setError] = useState(null)
diff --git a/client/src/components/MediaGrid.jsx b/client/src/components/MediaGrid.jsx
index fe48f4c..5fb8e37 100644
--- a/client/src/components/MediaGrid.jsx
+++ b/client/src/components/MediaGrid.jsx
@@ -52,6 +52,7 @@ function getDrmDashInfo(item, { entityId, entityType } = {}) {
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'])}`,
+ mpdUrl: drm.manifest.dash,
cookies: {
cp: sig['CloudFront-Policy'],
cs: sig['CloudFront-Signature'],
@@ -128,6 +129,7 @@ function MediaItem({ item, className = '', entityId, entityType }) {
>
{
+ 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 (
+
+ )
+ }
+
+ if (state === 'error') {
+ return (
+
+ {poster &&

}
+
+
+ )
+ }
+
+ // Processing / idle — show spinner
+ return (
+
+ {poster &&

}
+
+
+
Decrypting video...
+
+
+ )
+}
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index 616cedd..a775e96 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -14,3 +14,4 @@ services:
- DB_PATH=/data/db/ofapp.db
- MEDIA_PATH=/data/media
- DOWNLOAD_DELAY=1000
+ - NODE_TLS_REJECT_UNAUTHORIZED=0
diff --git a/server/drm-stream.js b/server/drm-stream.js
new file mode 100644
index 0000000..6b65612
--- /dev/null
+++ b/server/drm-stream.js
@@ -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;
diff --git a/server/index.js b/server/index.js
index a854b50..f29b885 100644
--- a/server/index.js
+++ b/server/index.js
@@ -12,6 +12,7 @@ import galleryRouter from './gallery.js';
import hlsRouter from './hls.js';
import settingsRouter from './settings.js';
import scrapeRouter from './scrape.js';
+import drmStreamRouter from './drm-stream.js';
import { scanMediaFiles } from './gallery.js';
const __filename = fileURLToPath(import.meta.url);
@@ -34,6 +35,7 @@ app.use(galleryRouter);
app.use(hlsRouter);
app.use(settingsRouter);
app.use(scrapeRouter);
+app.use(drmStreamRouter);
// Serve static client build in production
const clientDist = join(__dirname, '..', 'client', 'dist');