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:
parent
e9150ce0d7
commit
1732a16ce2
BIN
data/media.db
BIN
data/media.db
Binary file not shown.
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue