Jobs queue, video Edit dropdown: transcode/remux, edit title & description (#711)
* Jobs queue and video transcode from the video page
Add an in-memory FIFO Job queue (wrolpi/jobs.py): functions register by
name, callers enqueue by name with JSON kwargs, and the perpetual-tasks
owner runs them one at a time. Logging emitted inside a Job and the
output of its subprocesses are captured into a per-Job log; Jobs report
progress and can be cancelled (a running Job's subprocess is killed).
Jobs are not persisted; an API restart forgets them.
Add a transcode Job and POST /api/videos/<id>/transcode. ffmpeg reports
progress via -progress pipe:1. The existing FileGroup is pointed at the
new file before the refresh so tags and history survive a container
change.
Replace the video page Refresh button with an Edit dropdown (Refresh,
Transcode). The Transcode modal pre-fills from the preferred codecs in
the Videos settings and shows the Job's progress, log tail and Cancel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: move Delete into the Edit dropdown
The Edit dropdown now holds Refresh, Transcode and Delete, so the action
row is Download / Edit / Add to Playlist. Delete keeps its confirmation
(a Confirm dialog) and the tagged-files prompt, and is disabled in WROL
Mode like the other items.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: Edit > Edit to change a video's title
PUT /api/videos/<file_group_id> writes the title (and fulltitle) into the
Video's info json, creating the file when the video has none, then
validates the Video again so its title and search text follow. Files
stay the source of truth: the edit survives a refresh, and a re-download
of the metadata replaces it (the modal says so).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: edit the description in Edit > Edit
PUT /api/videos/<file_group_id> now takes optional title and description
and changes only the fields given; an empty description clears it. The
modal sends only what changed and refetches the description after saving.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: allow a container-only remux
Keeping both streams is no longer refused: ffmpeg copies them into the
chosen container with fast start. The container select starts at the
file's own container, the hint explains what a remux does, and the button
reads Remux when nothing is re-encoded.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* WROL Mode: refuse video edits and transcodes past the API
The routes already returned 403, but a transcode Job queued before WROL
Mode was enabled still ran, update_video could be called from anywhere,
and the Edit modal's Save button stayed live. The Job now fails before
ffmpeg starts, update_video carries wrol_mode_check, and the modal
disables Save with a notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: verify the output before replacing the original
After ffmpeg exits, the output is probed and compared with the source
(container, presence of video/audio streams, codec per stream, duration
within 1s or 1%) and its last 30 seconds are decoded to a null output.
Any failure removes the output and keeps the original.
The tail decode replaces the download-time completeness check, which
grabbed a single frame near the end: one ffmpeg_video_complete (sync for
the downloader, async for Jobs) now serves both, and is stricter, since
every frame of the tail plus the audio must decode.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: claim atomically, cap pending, own-session subprocesses; retarget before delete
Review fixes. The worker read a Job's status and wrote RUNNING as two
steps, so a cancel landing between them was overwritten and the Job ran;
_claim_job now does both under the lock. enqueue_job refuses past
JOB_PENDING_LIMIT pending Jobs. JobContext.run_command starts the
subprocess in its own session and raises CancelledError when the command
was killed, so a handler cannot mistake it for a normal exit.
The transcode Job now retargets the FileGroup (committed) while the
original file still exists, and deletes the original afterwards
(transcode_video_file gained keep_original). A crash in between leaves
both files with the database pointing at a real one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: the Edit menu owns the transcode Job; polling survives Close and errors
Review fixes. The transcode Job's id and polling (useJob) move from the
modal to VideoEditMenu, so closing the modal no longer stops the page
from learning the Job finished (and refetching the video, whose path may
have changed). While a Job runs the menu shows Transcoding..., reopening
shows its progress instead of the form, and Delete waits. A failed poll
request is retried instead of ending the polling. getJobs checks the
response like its siblings. Design narration trimmed from comments.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: audio files, and Remove video to turn a video into an audio file
video_codec 'none' (REMOVE_VIDEO) drops the video stream (-vn, audio
required); an audio-only source is audio-only output whatever the video
target says, and a video target on it is refused. Audio output gets an
audio container (m4a/ogg/mp3) checked against the codec it will hold, and
a video container asked for audio output is replaced by the codec's
natural one. faststart only for mp4/m4a. Verification learned the
audio-only shape and ffprobe's container names (mkv reports matroska).
The modal offers 'Remove video (audio only)', switches to audio
containers and follows the audio codec, reads Extract Audio, and opens
for audio files (no video codec select).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: yellow Edit button
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: remove the temp output on cancel; tell the user when a closed-modal Job fails or is lost
Review fixes. The temp output was only removed on Exception, but a
cancelled Job's run_command raises CancelledError (a BaseException), so a
cancel left the partial file for a refresh to index. Closing the modal
during a Job that then failed showed nothing; it now toasts the error
(and a cancellation). Polling gives up on a 404 or five failures in a
row (the API restarted and forgot its Jobs), warning the user and
unlocking Transcode and Delete instead of reading Transcoding... forever.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: _claim_job docstring states the invariant, not the old race
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: fragmented mp4 option for very long videos
A whole-file mp4 index grows with the frame count; for a many-hour video
it reaches tens of MB, which a phone must fetch and parse before the
first frame, and every seek walks it. `fragmented` writes the file with
-movflags +frag_keyframe+empty_moov+default_base_moof+global_sidx and
10s fragments: a ~1 KB moov plus a global seek index, so playback starts
and seeks quickly. mp4/m4a only; a remux, so it takes seconds per GB.
Measured on an 11-hour h264/aac file: moov 40 MB -> 1.4 KB, seek index
2 x 120 KB, 10k fragments, duration and codecs unchanged, remux in 25s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: the Fragmented label says audio for audio output
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video edits keep the original: custom_title/custom_description in the info json's wrolpi section
Editing a title or description no longer overwrites yt-dlp's keys. The
values go into the info json's `wrolpi` section (custom_title,
custom_description); the modeler prefers them when deriving the Video, so
a refresh keeps the edit while the original title and description stay in
the file. An empty custom description means none.
Both re-download paths carry the wrolpi section over: the metadata-only
refresh merges it into the fresh info json, and a full re-download
snapshots it before yt-dlp overwrites the file and restores it (then
re-validates) in finalize_download. FileGroup.get_wrolpi_json reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:25:23 -06:00
|
|
|
"""Tests for the transcode Job and its API (see `wrolpi.jobs`)."""
|
|
|
|
|
import json
|
|
|
|
|
import pathlib
|
|
|
|
|
import shutil
|
|
|
|
|
from unittest import mock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from modules.videos.models import Video
|
2026-09-26 14:59:51 -06:00
|
|
|
from modules.videos.transcode import make_ffmpeg_progress_parser, transcode_video_job, transcode_tmp_path
|
Jobs queue, video Edit dropdown: transcode/remux, edit title & description (#711)
* Jobs queue and video transcode from the video page
Add an in-memory FIFO Job queue (wrolpi/jobs.py): functions register by
name, callers enqueue by name with JSON kwargs, and the perpetual-tasks
owner runs them one at a time. Logging emitted inside a Job and the
output of its subprocesses are captured into a per-Job log; Jobs report
progress and can be cancelled (a running Job's subprocess is killed).
Jobs are not persisted; an API restart forgets them.
Add a transcode Job and POST /api/videos/<id>/transcode. ffmpeg reports
progress via -progress pipe:1. The existing FileGroup is pointed at the
new file before the refresh so tags and history survive a container
change.
Replace the video page Refresh button with an Edit dropdown (Refresh,
Transcode). The Transcode modal pre-fills from the preferred codecs in
the Videos settings and shows the Job's progress, log tail and Cancel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: move Delete into the Edit dropdown
The Edit dropdown now holds Refresh, Transcode and Delete, so the action
row is Download / Edit / Add to Playlist. Delete keeps its confirmation
(a Confirm dialog) and the tagged-files prompt, and is disabled in WROL
Mode like the other items.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: Edit > Edit to change a video's title
PUT /api/videos/<file_group_id> writes the title (and fulltitle) into the
Video's info json, creating the file when the video has none, then
validates the Video again so its title and search text follow. Files
stay the source of truth: the edit survives a refresh, and a re-download
of the metadata replaces it (the modal says so).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: edit the description in Edit > Edit
PUT /api/videos/<file_group_id> now takes optional title and description
and changes only the fields given; an empty description clears it. The
modal sends only what changed and refetches the description after saving.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: allow a container-only remux
Keeping both streams is no longer refused: ffmpeg copies them into the
chosen container with fast start. The container select starts at the
file's own container, the hint explains what a remux does, and the button
reads Remux when nothing is re-encoded.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* WROL Mode: refuse video edits and transcodes past the API
The routes already returned 403, but a transcode Job queued before WROL
Mode was enabled still ran, update_video could be called from anywhere,
and the Edit modal's Save button stayed live. The Job now fails before
ffmpeg starts, update_video carries wrol_mode_check, and the modal
disables Save with a notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: verify the output before replacing the original
After ffmpeg exits, the output is probed and compared with the source
(container, presence of video/audio streams, codec per stream, duration
within 1s or 1%) and its last 30 seconds are decoded to a null output.
Any failure removes the output and keeps the original.
The tail decode replaces the download-time completeness check, which
grabbed a single frame near the end: one ffmpeg_video_complete (sync for
the downloader, async for Jobs) now serves both, and is stricter, since
every frame of the tail plus the audio must decode.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: claim atomically, cap pending, own-session subprocesses; retarget before delete
Review fixes. The worker read a Job's status and wrote RUNNING as two
steps, so a cancel landing between them was overwritten and the Job ran;
_claim_job now does both under the lock. enqueue_job refuses past
JOB_PENDING_LIMIT pending Jobs. JobContext.run_command starts the
subprocess in its own session and raises CancelledError when the command
was killed, so a handler cannot mistake it for a normal exit.
The transcode Job now retargets the FileGroup (committed) while the
original file still exists, and deletes the original afterwards
(transcode_video_file gained keep_original). A crash in between leaves
both files with the database pointing at a real one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: the Edit menu owns the transcode Job; polling survives Close and errors
Review fixes. The transcode Job's id and polling (useJob) move from the
modal to VideoEditMenu, so closing the modal no longer stops the page
from learning the Job finished (and refetching the video, whose path may
have changed). While a Job runs the menu shows Transcoding..., reopening
shows its progress instead of the form, and Delete waits. A failed poll
request is retried instead of ending the polling. getJobs checks the
response like its siblings. Design narration trimmed from comments.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: audio files, and Remove video to turn a video into an audio file
video_codec 'none' (REMOVE_VIDEO) drops the video stream (-vn, audio
required); an audio-only source is audio-only output whatever the video
target says, and a video target on it is refused. Audio output gets an
audio container (m4a/ogg/mp3) checked against the codec it will hold, and
a video container asked for audio output is replaced by the codec's
natural one. faststart only for mp4/m4a. Verification learned the
audio-only shape and ffprobe's container names (mkv reports matroska).
The modal offers 'Remove video (audio only)', switches to audio
containers and follows the audio codec, reads Extract Audio, and opens
for audio files (no video codec select).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: yellow Edit button
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: remove the temp output on cancel; tell the user when a closed-modal Job fails or is lost
Review fixes. The temp output was only removed on Exception, but a
cancelled Job's run_command raises CancelledError (a BaseException), so a
cancel left the partial file for a refresh to index. Closing the modal
during a Job that then failed showed nothing; it now toasts the error
(and a cancellation). Polling gives up on a 404 or five failures in a
row (the API restarted and forgot its Jobs), warning the user and
unlocking Transcode and Delete instead of reading Transcoding... forever.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: _claim_job docstring states the invariant, not the old race
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: fragmented mp4 option for very long videos
A whole-file mp4 index grows with the frame count; for a many-hour video
it reaches tens of MB, which a phone must fetch and parse before the
first frame, and every seek walks it. `fragmented` writes the file with
-movflags +frag_keyframe+empty_moov+default_base_moof+global_sidx and
10s fragments: a ~1 KB moov plus a global seek index, so playback starts
and seeks quickly. mp4/m4a only; a remux, so it takes seconds per GB.
Measured on an 11-hour h264/aac file: moov 40 MB -> 1.4 KB, seek index
2 x 120 KB, 10k fragments, duration and codecs unchanged, remux in 25s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: the Fragmented label says audio for audio output
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video edits keep the original: custom_title/custom_description in the info json's wrolpi section
Editing a title or description no longer overwrites yt-dlp's keys. The
values go into the info json's `wrolpi` section (custom_title,
custom_description); the modeler prefers them when deriving the Video, so
a refresh keeps the edit while the original title and description stay in
the file. An empty custom description means none.
Both re-download paths carry the wrolpi section over: the metadata-only
refresh merges it into the fresh info json, and a full re-download
snapshots it before yt-dlp overwrites the file and restores it (then
re-validates) in finalize_download. FileGroup.get_wrolpi_json reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:25:23 -06:00
|
|
|
from wrolpi import jobs
|
|
|
|
|
from wrolpi.cmd import CommandResult
|
|
|
|
|
from wrolpi.files.models import FileGroup
|
|
|
|
|
from wrolpi.vars import PROJECT_DIR
|
|
|
|
|
|
|
|
|
|
MP4 = 'mov,mp4,m4a,3gp,3g2,mj2'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def mock_probe():
|
|
|
|
|
"""The output probe agrees with what the Job asked for (targets or copied source codecs)."""
|
|
|
|
|
|
|
|
|
|
async def probe(path: pathlib.Path):
|
|
|
|
|
# The test video is h264/aac; a copied stream keeps that, a target replaces it.
|
|
|
|
|
fmt = MP4 if path.suffix == '.mp4' else 'matroska,webm'
|
|
|
|
|
return {'streams': [{'codec_type': 'video', 'codec_name': 'h264'},
|
|
|
|
|
{'codec_type': 'audio', 'codec_name': 'aac'}],
|
|
|
|
|
'format': {'format_name': fmt, 'duration': '5.3'}}
|
|
|
|
|
|
|
|
|
|
with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probe):
|
|
|
|
|
yield
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_make_ffmpeg_progress_parser():
|
|
|
|
|
"""`-progress pipe:1` lines are converted to whole-percent progress, reported only on change."""
|
|
|
|
|
reported = []
|
|
|
|
|
parse = make_ffmpeg_progress_parser(100.0, reported.append)
|
|
|
|
|
parse('frame=12') # Ignored.
|
|
|
|
|
parse('out_time_us=25000000')
|
|
|
|
|
parse('out_time_us=25400000') # Still 25%, not reported again.
|
|
|
|
|
parse('out_time_ms=50000000') # Older ffmpeg key, also microseconds.
|
|
|
|
|
parse('out_time_us=garbage')
|
|
|
|
|
parse('out_time_us=250000000') # Clamped to 100.
|
|
|
|
|
assert reported == [25, 50, 100]
|
|
|
|
|
|
|
|
|
|
# No duration: nothing can be reported.
|
|
|
|
|
reported.clear()
|
|
|
|
|
make_ffmpeg_progress_parser(None, reported.append)('out_time_us=25000000')
|
|
|
|
|
assert reported == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_transcode_video_job(test_session, test_directory, async_client, video_factory):
|
|
|
|
|
"""The transcode Job runs ffmpeg inside the Job (log + progress captured) and re-indexes the
|
|
|
|
|
Video so its FileGroup points at the new file."""
|
|
|
|
|
video_path = test_directory / 'videos/NO CHANNEL/movie.webm'
|
|
|
|
|
video_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
video = video_factory(with_video_file=video_path)
|
|
|
|
|
video.file_group.length = 10
|
|
|
|
|
test_session.commit()
|
|
|
|
|
file_group_id = video.file_group_id
|
|
|
|
|
|
|
|
|
|
async def fake_run_command(cmd, stdout_callback=None, **kwargs):
|
|
|
|
|
# ffmpeg reports progress on stdout, then writes the temporary output file. The output
|
|
|
|
|
# must be a real video: the refresh would otherwise drop the Video model (mimetype changed).
|
|
|
|
|
stdout_callback('out_time_us=5000000')
|
|
|
|
|
if cmd[-1] == '-':
|
|
|
|
|
# The tail-decode check: nothing to write.
|
|
|
|
|
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=0)
|
|
|
|
|
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', cmd[-1])
|
|
|
|
|
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'ffmpeg version fake', elapsed=1)
|
|
|
|
|
|
|
|
|
|
# In tests the file refresh is drained inline by the waiter, inside the Job's task, so its
|
|
|
|
|
# (many) log lines land in the Job log too and could push the early lines out of the tail.
|
|
|
|
|
with mock.patch('wrolpi.cmd.run_command', side_effect=fake_run_command), \
|
|
|
|
|
mock.patch.object(jobs, 'JOB_LOG_LINES', 10_000):
|
|
|
|
|
job_id = transcode_video_job.enqueue(description='Transcode movie.webm', file_group_id=file_group_id,
|
|
|
|
|
video_codec='h264', audio_codec='aac', container='mp4')
|
|
|
|
|
record = await jobs.wait_for_job(job_id)
|
|
|
|
|
|
|
|
|
|
assert record['status'] == jobs.COMPLETE, record
|
|
|
|
|
assert record['result'] == {'path': 'videos/NO CHANNEL/movie.mp4'}
|
|
|
|
|
assert 'out_time_us=5000000' in record['log'], 'ffmpeg stdout is in the Job log'
|
|
|
|
|
assert 'ffmpeg version fake' in record['log'], 'ffmpeg stderr is in the Job log'
|
|
|
|
|
assert any('Transcoding' in i for i in record['log']), 'logger output is in the Job log'
|
|
|
|
|
|
|
|
|
|
assert not video_path.exists()
|
|
|
|
|
assert (test_directory / 'videos/NO CHANNEL/movie.mp4').is_file()
|
|
|
|
|
|
|
|
|
|
test_session.expire_all()
|
|
|
|
|
# The same FileGroup (tags, history) now points at the mp4; no new FileGroup was created.
|
|
|
|
|
video = Video.find_by_file_group_id(test_session, file_group_id)
|
|
|
|
|
assert video.video_path == test_directory / 'videos/NO CHANNEL/movie.mp4'
|
|
|
|
|
assert video.file_group.primary_path == test_directory / 'videos/NO CHANNEL/movie.mp4'
|
|
|
|
|
assert video.file_group.mimetype == 'video/mp4'
|
|
|
|
|
# The modeler probed the new file and wrote a fresh sidecar; the webm is gone.
|
|
|
|
|
assert sorted(i['path'] for i in video.file_group.files) == ['movie.ffprobe.json', 'movie.mp4']
|
|
|
|
|
assert video.ffprobe_json, 'ffprobe ran again on the new file'
|
|
|
|
|
assert video.file_group.indexed is True, 'The refresh re-modeled the Video'
|
|
|
|
|
assert test_session.query(FileGroup).count() == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_transcode_video_job_remux(test_session, test_directory, async_client, video_factory):
|
|
|
|
|
"""No target codec is a remux: both streams are copied into the requested container."""
|
|
|
|
|
video_path = test_directory / 'videos/NO CHANNEL/movie.mkv'
|
|
|
|
|
video_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
video = video_factory(with_video_file=video_path)
|
|
|
|
|
test_session.commit()
|
|
|
|
|
|
|
|
|
|
async def fake_run_command(cmd, stdout_callback=None, **kwargs):
|
|
|
|
|
if cmd[-1] == '-':
|
|
|
|
|
# The tail-decode check: nothing to write.
|
|
|
|
|
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=0)
|
|
|
|
|
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', cmd[-1])
|
|
|
|
|
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=1)
|
|
|
|
|
|
|
|
|
|
with mock.patch('wrolpi.cmd.run_command', side_effect=fake_run_command) as mock_run:
|
|
|
|
|
job_id = transcode_video_job.enqueue(file_group_id=video.file_group_id, container='mp4')
|
|
|
|
|
record = await jobs.wait_for_job(job_id)
|
|
|
|
|
|
|
|
|
|
assert record['status'] == jobs.COMPLETE, record
|
|
|
|
|
cmd = mock_run.call_args_list[0][0][0]
|
|
|
|
|
assert cmd[cmd.index('-c:v') + 1] == 'copy' and cmd[cmd.index('-c:a') + 1] == 'copy'
|
|
|
|
|
assert '+faststart' in cmd
|
|
|
|
|
assert not video_path.exists()
|
|
|
|
|
assert (test_directory / 'videos/NO CHANNEL/movie.mp4').is_file()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_transcode_video_job_failure(test_session, test_directory, async_client, video_factory):
|
|
|
|
|
"""A failed ffmpeg fails the Job and leaves the original file in place."""
|
|
|
|
|
video = video_factory()
|
|
|
|
|
video_path = video.video_path
|
|
|
|
|
|
|
|
|
|
async def fake_run_command(cmd, stdout_callback=None, **kwargs):
|
|
|
|
|
return CommandResult(return_code=1, cancelled=False, stdout=b'', stderr=b'Conversion failed!', elapsed=1)
|
|
|
|
|
|
|
|
|
|
with mock.patch('wrolpi.cmd.run_command', side_effect=fake_run_command):
|
|
|
|
|
job_id = transcode_video_job.enqueue(file_group_id=video.file_group_id, video_codec='h264')
|
|
|
|
|
record = await jobs.wait_for_job(job_id)
|
|
|
|
|
|
|
|
|
|
assert record['status'] == jobs.FAILED
|
|
|
|
|
assert 'Conversion failed!' in record['error']
|
|
|
|
|
assert video_path.is_file()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_video_transcode_api(async_client, test_session, video_factory, wrol_mode_fixture):
|
|
|
|
|
"""The transcode endpoint validates the request, then queues a Job."""
|
|
|
|
|
video = video_factory()
|
|
|
|
|
test_session.commit()
|
|
|
|
|
|
|
|
|
|
# Unsupported target.
|
|
|
|
|
request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode',
|
|
|
|
|
content=json.dumps({'video_codec': 'av1'}))
|
|
|
|
|
assert response.status == 400
|
|
|
|
|
# Unknown video.
|
|
|
|
|
request, response = await async_client.post('/api/videos/999999/transcode',
|
|
|
|
|
content=json.dumps({'video_codec': 'h264'}))
|
|
|
|
|
assert response.status == 404
|
|
|
|
|
assert jobs.get_jobs() == [], 'Nothing was queued'
|
|
|
|
|
|
|
|
|
|
request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode',
|
|
|
|
|
content=json.dumps({'video_codec': 'h264', 'audio_codec': 'aac'}))
|
|
|
|
|
assert response.status == 200, response.json
|
|
|
|
|
job_id = response.json['job_id']
|
|
|
|
|
record = jobs.get_job(job_id)
|
|
|
|
|
assert record['status'] == jobs.PENDING
|
|
|
|
|
assert record['name'] == 'transcode_video'
|
|
|
|
|
assert record['description'] == f'Transcode {video.video_path.name}'
|
|
|
|
|
assert record['kwargs'] == {'file_group_id': video.file_group_id, 'video_codec': 'h264',
|
|
|
|
|
'audio_codec': 'aac', 'container': 'mp4', 'fragmented': False}
|
|
|
|
|
# Do not run it (ffmpeg); cancel instead.
|
|
|
|
|
jobs.cancel_job(job_id)
|
|
|
|
|
|
|
|
|
|
# No codec at all is allowed: a container-only remux.
|
|
|
|
|
request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode',
|
|
|
|
|
content=json.dumps({'container': 'mkv'}))
|
|
|
|
|
assert response.status == 200, response.json
|
|
|
|
|
assert jobs.get_job(response.json['job_id'])['kwargs'] == {
|
|
|
|
|
'file_group_id': video.file_group_id, 'video_codec': None, 'audio_codec': None, 'container': 'mkv', 'fragmented': False}
|
|
|
|
|
jobs.cancel_job(response.json['job_id'])
|
|
|
|
|
|
|
|
|
|
# WROL mode forbids modifying files.
|
|
|
|
|
await wrol_mode_fixture(True)
|
|
|
|
|
request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode',
|
|
|
|
|
content=json.dumps({'video_codec': 'h264'}))
|
|
|
|
|
assert response.status == 403
|
|
|
|
|
await wrol_mode_fixture(False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_transcode_video_job_wrol_mode(test_session, test_directory, async_client, video_factory,
|
|
|
|
|
wrol_mode_fixture):
|
|
|
|
|
"""A transcode queued before WROL Mode was enabled must not run: the Job fails, ffmpeg is never
|
|
|
|
|
started, and the file is untouched."""
|
|
|
|
|
video = video_factory()
|
|
|
|
|
video_path = video.video_path
|
|
|
|
|
before = video_path.read_bytes()
|
|
|
|
|
|
|
|
|
|
job_id = transcode_video_job.enqueue(file_group_id=video.file_group_id, video_codec='h264')
|
|
|
|
|
await wrol_mode_fixture(True)
|
|
|
|
|
try:
|
|
|
|
|
with mock.patch('wrolpi.cmd.run_command') as mock_run:
|
|
|
|
|
record = await jobs.wait_for_job(job_id)
|
|
|
|
|
finally:
|
|
|
|
|
await wrol_mode_fixture(False)
|
|
|
|
|
|
|
|
|
|
assert record['status'] == jobs.FAILED, record
|
|
|
|
|
assert 'WROL Mode' in record['error']
|
|
|
|
|
mock_run.assert_not_called()
|
|
|
|
|
assert video_path.read_bytes() == before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_transcode_video_job_retargets_before_deleting(test_session, test_directory, async_client,
|
|
|
|
|
video_factory):
|
|
|
|
|
"""When the suffix changes, the FileGroup is pointed at the new file (and committed) while the
|
|
|
|
|
old file still exists; the old file is deleted only afterwards. A crash in between then leaves
|
|
|
|
|
both files with the database pointing at a real one."""
|
|
|
|
|
from modules.videos import transcode as transcode_module
|
|
|
|
|
|
|
|
|
|
video_path = test_directory / 'videos/NO CHANNEL/movie.webm'
|
|
|
|
|
video_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
video = video_factory(with_video_file=video_path)
|
|
|
|
|
test_session.commit()
|
|
|
|
|
seen = {}
|
|
|
|
|
|
|
|
|
|
real_retarget = transcode_module.retarget_file_group
|
|
|
|
|
|
|
|
|
|
def spy_retarget(file_group_id, old_path, new_path):
|
|
|
|
|
seen['old_exists'] = old_path.exists()
|
|
|
|
|
seen['new_exists'] = new_path.exists()
|
|
|
|
|
real_retarget(file_group_id, old_path, new_path)
|
|
|
|
|
test_session.expire_all()
|
|
|
|
|
seen['db_primary'] = Video.find_by_file_group_id(test_session, file_group_id).file_group.primary_path
|
|
|
|
|
|
|
|
|
|
async def fake_run_command(cmd, stdout_callback=None, **kwargs):
|
|
|
|
|
if cmd[-1] == '-':
|
|
|
|
|
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=0)
|
|
|
|
|
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', cmd[-1])
|
|
|
|
|
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=1)
|
|
|
|
|
|
|
|
|
|
with mock.patch('wrolpi.cmd.run_command', side_effect=fake_run_command), \
|
|
|
|
|
mock.patch.object(transcode_module, 'retarget_file_group', side_effect=spy_retarget):
|
|
|
|
|
job_id = transcode_video_job.enqueue(file_group_id=video.file_group_id, video_codec='h264',
|
|
|
|
|
audio_codec='aac', container='mp4')
|
|
|
|
|
record = await jobs.wait_for_job(job_id)
|
|
|
|
|
|
|
|
|
|
assert record['status'] == jobs.COMPLETE, record
|
|
|
|
|
new_path = test_directory / 'videos/NO CHANNEL/movie.mp4'
|
|
|
|
|
assert seen == {'old_exists': True, 'new_exists': True, 'db_primary': new_path}, seen
|
|
|
|
|
assert not video_path.exists(), 'The old file is deleted once the database points at the new one'
|
|
|
|
|
assert new_path.is_file()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_transcode_video_file_keep_original(test_directory, async_client):
|
|
|
|
|
"""`transcode_video_file(keep_original=True)` renames the output into place but leaves the
|
|
|
|
|
original for the caller to delete once its records point at the new file."""
|
|
|
|
|
from modules.videos.transcode import transcode_video_file
|
|
|
|
|
video_path = test_directory / 'video.webm'
|
|
|
|
|
video_path.write_bytes(b'fake video data')
|
|
|
|
|
|
|
|
|
|
async def fake_run_command(cmd, **kwargs):
|
|
|
|
|
if cmd[-1] == '-':
|
|
|
|
|
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=0)
|
|
|
|
|
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4', cmd[-1])
|
|
|
|
|
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=1)
|
|
|
|
|
|
|
|
|
|
with mock.patch('modules.videos.transcode.run_command', side_effect=fake_run_command):
|
|
|
|
|
result = await transcode_video_file(video_path, target_vcodec='h264', target_acodec='aac',
|
|
|
|
|
container='mp4', keep_original=True)
|
|
|
|
|
|
|
|
|
|
assert result == test_directory / 'video.mp4' and result.is_file()
|
|
|
|
|
assert video_path.read_bytes() == b'fake video data', 'The original is kept'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_video_transcode_api_fragmented(async_client, test_session, video_factory):
|
|
|
|
|
"""`fragmented` reaches the Job; it needs an mp4 container."""
|
|
|
|
|
video = video_factory()
|
|
|
|
|
test_session.commit()
|
|
|
|
|
request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode',
|
|
|
|
|
content=json.dumps({'container': 'mp4', 'fragmented': True}))
|
|
|
|
|
assert response.status == 200, response.json
|
|
|
|
|
record = jobs.get_job(response.json['job_id'])
|
|
|
|
|
assert record['kwargs']['fragmented'] is True
|
|
|
|
|
assert record['description'].startswith('Remux')
|
|
|
|
|
jobs.cancel_job(response.json['job_id'])
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-26 14:59:51 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|