mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
* 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>
265 lines
13 KiB
Python
265 lines
13 KiB
Python
"""Audio-only transcodes: removing the video stream from a video, and transcoding audio files."""
|
|
import json
|
|
import pathlib
|
|
import shutil
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from modules.videos.transcode import transcode_video_file, validate_transcode_request, verify_transcode_output, \
|
|
REMOVE_VIDEO, TRANSCODE_AUDIO_CONTAINERS, default_audio_container, transcode_video_job
|
|
from wrolpi import jobs
|
|
from wrolpi.cmd import CommandResult
|
|
from wrolpi.vars import PROJECT_DIR
|
|
|
|
MP4 = 'mov,mp4,m4a,3gp,3g2,mj2'
|
|
|
|
|
|
def make_probe(vcodec, acodec, duration=10.0, format_name=MP4):
|
|
streams = []
|
|
if vcodec:
|
|
streams.append({'codec_type': 'video', 'codec_name': vcodec})
|
|
if acodec:
|
|
streams.append({'codec_type': 'audio', 'codec_name': acodec})
|
|
return {'streams': streams, 'format': {'format_name': format_name, 'duration': str(duration)}}
|
|
|
|
|
|
class FakeProbes:
|
|
def __init__(self, source, output):
|
|
self.source, self.output = source, output
|
|
|
|
def __call__(self, path: pathlib.Path):
|
|
return self.output if '.transcode.' in path.name else self.source
|
|
|
|
|
|
def fake_ffmpeg(cmd, **kwargs):
|
|
"""Writes the output file; the tail-decode check writes nothing."""
|
|
if cmd[-1] != '-':
|
|
pathlib.Path(cmd[-1]).write_bytes(b'output data')
|
|
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=1)
|
|
|
|
|
|
async def async_fake_ffmpeg(cmd, **kwargs):
|
|
return fake_ffmpeg(cmd, **kwargs)
|
|
|
|
|
|
# ------------------------------------------------------------------ validation
|
|
|
|
def test_validate_remove_video():
|
|
"""Removing the video needs an audio container; an audio container needs a compatible codec."""
|
|
validate_transcode_request(REMOVE_VIDEO, 'aac', 'm4a')
|
|
validate_transcode_request(REMOVE_VIDEO, None, 'ogg') # Copy: checked against the source later.
|
|
validate_transcode_request(REMOVE_VIDEO, 'mp3', 'mp3')
|
|
with pytest.raises(ValueError, match='audio container'):
|
|
validate_transcode_request(REMOVE_VIDEO, 'aac', 'mp4')
|
|
with pytest.raises(ValueError, match='ogg'):
|
|
validate_transcode_request(REMOVE_VIDEO, 'aac', 'ogg')
|
|
with pytest.raises(ValueError, match='mp3'):
|
|
validate_transcode_request(REMOVE_VIDEO, 'opus', 'mp3')
|
|
with pytest.raises(ValueError, match='video'):
|
|
validate_transcode_request('h264', 'aac', 'm4a') # A video stream cannot live in m4a.
|
|
assert set(TRANSCODE_AUDIO_CONTAINERS) == {'m4a', 'ogg', 'mp3'}
|
|
|
|
|
|
def test_default_audio_container():
|
|
assert default_audio_container('aac') == 'm4a'
|
|
assert default_audio_container('opus') == 'ogg'
|
|
assert default_audio_container('vorbis') == 'ogg'
|
|
assert default_audio_container('mp3') == 'mp3'
|
|
assert default_audio_container('flac') == 'ogg'
|
|
assert default_audio_container(None) == 'm4a'
|
|
|
|
|
|
# ------------------------------------------------------------------ verification
|
|
|
|
def test_verify_audio_only_output():
|
|
source = make_probe('vp9', 'opus', 100.0, 'matroska,webm')
|
|
good = make_probe(None, 'aac', 100.0)
|
|
verify_transcode_output(source, good, REMOVE_VIDEO, 'aac', 'm4a')
|
|
# The video stream must be gone.
|
|
with pytest.raises(RuntimeError, match='video stream'):
|
|
verify_transcode_output(source, make_probe('vp9', 'aac', 100.0), REMOVE_VIDEO, 'aac', 'm4a')
|
|
# An embedded cover is not a video stream.
|
|
verify_transcode_output(source, make_probe('mjpeg', 'aac', 100.0), REMOVE_VIDEO, 'aac', 'm4a')
|
|
# Audio is required.
|
|
with pytest.raises(RuntimeError, match='audio'):
|
|
verify_transcode_output(source, make_probe(None, None, 100.0), REMOVE_VIDEO, 'aac', 'm4a')
|
|
|
|
|
|
def test_verify_container_names():
|
|
"""ffprobe names containers differently than the file suffix does."""
|
|
source = make_probe('h264', 'aac', 10.0)
|
|
verify_transcode_output(source, make_probe('h264', 'aac', 10.0, 'matroska,webm'), None, None, 'mkv')
|
|
verify_transcode_output(source, make_probe(None, 'opus', 10.0, 'ogg'), REMOVE_VIDEO, 'opus', 'ogg')
|
|
verify_transcode_output(source, make_probe(None, 'mp3', 10.0, 'mp3'), REMOVE_VIDEO, 'mp3', 'mp3')
|
|
verify_transcode_output(source, make_probe(None, 'aac', 10.0, MP4), REMOVE_VIDEO, None, 'm4a')
|
|
with pytest.raises(RuntimeError, match='container'):
|
|
verify_transcode_output(source, make_probe(None, 'mp3', 10.0, 'mp3'), REMOVE_VIDEO, 'mp3', 'ogg')
|
|
|
|
|
|
def test_verify_audio_source():
|
|
"""An audio-only source has no video to demand; the output is audio-only too."""
|
|
source = make_probe(None, 'mp3', 100.0, 'mp3')
|
|
verify_transcode_output(source, make_probe(None, 'aac', 100.0), None, 'aac', 'm4a')
|
|
verify_transcode_output(source, make_probe(None, 'mp3', 100.0, 'mp3'), None, None, 'mp3')
|
|
|
|
|
|
# ------------------------------------------------------------------ transcode_video_file
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_video(test_directory, async_client):
|
|
"""Removing the video: no video map, `-vn`, the audio is required, the output is an audio file
|
|
named for its container, and the original is replaced."""
|
|
video_path = test_directory / 'movie.mp4'
|
|
video_path.write_bytes(b'fake video data')
|
|
probes = FakeProbes(make_probe('h264', 'aac', 10.0), make_probe(None, 'aac', 10.0))
|
|
|
|
with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probes), \
|
|
mock.patch('modules.videos.transcode.run_command', side_effect=async_fake_ffmpeg) as mock_run:
|
|
result = await transcode_video_file(video_path, target_vcodec=REMOVE_VIDEO, target_acodec=None,
|
|
container='m4a')
|
|
|
|
assert result == test_directory / 'movie.m4a' and result.is_file()
|
|
assert not video_path.exists()
|
|
cmd = mock_run.call_args_list[0][0][0]
|
|
assert '-vn' in cmd
|
|
assert '0:V:0' not in cmd
|
|
assert cmd[cmd.index('-map') + 1] == '0:a:0', 'Audio is required, not optional'
|
|
assert '-c:v' not in cmd
|
|
assert cmd[cmd.index('-c:a') + 1] == 'copy'
|
|
assert '+faststart' in cmd, 'm4a is an mp4; fast start applies'
|
|
assert str(cmd[-1]).endswith('movie.transcode.m4a')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_video_to_ogg_no_faststart(test_directory, async_client):
|
|
video_path = test_directory / 'movie.webm'
|
|
video_path.write_bytes(b'fake video data')
|
|
probes = FakeProbes(make_probe('vp9', 'opus', 10.0, 'matroska,webm'), make_probe(None, 'opus', 10.0, 'ogg'))
|
|
|
|
with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probes), \
|
|
mock.patch('modules.videos.transcode.run_command', side_effect=async_fake_ffmpeg) as mock_run:
|
|
result = await transcode_video_file(video_path, target_vcodec=REMOVE_VIDEO, container='ogg')
|
|
|
|
assert result == test_directory / 'movie.ogg'
|
|
cmd = mock_run.call_args_list[0][0][0]
|
|
assert '-movflags' not in cmd, 'movflags is an mp4 muxer option'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_audio_source_is_audio_output(test_directory, async_client):
|
|
"""An audio file has no video to keep or remove: the output is audio-only whatever the video
|
|
target says, and an unsuitable container is replaced by the codec's natural one."""
|
|
audio_path = test_directory / 'song.webm'
|
|
audio_path.write_bytes(b'fake audio data')
|
|
probes = FakeProbes(make_probe(None, 'opus', 10.0, 'matroska,webm'), make_probe(None, 'opus', 10.0, 'ogg'))
|
|
|
|
with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probes), \
|
|
mock.patch('modules.videos.transcode.run_command', side_effect=async_fake_ffmpeg) as mock_run:
|
|
result = await transcode_video_file(audio_path, target_vcodec=None, target_acodec=None, container='mp4')
|
|
|
|
assert result == test_directory / 'song.ogg', 'opus copied: ogg, not the video container asked for'
|
|
cmd = mock_run.call_args_list[0][0][0]
|
|
assert '-vn' in cmd and '0:V:0' not in cmd
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_copy_audio_into_incompatible_container_is_refused(test_directory, async_client):
|
|
"""Copying opus into m4a cannot work; refuse before ffmpeg runs."""
|
|
video_path = test_directory / 'movie.webm'
|
|
video_path.write_bytes(b'fake video data')
|
|
probes = FakeProbes(make_probe('vp9', 'opus', 10.0, 'matroska,webm'), make_probe(None, 'opus', 10.0))
|
|
|
|
with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probes), \
|
|
mock.patch('modules.videos.transcode.run_command', side_effect=async_fake_ffmpeg) as mock_run:
|
|
with pytest.raises(RuntimeError, match='m4a'):
|
|
await transcode_video_file(video_path, target_vcodec=REMOVE_VIDEO, target_acodec=None, container='m4a')
|
|
mock_run.assert_not_called()
|
|
assert video_path.is_file()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_video_target_on_audio_source_is_refused(test_directory, async_client):
|
|
audio_path = test_directory / 'song.mp3'
|
|
audio_path.write_bytes(b'fake audio data')
|
|
probes = FakeProbes(make_probe(None, 'mp3', 10.0, 'mp3'), make_probe(None, 'mp3', 10.0, 'mp3'))
|
|
|
|
with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probes), \
|
|
mock.patch('modules.videos.transcode.run_command', side_effect=async_fake_ffmpeg) as mock_run:
|
|
with pytest.raises(RuntimeError, match='no video stream'):
|
|
await transcode_video_file(audio_path, target_vcodec='h264', container='mp4')
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
# ------------------------------------------------------------------ Job + API
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_video_job(test_session, test_directory, async_client, video_factory):
|
|
"""The FileGroup follows the video into its audio-only life: same id, new suffix and mimetype."""
|
|
from modules.videos.models import Video
|
|
from wrolpi.files.models import FileGroup
|
|
|
|
video = video_factory()
|
|
video.file_group.length = 5
|
|
test_session.commit()
|
|
file_group_id = video.file_group_id
|
|
video_path = video.video_path
|
|
probes = FakeProbes(make_probe('h264', 'aac', 5.3), make_probe(None, 'mp3', 5.3, 'mp3'))
|
|
|
|
async def fake_run_command(cmd, stdout_callback=None, **kwargs):
|
|
if cmd[-1] != '-':
|
|
shutil.copy(PROJECT_DIR / 'test/big_buck_bunny.mp3', cmd[-1])
|
|
return CommandResult(return_code=0, cancelled=False, stdout=b'', stderr=b'', elapsed=1)
|
|
|
|
with mock.patch('modules.videos.transcode.probe_for_verify', side_effect=probes), \
|
|
mock.patch('wrolpi.cmd.run_command', side_effect=fake_run_command):
|
|
job_id = transcode_video_job.enqueue(file_group_id=file_group_id, video_codec=REMOVE_VIDEO,
|
|
audio_codec='mp3', container='mp3')
|
|
record = await jobs.wait_for_job(job_id)
|
|
|
|
assert record['status'] == jobs.COMPLETE, record
|
|
new_path = video_path.with_suffix('.mp3')
|
|
assert new_path.is_file() and not video_path.exists()
|
|
|
|
test_session.expire_all()
|
|
video = Video.find_by_file_group_id(test_session, file_group_id)
|
|
assert video.video_path == new_path
|
|
assert video.file_group.mimetype.startswith('audio/')
|
|
assert test_session.query(FileGroup).count() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transcode_api_audio_file(async_client, test_session, audio_factory):
|
|
"""An audio file can be transcoded (audio codec / audio container); a video target is a 400."""
|
|
audio = audio_factory()
|
|
test_session.commit()
|
|
|
|
request, response = await async_client.post(f'/api/videos/{audio.file_group_id}/transcode',
|
|
content=json.dumps({'video_codec': 'h264', 'container': 'mp4'}))
|
|
assert response.status == 400, response.json
|
|
assert 'audio' in response.json['message'].lower() or 'audio' in response.json['error'].lower()
|
|
|
|
request, response = await async_client.post(f'/api/videos/{audio.file_group_id}/transcode',
|
|
content=json.dumps({'audio_codec': 'aac', 'container': 'm4a'}))
|
|
assert response.status == 200, response.json
|
|
jobs.cancel_job(response.json['job_id'])
|
|
|
|
# Remove-video on an audio file is a no-op wording-wise but allowed (it is already audio).
|
|
request, response = await async_client.post(f'/api/videos/{audio.file_group_id}/transcode',
|
|
content=json.dumps({'video_codec': REMOVE_VIDEO, 'container': 'mp3'}))
|
|
assert response.status == 200, response.json
|
|
jobs.cancel_job(response.json['job_id'])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transcode_api_remove_video_needs_audio_container(async_client, test_session, video_factory):
|
|
video = video_factory()
|
|
test_session.commit()
|
|
request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode',
|
|
content=json.dumps({'video_codec': REMOVE_VIDEO, 'container': 'mp4'}))
|
|
assert response.status == 400, response.json
|
|
request, response = await async_client.post(f'/api/videos/{video.file_group_id}/transcode',
|
|
content=json.dumps({'video_codec': REMOVE_VIDEO, 'audio_codec': 'aac',
|
|
'container': 'm4a'}))
|
|
assert response.status == 200, response.json
|
|
jobs.cancel_job(response.json['job_id'])
|