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
|
|
|
import React from 'react';
|
|
|
|
|
import {act, fireEvent, screen, waitFor} from '@testing-library/react';
|
|
|
|
|
import {renderWithProviders} from '../test-utils';
|
|
|
|
|
import {
|
2026-09-26 17:49:00 -06:00
|
|
|
audioCodecOptionsFor, chooseCodecs, containerFor, currentCodecs, currentContainer, EditVideoModal, JobProgress, preferredTarget,
|
|
|
|
|
TranscodeModal, useJob, VideoEditMenu, videoCodecOptionsFor,
|
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 './VideoEditMenu';
|
|
|
|
|
import {
|
2026-09-26 17:49:00 -06:00
|
|
|
audioContainerForCodec, TRANSCODE_COPY, TRANSCODE_REMOVE_AUDIO, TRANSCODE_REMOVE_VIDEO, transcodeAudioCodecOptions,
|
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
|
|
|
transcodeVideoCodecOptions,
|
|
|
|
|
} from './Vars';
|
|
|
|
|
|
|
|
|
|
jest.mock('../api', () => ({
|
|
|
|
|
cancelJob: jest.fn(),
|
|
|
|
|
fetchVideoDownloadDefaults: jest.fn(),
|
|
|
|
|
getJob: jest.fn(),
|
|
|
|
|
transcodeVideo: jest.fn(),
|
|
|
|
|
updateVideo: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
import {cancelJob, fetchVideoDownloadDefaults, getJob, transcodeVideo, updateVideo} from '../api';
|
|
|
|
|
|
|
|
|
|
// The ui index re-exports `toast` from here; a spy lets tests see what the page tells the user.
|
|
|
|
|
jest.mock('./ui/toast', () => ({...jest.requireActual('./ui/toast'), toast: jest.fn()}));
|
|
|
|
|
import {toast} from './ui/toast';
|
|
|
|
|
|
|
|
|
|
const video = {
|
|
|
|
|
codec_names: ['vp9', 'mjpeg', 'opus'],
|
|
|
|
|
codec_types: ['video', 'video', 'audio'],
|
|
|
|
|
video_path: 'videos/movie.webm',
|
|
|
|
|
};
|
|
|
|
|
const videoFile = {id: 7, url: 'https://example.com/watch?v=1', mimetype: 'video/webm', title: 'Old Title', video};
|
|
|
|
|
const audio = {codec_names: ['aac'], codec_types: ['audio'], video_path: 'videos/song.m4a'};
|
|
|
|
|
const audioFile = {id: 8, url: null, mimetype: 'audio/mp4', title: 'Song', video: audio};
|
|
|
|
|
|
|
|
|
|
// Mantine's Select renders a hidden input next to the visible one, both tied to the label.
|
|
|
|
|
const selectInput = (label) => screen.getAllByLabelText(label).find(i => i.type !== 'hidden');
|
|
|
|
|
|
|
|
|
|
// `renderWithProviders`' `settings` option replaces the SettingsContext value; the WROL flag lives
|
|
|
|
|
// one level down in its `settings`.
|
|
|
|
|
const wrolMode = {settings: {settings: {wrol_mode: true}}};
|
|
|
|
|
|
|
|
|
|
const pendingJob = {
|
|
|
|
|
id: 'transcode_video-abc', status: 'pending', progress: null, log: [], error: null, cancel_requested: false,
|
|
|
|
|
description: 'Transcode movie.webm',
|
|
|
|
|
};
|
|
|
|
|
const runningJob = {...pendingJob, status: 'running', progress: 40, log: ['out_time_us=1']};
|
|
|
|
|
const completeJob = {...pendingJob, status: 'complete', progress: 100, log: ['done']};
|
|
|
|
|
const failedJob = {...pendingJob, status: 'failed', progress: 12, error: 'ffmpeg exited with 1', log: ['boom']};
|
|
|
|
|
const notFound = Object.assign(new Error('Job does not exist'), {status: 404});
|
|
|
|
|
|
|
|
|
|
describe('helpers', () => {
|
|
|
|
|
test('currentCodecs ignores embedded thumbnail streams', () => {
|
|
|
|
|
expect(currentCodecs(video)).toEqual({video: ['vp9'], audio: ['opus']});
|
|
|
|
|
expect(currentCodecs(null)).toEqual({video: [], audio: []});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('currentContainer reads the file suffix, only for containers we can write', () => {
|
|
|
|
|
expect(currentContainer({video_path: 'videos/a/movie.MP4'})).toBe('mp4');
|
|
|
|
|
expect(currentContainer({video_path: 'videos/a/movie.mkv'})).toBe('mkv');
|
|
|
|
|
expect(currentContainer({video_path: 'videos/a/song.m4a'})).toBe('m4a');
|
|
|
|
|
expect(currentContainer({video_path: 'videos/a/movie.webm'})).toBeNull();
|
|
|
|
|
expect(currentContainer(null)).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('audioContainerForCodec mirrors the backend default', () => {
|
|
|
|
|
expect(audioContainerForCodec('aac')).toBe('m4a');
|
|
|
|
|
expect(audioContainerForCodec('mp3')).toBe('mp3');
|
|
|
|
|
expect(audioContainerForCodec('opus')).toBe('ogg');
|
|
|
|
|
expect(audioContainerForCodec('vorbis')).toBe('ogg');
|
|
|
|
|
expect(audioContainerForCodec(undefined)).toBe('m4a');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('containerFor follows the audio codec for audio output, the file for video output', () => {
|
|
|
|
|
// Removing the video from a vp9/opus webm, copying the audio: opus lives in ogg.
|
|
|
|
|
expect(containerFor({audioOutput: true, audioCodec: TRANSCODE_COPY, video})).toBe('ogg');
|
|
|
|
|
expect(containerFor({audioOutput: true, audioCodec: 'aac', video})).toBe('m4a');
|
|
|
|
|
expect(containerFor({audioOutput: true, audioCodec: 'mp3', video})).toBe('mp3');
|
|
|
|
|
// Video output keeps a container we can write, else mp4.
|
|
|
|
|
expect(containerFor({audioOutput: false, audioCodec: 'aac', video})).toBe('mp4');
|
|
|
|
|
expect(containerFor({audioOutput: false, audioCodec: 'aac', video: {video_path: 'a.mkv'}})).toBe('mkv');
|
|
|
|
|
// An audio file's own suffix is not a video container.
|
|
|
|
|
expect(containerFor({audioOutput: false, audioCodec: TRANSCODE_COPY, video: audio})).toBe('mp4');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('preferredTarget picks the first preference ffmpeg can produce', () => {
|
|
|
|
|
expect(preferredTarget(['vp9', 'h264'], transcodeVideoCodecOptions)).toBe('h264');
|
|
|
|
|
expect(preferredTarget(['vp9', 'av1'], transcodeVideoCodecOptions)).toBe(TRANSCODE_COPY);
|
|
|
|
|
expect(preferredTarget(['opus', 'aac'], transcodeAudioCodecOptions)).toBe('opus');
|
|
|
|
|
expect(preferredTarget([], transcodeAudioCodecOptions)).toBe(TRANSCODE_COPY);
|
|
|
|
|
expect(preferredTarget(undefined, transcodeAudioCodecOptions)).toBe(TRANSCODE_COPY);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('TranscodeModal', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
|
fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: ['h264'], audio_codecs: ['vorbis', 'aac']});
|
|
|
|
|
transcodeVideo.mockResolvedValue('transcode_video-abc');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pre-populates from the preferred codecs and queues the Job', async () => {
|
|
|
|
|
const onQueued = jest.fn();
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={7} video={video} onQueued={onQueued}/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText(/Current codecs: video vp9, audio opus/)).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
// The defaults arrive: h264 is producible, vorbis is not, so aac is the audio target.
|
|
|
|
|
await waitFor(() => expect(selectInput('Video codec')).toHaveValue('h264 (avc1)'));
|
|
|
|
|
expect(selectInput('Audio codec')).toHaveValue('aac');
|
|
|
|
|
expect(selectInput('Container')).toHaveValue('mp4');
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: /Transcode/}));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(transcodeVideo).toHaveBeenCalledWith(7, {
|
|
|
|
|
video_codec: 'h264', audio_codec: 'aac', container: 'mp4', fragmented: false,
|
|
|
|
|
}));
|
|
|
|
|
await waitFor(() => expect(onQueued).toHaveBeenCalledWith('transcode_video-abc'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('shows the Job instead of the form once one is queued', () => {
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={7} video={video}
|
|
|
|
|
jobId='transcode_video-abc' job={runningJob} onCancelJob={jest.fn()}/>,
|
|
|
|
|
);
|
|
|
|
|
expect(screen.queryAllByLabelText('Video codec')).toHaveLength(0);
|
|
|
|
|
expect(screen.getByText('running')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByRole('button', {name: 'Close'})).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByRole('button', {name: /Transcode/})).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('keeping every stream is a remux into the chosen container', async () => {
|
|
|
|
|
fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: [], audio_codecs: []});
|
|
|
|
|
// A webm cannot be kept (not a container we write), so the select starts at mp4: a change.
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={7} video={video} onQueued={jest.fn()}/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(selectInput('Container')).toHaveValue('mp4'));
|
|
|
|
|
expect(await screen.findByText(/only the container changes to mp4/)).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
const button = screen.getByRole('button', {name: 'Remux'});
|
|
|
|
|
expect(button).toBeEnabled();
|
|
|
|
|
fireEvent.click(button);
|
|
|
|
|
await waitFor(() => expect(transcodeVideo).toHaveBeenCalledWith(7, {
|
|
|
|
|
video_codec: null, audio_codec: null, container: 'mp4', fragmented: false,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Fragmented is offered for mp4 and sent with the request', async () => {
|
|
|
|
|
fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: [], audio_codecs: []});
|
|
|
|
|
const mp4 = {...video, video_path: 'videos/movie.mp4'};
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={7} video={mp4} onQueued={jest.fn()}/>,
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => expect(selectInput('Container')).toHaveValue('mp4'));
|
|
|
|
|
const toggle = screen.getByRole('switch', {name: /Fragmented/});
|
|
|
|
|
expect(toggle).not.toBeChecked();
|
|
|
|
|
|
|
|
|
|
fireEvent.click(toggle);
|
|
|
|
|
expect(await screen.findByText(/rewritten as-is in fragments/)).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText(/fast start/)).not.toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'Remux'}));
|
|
|
|
|
await waitFor(() => expect(transcodeVideo).toHaveBeenCalledWith(7, {
|
|
|
|
|
video_codec: null, audio_codec: null, container: 'mp4', fragmented: true,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Fragmented is not offered for mkv', async () => {
|
|
|
|
|
fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: [], audio_codecs: []});
|
|
|
|
|
const mkv = {...video, video_path: 'videos/movie.mkv'};
|
|
|
|
|
renderWithProviders(<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={7} video={mkv}/>);
|
|
|
|
|
await waitFor(() => expect(selectInput('Container')).toHaveValue('mkv'));
|
|
|
|
|
expect(screen.queryByRole('switch', {name: /Fragmented/})).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a remux into the same container is described as a fast-start rewrite', async () => {
|
|
|
|
|
fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: [], audio_codecs: []});
|
|
|
|
|
const mkv = {...video, video_path: 'videos/movie.mkv'};
|
|
|
|
|
renderWithProviders(<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={7} video={mkv}/>);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(selectInput('Container')).toHaveValue('mkv'));
|
|
|
|
|
// mkv has no fast start; the hint must not promise one.
|
|
|
|
|
expect(await screen.findByText(/Both streams are kept: the file is rewritten as-is\./)).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText(/fast start/)).not.toBeInTheDocument();
|
|
|
|
|
expect(screen.getByRole('button', {name: 'Remux'})).toBeEnabled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an audio file offers audio codecs and containers only', async () => {
|
|
|
|
|
fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: ['h264'], audio_codecs: []});
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={8} video={audio} audioOnly={true}
|
|
|
|
|
onQueued={jest.fn()}/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('Transcode Audio')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText(/Current codec: audio aac/)).toBeInTheDocument();
|
|
|
|
|
// The aac source, copied, belongs in m4a; the file already is one.
|
|
|
|
|
await waitFor(() => expect(selectInput('Container')).toHaveValue('m4a (aac)'));
|
|
|
|
|
expect(screen.queryAllByLabelText('Video codec')).toHaveLength(0);
|
|
|
|
|
expect(await screen.findByText(/The audio is kept: the file is rewritten as-is with fast start/))
|
|
|
|
|
.toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText(/Both streams/)).not.toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'Remux'}));
|
|
|
|
|
// No video codec is sent for an audio file, whatever the video preferences say.
|
|
|
|
|
await waitFor(() => expect(transcodeVideo).toHaveBeenCalledWith(8, {
|
|
|
|
|
video_codec: null, audio_codec: null, container: 'm4a', fragmented: false,
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the Remove video option is offered for videos', async () => {
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={7} video={video} onQueued={jest.fn()}/>,
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => expect(selectInput('Video codec')).toHaveValue('h264 (avc1)'));
|
|
|
|
|
expect(transcodeVideoCodecOptions.some(i => i.value === TRANSCODE_REMOVE_VIDEO)).toBe(true);
|
|
|
|
|
// Selecting it is exercised through containerFor (Mantine's dropdown is not driveable in jsdom).
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-26 17:49:00 -06:00
|
|
|
test('the Remove audio option is offered for videos, not audio files, and never with Remove video', () => {
|
|
|
|
|
expect(transcodeAudioCodecOptions.some(i => i.value === TRANSCODE_REMOVE_AUDIO)).toBe(true);
|
|
|
|
|
const has = (options, value) => options.some(i => i.value === value);
|
|
|
|
|
expect(has(audioCodecOptionsFor({audioOnly: false, removeVideo: false}), TRANSCODE_REMOVE_AUDIO)).toBe(true);
|
|
|
|
|
expect(has(audioCodecOptionsFor({audioOnly: true, removeVideo: false}), TRANSCODE_REMOVE_AUDIO)).toBe(false);
|
|
|
|
|
expect(has(audioCodecOptionsFor({audioOnly: false, removeVideo: true}), TRANSCODE_REMOVE_AUDIO)).toBe(false);
|
|
|
|
|
// The real codecs and Keep are never withdrawn.
|
|
|
|
|
expect(audioCodecOptionsFor({audioOnly: true, removeVideo: false}).map(i => i.value))
|
|
|
|
|
.toEqual([TRANSCODE_COPY, 'aac', 'opus', 'mp3']);
|
|
|
|
|
expect(has(videoCodecOptionsFor({removeAudio: false}), TRANSCODE_REMOVE_VIDEO)).toBe(true);
|
|
|
|
|
expect(has(videoCodecOptionsFor({removeAudio: true}), TRANSCODE_REMOVE_VIDEO)).toBe(false);
|
|
|
|
|
expect(has(videoCodecOptionsFor({removeAudio: true}), 'h264')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Remove video and Remove audio can never be chosen together', () => {
|
|
|
|
|
const keepBoth = {videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_COPY};
|
|
|
|
|
// Removing one stream after the other was chosen puts the other back to Keep.
|
|
|
|
|
expect(chooseCodecs({videoCodec: TRANSCODE_REMOVE_VIDEO, audioCodec: 'aac'}, {audio: TRANSCODE_REMOVE_AUDIO}))
|
|
|
|
|
.toEqual({videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_REMOVE_AUDIO});
|
|
|
|
|
expect(chooseCodecs({videoCodec: 'h264', audioCodec: TRANSCODE_REMOVE_AUDIO}, {video: TRANSCODE_REMOVE_VIDEO}))
|
|
|
|
|
.toEqual({videoCodec: TRANSCODE_REMOVE_VIDEO, audioCodec: TRANSCODE_COPY});
|
|
|
|
|
// Ordinary choices are untouched; a cleared select means Keep.
|
|
|
|
|
expect(chooseCodecs(keepBoth, {video: 'h264'})).toEqual({videoCodec: 'h264', audioCodec: TRANSCODE_COPY});
|
|
|
|
|
expect(chooseCodecs({videoCodec: 'h264', audioCodec: 'aac'}, {audio: null}))
|
|
|
|
|
.toEqual({videoCodec: 'h264', audioCodec: TRANSCODE_COPY});
|
|
|
|
|
expect(chooseCodecs(keepBoth, {audio: TRANSCODE_REMOVE_AUDIO}))
|
|
|
|
|
.toEqual({videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_REMOVE_AUDIO});
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
test('WROL Mode disables starting a transcode', async () => {
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={7} video={video}/>,
|
|
|
|
|
wrolMode,
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => expect(selectInput('Video codec')).toHaveValue('h264 (avc1)'));
|
|
|
|
|
expect(screen.getByRole('button', {name: /Transcode/})).toBeDisabled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('EditVideoModal', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
|
updateVideo.mockResolvedValue({...videoFile, title: 'New Title'});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('starts from the current title and saves a changed one', async () => {
|
|
|
|
|
const onSaved = jest.fn().mockResolvedValue(undefined);
|
|
|
|
|
const onClose = jest.fn();
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<EditVideoModal open={true} onClose={onClose} fileGroupId={7} videoFile={videoFile}
|
|
|
|
|
description='Old words' onSaved={onSaved}/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const input = screen.getByLabelText('Title');
|
|
|
|
|
expect(input).toHaveValue('Old Title');
|
|
|
|
|
expect(screen.getByLabelText('Description')).toHaveValue('Old words');
|
|
|
|
|
// Nothing changed yet: Save is disabled.
|
|
|
|
|
expect(screen.getByRole('button', {name: /Save/})).toBeDisabled();
|
|
|
|
|
|
|
|
|
|
fireEvent.change(input, {target: {value: ' New Title '}});
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: /Save/}));
|
|
|
|
|
|
|
|
|
|
// Only the changed field is sent.
|
|
|
|
|
await waitFor(() => expect(updateVideo).toHaveBeenCalledWith(7, {title: 'New Title'}));
|
|
|
|
|
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
|
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('saves a changed description on its own, including clearing it', async () => {
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile} description='Old words'/>,
|
|
|
|
|
);
|
|
|
|
|
fireEvent.change(screen.getByLabelText('Description'), {target: {value: ''}});
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: /Save/}));
|
|
|
|
|
await waitFor(() => expect(updateVideo).toHaveBeenCalledWith(7, {description: ''}));
|
|
|
|
|
});
|
|
|
|
|
|
app: video comments and description distinguish pending from empty (#727)
* app: video comments and description distinguish pending from empty
useVideoExtras initialised both values to null and the page treated
null the same as "none", so every video open (and every prev/next)
showed "No comments have been downloaded. Refresh the video?" with a
live Refresh button until the comments arrived. The Description tab
showed "No description available." the same way. A failed fetch was
also rendered as "none", and the previous video's data lingered until
the next video's responses arrived.
Invariants now enforced and tested:
* null = pending, undefined = fetch failed, [] / '' = loaded and empty.
The API serialises a missing info_json field as null, so the hook
normalises that to empty; consumers never see null for "none".
* Switching video resets both to pending, and a slow response for the
previous video is dropped.
* The page renders a placeholder while pending, an error message on
failure, and offers Refresh only once the server confirmed there are
no comments.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* app: useVideoExtras drops any request that is not the newest
Matching on the video id let a stale request land when the user left
and returned to the same video, or saved an edit while the page's
first description request was still in flight. Each fetch now takes a
generation number and only the newest generation may write state,
per value, so a description refetch never invalidates a comments
request.
The edit dialog adopts a description that arrives while it is open
only if the user has not typed, so a draft written over the
placeholder is not wiped by the empty or failed value that follows.
Tests: 7 -> none -> 8 -> 7 with the first request rejecting last; a
save refetch beating the initial request; the draft surviving; the
pending placeholder asserted; the console spy restored in finally.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 11:22:37 -06:00
|
|
|
test('a draft typed while the description was still loading survives its arrival', () => {
|
|
|
|
|
// The page passes null while the description is pending, then '' when the server has none.
|
|
|
|
|
const {rerender} = renderWithProviders(
|
|
|
|
|
<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile} description={null}/>,
|
|
|
|
|
);
|
|
|
|
|
fireEvent.change(screen.getByLabelText('Description'), {target: {value: 'my new words'}});
|
|
|
|
|
rerender(<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile} description=''/>);
|
|
|
|
|
expect(screen.getByLabelText('Description')).toHaveValue('my new words');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a description that arrives while the field is untouched is adopted', () => {
|
|
|
|
|
const {rerender} = renderWithProviders(
|
|
|
|
|
<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile} description={null}/>,
|
|
|
|
|
);
|
|
|
|
|
expect(screen.getByLabelText('Description')).toHaveValue('');
|
|
|
|
|
rerender(<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile}
|
|
|
|
|
description='From the server'/>);
|
|
|
|
|
expect(screen.getByLabelText('Description')).toHaveValue('From the server');
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
test('WROL Mode disables saving', async () => {
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile}/>,
|
|
|
|
|
wrolMode,
|
|
|
|
|
);
|
|
|
|
|
fireEvent.change(screen.getByLabelText('Title'), {target: {value: 'Changed'}});
|
|
|
|
|
expect(screen.getByRole('button', {name: /Save/})).toBeDisabled();
|
|
|
|
|
expect(screen.getByText(/WROL Mode/)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an empty title cannot be saved', () => {
|
|
|
|
|
renderWithProviders(<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile}/>);
|
|
|
|
|
fireEvent.change(screen.getByLabelText('Title'), {target: {value: ' '}});
|
|
|
|
|
expect(screen.getByText('A title is required')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByRole('button', {name: /Save/})).toBeDisabled();
|
|
|
|
|
expect(updateVideo).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a failed save keeps the modal open', async () => {
|
|
|
|
|
updateVideo.mockRejectedValue(new Error('boom'));
|
|
|
|
|
const onClose = jest.fn();
|
|
|
|
|
renderWithProviders(<EditVideoModal open={true} onClose={onClose} fileGroupId={7} videoFile={videoFile}/>);
|
|
|
|
|
fireEvent.change(screen.getByLabelText('Title'), {target: {value: 'Other'}});
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: /Save/}));
|
|
|
|
|
await waitFor(() => expect(updateVideo).toHaveBeenCalled());
|
|
|
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
|
expect(screen.getByLabelText('Title')).toHaveValue('Other');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A tiny harness so the hook can be driven like the menu drives it.
|
|
|
|
|
function JobHarness({jobId, onFinished, onLost}) {
|
|
|
|
|
const {job, cancel} = useJob(jobId, onFinished, onLost);
|
|
|
|
|
return <JobProgress job={job} onCancel={cancel}/>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Let pending promise callbacks run (a settled request schedules its next poll), then advance
|
|
|
|
|
// the fake clock past the poll interval.
|
|
|
|
|
const nextPoll = async () => {
|
|
|
|
|
await act(async () => {
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
jest.advanceTimersByTime(2000);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('useJob + JobProgress', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
|
jest.useFakeTimers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
jest.useRealTimers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('polls until the Job finishes, then reports once', async () => {
|
|
|
|
|
const onFinished = jest.fn();
|
|
|
|
|
getJob.mockResolvedValueOnce(runningJob).mockResolvedValueOnce(completeJob);
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<JobHarness jobId='transcode_video-abc' onFinished={onFinished}/>);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(screen.getByText('running')).toBeInTheDocument());
|
|
|
|
|
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '40');
|
|
|
|
|
expect(screen.getByTestId('job-log')).toHaveTextContent('out_time_us=1');
|
|
|
|
|
expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
await act(async () => {
|
|
|
|
|
jest.advanceTimersByTime(2000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(screen.getByText('complete')).toBeInTheDocument());
|
|
|
|
|
expect(onFinished).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(onFinished.mock.calls[0][0].status).toBe('complete');
|
|
|
|
|
// A finished Job is not polled again, and cannot be cancelled.
|
|
|
|
|
expect(getJob).toHaveBeenCalledTimes(2);
|
|
|
|
|
expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a failed request is retried instead of ending the polling', async () => {
|
|
|
|
|
getJob
|
|
|
|
|
.mockRejectedValueOnce(new Error('network'))
|
|
|
|
|
.mockResolvedValueOnce(runningJob)
|
|
|
|
|
.mockRejectedValueOnce(new Error('network'))
|
|
|
|
|
.mockResolvedValueOnce(completeJob);
|
|
|
|
|
const onFinished = jest.fn();
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<JobHarness jobId='transcode_video-abc' onFinished={onFinished}/>);
|
|
|
|
|
|
|
|
|
|
// First poll failed: still "Starting...", but another poll is scheduled.
|
|
|
|
|
await waitFor(() => expect(getJob).toHaveBeenCalledTimes(1));
|
|
|
|
|
expect(screen.getByText('Starting...')).toBeInTheDocument();
|
|
|
|
|
await nextPoll();
|
|
|
|
|
await waitFor(() => expect(screen.getByText('running')).toBeInTheDocument());
|
|
|
|
|
|
|
|
|
|
// A failure mid-way keeps the last good state and keeps polling.
|
|
|
|
|
await nextPoll();
|
|
|
|
|
await waitFor(() => expect(getJob).toHaveBeenCalledTimes(3));
|
|
|
|
|
expect(screen.getByText('running')).toBeInTheDocument();
|
|
|
|
|
await nextPoll();
|
|
|
|
|
await waitFor(() => expect(screen.getByText('complete')).toBeInTheDocument());
|
|
|
|
|
expect(onFinished).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a 404 means the API forgot the Job: give up at once', async () => {
|
|
|
|
|
getJob.mockRejectedValue(notFound);
|
|
|
|
|
const onLost = jest.fn();
|
|
|
|
|
const onFinished = jest.fn();
|
|
|
|
|
renderWithProviders(<JobHarness jobId='transcode_video-abc' onFinished={onFinished} onLost={onLost}/>);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(onLost).toHaveBeenCalledTimes(1));
|
|
|
|
|
expect(onLost.mock.calls[0][0].status).toBe(404);
|
|
|
|
|
await nextPoll();
|
|
|
|
|
expect(getJob).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(onFinished).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('repeated failures give up after a few polls', async () => {
|
|
|
|
|
getJob.mockRejectedValue(new Error('network'));
|
|
|
|
|
const onLost = jest.fn();
|
|
|
|
|
renderWithProviders(<JobHarness jobId='transcode_video-abc' onLost={onLost}/>);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
await nextPoll();
|
|
|
|
|
}
|
|
|
|
|
await waitFor(() => expect(onLost).toHaveBeenCalledTimes(1));
|
|
|
|
|
expect(getJob).toHaveBeenCalledTimes(5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Cancel asks the API to cancel the Job', async () => {
|
|
|
|
|
getJob.mockResolvedValue({...pendingJob, status: 'running', progress: 10});
|
|
|
|
|
cancelJob.mockResolvedValue({...pendingJob, status: 'running', progress: 10, cancel_requested: true});
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<JobHarness jobId='transcode_video-abc'/>);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument());
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'Cancel'}));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(cancelJob).toHaveBeenCalledWith('transcode_video-abc'));
|
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', {name: 'Cancelling...'})).toBeDisabled());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('shows the error of a failed Job', async () => {
|
|
|
|
|
getJob.mockResolvedValue({...pendingJob, status: 'failed', error: 'ffmpeg exited with 1', log: ['boom']});
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<JobHarness jobId='transcode_video-abc'/>);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(screen.getByText('failed')).toBeInTheDocument());
|
|
|
|
|
expect(screen.getByText('ffmpeg exited with 1')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('VideoEditMenu', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
|
fetchVideoDownloadDefaults.mockResolvedValue({video_codecs: ['h264'], audio_codecs: ['aac']});
|
|
|
|
|
transcodeVideo.mockResolvedValue('transcode_video-abc');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mantine's Menu in jsdom: after a modal has closed, the first click on the target can land
|
|
|
|
|
// while the dropdown still counts as open (and so toggles it shut). Click until items show.
|
|
|
|
|
const openMenu = async () => {
|
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: /Edit/}));
|
|
|
|
|
try {
|
|
|
|
|
return await screen.findByRole('menuitem', {name: /Refresh/}, {timeout: 300});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Toggled shut; try again.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw new Error('The Edit menu did not open');
|
|
|
|
|
};
|
|
|
|
|
const openMenuItem = async (name) => {
|
|
|
|
|
await openMenu();
|
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', {name}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
test('Refresh calls onRefresh; Transcode opens the modal', async () => {
|
|
|
|
|
const onRefresh = jest.fn().mockResolvedValue(undefined);
|
|
|
|
|
renderWithProviders(<VideoEditMenu videoFile={videoFile} video={video} onRefresh={onRefresh}/>);
|
|
|
|
|
|
|
|
|
|
await openMenuItem(/Refresh/);
|
|
|
|
|
await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1));
|
|
|
|
|
|
|
|
|
|
await openMenuItem(/Transcode/);
|
|
|
|
|
await waitFor(() => expect(screen.getByText('Transcode Video')).toBeInTheDocument());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Edit opens the details modal', async () => {
|
|
|
|
|
renderWithProviders(<VideoEditMenu videoFile={videoFile} video={video} onRefresh={jest.fn()}/>);
|
|
|
|
|
await openMenuItem(/^Edit/);
|
|
|
|
|
expect(await screen.findByText('Edit Video')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByLabelText('Title')).toHaveValue('Old Title');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Refresh is unavailable without a URL; audio files can be transcoded', async () => {
|
|
|
|
|
renderWithProviders(<VideoEditMenu videoFile={audioFile} video={audio} onRefresh={jest.fn()}/>);
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: /Edit/}));
|
|
|
|
|
const refresh = await screen.findByRole('menuitem', {name: /Refresh/});
|
|
|
|
|
expect(refresh).toBeDisabled();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: /Transcode/})).toBeEnabled();
|
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', {name: /Transcode/}));
|
|
|
|
|
expect(await screen.findByText('Transcode Audio')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Transcode is unavailable for files that are neither video nor audio', async () => {
|
|
|
|
|
const other = {...videoFile, mimetype: 'application/pdf'};
|
|
|
|
|
renderWithProviders(<VideoEditMenu videoFile={other} video={video} onRefresh={jest.fn()}/>);
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: /Edit/}));
|
|
|
|
|
expect(await screen.findByRole('menuitem', {name: /Transcode/})).toBeDisabled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('WROL Mode disables every item', async () => {
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<VideoEditMenu videoFile={videoFile} video={video} onRefresh={jest.fn()} onDelete={jest.fn()}/>,
|
|
|
|
|
wrolMode,
|
|
|
|
|
);
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: /Edit/}));
|
|
|
|
|
expect(await screen.findByRole('menuitem', {name: /Refresh/})).toBeDisabled();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: /Transcode/})).toBeDisabled();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: /Delete/})).toBeDisabled();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: /^Edit/})).toBeDisabled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Delete asks for confirmation before calling onDelete', async () => {
|
|
|
|
|
const onDelete = jest.fn().mockResolvedValue(undefined);
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<VideoEditMenu videoFile={videoFile} video={video} onRefresh={jest.fn()} onDelete={onDelete}/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await openMenuItem(/Delete/);
|
|
|
|
|
expect(await screen.findByText('Delete video?')).toBeInTheDocument();
|
|
|
|
|
expect(onDelete).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
fireEvent.click(await screen.findByRole('button', {name: 'Delete'}));
|
|
|
|
|
await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('backing out of the Delete confirmation deletes nothing', async () => {
|
|
|
|
|
const onDelete = jest.fn();
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<VideoEditMenu videoFile={videoFile} video={video} onRefresh={jest.fn()} onDelete={onDelete}/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await openMenuItem(/Delete/);
|
|
|
|
|
expect(await screen.findByText('Delete video?')).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'Cancel'}));
|
|
|
|
|
await waitFor(() => expect(screen.queryByText('Delete video?')).not.toBeInTheDocument());
|
|
|
|
|
expect(onDelete).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('closing the modal keeps watching the Job and reports completion to the page', async () => {
|
|
|
|
|
jest.useFakeTimers();
|
|
|
|
|
try {
|
|
|
|
|
getJob.mockResolvedValueOnce(runningJob).mockResolvedValueOnce(completeJob);
|
|
|
|
|
const onTranscodeComplete = jest.fn();
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<VideoEditMenu videoFile={videoFile} video={video} onRefresh={jest.fn()} onDelete={jest.fn()}
|
|
|
|
|
onTranscodeComplete={onTranscodeComplete}/>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await openMenuItem(/Transcode/);
|
|
|
|
|
await waitFor(() => expect(selectInput('Video codec')).toHaveValue('h264 (avc1)'));
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'Transcode'}));
|
|
|
|
|
await waitFor(() => expect(screen.getByText('running')).toBeInTheDocument());
|
|
|
|
|
|
|
|
|
|
// Close while it runs. The modal is gone but the Job is still watched.
|
|
|
|
|
fireEvent.click(screen.getByRole('button', {name: 'Close'}));
|
|
|
|
|
await waitFor(() => expect(screen.queryByText('Transcode Video')).not.toBeInTheDocument());
|
|
|
|
|
|
|
|
|
|
await nextPoll();
|
|
|
|
|
await waitFor(() => expect(onTranscodeComplete).toHaveBeenCalledTimes(1));
|
|
|
|
|
expect(onTranscodeComplete.mock.calls[0][0].status).toBe('complete');
|
|
|
|
|
expect(getJob).toHaveBeenCalledTimes(2);
|
|
|
|
|
} finally {
|
|
|
|
|
jest.useRealTimers();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('while a Job runs, Transcode shows its progress and Delete waits', async () => {
|
|
|
|
|
getJob.mockResolvedValue(runningJob);
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<VideoEditMenu videoFile={videoFile} video={video} onRefresh={jest.fn()} onDelete={jest.fn()}
|
|
|
|
|
initialJobId='transcode_video-abc'/>,
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => expect(getJob).toHaveBeenCalled());
|
|
|
|
|
|
|
|
|
|
await openMenu();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: 'Transcoding...'})).toBeEnabled();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: /Delete/})).toBeDisabled();
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', {name: 'Transcoding...'}));
|
|
|
|
|
expect(await screen.findByText('running')).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryAllByLabelText('Video codec')).toHaveLength(0);
|
|
|
|
|
expect(transcodeVideo).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a Job that fails after the modal was closed still tells the user', async () => {
|
|
|
|
|
getJob.mockResolvedValue(failedJob);
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<VideoEditMenu videoFile={videoFile} video={video} onRefresh={jest.fn()} onDelete={jest.fn()}
|
|
|
|
|
onTranscodeComplete={jest.fn()} initialJobId='transcode_video-abc'/>,
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => expect(toast).toHaveBeenCalledTimes(1));
|
|
|
|
|
expect(toast.mock.calls[0][0]).toMatchObject({type: 'error', title: 'Transcode failed'});
|
|
|
|
|
expect(toast.mock.calls[0][0].description).toMatch(/ffmpeg exited with 1/);
|
|
|
|
|
expect(toast.mock.calls[0][0].description).toMatch(/original file was kept/);
|
|
|
|
|
|
|
|
|
|
// Forgotten: the menu is unlocked again.
|
|
|
|
|
await openMenu();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: 'Transcode...'})).toBeEnabled();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: /Delete/})).toBeEnabled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a Job the API no longer knows unlocks the menu with a warning', async () => {
|
|
|
|
|
getJob.mockRejectedValue(notFound);
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<VideoEditMenu videoFile={videoFile} video={video} onRefresh={jest.fn()} onDelete={jest.fn()}
|
|
|
|
|
initialJobId='transcode_video-abc'/>,
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => expect(toast).toHaveBeenCalledTimes(1));
|
|
|
|
|
expect(toast.mock.calls[0][0]).toMatchObject({type: 'warning', title: 'Lost track of the transcode'});
|
|
|
|
|
|
|
|
|
|
await openMenu();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: 'Transcode...'})).toBeEnabled();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: /Delete/})).toBeEnabled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a Job that finished unwatched is forgotten, so the next open offers the form', async () => {
|
|
|
|
|
getJob.mockResolvedValue(completeJob);
|
|
|
|
|
renderWithProviders(
|
|
|
|
|
<VideoEditMenu videoFile={videoFile} video={video} onRefresh={jest.fn()} onDelete={jest.fn()}
|
|
|
|
|
initialJobId='transcode_video-abc'/>,
|
|
|
|
|
);
|
|
|
|
|
await waitFor(() => expect(getJob).toHaveBeenCalled());
|
|
|
|
|
|
|
|
|
|
await openMenu();
|
|
|
|
|
expect(await screen.findByRole('menuitem', {name: 'Transcode...'})).toBeEnabled();
|
|
|
|
|
expect(screen.getByRole('menuitem', {name: /Delete/})).toBeEnabled();
|
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', {name: 'Transcode...'}));
|
|
|
|
|
await waitFor(() => expect(selectInput('Video codec')).toBeInTheDocument());
|
|
|
|
|
});
|
|
|
|
|
});
|