From 1732a16ce2c2683f1e589b9e607bc80e472d376e Mon Sep 17 00:00:00 2001 From: tigerenwork Date: Thu, 6 Aug 2026 23:34:14 +0800 Subject: [PATCH] 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 --- data/media.db | Bin 212992 -> 212992 bytes src/app/api/transcode/[jobId]/route.ts | 12 +- .../api/transcode/[jobId]/seg/[name]/route.ts | 14 +- src/components/artplayer-wrapper.tsx | 253 +++++++++++------- src/components/unified-video-player.tsx | 68 ++++- src/lib/transcode/orchestrator.ts | 47 +++- tests/streaming/test-transcode-lifecycle.mjs | 176 ++++++++++++ 7 files changed, 465 insertions(+), 105 deletions(-) create mode 100644 tests/streaming/test-transcode-lifecycle.mjs diff --git a/data/media.db b/data/media.db index 7491e10e0602984731e5c708ef00e153abdb632b..3412255a1296bbed81ac13b73c597e4d286eee45 100644 GIT binary patch delta 10626 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 diff --git a/src/app/api/transcode/[jobId]/route.ts b/src/app/api/transcode/[jobId]/route.ts index ddb66bd..279b2f2 100644 --- a/src/app/api/transcode/[jobId]/route.ts +++ b/src/app/api/transcode/[jobId]/route.ts @@ -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 }); } diff --git a/src/app/api/transcode/[jobId]/seg/[name]/route.ts b/src/app/api/transcode/[jobId]/seg/[name]/route.ts index 47e8635..7e48481 100644 --- a/src/app/api/transcode/[jobId]/seg/[name]/route.ts +++ b/src/app/api/transcode/[jobId]/seg/[name]/route.ts @@ -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 { + 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 }); } diff --git a/src/components/artplayer-wrapper.tsx b/src/components/artplayer-wrapper.tsx index 2bc8f82..045c882 100644 --- a/src/components/artplayer-wrapper.tsx +++ b/src/components/artplayer-wrapper.tsx @@ -48,6 +48,8 @@ export default function ArtPlayerWrapper({ const containerRef = useRef(null); const playerRef = useRef(null); const hlsInstanceRef = useRef(null); // Store HLS instance for cleanup + const formatOverrideRef = useRef(formatOverride); + const mediaElementsRef = useRef>(new Set()); const [format, setFormat] = useState(null); const [error, setError] = useState(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(null); + const hlsShuttingDownRef = useRef(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; + 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('video, audio').forEach(mediaEl => { + mediaEl.pause(); + if (clearSource) { + mediaEl.removeAttribute('src'); + mediaEl.load(); + } + }); + + if (clearSource) { + document.querySelectorAll('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