fix(transcode): fast job teardown and resilient HLS delivery

- DELETE markKilled removes the job synchronously; FFmpeg is killed in the background so playlist/segment routes 404 immediately
- segment route waits up to 5s for in-progress segment writes
- centralize player teardown (pause, destroy HLS, clear media sources) to stop lingering audio/requests on close
- track and release transcode jobs on player close/reopen and reset format to avoid reusing killed job URLs
- add e2e transcode lifecycle test
This commit is contained in:
tigerenwork 2026-08-06 23:34:14 +08:00
parent e9150ce0d7
commit 1732a16ce2
7 changed files with 465 additions and 105 deletions

Binary file not shown.

View File

@ -6,6 +6,16 @@ export const dynamic = 'force-dynamic';
export async function DELETE(_request: Request, { params }: { params: Promise<{ jobId: string }> }) {
const { jobId } = await params;
await transcodeOrchestrator.kill(jobId, 'client closed');
// markKilled() synchronously removes the job from the in-memory map so that
// segment/playlist routes return 404 immediately. The FFmpeg process is then
// terminated in the background so this response returns in <10ms.
const proc = transcodeOrchestrator.markKilled(jobId);
if (proc) {
transcodeOrchestrator.killProcess(proc).catch(() => {});
} else {
// Job not found via markKilled (may have already been cleaned up) — still
// try the normal kill path in case state differs.
transcodeOrchestrator.kill(jobId, 'client closed').catch(() => {});
}
return NextResponse.json({ success: true });
}

View File

@ -6,6 +6,18 @@ import { segmentPath } from '@/lib/transcode/playlist';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
async function waitForSegment(filePath: string, timeoutMs = 5000): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (fs.existsSync(filePath)) {
const stat = fs.statSync(filePath);
if (stat.size > 0) return true;
}
await new Promise(resolve => setTimeout(resolve, 150));
}
return false;
}
export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string; name: string }> }) {
const { jobId, name } = await params;
const job = transcodeOrchestrator.get(jobId);
@ -15,7 +27,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ job
}
const resolvedPath = segmentPath(job.outDir, name);
if (!resolvedPath || !fs.existsSync(resolvedPath)) {
if (!resolvedPath || !(await waitForSegment(resolvedPath))) {
return new Response(null, { status: 404 });
}

View File

@ -48,6 +48,8 @@ export default function ArtPlayerWrapper({
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<Artplayer | null>(null);
const hlsInstanceRef = useRef<Hls | null>(null); // Store HLS instance for cleanup
const formatOverrideRef = useRef<VideoFormat | undefined>(formatOverride);
const mediaElementsRef = useRef<Set<HTMLVideoElement>>(new Set());
const [format, setFormat] = useState<VideoFormat | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
@ -61,6 +63,102 @@ export default function ArtPlayerWrapper({
const [localBookmarkCount, setLocalBookmarkCount] = useState(bookmarkCount);
const [localAvgRating, setLocalAvgRating] = useState(avgRating);
const hlsErrorHandlerRef = useRef<HLSErrorHandler | null>(null);
const hlsShuttingDownRef = useRef<boolean>(false); // Blocks HLS error-recovery after teardown
const cloneQualities = (qualities: VideoFormat['qualities']) => (
qualities?.map(quality => ({ ...quality })) || []
);
const resetArtPlayerContainer = () => {
if (!containerRef.current) return;
const el = containerRef.current as unknown as Record<string, unknown>;
for (const prop of ['$control_option', '$video', '$player', '$art']) {
try { delete el[prop]; } catch (_) { /* non-configurable */ }
}
};
const stopTrackedMedia = (clearSource: boolean) => {
mediaElementsRef.current.forEach(videoEl => {
videoEl.pause();
if (clearSource) {
videoEl.removeAttribute('src');
videoEl.load();
}
});
containerRef.current?.querySelectorAll<HTMLMediaElement>('video, audio').forEach(mediaEl => {
mediaEl.pause();
if (clearSource) {
mediaEl.removeAttribute('src');
mediaEl.load();
}
});
if (clearSource) {
document.querySelectorAll<HTMLMediaElement>('video, audio').forEach(mediaEl => {
mediaEl.pause();
mediaEl.removeAttribute('src');
mediaEl.load();
});
}
};
const releasePlayer = () => {
// Mark HLS as shutting down BEFORE stopping/destroying so that any
// in-flight error callbacks (NETWORK_ERROR → startLoad recovery) are blocked.
hlsShuttingDownRef.current = true;
// ──── Step 1: Immediately pause all media to stop audio output ────
// Only pause here — do NOT remove src or call load() yet, because
// HLS.js still owns the MediaSource on the <video> element.
// Calling video.load() while HLS owns the MediaSource puts the element
// in a conflicting state where HLS can't cleanly abort its XHR requests.
mediaElementsRef.current.forEach(videoEl => {
try { videoEl.pause(); } catch (_) { /* ignore */ }
});
containerRef.current?.querySelectorAll<HTMLMediaElement>('video, audio').forEach(el => {
try { el.pause(); } catch (_) { /* ignore */ }
});
// ──── Step 2: Destroy HLS.js (stops all network requests) ────
// Monkey-patch recovery methods so in-flight error callbacks can't
// trigger a recovery loop between stopLoad() and destroy().
if (hlsInstanceRef.current) {
try {
hlsInstanceRef.current.startLoad = () => {};
(hlsInstanceRef.current as any).recoverMediaError = () => {};
} catch (_) { /* ignore */ }
// stopLoad() cancels pending fragment requests.
// destroy() internally calls detachMedia() (removes the MediaSource
// from the <video>) and aborts all remaining XHR — do NOT call
// detachMedia() separately, as that causes destroy() to skip XHR cleanup.
try { hlsInstanceRef.current.stopLoad(); } catch (_) { /* ignore */ }
try { hlsInstanceRef.current.destroy(); } catch (_) { /* ignore */ }
hlsInstanceRef.current = null;
}
hlsErrorHandlerRef.current?.detach();
hlsErrorHandlerRef.current = null;
// ──── Step 3: Clear media sources and destroy ArtPlayer ────
// Now that HLS is gone, scrub any remaining src/srcObject on media
// elements so the browser releases the underlying decode resources.
stopTrackedMedia(true);
if (playerRef.current) {
try { playerRef.current.pause(); } catch (_) { /* ignore */ }
try { playerRef.current.destroy(); } catch (_) { /* ignore */ }
playerRef.current = null;
}
mediaElementsRef.current.clear();
resetArtPlayerContainer();
};
const handleClose = () => {
releasePlayer();
onClose();
};
// Prevent ALL scrolling when video player is open
useEffect(() => {
@ -127,10 +225,18 @@ export default function ArtPlayerWrapper({
setLocalAvgRating(avgRating);
}, [isBookmarked, bookmarkCount, avgRating]);
// Keep formatOverrideRef in sync (no re-init needed, handled separately)
useEffect(() => {
formatOverrideRef.current = formatOverride;
}, [formatOverride]);
// Initialize ArtPlayer
useEffect(() => {
if (!useArtPlayer || !isOpen || !containerRef.current) return;
// Prevent duplicate instances/audio when React dev mode re-runs effects.
releasePlayer();
// Inject custom styles to remove shadows
const styleId = 'artplayer-styles';
if (!document.getElementById(styleId)) {
@ -144,7 +250,10 @@ export default function ArtPlayerWrapper({
setError(null);
try {
const detectedFormat = formatOverride || detectVideoFormat(video);
// Read from ref so this effect does NOT re-run when formatOverride changes
const detectedFormat = formatOverrideRef.current || detectVideoFormat(video);
const qualityOptions = cloneQualities(detectedFormat.qualities);
const settingsQualityOptions = cloneQualities(detectedFormat.qualities);
setFormat(detectedFormat);
// HLS.js plugin for ArtPlayer
@ -186,7 +295,7 @@ export default function ArtPlayerWrapper({
theme: '#3b82f6', // Blue theme
// Quality control (for HLS)
quality: detectedFormat.qualities || [],
quality: qualityOptions,
// Subtitle support
subtitle: {
@ -204,7 +313,7 @@ export default function ArtPlayerWrapper({
{
html: 'Quality',
icon: '<span class="artplayer-icon-settings-quality">⚙️</span>',
selector: detectedFormat.qualities || [],
selector: settingsQualityOptions,
onSelect: function(item: any) {
console.log('Quality selected:', item);
if (hlsInstance && item.level !== undefined) {
@ -220,30 +329,45 @@ export default function ArtPlayerWrapper({
// Custom initialization for HLS
customType: {
m3u8: function(video: HTMLVideoElement, url: string) {
mediaElementsRef.current.add(video);
if (Hls.isSupported()) {
// Reset shutdown flag — we're starting fresh
hlsShuttingDownRef.current = false;
hlsInstance = new Hls({
debug: process.env.NODE_ENV === 'development',
enableWorker: true,
lowLatencyMode: false, // Disable for better buffering
backBufferLength: 90,
maxBufferLength: 120, // Increase buffer length
maxBufferSize: 100 * 1000 * 1000, // 100MB buffer
maxBufferHole: 0.5, // Allow small holes
startLevel: -1, // Auto-select optimal quality
// Route HLS internal errors through console.warn so they don't
// trigger the Next.js dev overlay (which intercepts console.error).
debug: process.env.NODE_ENV === 'development' ? {
trace: () => {},
debug: () => {},
log: () => {},
info: () => {},
warn: (...a: unknown[]) => console.debug('[HLS]', ...a),
error: (...a: unknown[]) => console.warn('[HLS]', ...a),
} : false,
// Disable the web worker so XHR abort is synchronous on the main
// thread — eliminates worker-race requests after hls.destroy().
enableWorker: false,
lowLatencyMode: false,
backBufferLength: 30,
// Smaller buffer = fewer in-flight segments when user closes video.
maxBufferLength: 30,
maxBufferSize: 60 * 1000 * 1000, // 60MB
maxBufferHole: 0.5,
startLevel: -1,
capLevelToPlayerSize: true,
autoStartLoad: true,
maxFragLookUpTolerance: 0.25,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 10,
// Aggressive preloading for better buffering
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 2,
// Fewer retries so recovery loops die quickly.
manifestLoadingMaxRetry: 1,
levelLoadingTimeOut: 10000,
levelLoadingMaxRetry: 2,
fragLoadingTimeOut: 20000, // Longer timeout for large segments
fragLoadingMaxRetry: 3,
// Bandwidth estimation settings
abrEwmaDefaultEstimate: 500000, // 500kbps initial estimate
levelLoadingMaxRetry: 1,
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 1,
abrEwmaDefaultEstimate: 500000,
abrBandWidthFactor: 0.95,
abrBandWidthUpFactor: 0.7,
});
@ -309,7 +433,10 @@ export default function ArtPlayerWrapper({
});
hlsInstance.on(Hls.Events.ERROR, (event: string, data: any) => {
console.error('HLS error details:', {
// If we're shutting down, ignore all errors — no recovery needed.
if (hlsShuttingDownRef.current) return;
console.warn('HLS error details:', {
type: data.type,
details: data.details,
fatal: data.fatal,
@ -331,7 +458,7 @@ export default function ArtPlayerWrapper({
hlsInstance?.recoverMediaError();
break;
default:
console.error('HLS fatal error, cannot recover');
console.warn('HLS fatal error, cannot recover');
setError('HLS streaming failed. Falling back to direct playback.');
// This will trigger fallback in the parent component
break;
@ -362,6 +489,10 @@ export default function ArtPlayerWrapper({
}
});
if (player.video) {
mediaElementsRef.current.add(player.video as HTMLVideoElement);
}
// Event listeners
player.on('ready', () => {
console.log('ArtPlayer ready');
@ -460,28 +591,7 @@ export default function ArtPlayerWrapper({
return () => {
console.log('[ArtPlayer] Starting cleanup...');
// Stop HLS loading immediately
if (hlsInstanceRef.current) {
console.log('[ArtPlayer] Stopping HLS loading...');
hlsInstanceRef.current.stopLoad();
hlsInstanceRef.current.destroy();
hlsInstanceRef.current = null;
}
// Destroy player
if (playerRef.current) {
console.log('[ArtPlayer] Destroying player...');
playerRef.current.destroy();
playerRef.current = null;
}
// Clean up HLS error handler
if (hlsErrorHandlerRef.current) {
hlsErrorHandlerRef.current.detach();
hlsErrorHandlerRef.current = null;
}
releasePlayer();
console.log('[ArtPlayer] Cleanup completed');
};
} catch (error) {
@ -489,7 +599,8 @@ export default function ArtPlayerWrapper({
setError(`Failed to initialize player: ${error instanceof Error ? error.message : 'Unknown error'}`);
setIsLoading(false);
}
}, [useArtPlayer, isOpen, video, onProgress, volume, autoplay, formatOverride]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useArtPlayer, isOpen, video, onProgress, volume, autoplay]);
useEffect(() => {
const activeJobId = getTranscodeJobId(format?.url);
@ -602,7 +713,7 @@ export default function ArtPlayerWrapper({
switch (e.key) {
case 'Escape':
e.preventDefault();
onClose();
handleClose();
break;
case ' ':
e.preventDefault();
@ -635,32 +746,13 @@ export default function ArtPlayerWrapper({
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, isPlaying]);
}, [isOpen, isPlaying]);
// Cleanup on unmount
useEffect(() => {
return () => {
console.log('[ArtPlayer] Unmount cleanup...');
// Stop HLS loading
if (hlsInstanceRef.current) {
console.log('[ArtPlayer] Stopping HLS on unmount...');
hlsInstanceRef.current.stopLoad();
hlsInstanceRef.current.destroy();
hlsInstanceRef.current = null;
}
// Destroy player
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
// Clean up HLS error handler
if (hlsErrorHandlerRef.current) {
hlsErrorHandlerRef.current.detach();
hlsErrorHandlerRef.current = null;
}
releasePlayer();
// Clean up custom styles
const styleElement = document.getElementById('artplayer-styles');
@ -674,34 +766,9 @@ export default function ArtPlayerWrapper({
useEffect(() => {
if (!isOpen) {
console.log('[ArtPlayer] Modal closed, stopping HLS...');
// Stop HLS loading immediately when modal closes
if (hlsInstanceRef.current) {
hlsInstanceRef.current.stopLoad();
hlsInstanceRef.current.destroy();
hlsInstanceRef.current = null;
releasePlayer();
}
// Also destroy player when modal closes
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
// Clean up error handler
if (hlsErrorHandlerRef.current) {
hlsErrorHandlerRef.current.detach();
hlsErrorHandlerRef.current = null;
}
const activeJobId = getTranscodeJobId(format?.url);
if (activeJobId) {
fetch(`/api/transcode/${activeJobId}`, { method: 'DELETE' }).catch(error => {
console.warn('[ArtPlayer] Failed to release transcode session:', error);
});
}
}
}, [isOpen, format?.type, video.id]);
}, [isOpen]);
if (!isOpen) return null;
@ -710,7 +777,7 @@ export default function ArtPlayerWrapper({
<div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8">
{/* Close button */}
<button
onClick={onClose}
onClick={handleClose}
className="absolute top-4 right-4 z-20 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector';
import ArtPlayerWrapper from '@/components/artplayer-wrapper';
import LocalPlayerLauncher from '@/components/local-player-launcher';
@ -46,6 +46,39 @@ export default function UnifiedVideoPlayer({
const [ratingCheckLoading, setRatingCheckLoading] = useState(true);
const [transcodeLoading, setTranscodeLoading] = useState(false);
const [transcodeError, setTranscodeError] = useState<string | null>(null);
const [activeTranscodeJobId, setActiveTranscodeJobId] = useState<string | null>(null);
const activeTranscodeJobIdRef = useRef<string | null>(null);
// Keep ref in sync with state
useEffect(() => {
activeTranscodeJobIdRef.current = activeTranscodeJobId;
}, [activeTranscodeJobId]);
const releaseTranscodeJob = useCallback((jobId: string | null) => {
if (!jobId) return;
fetch(`/api/transcode/${jobId}`, { method: 'DELETE', keepalive: true }).catch(error => {
console.warn('[UnifiedVideoPlayer] Failed to release transcode job:', error);
});
}, []);
const resetDetectedFormat = useCallback(() => {
const detectedFormat = detectVideoFormat(video);
setFormat(detectedFormat);
setTranscodeError(null);
setTranscodeLoading(false);
return detectedFormat;
}, [video]);
const handleClose = useCallback(() => {
// Read from ref to get the latest value, not a stale closure
const jobId = activeTranscodeJobIdRef.current;
if (jobId) {
releaseTranscodeJob(jobId);
setActiveTranscodeJobId(null);
}
resetDetectedFormat();
onClose();
}, [onClose, releaseTranscodeJob, resetDetectedFormat]);
// Check current bookmark status and rating when video opens
useEffect(() => {
@ -97,16 +130,23 @@ export default function UnifiedVideoPlayer({
}
};
// Detect format on mount
// Detect/reset format whenever a video is opened. This avoids reusing a killed HLS job URL
// after closing and reopening the same video.
useEffect(() => {
if (video) {
if (video && isOpen) {
console.log('[UnifiedVideoPlayer] Detecting format for video:', video);
const detectedFormat = detectVideoFormat(video);
// Read from ref to get latest jobId without needing it in deps
const prevJobId = activeTranscodeJobIdRef.current;
if (prevJobId) {
releaseTranscodeJob(prevJobId);
setActiveTranscodeJobId(null);
}
const detectedFormat = resetDetectedFormat();
console.log('[UnifiedVideoPlayer] Detected format:', detectedFormat);
setFormat(detectedFormat);
setIsLoading(false);
}
}, [video]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video, isOpen]);
useEffect(() => {
if (!isOpen || format?.type !== 'local-player' || process.env.NEXT_PUBLIC_ENABLE_LIVE_TRANSCODE !== 'true') {
@ -128,6 +168,7 @@ export default function UnifiedVideoPlayer({
throw new Error(data.error || 'Failed to start live transcode');
}
if (!cancelled) {
setActiveTranscodeJobId(data.jobId || null);
setFormat({
type: 'hls',
supportLevel: 'hls',
@ -152,6 +193,17 @@ export default function UnifiedVideoPlayer({
};
}, [isOpen, format?.type, video.id]);
// When the modal closes while a transcode job is active, kill it.
useEffect(() => {
if (isOpen) return;
const jobId = activeTranscodeJobIdRef.current;
if (!jobId) return;
releaseTranscodeJob(jobId);
setActiveTranscodeJobId(null);
resetDetectedFormat();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// Handle ArtPlayer errors with recovery
const handleArtPlayerError = useCallback((error: string) => {
console.log('ArtPlayer encountered error:', error);
@ -216,7 +268,7 @@ export default function UnifiedVideoPlayer({
<LocalPlayerLauncher
video={video}
format={format}
onClose={onClose}
onClose={handleClose}
onPlayerSelect={(playerId) => {
console.log(`Selected player: ${playerId}`);
}}
@ -237,7 +289,7 @@ export default function UnifiedVideoPlayer({
<ArtPlayerWrapper
video={video}
isOpen={isOpen}
onClose={onClose}
onClose={handleClose}
onProgress={handleProgressUpdate}
onBookmark={handleBookmarkToggle}
onUnbookmark={handleUnbookmark}

View File

@ -174,9 +174,52 @@ class TranscodeOrchestrator {
return { reused: false, job: newJob };
}
async kill(jobId: string, reason = 'client closed', cleanupDelay = getTranscodeConfig().cacheGraceMs): Promise<void> {
/**
* Synchronously remove the job from the in-memory map so that segment/playlist
* routes start returning 404 immediately. Returns the job's FFmpeg process (if
* still running) so the caller can kill it asynchronously in the background.
*/
markKilled(jobId: string): ChildProcessWithoutNullStreams | null {
const job = this.jobs.get(jobId);
if (!job) return;
if (!job || job.status === 'killed') return null;
job.status = 'killed';
job.error = 'client closed';
this.heartbeat.clear(jobId);
job.throttler?.stop();
this.gate.release();
this.jobs.delete(jobId);
// Persist status to DB so recovery on restart doesn't see stale jobs.
try {
getDatabase().prepare('UPDATE transcode_jobs SET status = ?, error = ? WHERE job_id = ?').run('killed', 'client closed', jobId);
} catch { /* best-effort */ }
// Schedule cleanup with the normal grace period.
this.janitor.scheduleCleanup(job.outDir, getTranscodeConfig().cacheGraceMs);
// Return the live process so the caller can terminate it asynchronously.
return (job.process && job.process.exitCode === null) ? job.process : null;
}
/** Terminate a raw FFmpeg process (graceful → SIGTERM → SIGKILL). */
async killProcess(proc: ChildProcessWithoutNullStreams): Promise<void> {
if (proc.exitCode !== null) return;
try { proc.stdin.write('q\n'); } catch { /* already gone */ }
const exited = await waitForExit(proc, 3000);
if (!exited && proc.exitCode === null) {
proc.kill('SIGTERM');
await waitForExit(proc, 2000);
}
if (proc.exitCode === null) {
proc.kill('SIGKILL');
}
}
async kill(jobId: string, reason = 'client closed', cleanupDelay = getTranscodeConfig().cacheGraceMs): Promise<void> {
// If markKilled() already ran, the job is gone from the map but we might
// still need to wait for FFmpeg to exit. Re-check via a local reference.
const job = this.jobs.get(jobId);
if (!job) {
// Job was already removed by markKilled(); just return.
return;
}
job.status = reason === 'completed' ? 'ready' : 'killed';
job.error = reason === 'completed' ? undefined : reason;

View File

@ -0,0 +1,176 @@
#!/usr/bin/env node
/**
* End-to-end test for the live transcoding lifecycle (v2 rewrite).
*
* Covers the staged changes:
* - POST /api/transcode/start -> job + playlist URL
* - master.m3u8 serves a VOD playlist immediately
* - seg/ waits up to 5s for a segment to exist (waitForSegment)
* - ping keeps the job alive; status reports it
* - seek within the encoded window is reused (204)
* - DELETE is fast (markKilled) and segment/playlist 404 immediately after
* - the FFmpeg process actually exits afterwards
* - repeated DELETE is idempotent
*
* Usage:
* node tests/streaming/test-transcode-lifecycle.mjs [mediaId]
*
* Requires the dev server running with ENABLE_LIVE_TRANSCODE=true and a media
* row whose format decision is hls-remux or hls-transcode (e.g. a .mov).
*/
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
const MEDIA_ID = Number(process.argv[2] || process.env.TEST_MEDIA_ID || 127);
let failures = 0;
function ok(condition, label, extra = '') {
if (condition) {
console.log(`${label}${extra ? ` (${extra})` : ''}`);
} else {
failures += 1;
console.log(`${label}${extra ? ` (${extra})` : ''}`);
}
}
async function request(path, { method = 'GET', body, timeoutMs = 15_000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
try {
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const text = await response.text();
return {
status: response.status,
headers: response.headers,
body: text,
json: (() => { try { return JSON.parse(text); } catch { return null; } })(),
elapsedMs: Date.now() - startedAt,
};
} finally {
clearTimeout(timer);
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function main() {
console.log(`🧪 Transcode lifecycle e2e — media #${MEDIA_ID} @ ${BASE_URL}`);
console.log('======================================================');
// ── 1. Start a transcode job ──────────────────────────────────────
console.log('\n1. Start transcode job');
const start = await request('/api/transcode/start', { method: 'POST', body: { mediaId: MEDIA_ID, startTime: 0 } });
ok(start.status === 200, 'POST /api/transcode/start -> 200', `elapsed ${start.elapsedMs}ms`);
if (start.status !== 200) {
console.log(` response: ${start.body.slice(0, 300)}`);
process.exit(1);
}
const jobId = start.json.jobId;
const playlistUrl = start.json.playlistUrl;
ok(typeof jobId === 'string' && jobId.length > 0, 'jobId returned');
ok(typeof playlistUrl === 'string' && playlistUrl.includes(jobId), 'playlistUrl returned');
ok(start.json.decision === 'hls-remux' || start.json.decision === 'hls-transcode', `decision ${start.json.decision}`);
// ── 2. Playlist serves a VOD playlist ─────────────────────────────
console.log('\n2. Master playlist');
const playlist = await request(playlistUrl);
ok(playlist.status === 200, `GET ${playlistUrl} -> 200`, `elapsed ${playlist.elapsedMs}ms`);
ok(playlist.body.includes('#EXTM3U'), 'contains #EXTM3U');
ok(playlist.body.includes('#EXT-X-PLAYLIST-TYPE:VOD'), 'playlist type VOD');
const segmentMatch = playlist.body.match(/seg\/seg_\d{5}\.ts/);
ok(!!segmentMatch, 'references a segment file');
// ── 3. Segment delivery (includes waitForSegment path) ────────────
console.log('\n3. Segment delivery');
const firstSegmentName = segmentMatch[0].replace('seg/', '');
const firstSeg = await request(`/api/transcode/${jobId}/seg/${firstSegmentName}`);
ok(firstSeg.status === 200, `GET ${firstSegmentName} -> 200`, `elapsed ${firstSeg.elapsedMs}ms`);
ok(firstSeg.headers.get('content-type') === 'video/mp2t', 'content-type video/mp2t');
ok(Number(firstSeg.headers.get('content-length') || 0) > 0, 'segment non-empty');
// A segment a bit further ahead: waitForSegment polls until ffmpeg writes it.
const aheadIndex = String(30).padStart(5, '0');
const aheadName = `seg_${aheadIndex}.ts`;
const ahead = await request(`/api/transcode/${jobId}/seg/${aheadName}`, { timeoutMs: 10_000 });
ok(ahead.status === 200, `GET ${aheadName} (waitForSegment) -> 200`, `elapsed ${ahead.elapsedMs}ms`);
// A segment that will never exist: should poll for ~5s then 404.
const neverSeg = await request(`/api/transcode/${jobId}/seg/seg_99999.ts`, { timeoutMs: 10_000 });
ok(neverSeg.status === 404, 'GET seg_99999.ts -> 404', `elapsed ${neverSeg.elapsedMs}ms (expected ~5000)`);
ok(neverSeg.elapsedMs >= 4500, 'waited for segment before 404ing (poll path exercised)');
// ── 4. Ping keeps the job alive ───────────────────────────────────
console.log('\n4. Heartbeat ping');
const ping = await request(`/api/transcode/${jobId}/ping`, { method: 'POST', body: { position: 0, isPaused: false } });
ok(ping.status === 200 && ping.json?.success === true, 'POST ping -> 200');
const statusBefore = await request('/api/transcode/status');
const listed = statusBefore.json?.jobs?.find(j => j.id === jobId);
ok(statusBefore.status === 200 && !!listed, 'job appears in /api/transcode/status', listed ? `status=${listed.status}` : '');
// ── 5. Seek within the encoded window is reused ───────────────────
console.log('\n5. Seek within encoded window');
const seek = await request(`/api/transcode/${jobId}/seek`, { method: 'POST', body: { position: 0 } });
ok(seek.status === 204, 'POST seek {position:0} -> 204 (job reused)');
// ── 6. DELETE fast-path + immediate 404s ─────────────────────────
console.log('\n6. DELETE (markKilled fast path)');
const del = await request(`/api/transcode/${jobId}`, { method: 'DELETE' });
ok(del.status === 200 && del.json?.success === true, 'DELETE -> 200 success', `elapsed ${del.elapsedMs}ms`);
ok(del.elapsedMs < 1000, 'DELETE responds in <1s (markKilled is synchronous)', `${del.elapsedMs}ms`);
const playlistAfter = await request(playlistUrl);
ok(playlistAfter.status === 404, 'playlist 404 immediately after DELETE');
const segAfter = await request(`/api/transcode/${jobId}/seg/${firstSegmentName}`);
ok(segAfter.status === 404, 'segment 404 immediately after DELETE');
const statusAfter = await request('/api/transcode/status');
ok(!statusAfter.json?.jobs?.some(j => j.id === jobId), 'job removed from status');
// ── 7. FFmpeg process actually exits ──────────────────────────────
console.log('\n7. FFmpeg process termination');
await sleep(5000);
const { execSync } = await import('node:child_process');
let ffmpegStillRunning = false;
try {
const out = execSync(`pgrep -fl "${jobId}" || true`, { encoding: 'utf8' }).trim();
ffmpegStillRunning = out.length > 0;
if (ffmpegStillRunning) console.log(` lingering: ${out}`);
} catch {
// pgrep unavailable — treat as pass-through, best-effort check
}
ok(!ffmpegStillRunning, 'no FFmpeg process remains for the job after grace period');
// ── 8. Idempotent DELETE ──────────────────────────────────────────
console.log('\n8. Idempotent DELETE');
const delAgain = await request(`/api/transcode/${jobId}`, { method: 'DELETE' });
ok(delAgain.status === 200, 'second DELETE -> 200 (no crash)');
// ── 9. Unknown job 404s ───────────────────────────────────────────
console.log('\n9. Unknown job handling');
const missing = await request('/api/transcode/00000000-0000-0000-0000-000000000000/master.m3u8');
ok(missing.status === 404, 'playlist for unknown job -> 404');
console.log('\n======================================================');
if (failures === 0) {
console.log(`🎉 ALL CHECKS PASSED (job ${jobId})`);
process.exit(0);
} else {
console.log(`💥 ${failures} check(s) FAILED (job ${jobId})`);
process.exit(1);
}
}
main().catch(error => {
console.error('❌ Test runner crashed:', error);
process.exit(1);
});