feat(transcoding): add sentinel-based transcode guard and frontend retry

- Introduce a `.transcoding` sentinel file to prevent concurrent
  ffmpeg processes for the same video; return HTTP 202 with
  `Retry-After: 5` when a transcode is already in progress
- Serve cached transcode immediately when the output file exists,
  avoiding unnecessary re-transcodes on repeat requests
- Fix transcode failure response code from 404 to 500
- Add `isTranscoding` state in VideoPlayer that triggers an
  in-UI message and auto-retries the video source after 5 s
  when the server responds with 202
- Use `retryKey` to force re-mount of the `<video>` element on retry
This commit is contained in:
Dakota Gravitt 2026-02-25 21:02:48 -06:00
parent 89e558207f
commit a32e95d913
2 changed files with 51 additions and 3 deletions

View File

@ -335,7 +335,24 @@ class VideoStreamView(ApiBaseView):
os.makedirs(cache_dir, exist_ok=True)
cache_path = os.path.join(cache_dir, f"{video_id}.mp4")
if not os.path.exists(cache_path):
sentinel_path = cache_path + ".transcoding"
# Serve cached transcode immediately if available
if os.path.exists(cache_path):
response = FileResponse(open(cache_path, "rb"))
response["Content-Type"] = "video/mp4"
return response
# Guard against concurrent transcode of the same video
if os.path.exists(sentinel_path):
in_progress = Response({"status": "transcoding"}, status=202)
in_progress["Retry-After"] = "5"
return in_progress
# Run transcode; sentinel is removed regardless of outcome
try:
with open(sentinel_path, "w"):
pass # create sentinel
cmd = [
"ffmpeg",
"-y",
@ -358,10 +375,15 @@ class VideoStreamView(ApiBaseView):
cache_path,
]
subprocess.run(cmd, check=False)
finally:
try:
os.remove(sentinel_path)
except FileNotFoundError:
pass
if not os.path.exists(cache_path):
error = ErrorResponseSerializer({"error": "transcode failed"})
return Response(error.data, status=404)
return Response(error.data, status=500)
response = FileResponse(open(cache_path, "rb"))
response["Content-Type"] = "video/mp4"

View File

@ -128,6 +128,19 @@ const VideoPlayer = ({
setSeekToTimestamp,
}: VideoPlayerProps) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isTranscoding, setIsTranscoding] = useState(false);
const [retryKey, setRetryKey] = useState(0);
// If the video element errors (e.g. server returned 202 Transcoding),
// show a message and automatically retry after 5 seconds.
useEffect(() => {
if (!isTranscoding) return;
const timer = setTimeout(() => {
setIsTranscoding(false);
setRetryKey((k: number) => k + 1);
}, 5000);
return () => clearTimeout(timer);
}, [isTranscoding]);
useEffect(() => {
if (seekToTimestamp === undefined || !videoRef.current) {
@ -413,9 +426,12 @@ const VideoPlayer = ({
className={embed ? '' : `player-wrapper ${isTheaterMode ? 'theater-mode' : ''}`}
>
<div className={embed ? '' : `video-main ${isTheaterMode ? 'theater-mode' : ''}`}>
{isTranscoding && (
<p className="video-transcoding">Transcoding video for browser playback, retrying in 5 seconds</p>
)}
<video
ref={videoRef}
key={`${getApiUrl()}${videoUrl}`}
key={`${getApiUrl()}${videoUrl}-${retryKey}`}
poster={`${getApiUrl()}${videoThumbUrl}`}
onVolumeChange={(videoTag: VideoTag) => {
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
@ -445,6 +461,16 @@ const VideoPlayer = ({
});
}}
onEnded={handleVideoEnd(videoId, watched)}
onError={async () => {
try {
const res = await fetch(`${getApiUrl()}${videoUrl}`, { method: 'HEAD' });
if (res.status === 202) {
setIsTranscoding(true);
}
} catch {
// network error — ignore
}
}}
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();