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>
This commit is contained in:
Roland 2026-09-14 19:05:05 -06:00
parent 749d5ac32b
commit d5917749a7
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
6 changed files with 62 additions and 2 deletions

View file

@ -285,6 +285,7 @@ export function TranscodeModal({open, onClose, fileGroupId, video, onComplete})
* refresh; a re-download of the Video's metadata will overwrite it.
*/
export function EditVideoModal({open, onClose, fileGroupId, videoFile, description: currentDescription, onSaved}) {
const wrolModeEnabled = useWROLMode();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
@ -345,6 +346,9 @@ export function EditVideoModal({open, onClose, fileGroupId, videoFile, descripti
<Text size='sm' c='var(--muted)' mt='sm'>
Saved to the video's info json. Refreshing the video from its source will replace it.
</Text>
{wrolModeEnabled && <Text size='sm' c='var(--danger)' mt='sm'>
Videos cannot be edited while WROL Mode is enabled.
</Text>}
</form>
</Modal.Content>
<Modal.Actions>
@ -354,7 +358,7 @@ export function EditVideoModal({open, onClose, fileGroupId, videoFile, descripti
icon='save'
onClick={handleSave}
loading={saving}
disabled={saving || !trimmed || unchanged}
disabled={saving || !trimmed || unchanged || !!wrolModeEnabled}
>
Save
</Button>

View file

@ -160,6 +160,16 @@ describe('EditVideoModal', () => {
await waitFor(() => expect(updateVideo).toHaveBeenCalledWith(7, {description: ''}));
});
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: ' '}});

View file

@ -164,3 +164,26 @@ async def test_video_transcode_api(async_client, test_session, video_factory, wr
content=json.dumps({'video_codec': 'h264'}))
assert response.status == 403
await wrol_mode_fixture(False)
@pytest.mark.asyncio
async def test_transcode_video_job_wrol_mode(test_session, test_directory, async_client, video_factory,
wrol_mode_fixture):
"""A transcode queued before WROL Mode was enabled must not run: the Job fails, ffmpeg is never
started, and the file is untouched."""
video = video_factory()
video_path = video.video_path
before = video_path.read_bytes()
job_id = transcode_video_job.enqueue(file_group_id=video.file_group_id, video_codec='h264')
await wrol_mode_fixture(True)
try:
with mock.patch('wrolpi.cmd.run_command') as mock_run:
record = await jobs.wait_for_job(job_id)
finally:
await wrol_mode_fixture(False)
assert record['status'] == jobs.FAILED, record
assert 'WROL Mode' in record['error']
mock_run.assert_not_called()
assert video_path.read_bytes() == before

View file

@ -115,3 +115,21 @@ async def test_video_update_api(async_client, test_session, video_factory, wrol_
content=json.dumps({'title': 'Nope'}))
assert response.status == 403
await wrol_mode_fixture(False)
@pytest.mark.asyncio
async def test_update_video_wrol_mode(test_session, video_factory, wrol_mode_fixture):
"""The library function refuses in WROL Mode, not only the API route, so no caller can edit."""
from wrolpi.errors import WROLModeEnabled
video = video_factory(with_info_json={'title': 'Old'})
test_session.commit()
await wrol_mode_fixture(True)
try:
with pytest.raises(WROLModeEnabled):
update_video(video.file_group_id, title='Nope')
finally:
await wrol_mode_fixture(False)
test_session.expire_all()
assert Video.find_by_file_group_id(test_session, video.file_group_id).file_group.title == 'Old'

View file

@ -6,7 +6,7 @@ from typing import List, Optional, Tuple, Callable
from wrolpi.captions import FFMPEG_BIN
from wrolpi.cmd import run_command
from wrolpi.common import logger, get_relative_to_media_directory
from wrolpi.common import logger, get_relative_to_media_directory, wrol_mode_enabled
from wrolpi.db import get_db_session
from wrolpi.jobs import register_job, get_current_job
from wrolpi.vars import DEFAULT_FILE_PERMISSIONS, PYTEST
@ -269,6 +269,10 @@ async def transcode_video_job(file_group_id: int, video_codec: Optional[str] = N
from wrolpi.files.lib import get_mimetype
from wrolpi.files.worker import file_worker
# The API refuses to queue in WROL Mode; this catches a Job queued before it was enabled.
if wrol_mode_enabled():
raise RuntimeError('Cannot transcode while WROL Mode is enabled')
validate_transcode_request(video_codec, audio_codec, container)
with get_db_session() as session:

View file

@ -50,6 +50,7 @@ def get_video(file_group_id: int) -> Video:
return video
@wrol_mode_check
def update_video(file_group_id: int, title: str = None, description: str = None) -> Video:
"""Edit a Video's details. The files are the source of truth, so the change is written to the
Video's .info.json (created if missing) and the Video is validated again from it. Only the