feat(media): HLS adaptive bitrate streaming with MP4 fallback

Replaces single-MP4 + range-request streaming with HLS multi-bitrate
segments to fix video stutter through the Newt tunnel. Range-request
bursts were the root cause; HLS chunks are small and tunnel-friendly,
plus the player adapts bitrate to bandwidth.

Backend
- New BullMQ `hls-transcode` queue (in-process worker, concurrency 1)
- FFmpeg single-pass transcode → 360p/720p/1080p variants with aligned
  keyframes; output at /media/local/hls/{id}/master.m3u8
- New /api/{videos|public}/{id}/hls/* routes serving signed manifests
  and segments (URLs emitted as /media/* so nginx rewrites to media-api)
- Prisma: HlsStatus enum + 6 fields on Video + index, migration
- Upload + yt-dlp fetch paths enqueue transcode jobs
- ENABLE_HLS_TRANSCODE flag (default off; gates enqueue only)
- Backfill script: `npm run backfill:hls`
- media-api bumped to 4 CPU / 2G for FFmpeg headroom

Frontend
- New useHls hook: lazy-imports hls.js (kept out of main bundle),
  native HLS on Safari/iOS, gives up after 2 NETWORK_ERRORs so MP4
  fallback engages cleanly
- VideoPlayer, VideoViewerModal, ShortsPage, ProductDetailPage now
  prefer HLS when ready; MP4 fallback is automatic
- ShortsPage prefetches next-3 master manifests via <link rel="prefetch">
- PublicVideoCard hover preview stays MP4 (avoids hls.js init latency)

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-30 19:03:29 -06:00
parent 2ae7d8b968
commit 21208b58c7
25 changed files with 1421 additions and 47 deletions

View File

@ -188,6 +188,13 @@ MEDIA_API_PORT=4100
MEDIA_API_PUBLIC_URL=http://media-api:4100
# Used during admin Docker build to set the media API endpoint for Vite
VITE_MEDIA_API_URL=http://changemaker-media-api:4100
# HLS adaptive bitrate transcoding. When 'true', uploaded videos are queued
# for FFmpeg transcoding into 360p/720p/1080p HLS variants and the player
# prefers HLS over the MP4 range-request stream. When 'false' (default),
# uploads are tagged SKIPPED and the player falls back to MP4 — no DB or
# disk impact, fully reversible. The worker is always registered so existing
# PENDING jobs from a prior run still process if you flip the flag back on.
ENABLE_HLS_TRANSCODE=false
MEDIA_ROOT=/media/library
MEDIA_UPLOADS=/media/uploads
MAX_UPLOAD_SIZE_GB=10

View File

@ -298,6 +298,7 @@ Most features are toggled via **SiteSettings** in the database (admin Settings p
```bash
# .env feature flags (env-level)
ENABLE_MEDIA_FEATURES=true # Media manager
ENABLE_HLS_TRANSCODE=true # HLS adaptive bitrate transcoding (off by default)
ENABLE_PAYMENTS=true # Stripe integration
ENABLE_SMS=true # SMS campaigns
ENABLE_CHAT=true # Rocket.Chat
@ -489,9 +490,13 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
**Files:**
- `api/src/modules/media/` — Fastify media API (videos, reactions, jobs, analytics)
- `api/src/modules/media/services/` — FFprobe, video analytics service
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload
- `api/src/modules/media/services/` — FFprobe, thumbnail, **HLS transcode** services
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload, **HLS streaming**
- `api/src/services/video-schedule-queue.service.ts` — BullMQ queue for scheduled publishing
- `api/src/services/hls-transcode-queue.service.ts` — BullMQ queue for HLS adaptive bitrate transcoding (concurrency 1, in-process worker)
- `api/src/modules/media/routes/hls.routes.ts` — Master/variant playlist + segment serving with signed URLs
- `api/scripts/backfill-hls.ts` — Backfill HLS for pre-existing videos (`npm run backfill:hls`)
- `admin/src/lib/use-hls.ts` — React hook attaching hls.js (Chrome/FF/Edge) or native (Safari/iOS)
- `admin/src/lib/media-api.ts` — Dedicated axios instance for Media API
- `admin/src/pages/media/LibraryPage.tsx` — Video library with quick actions + calendar
- `admin/src/pages/media/AnalyticsDashboardPage.tsx` — Global analytics dashboard
@ -499,7 +504,15 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
- `admin/src/pages/public/MediaGalleryPage.tsx` — Public video gallery
- `admin/src/components/media/` — VideoCard, VideoActions, modals, charts
**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts
**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts, **HLS adaptive bitrate streaming (360p/720p/1080p, MP4 fallback)**.
**HLS adaptive bitrate streaming:**
- On upload, a BullMQ `hls-transcode` job runs FFmpeg to produce a master playlist + 3 keyframe-aligned variants under `/media/local/hls/{videoId}/`. Concurrency is 1; the worker runs in-process with the media-api Fastify server.
- Player prefers HLS over MP4 when `Video.hlsStatus === 'READY'`. MP4 streaming routes stay as the always-on fallback for un-transcoded videos and for hover-preview cards (where 200ms hls.js init defeats the UX — `PublicVideoCard` stays MP4).
- `useHls()` hook lazy-imports hls.js (~75 KB gzipped, never enters main bundle), uses native HLS on Safari/iOS, gives up after 2 NETWORK_ERROR retries so the MP4 fallback can kick in.
- Manifest URLs are HMAC-signed (`?sig=&exp=&uid=`) per existing `signMediaPath()` pattern. Variant playlists rewrite their segment URIs server-side at fetch time so each segment carries a fresh signature.
- Feature flag: `ENABLE_HLS_TRANSCODE` (default `false`). When off, uploads are tagged `SKIPPED` and the player falls back to MP4 — fully reversible. The worker stays registered so existing `PENDING` jobs still process if the flag flips back on.
- Backfill: `docker compose exec api npm run backfill:hls` enqueues all `hlsStatus IS NULL` videos. Bypasses the flag (operator opt-in). At ~2 min per 1080p video, throughput is ~30/hour.
**Routes:**
- Admin: `/app/media/library`, `/app/media/analytics`, `/app/media/shared`, `/app/media/jobs`
@ -770,6 +783,27 @@ Check in order:
### Database/Redis Connection Failures
Check container status (`docker compose ps`), verify credentials in `.env`, check logs (`docker compose logs <service> --tail 50`). Test DB: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"`. Test Redis: `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping`.
### Video Stuck in HLS PROCESSING / FAILED with EACCES
**Symptom:** A video shows `hlsStatus = 'PROCESSING'` for many minutes; or `'FAILED'` with `hls_transcode_error LIKE '%EACCES%'`. Player keeps falling back to MP4.
Check in order:
1. **First-run perms.** If `hls_transcode_error` contains `EACCES: permission denied, mkdir '/media/local/hls/<id>'`, the bind-mount got created as `root:root` but the Node process runs as `node` (UID 1000). One-time fix:
```
docker compose exec -u 0 media-api chown -R 1000:1000 /media/local/hls
```
Then reset and re-enqueue:
```
docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 -c "UPDATE videos SET hls_status = NULL, hls_transcode_error = NULL WHERE hls_status = 'FAILED';"
docker compose exec api npm run backfill:hls
```
2. **Worker running:** `docker compose logs media-api --tail 100 | grep -i hls` — expect `[hls]` lines for the queue worker startup and per-job progress.
3. **FFmpeg in container:** `docker compose exec media-api ffmpeg -version` — should print FFmpeg version. (Already in `Dockerfile.media`.)
4. **Queue depth:** `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD LLEN bull:hls-transcode:wait` — non-zero means jobs are queued behind a slow one.
5. **Disk space at output:** `docker compose exec media-api df -h /media/local/hls` — transcoding can consume several GB per video.
6. **Failure record:** `docker compose exec api npx prisma studio` → Video table → check `hlsTranscodeError`.
To force a re-transcode of a failed video, set `hlsStatus = NULL` in the DB and run `npm run backfill:hls`.
---
## V1 Reference (Legacy)

View File

@ -33,6 +33,7 @@
"grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1",
"hls.js": "^1.6.16",
"html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
@ -2634,6 +2635,12 @@
"node": ">= 0.4"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/html-entities": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",

View File

@ -34,6 +34,7 @@
"grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1",
"hls.js": "^1.6.16",
"html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",

View File

@ -3,6 +3,7 @@ import { Alert, Spin } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api';
import { signedMediaUrl } from '@/lib/media-url';
import { useHls } from '@/lib/use-hls';
export interface VideoMetadata {
id: number;
@ -15,6 +16,8 @@ export interface VideoMetadata {
quality: string | null;
streamUrl: string;
thumbnailUrl: string | null;
hlsStatus?: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'SKIPPED' | null;
hlsManifestUrl?: string | null;
createdAt: string;
}
@ -68,6 +71,13 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Attach HLS when manifest is ready. Must be called unconditionally on
// every render (rules of hooks) — even before the loading/error early
// returns. The hook is a no-op when manifestUrl is null.
const hlsManifestUrl = metadata?.hlsStatus === 'READY' ? metadata.hlsManifestUrl ?? null : null;
const { error: hlsError } = useHls(videoRef, hlsManifestUrl);
const useMp4Src = !hlsManifestUrl || !!hlsError;
// Expose control methods via ref
useImperativeHandle(ref, () => ({
play: () => {
@ -150,7 +160,9 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const data = await response.json();
// For admin previews of unpublished media, sign stream/thumbnail URLs
// (the legacy ?token=<JWT> path was removed 2026-04-12).
// (the legacy ?token=<JWT> path was removed 2026-04-12). The HLS
// manifest URL is already signed server-side by the metadata route, so
// we leave it untouched.
if (isAdmin) {
if (data.streamUrl) data.streamUrl = await signedMediaUrl(data.streamUrl);
if (data.thumbnailUrl) data.thumbnailUrl = await signedMediaUrl(data.thumbnailUrl);
@ -212,6 +224,10 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
? (metadata.height / metadata.width) * 100
: 56.25; // Default to 16:9
// (HLS attachment + MP4 fallback flag are computed at the top of the
// component, before the loading/error early returns, to satisfy the rules
// of hooks. See useMp4Src above.)
return (
<div
style={{
@ -224,7 +240,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
>
<video
ref={videoRef}
src={metadata.streamUrl}
src={useMp4Src ? metadata.streamUrl : undefined}
poster={poster || metadata.thumbnailUrl || undefined}
autoPlay={autoplay}
controls={controls}

View File

@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
import type { Video } from '@/types/media';
import { mediaApi } from '@/lib/media-api';
import { useSignedMediaUrl } from '@/lib/media-url';
import { useHls } from '@/lib/use-hls';
interface VideoViewerModalProps {
video: Video | null;
@ -16,6 +17,16 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const lastWatchTime = useRef<number>(0);
const streamUrl = useSignedMediaUrl(video ? `/media/videos/${video.id}/stream` : null);
// Sign the HLS manifest URL too so admin previews of unpublished videos
// can play HLS. The hook is a no-op for nulls.
const hlsManifestUrl = useSignedMediaUrl(
video && video.hlsStatus === 'READY'
? `/media/videos/${video.id}/hls/master.m3u8`
: null,
);
const { error: hlsError } = useHls(videoRef, hlsManifestUrl ?? null);
// Fall back to MP4 src when HLS isn't ready or hls.js fatal-errored.
const useMp4Src = !hlsManifestUrl || !!hlsError;
useEffect(() => {
if (open && video) {
@ -167,7 +178,7 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
>
<video
ref={videoRef}
src={streamUrl}
src={useMp4Src ? streamUrl : undefined}
controls
autoPlay
style={{

150
admin/src/lib/use-hls.ts Normal file
View File

@ -0,0 +1,150 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
/**
* useHls attach an HLS source to a `<video>` element.
*
* Strategy:
* - If the browser natively plays application/vnd.apple.mpegurl (Safari/iOS),
* just set videoEl.src and let the platform handle it. No JS bundle cost.
* - Otherwise dynamic-import hls.js (Vite chunk-splits this so the
* ~75 KB gzipped library never enters the main bundle), instantiate it,
* attach to the video element, and load the manifest.
*
* Falls back gracefully: when `manifestUrl` is null or `enabled` is false,
* the hook is a no-op so the caller can still set `<video src={mp4Url}>`.
*
* On a fatal hls.js error, `error` is populated and the caller can switch to
* the MP4 fallback URL by re-rendering with `manifestUrl=null`.
*/
export interface UseHlsOptions {
enabled?: boolean;
}
export interface UseHlsResult {
ready: boolean;
error: Error | null;
}
export function useHls(
videoRef: RefObject<HTMLVideoElement | null>,
manifestUrl: string | null,
opts: UseHlsOptions = {},
): UseHlsResult {
const { enabled = true } = opts;
const [ready, setReady] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Track the cleanup function so successive renders don't leak hls.js instances.
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
setReady(false);
setError(null);
// Tear down any previous attachment.
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
if (!enabled || !manifestUrl) return;
const video = videoRef.current;
if (!video) return;
let cancelled = false;
// Native HLS path (Safari/iOS).
if (video.canPlayType('application/vnd.apple.mpegurl')) {
const onLoaded = () => setReady(true);
const onErr = () => setError(new Error('Native HLS playback error'));
video.addEventListener('loadedmetadata', onLoaded);
video.addEventListener('error', onErr);
video.src = manifestUrl;
cleanupRef.current = () => {
video.removeEventListener('loadedmetadata', onLoaded);
video.removeEventListener('error', onErr);
// Don't clear src — caller may want to swap to MP4.
};
return () => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
};
}
// hls.js path. Dynamic import keeps this out of the main bundle.
(async () => {
try {
const mod = await import('hls.js');
if (cancelled) return;
const Hls = mod.default;
if (!Hls.isSupported()) {
setError(new Error('HLS not supported in this browser'));
return;
}
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
// Treat slow 3G gracefully: don't immediately escalate quality.
startLevel: -1,
});
// Track NETWORK_ERROR recoveries so a permanently-404 manifest gives
// up instead of looping — this is the signal callers use to trigger
// their MP4 fallback. 2 retries handles transient network blips.
let networkRetries = 0;
const MAX_NETWORK_RETRIES = 2;
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (!cancelled) setReady(true);
});
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal) return;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
if (networkRetries < MAX_NETWORK_RETRIES) {
networkRetries++;
hls.startLoad();
return;
}
setError(new Error(`HLS network error: ${data.details}`));
hls.destroy();
cleanupRef.current = null;
return;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
return;
default:
setError(new Error(`HLS fatal error: ${data.details}`));
hls.destroy();
cleanupRef.current = null;
}
});
hls.loadSource(manifestUrl);
cleanupRef.current = () => {
try {
hls.destroy();
} catch {
/* ignore */
}
};
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error('Failed to load hls.js'));
}
}
})();
return () => {
cancelled = true;
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
};
}, [enabled, manifestUrl, videoRef]);
return { ready, error };
}

