From 5358c75a2b33bc529a4a184b8f9b9dd6ea7a4927 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:25:23 -0600 Subject: [PATCH] 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//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 * 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 * Video page: Edit > Edit to change a video's title PUT /api/videos/ 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 * Video page: edit the description in Edit > Edit PUT /api/videos/ 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 * 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 * 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 * 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 * 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 * 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 * 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 * Video page: yellow Edit button Co-Authored-By: Claude Fable 5.1 * 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 * Jobs: _claim_job docstring states the invariant, not the old race Co-Authored-By: Claude Fable 5.1 * 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 * Transcode modal: the Fragmented label says audio for audio output Co-Authored-By: Claude Fable 5.1 * 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 --------- Co-authored-by: Claude Fable 5.1 --- app/src/api.js | 59 ++ app/src/components/ThemeSamplePage.js | 17 + app/src/components/Vars.js | 35 ++ app/src/components/VideoEditMenu.js | 612 +++++++++++++++++++ app/src/components/VideoEditMenu.test.js | 616 ++++++++++++++++++++ app/src/components/VideoPlayer.js | 28 +- app/src/hooks/customHooks.js | 2 +- modules/videos/common.py | 77 ++- modules/videos/downloader.py | 16 +- modules/videos/lib.py | 4 +- modules/videos/models.py | 12 +- modules/videos/schema.py | 20 + modules/videos/test/test_common.py | 35 ++ modules/videos/test/test_downloader.py | 73 +++ modules/videos/test/test_transcode.py | 280 ++++++++- modules/videos/test/test_transcode_audio.py | 265 +++++++++ modules/videos/test/test_transcode_job.py | 295 ++++++++++ modules/videos/test/test_video_edit.py | 212 +++++++ modules/videos/transcode.py | 345 ++++++++++- modules/videos/video/api.py | 54 +- modules/videos/video/lib.py | 40 ++ wrolpi/contexts.py | 14 + wrolpi/errors.py | 12 + wrolpi/files/models.py | 13 + wrolpi/jobs.py | 438 ++++++++++++++ wrolpi/root_api.py | 2 + wrolpi/test/test_jobs.py | 266 +++++++++ 27 files changed, 3770 insertions(+), 72 deletions(-) create mode 100644 app/src/components/VideoEditMenu.js create mode 100644 app/src/components/VideoEditMenu.test.js create mode 100644 modules/videos/test/test_transcode_audio.py create mode 100644 modules/videos/test/test_transcode_job.py create mode 100644 modules/videos/test/test_video_edit.py create mode 100644 wrolpi/jobs.py create mode 100644 wrolpi/test/test_jobs.py diff --git a/app/src/api.js b/app/src/api.js index 2d57e456..b06ef557 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -236,6 +236,65 @@ export async function getVideo(fileGroupId) { return [data['file_group'], data['prev'], data['next']]; } +export async function updateVideo(fileGroupId, {title, description}) { + // Edits the Video's details (written to its info json). Only the fields given are changed. + // Returns the updated video file group. + const response = await apiPut(`${VIDEOS_API}/${fileGroupId}`, {title, description}); + if (!response.ok) { + const message = await getErrorMessage(response, 'Unable to save the video. See server logs.'); + toast({type: 'error', title: 'Failed to save', description: message, time: 5000}); + throw new Error(message); + } + const data = await response.json(); + return data['file_group']; +} + +export async function transcodeVideo(fileGroupId, { + video_codec = null, audio_codec = null, container = 'mp4', fragmented = false, +}) { + // Queues a transcode Job; returns the job id. Watch it with `getJob`. + const body = {video_codec, audio_codec, container, fragmented}; + const response = await apiPost(`${VIDEOS_API}/${fileGroupId}/transcode`, body); + if (!response.ok) { + const message = await getErrorMessage(response, 'Unable to queue the transcode. See server logs.'); + toast({type: 'error', title: 'Failed to transcode', description: message, time: 5000}); + throw new Error(message); + } + const data = await response.json(); + return data['job_id']; +} + +export async function getJobs() { + const response = await apiGet(`${API_URI}/jobs`); + if (!response.ok) { + throw new Error(await getErrorMessage(response, 'Unable to get the jobs')); + } + const data = await response.json(); + return data['jobs']; +} + +export async function getJob(jobId) { + const response = await apiGet(`${API_URI}/jobs/${jobId}`); + if (!response.ok) { + const error = new Error(await getErrorMessage(response, 'Unable to get the job')); + error.status = response.status; + throw error; + } + const data = await response.json(); + return data['job']; +} + +export async function cancelJob(jobId) { + const response = await apiDelete(`${API_URI}/jobs/${jobId}`); + if (!response.ok) { + const message = await getErrorMessage(response, 'Unable to cancel the job'); + toast({type: 'error', title: 'Failed to cancel', description: message, time: 5000}); + throw new Error(message); + } + const data = await response.json(); + return data['job']; +} + export async function getVideoComments(fileGroupId) { const response = await apiGet(`${VIDEOS_API}/${fileGroupId}/comments`); let data = await response.json(); diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index 7767a317..3f95df25 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -16,6 +16,7 @@ import { IconStack, MediaFilterToggle, MediaGate, + Menu, Message, MultiSelect, NumberInput, @@ -382,6 +383,22 @@ export function ThemeSamplePage() { )} +
+
A dropdown of actions
+ {/* Mirrors the video page's Edit control. */} + + + + + + + }>Refresh + }>Transcode... + } disabled>Unavailable + + + +
A row of actions
{/* `.wrolpi-button-row` is how a page lists the actions on a thing -- the diff --git a/app/src/components/Vars.js b/app/src/components/Vars.js index 02d293c3..01631236 100644 --- a/app/src/components/Vars.js +++ b/app/src/components/Vars.js @@ -158,6 +158,41 @@ export const downloadAudioCodecOptions = [ {key: 'vorbis', text: 'vorbis', value: 'vorbis'}, ]; +// Codecs WROLPi's ffmpeg step can produce. Mirrors TRANSCODE_VIDEO_TARGETS / TRANSCODE_AUDIO_TARGETS / +// TRANSCODE_CONTAINERS in modules/videos/transcode.py; the API rejects anything else. +export const TRANSCODE_COPY = 'copy'; +// REMOVE_VIDEO in modules/videos/transcode.py: drop the video stream, the result is an audio file. +export const TRANSCODE_REMOVE_VIDEO = 'none'; +export const transcodeVideoCodecOptions = [ + {value: TRANSCODE_COPY, label: 'Keep (copy)'}, + {value: 'h264', label: 'h264 (avc1)'}, + {value: TRANSCODE_REMOVE_VIDEO, label: 'Remove video (audio only)'}, +]; +export const transcodeAudioCodecOptions = [ + {value: TRANSCODE_COPY, label: 'Keep (copy)'}, + {value: 'aac', label: 'aac'}, + {value: 'opus', label: 'opus'}, + {value: 'mp3', label: 'mp3'}, +]; +// Containers for output with a video stream ... +export const transcodeContainerOptions = [ + {value: 'mp4', label: 'mp4'}, + {value: 'mkv', label: 'mkv'}, +]; +// ... and for audio-only output. AUDIO_CONTAINER_CODECS in transcode.py says what each holds. +export const transcodeAudioContainerOptions = [ + {value: 'm4a', label: 'm4a (aac)'}, + {value: 'ogg', label: 'ogg (opus)'}, + {value: 'mp3', label: 'mp3'}, +]; + +/** The audio container a player expects for a codec (default_audio_container in transcode.py). */ +export function audioContainerForCodec(codec) { + if (codec === 'mp3') return 'mp3'; + if (['opus', 'vorbis', 'flac'].includes(codec)) return 'ogg'; + return 'm4a'; +} + export const Downloaders = { Archive: 'archive', File: 'file', diff --git a/app/src/components/VideoEditMenu.js b/app/src/components/VideoEditMenu.js new file mode 100644 index 00000000..5fa2eee6 --- /dev/null +++ b/app/src/components/VideoEditMenu.js @@ -0,0 +1,612 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {cancelJob, fetchVideoDownloadDefaults, getJob, transcodeVideo, updateVideo} from '../api'; +import {useWROLMode} from '../hooks/customHooks'; +import { + Button, Confirm, Group, Icon, Menu, Modal, Progress, Select, Stack, Status, Text, Textarea, TextInput, Toggle, + toast, +} from './ui'; +import { + audioContainerForCodec, + TRANSCODE_COPY, + TRANSCODE_REMOVE_VIDEO, + transcodeAudioCodecOptions, + transcodeAudioContainerOptions, + transcodeContainerOptions, + transcodeVideoCodecOptions, +} from './Vars'; + +const JOB_POLL_MS = 2000; +// Consecutive failed polls before a Job is given up as lost (the API restarted and forgot it). +const JOB_POLL_MAX_FAILURES = 5; +export const FINISHED_STATUSES = ['complete', 'failed', 'cancelled']; +// ffprobe calls an embedded thumbnail a video stream. +const THUMBNAIL_CODECS = ['mjpeg', 'png', 'bmp', 'gif']; + +export const isJobFinished = (job) => !!job && FINISHED_STATUSES.includes(job.status); + +/** The codec names of a Video's real streams, from the `codec_names`/`codec_types` the API sends. */ +export function currentCodecs(video) { + const names = video?.codec_names || []; + const types = video?.codec_types || []; + const video_ = names.filter((name, i) => types[i] === 'video' && !THUMBNAIL_CODECS.includes(name)); + const audio = names.filter((name, i) => types[i] === 'audio'); + return {video: video_, audio}; +} + +/** + * The transcode target implied by the user's ordered codec preferences: the first preferred codec + * that ffmpeg can produce (the same rule the downloader uses). `copy` when none can be. + */ +export function preferredTarget(preferences, options) { + const producible = options.map(i => i.value).filter(i => i !== TRANSCODE_COPY); + for (const codec of preferences || []) { + if (producible.includes(codec)) { + return codec; + } + } + return TRANSCODE_COPY; +} + +/** The container of a video (or audio) file, when it is one ffmpeg can write for us. */ +export function currentContainer(video) { + const suffix = String(video?.video_path || '').split('.').pop().toLowerCase(); + const known = [...transcodeContainerOptions, ...transcodeAudioContainerOptions]; + return known.some(i => i.value === suffix) ? suffix : null; +} + +/** + * The container to offer for the chosen codecs: audio-only output gets the natural container of + * the audio codec that will be in it (the target, or the source's when copied); output with video + * keeps the file's own container when possible. + */ +export function containerFor({audioOutput, audioCodec, video}) { + if (audioOutput) { + const codec = audioCodec !== TRANSCODE_COPY ? audioCodec : currentCodecs(video).audio[0]; + return audioContainerForCodec(codec); + } + const current = currentContainer(video); + return transcodeContainerOptions.some(i => i.value === current) ? current : 'mp4'; +} + +const statusKind = (status) => { + if (status === 'complete') return 'complete'; + if (status === 'running') return 'active'; + if (status === 'failed' || status === 'cancelled') return 'failed'; + return 'pending'; +} + +/** + * Poll a Job until it finishes; `onFinished(job)` is called once. A failed request is retried; + * a 404 (the API restarted and forgot its Jobs) or `JOB_POLL_MAX_FAILURES` failures in a row stop + * the polling and call `onLost(error)` instead. + */ +export function useJob(jobId, onFinished, onLost) { + const [job, setJob] = useState(null); + const onFinishedRef = useRef(onFinished); + const onLostRef = useRef(onLost); + onFinishedRef.current = onFinished; + onLostRef.current = onLost; + + useEffect(() => { + setJob(null); + if (!jobId) { + return; + } + let stopped = false; + let timer = null; + let failures = 0; + + const poll = async () => { + let latest = null; + try { + latest = await getJob(jobId); + failures = 0; + } catch (e) { + console.error(e); + failures += 1; + if (!stopped && (e?.status === 404 || failures >= JOB_POLL_MAX_FAILURES)) { + if (onLostRef.current) onLostRef.current(e); + return; + } + } + if (stopped) return; + if (latest) { + setJob(latest); + if (isJobFinished(latest)) { + if (onFinishedRef.current) onFinishedRef.current(latest); + return; + } + } + timer = window.setTimeout(poll, JOB_POLL_MS); + }; + + poll(); + return () => { + stopped = true; + clearTimeout(timer); + }; + }, [jobId]); + + const cancel = async () => { + const latest = await cancelJob(jobId); + setJob(latest); + return latest; + }; + + return {job, cancel}; +} + +/** Progress, status and log tail of one Job. */ +export function JobProgress({job, onCancel}) { + const [cancelling, setCancelling] = useState(false); + + const handleCancel = async () => { + setCancelling(true); + try { + await onCancel(); + } catch (e) { + console.error(e); + } finally { + setCancelling(false); + } + }; + + if (!job) { + return ; + } + + const finished = isJobFinished(job); + const percent = job.progress ?? 0; + const logTail = (job.log || []).slice(-8); + + return + + {job.status} + {!finished && onCancel && } + + + {job.error && {job.error}} + {logTail.length > 0 &&
{logTail.join('\n')}
} +
+} + +/** + * Choose target codecs for one Video and queue the transcode Job (`onQueued(jobId)`), or show the + * given `job`. The selects start at the user's preferred codecs from the Videos settings. + */ +export function TranscodeModal({ + open, onClose, fileGroupId, video, audioOnly = false, job, jobId, onQueued, + onCancelJob, + }) { + const wrolModeEnabled = useWROLMode(); + const [videoCodec, setVideoCodec] = useState(TRANSCODE_COPY); + const [audioCodec, setAudioCodec] = useState(TRANSCODE_COPY); + const [container, setContainer] = useState('mp4'); + const [fragmented, setFragmented] = useState(false); + const [defaultsLoaded, setDefaultsLoaded] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const current = currentCodecs(video); + const removeVideo = videoCodec === TRANSCODE_REMOVE_VIDEO; + // An audio file has no video stream to keep or remove; removing the video makes an audio file. + const audioOutput = audioOnly || removeVideo; + + useEffect(() => { + if (!open) { + return; + } + setDefaultsLoaded(false); + setFragmented(false); + let stale = false; + const load = async () => { + let nextVideo = TRANSCODE_COPY; + let nextAudio = TRANSCODE_COPY; + try { + const defaults = await fetchVideoDownloadDefaults(); + if (stale) return; + nextVideo = audioOnly ? TRANSCODE_COPY + : preferredTarget(defaults?.video_codecs, transcodeVideoCodecOptions); + nextAudio = preferredTarget(defaults?.audio_codecs, transcodeAudioCodecOptions); + } catch (e) { + console.error(e); + } finally { + if (!stale) { + setVideoCodec(nextVideo); + setAudioCodec(nextAudio); + setContainer(containerFor({audioOutput: audioOnly, audioCodec: nextAudio, video})); + setDefaultsLoaded(true); + } + } + }; + load(); + return () => { + stale = true; + }; + }, [open]); + + const changeVideoCodec = (value) => { + const next = value || TRANSCODE_COPY; + setVideoCodec(next); + setContainer(containerFor({audioOutput: audioOnly || next === TRANSCODE_REMOVE_VIDEO, audioCodec, video})); + }; + const changeAudioCodec = (value) => { + const next = value || TRANSCODE_COPY; + setAudioCodec(next); + setContainer(containerFor({audioOutput, audioCodec: next, video})); + }; + + const remuxOnly = !removeVideo && videoCodec === TRANSCODE_COPY && audioCodec === TRANSCODE_COPY; + const sameContainer = container === currentContainer(video); + const containerOptions = audioOutput ? transcodeAudioContainerOptions : transcodeContainerOptions; + // Fragments are an mp4 concept (m4a is an mp4). + const canFragment = container === 'mp4' || container === 'm4a'; + + const handleStart = async () => { + setSubmitting(true); + try { + const id = await transcodeVideo(fileGroupId, { + video_codec: videoCodec === TRANSCODE_COPY ? null : videoCodec, + audio_codec: audioCodec === TRANSCODE_COPY ? null : audioCodec, + container, + fragmented: canFragment && fragmented, + }); + onQueued(id); + } catch (e) { + // transcodeVideo already toasted. + console.error(e); + } finally { + setSubmitting(false); + } + }; + + const describe = (codecs) => codecs.length ? codecs.join(', ') : 'unknown'; + const showJob = !!jobId; + + let hint = null; + if (defaultsLoaded && removeVideo) { + hint = `The video stream is removed; the result is an audio file (.${container}). ` + + (audioCodec === TRANSCODE_COPY ? 'The audio is copied without re-encoding.' : `The audio is converted to ${audioCodec}.`); + } else if (defaultsLoaded && remuxOnly) { + const kept = audioOnly ? 'The audio is kept' : 'Both streams are kept'; + if (sameContainer && canFragment && fragmented) { + hint = `${kept}: the file is rewritten as-is in fragments (a quick remux, no quality loss).`; + } else if (sameContainer) { + hint = `${kept}: the file is rewritten as-is` + (canFragment + ? ' with fast start (moves the index to the front for quicker playback start).' : '.'); + } else { + hint = `${kept}: only the container changes to ${container} (a quick remux, no quality loss).`; + } + } + const buttonLabel = removeVideo ? 'Extract Audio' : remuxOnly ? 'Remux' : 'Transcode'; + + return + {audioOnly ? 'Transcode Audio' : 'Transcode Video'} + + + + {audioOnly + ? `Current codec: audio ${describe(current.audio)}. ` + : `Current codecs: video ${describe(current.video)}, audio ${describe(current.audio)}. `} + Streams set to Keep are copied without re-encoding. Transcoding is slow and slightly + reduces quality; the original file is replaced when it finishes. + + {!showJob && <> + {!audioOnly && +