videos: hide the transcoder's temporary output from the file refresh (#731)

ffmpeg wrote `<stem>.transcode.<container>` next to the source.  A refresh
that overlapped the transcode discovered that file, probed the half-written
output (errors in the log), and created a FileGroup and Video for a path the
final rename removed moments later.

The temporary file is now `.<stem>.transcode.<container>`: every discovery
path already skips hidden files, and staying in the same directory keeps
the rename over the final path atomic.  `transcode_tmp_path` is the single
place the name is built.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-09-26 14:59:51 -06:00 • committed by GitHub
parent 86d4bbab98
commit 5dda94492e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 36 additions and 2 deletions

View file

@ -7,7 +7,7 @@ from unittest import mock
import pytest
from modules.videos.models import Video
from modules.videos.transcode import make_ffmpeg_progress_parser, transcode_video_job
from modules.videos.transcode import make_ffmpeg_progress_parser, transcode_video_job, transcode_tmp_path
from wrolpi import jobs
from wrolpi.cmd import CommandResult
from wrolpi.files.models import FileGroup
@ -293,3 +293,28 @@ async def test_video_transcode_api_fragmented(async_client, test_session, video_
request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode',
content=json.dumps({'container': 'mkv', 'fragmented': True}))
assert response.status == 400
@pytest.mark.asyncio
async def test_transcode_temp_file_is_not_discovered_by_refresh(test_session, test_directory, async_client,
video_factory, refresh_files):
"""A refresh that runs while ffmpeg is still writing the transcoder's output must not model it.
The temporary file is renamed over the final path moments later; a FileGroup for it would point
at a file that no longer exists, and probing a half-written file logs errors.
"""
video_path = test_directory / 'videos/NO CHANNEL/movie.mp4'
video_path.parent.mkdir(parents=True, exist_ok=True)
video = video_factory(with_video_file=video_path)
test_session.commit()
tmp_path = transcode_tmp_path(video_path, 'mp4')
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', tmp_path)
assert tmp_path.is_file()
await refresh_files()
primary_paths = {str(i.primary_path) for i in test_session.query(FileGroup)}
assert str(video_path) in primary_paths
assert str(tmp_path) not in primary_paths, 'the transcoder temporary file was indexed'
assert not any(str(tmp_path) in str(p) for p in primary_paths)

View file

@ -149,6 +149,15 @@ async def _acquire_transcode_lock():
return lock
def transcode_tmp_path(video_path: pathlib.Path, container: str) -> pathlib.Path:
"""Where ffmpeg writes its output before it is renamed over the final path.
Hidden (dot-prefixed) in the video's own directory: a refresh skips hidden files, so a refresh that
overlaps the transcode neither probes the half-written output nor creates a FileGroup for a file
that the rename removes moments later; and same-directory keeps the final rename atomic."""
return video_path.with_name(f'.{video_path.stem}.transcode.{container}')
async def transcode_video_file(video_path: pathlib.Path,
target_vcodec: Optional[str] = None,
target_acodec: Optional[str] = None,
@ -363,7 +372,7 @@ async def _transcode_video_file(video_path: pathlib.Path, target_vcodec: Optiona
mux_args = ()
final_path = video_path.with_suffix(f'.{container}')
tmp_path = video_path.with_suffix(f'.transcode.{container}')
tmp_path = transcode_tmp_path(video_path, container)
cmd = (FFMPEG_BIN, '-y',
'-i', video_path,
*map_args,