View File

@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Typography, Button, Tag, Spin, App, Input, Grid } from 'antd';
import { ShoppingCartOutlined, ArrowLeftOutlined, PlayCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useHls } from '@/lib/use-hls';
import type { Product, ProductType } from '@/types/api';
const { Title, Text, Paragraph } = Typography;
@ -213,25 +214,17 @@ export default function ProductDetailPage() {
)}
{/* Video */}
{hasVideo && (
{hasVideo && product.videoId && (
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<PlayCircleOutlined />
<Text strong>Product Video</Text>
</div>
<video
controls
preload="metadata"
<ProductVideoPlayer
videoId={product.videoId}
streamUrl={product.videoStreamUrl!}
poster={product.videoThumbnailUrl || undefined}
style={{
width: '100%',
borderRadius: 8,
background: '#000',
maxHeight: 360,
}}
>
<source src={product.videoStreamUrl!} type="video/mp4" />
</video>
/>
</div>
)}
</div>
@ -291,3 +284,38 @@ export default function ProductDetailPage() {
</div>
);
}
// Product video player. Optimistically attempts the public HLS manifest; if
// the video isn't transcoded yet, hls.js fails fast (after 2 retries) and we
// fall back to the MP4 stream. This avoids needing the API to expose
// hlsStatus on the Product serializer for v1.
function ProductVideoPlayer({
videoId,
streamUrl,
poster,
}: {
videoId: number;
streamUrl: string;
poster?: string;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsManifestUrl = `/media/public/${videoId}/hls/master.m3u8`;
const { error: hlsError } = useHls(videoRef, hlsManifestUrl);
const useMp4Src = !!hlsError;
return (
<video
ref={videoRef}
src={useMp4Src ? streamUrl : undefined}
controls
preload="metadata"
poster={poster}
style={{
width: '100%',
borderRadius: 8,
background: '#000',
maxHeight: 360,
}}
/>
);
}

View File

@ -26,6 +26,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { MediaAuthProvider } from '@/contexts/MediaAuthContext';
import { useHls } from '@/lib/use-hls';
import LiveChat from '@/components/media/LiveChat';
import axios from 'axios';
@ -46,6 +47,9 @@ interface ShortVideo {
commentCount: number;
width: number | null;
height: number | null;
// HLS adaptive bitrate state. When 'READY', the player uses the HLS manifest
// for smoother streaming over restricted tunnels. Otherwise falls back to MP4.
hlsStatus?: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'SKIPPED' | null;
}
interface ShortsResponse {
@ -563,10 +567,25 @@ export default function ShortsPage() {
background: '#000',
}}
>
{/* Prefetch links for next videos */}
{shorts.slice(currentIndex + 1, currentIndex + 3).map((s) => (
s.videoUrl && <link key={`prefetch-${s.id}`} rel="prefetch" href={`/media/public/${s.id}/stream`} />
))}
{/* Prefetch links for next videos.
When HLS is ready, prefetching the master manifest (~1 KB) warms the
browser cache so swiping forward starts playback in <100ms. For
MP4-only shorts we keep the original prefetch of the stream URL. */}
{shorts.slice(currentIndex + 1, currentIndex + 3).map((s) => {
if (!s.videoUrl) return null;
if (s.hlsStatus === 'READY') {
return (
<link
key={`prefetch-hls-${s.id}`}
rel="prefetch"
as="fetch"
href={`/media/public/${s.id}/hls/master.m3u8`}
crossOrigin="anonymous"
/>
);
}
return <link key={`prefetch-${s.id}`} rel="prefetch" href={`/media/public/${s.id}/stream`} />;
})}
{shorts.map((short, index) => {
const distance = Math.abs(index - currentIndex);
@ -653,23 +672,15 @@ export default function ShortsPage() {
{/* Render tier: Full (active + adjacent), Simplified (medium), Placeholder (far) */}
{isNear ? (
<video
ref={(el) => handleVideoRef(index, el)}
src={`/media/public/${short.id}/stream`}
poster={short.thumbnailPath ? `/media/public/${short.id}/thumbnail` : undefined}
<HlsAwareShortVideo
short={short}
isActive={isActive}
onRefChange={(el) => handleVideoRef(index, el)}
loop={!autoplay}
playsInline
muted={muted}
preload={isActive ? 'auto' : 'metadata'}
isZoomed={isZoomed}
onClick={handleTogglePlay}
onEnded={isActive ? handleVideoEnded : undefined}
style={{
width: '100%',
height: '100%',
objectFit: isZoomed ? 'cover' : 'contain',
cursor: 'pointer',
background: '#000',
}}
/>
) : isMedium ? (
<img
@ -1264,3 +1275,65 @@ function ActionButton({
</div>
);
}
// HLS-aware video element for the shorts feed. Uses HLS when the short has
// hlsStatus='READY', otherwise falls back to the MP4 stream URL. Owns its
// own ref so useHls can attach hls.js cleanly, and forwards the underlying
// HTMLVideoElement to the parent's videoRefs Map via onRefChange.
interface HlsAwareShortVideoProps {
short: ShortVideo;
isActive: boolean;
onRefChange: (el: HTMLVideoElement | null) => void;
loop: boolean;
muted: boolean;
isZoomed: boolean;
onClick: () => void;
onEnded?: () => void;
}
function HlsAwareShortVideo({
short,
isActive,
onRefChange,
loop,
muted,
isZoomed,
onClick,
onEnded,
}: HlsAwareShortVideoProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsManifestUrl = short.hlsStatus === 'READY'
? `/api/public/${short.id}/hls/master.m3u8`
: null;
const { error: hlsError } = useHls(videoRef, hlsManifestUrl);
const useMp4Src = !hlsManifestUrl || !!hlsError;
// Register/deregister with the parent's refs Map.
useEffect(() => {
onRefChange(videoRef.current);
return () => onRefChange(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<video
ref={videoRef}
src={useMp4Src ? `/media/public/${short.id}/stream` : undefined}
poster={short.thumbnailPath ? `/media/public/${short.id}/thumbnail` : undefined}
loop={loop}
playsInline
muted={muted}
preload={isActive ? 'auto' : 'metadata'}
onClick={onClick}
onEnded={onEnded}
style={{
width: '100%',
height: '100%',
objectFit: isZoomed ? 'cover' : 'contain',
cursor: 'pointer',
background: '#000',
}}
/>
);
}

View File

@ -33,6 +33,11 @@ export interface Video {
publishedAt?: string | null;
isShort?: boolean;
accessLevel?: 'free' | 'member' | 'premium';
// HLS adaptive bitrate state. Populated when the video has been transcoded
// and the HLS manifest is ready to serve. Players prefer this over the MP4
// stream when present.
hlsStatus?: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'SKIPPED' | null;
hlsManifestUrl?: string | null;
}
// Video Analytics interfaces

View File

@ -157,3 +157,20 @@ export function getVideoThumbnailUrl(videoId: number): string {
export function getVideoMetadataUrl(videoId: number): string {
return `/media/videos/${videoId}/metadata`;
}
/**
* Build HLS master manifest URL for an admin context.
* Sign this via useSignedMediaUrl before binding to a player to authorize
* unpublished or access-gated content.
*/
export function getVideoHlsManifestUrl(videoId: number): string {
return `/media/videos/${videoId}/hls/master.m3u8`;
}
/**
* Build HLS master manifest URL for the public gallery.
* No signing needed the public endpoint gates by isPublished + access tier.
*/
export function getPublicHlsManifestUrl(videoId: number): string {
return `/media/public/${videoId}/hls/master.m3u8`;
}

View File

@ -13,7 +13,8 @@
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
"prisma:seed": "tsx prisma/seed.ts",
"prisma:studio": "prisma studio"
"prisma:studio": "prisma studio",
"backfill:hls": "tsx scripts/backfill-hls.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",

View File

@ -0,0 +1,13 @@
-- CreateEnum
CREATE TYPE "HlsStatus" AS ENUM ('PENDING', 'PROCESSING', 'READY', 'FAILED', 'SKIPPED');
-- AlterTable
ALTER TABLE "videos" ADD COLUMN "hls_job_id" TEXT,
ADD COLUMN "hls_manifest_path" TEXT,
ADD COLUMN "hls_status" "HlsStatus",
ADD COLUMN "hls_transcode_error" TEXT,
ADD COLUMN "hls_transcoded_at" TIMESTAMP(3),
ADD COLUMN "hls_variants" JSONB;
-- CreateIndex
CREATE INDEX "idx_videos_hls_status" ON "videos"("hls_status");

View File

@ -1485,6 +1485,15 @@ enum DirectoryType {
highlights
}
// HLS adaptive bitrate transcoding state for the Video model.
enum HlsStatus {
PENDING
PROCESSING
READY
FAILED
SKIPPED
}
enum ResourceCategory {
gpu_ai
gpu_encode
@ -1800,6 +1809,17 @@ model Video {
// Uploader tracking
uploaderId String? @map("uploader_id")
// HLS adaptive bitrate transcoding state.
// null = never queued; PENDING after upload; PROCESSING when worker picks up;
// READY when master.m3u8 + variants exist on disk; FAILED on transcode error;
// SKIPPED when ENABLE_HLS_TRANSCODE was off at enqueue time.
hlsStatus HlsStatus? @map("hls_status")
hlsManifestPath String? @map("hls_manifest_path") // /media/local/hls/{id}/master.m3u8
hlsTranscodedAt DateTime? @map("hls_transcoded_at")
hlsTranscodeError String? @map("hls_transcode_error")
hlsVariants Json? @map("hls_variants") // [{height, bitrate, path}, ...]
hlsJobId String? @map("hls_job_id")
// Relations
uploader User? @relation("VideoUploader", fields: [uploaderId], references: [id])
locker User? @relation("VideoLocker", fields: [lockedById], references: [id])
@ -1840,6 +1860,7 @@ model Video {
@@index([category, isPublished], map: "idx_videos_category_published")
@@index([isShort, isPublished, isLocked], map: "idx_videos_short_published")
@@index([uploaderId], map: "idx_videos_uploader")
@@index([hlsStatus], map: "idx_videos_hls_status")
@@map("videos")
}

View File

@ -0,0 +1,67 @@
/**
* Backfill HLS transcoding for existing videos.
*
* Finds every Video record that has never been queued for HLS (hlsStatus is
* NULL) and is otherwise transcodable, then enqueues a transcode job for
* each. Idempotent re-runs only pick up still-NULL rows. Skips invalid or
* zero-duration videos.
*
* Bypasses the ENABLE_HLS_TRANSCODE flag (calls forceSubmitTranscode)
* because the flag is meant to gate the *upload-time* enqueue; once an
* operator runs this script they're explicitly asking for transcoding.
*
* Usage:
* docker compose exec api tsx scripts/backfill-hls.ts
* # or after building:
* docker compose exec api node dist/scripts/backfill-hls.js
*/
import { prisma } from '../src/config/database';
import { hlsTranscodeQueueService } from '../src/services/hls-transcode-queue.service';
import { logger } from '../src/utils/logger';
async function main() {
const candidates = await prisma.video.findMany({
where: {
hlsStatus: null,
isValid: true,
durationSeconds: { gt: 0 },
width: { gt: 0 },
height: { gt: 0 },
},
select: { id: true, filename: true, durationSeconds: true },
orderBy: { id: 'asc' },
});
if (candidates.length === 0) {
logger.info('[backfill-hls] No videos require HLS transcoding.');
process.exit(0);
}
logger.info(`[backfill-hls] Enqueueing ${candidates.length} video(s) for HLS transcoding`);
let enqueued = 0;
for (const video of candidates) {
try {
const jobId = await hlsTranscodeQueueService.forceSubmitTranscode(video.id);
enqueued++;
logger.info(`[backfill-hls] Enqueued video ${video.id} (${video.filename}) → job ${jobId}`);
} catch (err) {
logger.error(`[backfill-hls] Failed to enqueue video ${video.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
logger.info(`[backfill-hls] Done. ${enqueued}/${candidates.length} jobs enqueued. Worker concurrency is 1, so total wall time depends on per-video transcode duration (~2 min per 1080p video).`);
// Give BullMQ a moment to flush, then exit cleanly.
await new Promise((r) => setTimeout(r, 500));
await hlsTranscodeQueueService.close();
await prisma.$disconnect();
process.exit(0);
}
main().catch(async (err) => {
logger.error(`[backfill-hls] Fatal: ${err instanceof Error ? err.stack : String(err)}`);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@ -188,6 +188,12 @@ const envSchema = z.object({
MEDIA_ROOT: z.string().default('/media/library'),
MEDIA_UPLOADS: z.string().default('/media/uploads'),
MAX_UPLOAD_SIZE_GB: z.coerce.number().default(10),
// HLS adaptive bitrate transcoding. When false, uploads are not enqueued
// for transcoding (the worker stays registered so PENDING jobs from a
// previous run still process if the flag is flipped back on). MP4 range-
// request streaming continues to work as a fallback for un-transcoded
// videos regardless of this flag.
ENABLE_HLS_TRANSCODE: z.string().default('false'),
// Container Registry (remote — gitea.bnkops.com)
GITEA_REGISTRY: z.string().default('gitea.bnkops.com/admin'),

View File

@ -21,7 +21,9 @@ import { shortsRoutes } from './modules/media/routes/shorts.routes';
import { upvoteRoutes } from './modules/media/routes/upvote.routes';
import { videoScheduleQueueService } from './services/video-schedule-queue.service';
import { videoFetchQueueService } from './services/video-fetch-queue.service';
import { hlsTranscodeQueueService } from './services/hls-transcode-queue.service';
import { fetchRoutes } from './modules/media/routes/fetch.routes';
import { hlsRoutes } from './modules/media/routes/hls.routes';
import { playlistsPublicRoutes } from './modules/media/routes/playlists-public.routes';
import { playlistsUserRoutes } from './modules/media/routes/playlists-user.routes';
import { playlistsAdminRoutes } from './modules/media/routes/playlists-admin.routes';
@ -55,6 +57,7 @@ process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully...');
await videoScheduleQueueService.close();
await videoFetchQueueService.close();
await hlsTranscodeQueueService.close();
fastify.close(() => {
logger.info('Media API server closed');
process.exit(0);
@ -135,6 +138,7 @@ const start = async () => {
await fastify.register(uploadRoutes, { prefix: '/api/videos' });
await fastify.register(videoActionsRoutes, { prefix: '/api/videos' });
await fastify.register(videoScheduleRoutes, { prefix: '/api/videos' });
await fastify.register(hlsRoutes, { prefix: '/api' });
await fastify.register(videoTrackingRoutes, { prefix: '/api/track' });
await fastify.register(reactionsRoutes, { prefix: '/api/reactions' });
await fastify.register(publicRoutes, { prefix: '/api' });
@ -184,6 +188,12 @@ const start = async () => {
videoFetchQueueService.startWorker();
logger.info('Video fetch queue worker initialized');
// Start HLS transcode worker (always on; the ENABLE_HLS_TRANSCODE flag
// gates enqueue, not worker registration, so existing PENDING jobs from
// a prior run still process if the flag was flipped back on).
hlsTranscodeQueueService.startWorker();
logger.info('HLS transcode queue worker initialized');
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
logger.warn('Media features are disabled (ENABLE_MEDIA_FEATURES=false)');
}

View File

@ -0,0 +1,383 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { createReadStream } from 'fs';
import { readFile, access, stat } from 'fs/promises';
import path from 'path';
import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
import { signMediaPath, verifyMediaSignature } from '../../../utils/signed-url';
const HLS_ROOT = '/media/local/hls';
// 2-hour TTL on segment URLs — longer than typical viewing session, so a
// player that has the manifest cached doesn't have to re-sign mid-playback.
const SEGMENT_TTL_SECONDS = 2 * 60 * 60;
// Master/variant playlists share the same TTL for consistency.
const MANIFEST_TTL_SECONDS = SEGMENT_TTL_SECONDS;
// Whitelist sanitizer for path components in URLs (variant + filename).
const SAFE_PATH_RE = /^[a-zA-Z0-9._-]+$/;
/**
* Identify whether a request is from an authenticated admin/media-role user.
* Mirrors the logic in video-streaming.routes.ts so admin HLS access works
* the same way (Bearer JWT or signed URL params).
*/
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
try {
let userId: string | undefined;
const authHeader = request.headers.authorization;
const query = request.query as Record<string, string | undefined>;
if (authHeader?.startsWith('Bearer ')) {
const payload = jwt.verify(authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
id: string;
role: UserRole;
roles?: UserRole[];
};
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
userId = payload.id;
} else if (query.sig && query.exp && query.uid) {
const result = verifyMediaSignature(request.url, query);
if (!result.valid) return false;
userId = result.userId;
}
if (!userId) return false;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { status: true, role: true, roles: true },
});
if (!user || user.status !== UserStatus.ACTIVE) return false;
return hasAnyRole({ role: user.role as UserRole, roles: getUserRoles(user) }, MEDIA_ROLES);
} catch {
return false;
}
}
/**
* Sanitize a manifest line: rewrite a relative URI (`360p/index.m3u8` or
* `seg_00001.ts`) into an absolute path beneath the given basePath, with a
* fresh signed-URL query string. Lines starting with `#` or empty are
* passed through untouched.
*
* The path emitted to the player is the *client-side* `/media/*` path that
* nginx rewrites to `/api/*` before reaching this server. Signatures are
* computed against the *server-side* `/api/*` path because that's what
* `verifyMediaSignature(request.url, ...)` sees on the inbound request.
*/
function rewriteManifestLines(
manifestText: string,
clientBasePath: string, // e.g. `/media/videos/123/hls` (browser-facing)
serverBasePath: string, // e.g. `/api/videos/123/hls` (post-nginx-rewrite, used for signing)
prefixSegmentsWith: string, // e.g. `360p/` for variant playlists; '' for master
uid: string,
ttlSeconds: number,
): string {
return manifestText
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const clientUrl = `${clientBasePath}/${prefixSegmentsWith}${trimmed}`;
const serverPath = `${serverBasePath}/${prefixSegmentsWith}${trimmed}`;
const signed = signMediaPath(serverPath, uid, ttlSeconds);
const sep = clientUrl.includes('?') ? '&' : '?';
return `${clientUrl}${sep}sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`;
})
.join('\n');
}
/** Lookup video + HLS state from DB, returning a lightweight record. */
async function loadVideoForHls(videoId: number, scope: 'admin' | 'public') {
if (scope === 'public') {
return prisma.video.findFirst({
where: { id: videoId, isPublished: true, isLocked: false },
select: {
id: true,
accessLevel: true,
hlsStatus: true,
hlsManifestPath: true,
},
});
}
return prisma.video.findUnique({
where: { id: videoId },
select: {
id: true,
accessLevel: true,
hlsStatus: true,
hlsManifestPath: true,
},
});
}
/**
* Subscription check for non-free videos on public endpoints. Mirrors
* public.routes.ts content-gating logic.
*/
async function checkPublicAccess(
request: FastifyRequest,
reply: FastifyReply,
accessLevel: string | null | undefined,
): Promise<boolean> {
if (!accessLevel || accessLevel === 'free') return true;
const userId = (request as any).user?.id;
if (!userId) {
reply.code(403).send({
message: 'This content requires a subscription',
accessLevel,
requiresAuth: true,
});
return false;
}
const subscription = await prisma.userSubscription.findFirst({
where: { userId, status: 'active' },
include: { plan: true },
});
if (!subscription) {
reply.code(403).send({
message: 'This content requires an active subscription',
accessLevel,
requiresSubscription: true,
});
return false;
}
if (accessLevel === 'premium' && (subscription.plan?.tier ?? 0) < 2) {
reply.code(403).send({
message: 'This content requires a premium subscription',
accessLevel,
requiresUpgrade: true,
});
return false;
}
return true;
}
/** Resolve a path inside the HLS root, blocking traversal. */
function resolveHlsPath(...parts: string[]): string {
const candidate = path.resolve(path.join(HLS_ROOT, ...parts));
if (!candidate.startsWith(path.resolve(HLS_ROOT))) {
throw new Error('Path traversal blocked');
}
return candidate;
}
export async function hlsRoutes(fastify: FastifyInstance) {
// ───────────────────────────────────────────────────────────────────
// Admin: master, variant, segment
// ───────────────────────────────────────────────────────────────────
fastify.get<{ Params: { id: string } }>(
'/videos/:id/hls/master.m3u8',
async (request, reply) => {
if (!(await isAdminRequest(request))) {
return reply.code(401).send({ message: 'Authentication required' });
}
return serveMaster(request, reply, 'admin');
},
);
fastify.get<{ Params: { id: string; variant: string } }>(
'/videos/:id/hls/:variant/index.m3u8',
async (request, reply) => {
if (!(await isAdminRequest(request))) {
return reply.code(401).send({ message: 'Authentication required' });
}
return serveVariant(request, reply, 'admin');
},
);
fastify.get<{ Params: { id: string; variant: string; filename: string } }>(
'/videos/:id/hls/:variant/:filename',
async (request, reply) => {
// Segments are only authorized via the signed URL embedded in the
// variant playlist — we do NOT also accept Bearer auth here (keeps the
// hot path tiny and avoids a DB lookup per segment).
const query = request.query as Record<string, string | undefined>;
const result = verifyMediaSignature(request.url, query);
if (!result.valid) {
return reply.code(403).send({ message: 'Invalid or expired signature' });
}
return serveSegment(request, reply);
},
);
// ───────────────────────────────────────────────────────────────────
// Public: master, variant, segment (gated by publish + access level)
// ───────────────────────────────────────────────────────────────────
fastify.get<{ Params: { id: string } }>(
'/public/:id/hls/master.m3u8',
async (request, reply) => {
return serveMaster(request, reply, 'public');
},
);
fastify.get<{ Params: { id: string; variant: string } }>(
'/public/:id/hls/:variant/index.m3u8',
async (request, reply) => {
return serveVariant(request, reply, 'public');
},
);
fastify.get<{ Params: { id: string; variant: string; filename: string } }>(
'/public/:id/hls/:variant/:filename',
async (request, reply) => {
const query = request.query as Record<string, string | undefined>;
const result = verifyMediaSignature(request.url, query);
if (!result.valid) {
return reply.code(403).send({ message: 'Invalid or expired signature' });
}
return serveSegment(request, reply);
},
);
}
// ─────────────────────────────────────────────────────────────────────
// Handlers
// ─────────────────────────────────────────────────────────────────────
async function serveMaster(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
scope: 'admin' | 'public',
) {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
const video = await loadVideoForHls(videoId, scope);
if (!video || video.hlsStatus !== 'READY' || !video.hlsManifestPath) {
return reply.code(404).send({ message: 'HLS manifest not available for this video' });
}
if (scope === 'public') {
if (!(await checkPublicAccess(request, reply, video.accessLevel))) return; // reply already sent
}
// Read master.m3u8 from disk.
let masterPath: string;
try {
masterPath = resolveHlsPath(String(videoId), 'master.m3u8');
await access(masterPath);
} catch (err) {
logger.warn(`HLS master.m3u8 missing on disk for video ${videoId}: ${err}`);
return reply.code(404).send({ message: 'HLS manifest file missing' });
}
const masterText = await readFile(masterPath, 'utf-8');
// The master's variant URIs look like "360p/index.m3u8". Rewrite each to
// an absolute, server-signed URL pointing at our variant endpoint. We emit
// browser-facing `/media/*` paths (rewritten to `/api/*` by nginx) but
// sign against the server-side `/api/*` path the verifier will see.
const clientBase = scope === 'admin'
? `/media/videos/${videoId}/hls`
: `/media/public/${videoId}/hls`;
const serverBase = scope === 'admin'
? `/api/videos/${videoId}/hls`
: `/api/public/${videoId}/hls`;
const uid = scope === 'admin'
? ((request.query as Record<string, string | undefined>).uid ?? 'admin')
: `public-${videoId}`;
const rewritten = rewriteManifestLines(masterText, clientBase, serverBase, '', uid, MANIFEST_TTL_SECONDS);
reply
.header('Content-Type', 'application/vnd.apple.mpegurl')
.header('Cache-Control', 'no-store')
.send(rewritten);
}
async function serveVariant(
request: FastifyRequest<{ Params: { id: string; variant: string } }>,
reply: FastifyReply,
scope: 'admin' | 'public',
) {
const videoId = parseInt(request.params.id);
const variant = request.params.variant;
if (isNaN(videoId) || !SAFE_PATH_RE.test(variant)) {
return reply.code(400).send({ message: 'Invalid params' });
}
const video = await loadVideoForHls(videoId, scope);
if (!video || video.hlsStatus !== 'READY') {
return reply.code(404).send({ message: 'HLS manifest not available' });
}
if (scope === 'public') {
if (!(await checkPublicAccess(request, reply, video.accessLevel))) return;
}
let variantPath: string;
try {
variantPath = resolveHlsPath(String(videoId), variant, 'index.m3u8');
await access(variantPath);
} catch {
return reply.code(404).send({ message: 'Variant playlist not found' });
}
const variantText = await readFile(variantPath, 'utf-8');
const clientBase = scope === 'admin'
? `/media/videos/${videoId}/hls`
: `/media/public/${videoId}/hls`;
const serverBase = scope === 'admin'
? `/api/videos/${videoId}/hls`
: `/api/public/${videoId}/hls`;
const uid = scope === 'admin'
? ((request.query as Record<string, string | undefined>).uid ?? 'admin')
: `public-${videoId}`;
const rewritten = rewriteManifestLines(
variantText,
clientBase,
serverBase,
`${variant}/`,
uid,
SEGMENT_TTL_SECONDS,
);
reply
.header('Content-Type', 'application/vnd.apple.mpegurl')
.header('Cache-Control', 'no-store')
.send(rewritten);
}
async function serveSegment(
request: FastifyRequest<{ Params: { id: string; variant: string; filename: string } }>,
reply: FastifyReply,
) {
const videoId = parseInt(request.params.id);
const { variant, filename } = request.params;
if (isNaN(videoId) || !SAFE_PATH_RE.test(variant) || !SAFE_PATH_RE.test(filename)) {
return reply.code(400).send({ message: 'Invalid params' });
}
let segmentPath: string;
try {
segmentPath = resolveHlsPath(String(videoId), variant, filename);
await access(segmentPath);
} catch {
return reply.code(404).send({ message: 'Segment not found' });
}
const stats = await stat(segmentPath);
const isPlaylist = filename.endsWith('.m3u8');
const contentType = isPlaylist ? 'application/vnd.apple.mpegurl' : 'video/mp2t';
reply
.header('Content-Type', contentType)
.header('Content-Length', stats.size)
// Segments are content-addressed in the sense that {videoId}/{variant}/{name}
// never changes content; safe to cache aggressively at the browser. The
// signature in the URL keeps cache keys per-session.
.header('Cache-Control', 'private, max-age=3600');
return reply.send(createReadStream(segmentPath));
}

View File

@ -7,6 +7,7 @@ import { randomUUID } from 'crypto';
import { prisma } from '../../../config/database';
import { extractVideoMetadata, validateVideoFile } from '../services/ffprobe.service';
import { ThumbnailService } from '../services/thumbnail.service';
import { hlsTranscodeQueueService } from '../../../services/hls-transcode-queue.service';
import { logger } from '../../../utils/logger';
import { z } from 'zod';
import { requireAdminRole } from '../middleware/auth';
@ -126,6 +127,13 @@ async function uploadVideo(request: FastifyRequest, reply: FastifyReply) {
logger.error(`Failed to generate thumbnail for video ${video.id}:`, thumbnailError);
}
// Enqueue HLS transcode (no-op when ENABLE_HLS_TRANSCODE=false; sets SKIPPED).
try {
await hlsTranscodeQueueService.submitTranscode(video.id);
} catch (hlsErr) {
logger.error(`Failed to enqueue HLS transcode for video ${video.id}:`, hlsErr);
}
return reply.code(201).send({
message: 'Video uploaded successfully',
video,
@ -247,6 +255,13 @@ async function uploadBatch(request: FastifyRequest, reply: FastifyReply) {
logger.error(`Failed to generate thumbnail for video ${video.id}:`, thumbnailError);
}
// Enqueue HLS transcode (no-op when flag off).
try {
await hlsTranscodeQueueService.submitTranscode(video.id);
} catch (hlsErr) {
logger.error(`Failed to enqueue HLS transcode for video ${video.id}:`, hlsErr);
}
results.push({
filename: file.filename,
success: true,

View File

@ -9,7 +9,7 @@ import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
import { verifyMediaSignature } from '../../../utils/signed-url';
import { signMediaPath, verifyMediaSignature } from '../../../utils/signed-url';
/**
* Check if the request is from an authenticated admin user.
@ -284,6 +284,43 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
? `/media/videos/${video.id}/thumbnail`
: null;
// HLS manifest URL — only present when transcoding has completed.
// We emit browser-facing `/media/*` paths (rewritten by nginx to
// `/api/*` and proxied to media-api). For admin previews we sign
// against the post-rewrite server-side path so the verifier matches.
let hlsManifestUrl: string | null = null;
if (video.hlsStatus === 'READY' && video.hlsManifestPath) {
const clientPath = admin
? `/media/videos/${video.id}/hls/master.m3u8`
: `/media/public/${video.id}/hls/master.m3u8`;
const serverPath = admin
? `/api/videos/${video.id}/hls/master.m3u8`
: `/api/public/${video.id}/hls/master.m3u8`;
if (admin) {
let uid = 'admin';
try {
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const payload = jwt.verify(
authHeader.substring(7),
env.JWT_ACCESS_SECRET,
{ algorithms: ['HS256'] },
) as { id?: string };
if (payload.id) uid = payload.id;
} else {
const q = request.query as Record<string, string | undefined>;
if (q.uid) uid = q.uid;
}
} catch { /* keep default */ }
const signed = signMediaPath(serverPath, uid, 2 * 60 * 60);
hlsManifestUrl = `${clientPath}?sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`;
} else {
// Public manifest: nginx-rewritten path; the public master route
// is unsigned (gated by isPublished + access level on the server).
hlsManifestUrl = clientPath;
}
}
// Return public metadata
return {
id: video.id,
@ -296,6 +333,8 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
quality: video.quality,
streamUrl,
thumbnailUrl,
hlsStatus: video.hlsStatus,
hlsManifestUrl,
createdAt: video.createdAt,
};
} catch (error) {

View File

@ -0,0 +1,211 @@
import { spawn } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import { logger } from '../../../utils/logger';
const HLS_ROOT = '/media/local/hls';
const FFMPEG_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour cap; long-form video can exceed default 30s
export interface HlsVariant {
name: string; // '360p' | '720p' | '1080p'
height: number; // short-side height in pixels
bitrate: number; // target video bitrate in kbps
path: string; // relative path under {videoId}/, e.g. '360p/index.m3u8'
}
export interface HlsTranscodeOptions {
videoId: number;
sourcePath: string;
durationSeconds: number;
sourceWidth: number;
sourceHeight: number;
/** Optional progress callback, called with 0-100 as transcoding advances. */
onProgress?: (percent: number) => void | Promise<void>;
}
export interface HlsTranscodeResult {
manifestPath: string; // absolute path to master.m3u8 on disk
manifestRelativePath: string; // e.g. {videoId}/master.m3u8 (relative to HLS_ROOT)
variants: HlsVariant[];
}
// Bitrate ladder. Each rung is { name, height, videoKbps, maxKbps, bufKbps, audioKbps }.
// Ladder is keyed off short-side height so a 1080×1920 vertical short still
// gets 360p/720p/1080p *short-side* renditions (width follows via scale=-2:H).
const LADDER = [
{ name: '360p', height: 360, videoKbps: 800, maxKbps: 856, bufKbps: 1200, audioKbps: 96 },
{ name: '720p', height: 720, videoKbps: 2800, maxKbps: 2996, bufKbps: 4200, audioKbps: 128 },
{ name: '1080p', height: 1080, videoKbps: 5000, maxKbps: 5350, bufKbps: 7500, audioKbps: 128 },
];
/**
* Transcode a source video to HLS adaptive bitrate using a single FFmpeg
* invocation. One decode pass produces all variants in parallel, with
* keyframes aligned every 2s so hls.js can switch renditions cleanly.
*
* Output layout:
* /media/local/hls/{videoId}/master.m3u8
* /media/local/hls/{videoId}/360p/index.m3u8
* /media/local/hls/{videoId}/360p/seg_00000.ts
* ...
*/
export async function transcodeToHls(opts: HlsTranscodeOptions): Promise<HlsTranscodeResult> {
const { videoId, sourcePath, durationSeconds, sourceWidth, sourceHeight, onProgress } = opts;
// Pick variants up to the source's short-side resolution. Always include 360p.
const shortSide = Math.min(sourceWidth, sourceHeight);
const variants = LADDER.filter((v, i) => i === 0 || v.height <= shortSide);
const outDir = path.join(HLS_ROOT, String(videoId));
await fs.rm(outDir, { recursive: true, force: true });
await fs.mkdir(outDir, { recursive: true });
// Build the filter_complex graph: split video N ways, scale each.
const splitTargets = variants.map((_, i) => `[v${i}]`).join('');
const scaleFilters = variants
.map((v, i) => `[v${i}]scale=-2:${v.height}[v${i}o]`)
.join('; ');
const filterComplex = `[0:v]split=${variants.length}${splitTargets}; ${scaleFilters}`;
// Per-stream encode args.
const streamArgs: string[] = [];
variants.forEach((v, i) => {
streamArgs.push(
'-map', `[v${i}o]`,
`-c:v:${i}`, 'libx264',
`-b:v:${i}`, `${v.videoKbps}k`,
`-maxrate:v:${i}`, `${v.maxKbps}k`,
`-bufsize:v:${i}`, `${v.bufKbps}k`,
);
});
// Audio: map source audio once per variant so each rendition has its own audio track.
variants.forEach((v, i) => {
streamArgs.push('-map', 'a:0?', `-c:a:${i}`, 'aac', `-b:a:${i}`, `${v.audioKbps}k`, '-ac', '2');
});
// var_stream_map associates video+audio streams per variant.
const varStreamMap = variants
.map((v, i) => `v:${i},a:${i},name:${v.name}`)
.join(' ');
const ffmpegArgs = [
'-hide_banner',
'-y',
'-i', sourcePath,
'-filter_complex', filterComplex,
...streamArgs,
'-preset', 'veryfast',
'-profile:v', 'main',
'-sc_threshold', '0',
'-g', '48',
'-keyint_min', '48',
'-force_key_frames', 'expr:gte(t,n_forced*2)',
'-hls_time', '4',
'-hls_playlist_type', 'vod',
'-hls_flags', 'independent_segments',
'-hls_segment_filename', path.join(outDir, '%v', 'seg_%05d.ts'),
'-master_pl_name', 'master.m3u8',
'-var_stream_map', varStreamMap,
path.join(outDir, '%v', 'index.m3u8'),
];
// Pre-create per-variant subdirs so FFmpeg can write segments.
for (const v of variants) {
await fs.mkdir(path.join(outDir, v.name), { recursive: true });
}
logger.info(`[hls] Starting transcode for video ${videoId} (${variants.length} variants: ${variants.map(v => v.name).join(', ')})`);
await runFfmpeg(ffmpegArgs, durationSeconds, onProgress);
// FFmpeg's HLS muxer writes per-variant playlists into directories named
// by the var_stream_map's name. Confirm master.m3u8 exists.
const masterPath = path.join(outDir, 'master.m3u8');
try {
await fs.access(masterPath);
} catch {
// Cleanup partial output.
await fs.rm(outDir, { recursive: true, force: true });
throw new Error('FFmpeg completed but master.m3u8 was not produced');
}
const result: HlsTranscodeResult = {
manifestPath: masterPath,
manifestRelativePath: path.join(String(videoId), 'master.m3u8'),
variants: variants.map(v => ({
name: v.name,
height: v.height,
bitrate: v.videoKbps,
path: path.join(v.name, 'index.m3u8'),
})),
};
logger.info(`[hls] Transcode complete for video ${videoId}`);
return result;
}
/**
* Spawn FFmpeg with a hard timeout and stderr-based progress parsing.
* FFmpeg writes "time=HH:MM:SS.cc" lines to stderr periodically; we parse
* those and call onProgress with 0-100.
*/
function runFfmpeg(
args: string[],
durationSeconds: number,
onProgress?: (percent: number) => void | Promise<void>,
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', args);
let stderrTail = '';
let lastReportedPercent = -1;
const timeout = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`FFmpeg timeout after ${FFMPEG_TIMEOUT_MS / 1000}s`));
}, FFMPEG_TIMEOUT_MS);
child.stderr.on('data', (data: Buffer) => {
const text = data.toString();
// Keep a tail for error reporting on non-zero exit.
stderrTail = (stderrTail + text).slice(-4000);
if (!onProgress || durationSeconds <= 0) return;
// Parse "time=HH:MM:SS.cc" — newest occurrence in this chunk.
const matches = text.match(/time=(\d+):(\d+):(\d+(?:\.\d+)?)/g);
if (!matches || matches.length === 0) return;
const last = matches[matches.length - 1];
const m = last.match(/time=(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (!m) return;
const elapsed = Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
const percent = Math.min(99, Math.floor((elapsed / durationSeconds) * 100));
if (percent > lastReportedPercent) {
lastReportedPercent = percent;
// Fire-and-forget; queue can swallow errors here.
Promise.resolve(onProgress(percent)).catch(() => {});
}
});
child.on('close', (code) => {
clearTimeout(timeout);
if (code === 0) {
resolve();
} else {
reject(new Error(`FFmpeg exited with code ${code}: ${stderrTail.slice(-1000)}`));
}
});
child.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
}
/**
* Remove the HLS output directory for a video (e.g. on transcode failure or video deletion).
*/
export async function cleanupHlsOutput(videoId: number): Promise<void> {
const dir = path.join(HLS_ROOT, String(videoId));
await fs.rm(dir, { recursive: true, force: true });
}

View File

@ -0,0 +1,238 @@
import { Queue, Worker, type Job } from 'bullmq';
import Redis from 'ioredis';
import { env } from '../config/env';
import { prisma } from '../config/database';
import {
transcodeToHls,
cleanupHlsOutput,
type HlsTranscodeResult,
} from '../modules/media/services/hls-transcode.service';
import { logger } from '../utils/logger';
interface HlsTranscodeJobData {
videoId: number;
}
interface HlsTranscodeJobResult {
videoId: number;
variants: Array<{ name: string; height: number; bitrate: number; path: string }>;
}
const QUEUE_NAME = 'hls-transcode';
class HlsTranscodeQueueService {
private queue: Queue<HlsTranscodeJobData, HlsTranscodeJobResult>;
private worker: Worker<HlsTranscodeJobData, HlsTranscodeJobResult> | null = null;
private redis: Redis | null = null;
constructor() {
this.queue = new Queue(QUEUE_NAME, {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
attempts: 2,
backoff: { type: 'exponential', delay: 60_000 },
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 200 },
removeOnFail: { age: 30 * 24 * 60 * 60 },
},
});
}
private getRedis(): Redis {
if (!this.redis) {
this.redis = new Redis(env.REDIS_URL);
}
return this.redis;
}
/** Append a log line for a job (Redis list with 24h TTL + pubsub for SSE). */
private async appendJobLog(jobId: string, line: string): Promise<void> {
const key = `hls-log:${jobId}`;
const redis = this.getRedis();
await redis.rpush(key, line);
await redis.expire(key, 86400);
await redis.publish(`hls-log-stream:${jobId}`, line);
}
/** Get accumulated log lines for a job. */
async getJobLog(jobId: string): Promise<string[]> {
const key = `hls-log:${jobId}`;
return this.getRedis().lrange(key, 0, -1);
}
/**
* Start the in-process worker. Concurrency is 1 because FFmpeg saturates
* the available cores; running two transcodes in parallel just slows both
* down and risks OOM.
*/
startWorker(): void {
this.worker = new Worker<HlsTranscodeJobData, HlsTranscodeJobResult>(
QUEUE_NAME,
async (job) => this.processJob(job),
{
connection: { url: env.REDIS_URL },
concurrency: 1,
},
);
this.worker.on('completed', (job) => {
logger.info(`[hls] job ${job.id} completed for video ${job.data.videoId}`);
});
this.worker.on('failed', (job, err) => {
logger.error(`[hls] job ${job?.id} failed: ${err.message}`);
});
logger.info('[hls] HLS transcode queue worker started');
}
private async processJob(job: Job<HlsTranscodeJobData>): Promise<HlsTranscodeJobResult> {
const { videoId } = job.data;
const jobId = job.id!;
await this.appendJobLog(jobId, `Starting HLS transcode for video ${videoId}`);
const video = await prisma.video.findUnique({ where: { id: videoId } });
if (!video) {
throw new Error(`Video ${videoId} not found`);
}
if (!video.path) {
throw new Error(`Video ${videoId} has no source path`);
}
if (!video.durationSeconds || video.durationSeconds <= 0) {
throw new Error(`Video ${videoId} has no duration`);
}
if (!video.width || !video.height) {
throw new Error(`Video ${videoId} has no dimensions`);
}
await prisma.video.update({
where: { id: videoId },
data: {
hlsStatus: 'PROCESSING',
hlsJobId: jobId,
hlsTranscodeError: null,
},
});
let result: HlsTranscodeResult;
try {
result = await transcodeToHls({
videoId,
sourcePath: video.path,
durationSeconds: video.durationSeconds,
sourceWidth: video.width,
sourceHeight: video.height,
onProgress: async (percent) => {
await job.updateProgress(percent);
// Coarse log lines so the UI can stream progress without flooding.
if (percent % 10 === 0) {
await this.appendJobLog(jobId, `Transcoding: ${percent}%`);
}
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.appendJobLog(jobId, `FAILED: ${message}`);
// Best-effort cleanup; transcodeToHls already rms partial output on failure.
await cleanupHlsOutput(videoId).catch(() => {});
await prisma.video.update({
where: { id: videoId },
data: {
hlsStatus: 'FAILED',
hlsTranscodeError: message.slice(0, 1000),
},
});
throw err; // BullMQ will retry per attempts/backoff config.
}
await prisma.video.update({
where: { id: videoId },
data: {
hlsStatus: 'READY',
hlsManifestPath: result.manifestRelativePath,
hlsTranscodedAt: new Date(),
hlsTranscodeError: null,
hlsVariants: result.variants as unknown as object,
},
});
await this.appendJobLog(jobId, `Transcode complete: ${result.variants.map(v => v.name).join(', ')}`);
await this.getRedis().publish(`hls-log-stream:${jobId}`, '__DONE__');
return {
videoId,
variants: result.variants,
};
}
/**
* Submit a transcode job. When ENABLE_HLS_TRANSCODE=false, this is a no-op
* that marks the video as SKIPPED (so the upload flow stays synchronous and
* the operator can opt in later via the backfill script).
*/
async submitTranscode(videoId: number): Promise<{ jobId: string | null; skipped: boolean }> {
if (env.ENABLE_HLS_TRANSCODE !== 'true') {
await prisma.video.update({
where: { id: videoId },
data: { hlsStatus: 'SKIPPED' },
});
return { jobId: null, skipped: true };
}
const job = await this.queue.add('transcode', { videoId });
await prisma.video.update({
where: { id: videoId },
data: { hlsStatus: 'PENDING', hlsJobId: job.id ?? null },
});
logger.info(`[hls] enqueued transcode job ${job.id} for video ${videoId}`);
return { jobId: job.id ?? null, skipped: false };
}
/**
* Force-enqueue a transcode job, bypassing the ENABLE_HLS_TRANSCODE flag.
* Used by the backfill script so an admin can run the backfill against
* existing videos without flipping the flag for new uploads.
*/
async forceSubmitTranscode(videoId: number): Promise<string> {
const job = await this.queue.add('transcode', { videoId });
await prisma.video.update({
where: { id: videoId },
data: { hlsStatus: 'PENDING', hlsJobId: job.id ?? null },
});
logger.info(`[hls] (forced) enqueued transcode job ${job.id} for video ${videoId}`);
return job.id!;
}
/** Get a single job by ID. */
async getJob(jobId: string) {
const job = await this.queue.getJob(jobId);
if (!job) return null;
const state = await job.getState();
return {
id: job.id,
data: job.data,
state,
progress: job.progress as number,
returnvalue: job.returnvalue,
failedReason: job.failedReason,
timestamp: job.timestamp,
finishedOn: job.finishedOn,
processedOn: job.processedOn,
};
}
async close(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
await this.queue.close();
if (this.redis) {
await this.redis.quit();
}
logger.info('[hls] HLS transcode queue closed');
}
}
export const hlsTranscodeQueueService = new HlsTranscodeQueueService();

View File

@ -372,6 +372,19 @@ class VideoFetchQueueService {
await this.appendJobLog(jobId, `Thumbnail generation failed (non-fatal): ${thumbnailErr instanceof Error ? thumbnailErr.message : 'Unknown error'}`);
}
// Enqueue HLS transcode (lazy import to avoid module-cycle with media-server bootstrap).
try {
const { hlsTranscodeQueueService } = await import('./hls-transcode-queue.service');
const result = await hlsTranscodeQueueService.submitTranscode(video.id);
if (result.skipped) {
await this.appendJobLog(jobId, 'HLS transcode skipped (flag off)');
} else {
await this.appendJobLog(jobId, `HLS transcode enqueued (job ${result.jobId})`);
}
} catch (hlsErr) {
await this.appendJobLog(jobId, `HLS enqueue failed (non-fatal): ${hlsErr instanceof Error ? hlsErr.message : 'Unknown error'}`);
}
resolve({ videoId: video.id, title });
} catch (err) {
reject(err);

View File

@ -196,6 +196,7 @@ services:
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
- ENABLE_HLS_TRANSCODE=${ENABLE_HLS_TRANSCODE:-false}
- MEDIA_ROOT=/media/local
- MEDIA_UPLOADS=/media/uploads
- MAX_UPLOAD_SIZE_GB=${MAX_UPLOAD_SIZE_GB:-10}
@ -206,15 +207,17 @@ services:
- ${MEDIA_ROOT:-./media}/local/thumbnails:/media/local/thumbnails:rw
- ${MEDIA_ROOT:-./media}/local/photos:/media/local/photos:rw
- ${MEDIA_ROOT:-./media}/local/documents:/media/local/documents:rw
- ${MEDIA_ROOT:-./media}/local/hls:/media/local/hls:rw
- ${MEDIA_ROOT:-./media}/public:/media/public:rw
deploy:
resources:
# Bumped to 4/2G for in-process HLS FFmpeg transcoding headroom.
limits:
cpus: '2'
memory: 1G
cpus: '4'
memory: 2G
reservations:
cpus: '0.25'
memory: 256M
cpus: '0.5'
memory: 512M
depends_on:
v2-postgres:
condition: service_healthy

View File

@ -203,6 +203,7 @@ services:
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
- ENABLE_HLS_TRANSCODE=${ENABLE_HLS_TRANSCODE:-false}
- MEDIA_ROOT=/media/local
- MEDIA_UPLOADS=/media/uploads
- MAX_UPLOAD_SIZE_GB=${MAX_UPLOAD_SIZE_GB:-10}
@ -215,15 +216,19 @@ services:
- ${MEDIA_ROOT:-./media}/local/thumbnails:/media/local/thumbnails:rw
- ${MEDIA_ROOT:-./media}/local/photos:/media/local/photos:rw
- ${MEDIA_ROOT:-./media}/local/documents:/media/local/documents:rw
- ${MEDIA_ROOT:-./media}/local/hls:/media/local/hls:rw
- ${MEDIA_ROOT:-./media}/public:/media/public:rw
deploy:
resources:
# Bumped from 2/1G to 4/2G to give FFmpeg HLS transcoding (in-process
# via hls-transcode-queue worker, concurrency 1) headroom without
# starving the API thread. Revisit if upload spikes saturate cores.
limits:
cpus: '2'
memory: 1G
cpus: '4'
memory: 2G
reservations:
cpus: '0.25'
memory: 256M
cpus: '0.5'
memory: 512M
depends_on:
v2-postgres:
condition: service_healthy