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>
This commit is contained in:
Roland 2026-09-14 18:50:25 -06:00
parent 712d89c424
commit cc25e342d4
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
8 changed files with 282 additions and 6 deletions

View file

@ -236,6 +236,18 @@ export async function getVideo(fileGroupId) {
return [data['file_group'], data['prev'], data['next']];
}
export async function updateVideo(fileGroupId, {title}) {
// Edits the Video's details (written to its info json). Returns the updated video file group.
const response = await apiPut(`${VIDEOS_API}/${fileGroupId}`, {title});
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'}) {
// Queues a transcode Job; returns the job id. Watch it with `getJob`.
const body = {video_codec, audio_codec, container};

View file

@ -1,7 +1,7 @@
import React, {useEffect, useRef, useState} from 'react';
import {cancelJob, fetchVideoDownloadDefaults, getJob, transcodeVideo} from '../api';
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, toast} from './ui';
import {Button, Confirm, Group, Icon, Menu, Modal, Progress, Select, Stack, Status, Text, TextInput, toast} from './ui';
import {
TRANSCODE_COPY,
transcodeAudioCodecOptions,
@ -268,6 +268,73 @@ export function TranscodeModal({open, onClose, fileGroupId, video, onComplete})
</Modal>
}
/**
* Edit a Video's details. The change is written to the Video's info json, so it survives a
* refresh; a re-download of the Video's metadata will overwrite it.
*/
export function EditVideoModal({open, onClose, fileGroupId, videoFile, onSaved}) {
const [title, setTitle] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
// Start from the current details on every open, discarding an abandoned edit.
setTitle(videoFile?.title || '');
}
}, [open, videoFile?.title]);
const trimmed = title.trim();
const unchanged = trimmed === (videoFile?.title || '');
const handleSave = async () => {
setSaving(true);
try {
const updated = await updateVideo(fileGroupId, {title: trimmed});
toast({type: 'success', title: 'Saved', description: 'The video was updated.', time: 3000});
if (onSaved) await onSaved(updated);
onClose();
} catch (e) {
// updateVideo already toasted.
console.error(e);
} finally {
setSaving(false);
}
};
return <Modal open={open} onClose={onClose} size='small'>
<Modal.Header>Edit Video</Modal.Header>
<Modal.Content>
<form onSubmit={e => {
e.preventDefault();
if (trimmed && !unchanged && !saving) handleSave();
}}>
<TextInput
label='Title'
value={title}
onChange={e => setTitle(e.currentTarget.value)}
error={!trimmed ? 'A title is required' : null}
data-autofocus
/>
<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>
</form>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={onClose}>Cancel</Button>
<Button
role='save'
icon='save'
onClick={handleSave}
loading={saving}
disabled={saving || !trimmed || unchanged}
>
Save
</Button>
</Modal.Actions>
</Modal>
}
/**
* The "Edit" dropdown on the Video page: Refresh (re-download metadata), Transcode, and Delete.
*
@ -275,8 +342,9 @@ export function TranscodeModal({open, onClose, fileGroupId, video, onComplete})
* page can fetch the Video again (its path and codecs changed). `onDelete` is awaited after the
* user confirms; the page decides what happens next (navigate away, or the tagged-files prompt).
*/
export function VideoEditMenu({videoFile, video, onRefresh, onTranscodeComplete, onDelete}) {
export function VideoEditMenu({videoFile, video, onRefresh, onTranscodeComplete, onDelete, onSaved}) {
const wrolModeEnabled = useWROLMode();
const [editOpen, setEditOpen] = useState(false);
const [transcodeOpen, setTranscodeOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@ -309,6 +377,13 @@ export function VideoEditMenu({videoFile, video, onRefresh, onTranscodeComplete,
<Button role='primary' icon='edit' iconAfter='dropdown' loading={refreshing}>Edit</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Icon name='edit'/>}
onClick={() => setEditOpen(true)}
disabled={!!wrolModeEnabled}
>
Edit...
</Menu.Item>
<Menu.Item
leftSection={<Icon name='refresh'/>}
onClick={handleRefresh}
@ -334,6 +409,13 @@ export function VideoEditMenu({videoFile, video, onRefresh, onTranscodeComplete,
</Menu.Item>
</Menu.Dropdown>
</Menu>
<EditVideoModal
open={editOpen}
onClose={() => setEditOpen(false)}
fileGroupId={videoFile?.id}
videoFile={videoFile}
onSaved={onSaved}
/>
<Confirm
open={deleteOpen}
title='Delete video?'

View file

@ -1,7 +1,7 @@
import React from 'react';
import {act, fireEvent, screen, waitFor} from '@testing-library/react';
import {renderWithProviders} from '../test-utils';
import {currentCodecs, JobProgress, preferredTarget, TranscodeModal, VideoEditMenu} from './VideoEditMenu';
import {currentCodecs, EditVideoModal, JobProgress, preferredTarget, TranscodeModal, VideoEditMenu} from './VideoEditMenu';
import {TRANSCODE_COPY, transcodeAudioCodecOptions, transcodeVideoCodecOptions} from './Vars';
jest.mock('../api', () => ({
@ -9,16 +9,17 @@ jest.mock('../api', () => ({
fetchVideoDownloadDefaults: jest.fn(),
getJob: jest.fn(),
transcodeVideo: jest.fn(),
updateVideo: jest.fn(),
}));
import {cancelJob, fetchVideoDownloadDefaults, getJob, transcodeVideo} from '../api';
import {cancelJob, fetchVideoDownloadDefaults, getJob, transcodeVideo, updateVideo} from '../api';
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', video};
const videoFile = {id: 7, url: 'https://example.com/watch?v=1', mimetype: 'video/webm', title: 'Old Title', video};
// 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');
@ -95,6 +96,52 @@ describe('TranscodeModal', () => {
});
});
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} onSaved={onSaved}/>,
);
const input = screen.getByLabelText('Title');
expect(input).toHaveValue('Old Title');
// 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/}));
await waitFor(() => expect(updateVideo).toHaveBeenCalledWith(7, {title: 'New Title'}));
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
expect(onClose).toHaveBeenCalledTimes(1);
});
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');
});
});
describe('JobProgress', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -191,6 +238,15 @@ describe('VideoEditMenu', () => {
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('Edit opens the details modal', async () => {
renderWithProviders(<VideoEditMenu videoFile={videoFile} video={video} onRefresh={jest.fn()}/>);
fireEvent.click(screen.getByRole('button', {name: /Edit/}));
fireEvent.click(await screen.findByRole('menuitem', {name: /^Edit/}));
expect(await screen.findByText('Edit Video')).toBeInTheDocument();
expect(screen.getByLabelText('Title')).toHaveValue('Old Title');
});
test('Delete asks for confirmation before calling onDelete', async () => {

View file

@ -495,6 +495,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
onRefresh={handleRefresh}
onTranscodeComplete={fetchVideo}
onDelete={async () => await handleDeleteVideo(videoFile.id)}
onSaved={fetchVideo}
/>
<AddToPlaylistButton fileGroupId={videoFile.id}/>
</div>

View file

@ -186,6 +186,11 @@ class VideoSearchRequest:
deep: bool = False # When True, search all text including captions (d_text)
@dataclass
class VideoUpdateRequest:
title: str
@dataclass
class VideoTranscodeRequest:
video_codec: Optional[str] = None # A key of TRANSCODE_VIDEO_TARGETS, or None to copy the stream.

View file

@ -0,0 +1,77 @@
"""Editing a Video's details from the video page."""
import json
import pytest
from modules.videos.models import Video
from modules.videos.video.lib import update_video
from wrolpi.errors import ValidationError
def test_update_video_title(test_session, test_directory, video_factory):
"""The new title is written to the info json (title and fulltitle) and the Video re-validated,
so its title and search text change too. Other info json keys are kept."""
video = video_factory(with_info_json={'title': 'Old Title', 'fulltitle': 'Old Title', 'duration': 7,
'formats': ['kept, not cleaned']})
test_session.commit()
assert video.file_group.title == 'Old Title'
update_video(video.file_group_id, ' New Title ')
test_session.expire_all()
video = Video.find_by_file_group_id(test_session, video.file_group_id)
assert video.file_group.title == 'New Title'
assert video.file_group.a_text == 'New Title'
assert video.file_group.length == 7, 'Other details from the info json are unchanged'
info_json = json.loads(video.info_json_path.read_text())
assert info_json['title'] == 'New Title' and info_json['fulltitle'] == 'New Title'
assert info_json['formats'] == ['kept, not cleaned']
def test_update_video_title_creates_info_json(test_session, test_directory, video_factory):
"""A Video without an info json gets one holding the title."""
video = video_factory()
test_session.commit()
assert video.info_json_path is None
update_video(video.file_group_id, 'Brand New')
test_session.expire_all()
video = Video.find_by_file_group_id(test_session, video.file_group_id)
assert video.file_group.title == 'Brand New'
assert video.info_json_path and video.info_json_path.is_file()
assert json.loads(video.info_json_path.read_text())['title'] == 'Brand New'
assert video.info_json_path.name in [i['path'] for i in video.file_group.files], 'The new file is tracked'
def test_update_video_title_empty(test_session, video_factory):
video = video_factory(with_info_json={'title': 'Old'})
test_session.commit()
with pytest.raises(ValidationError):
update_video(video.file_group_id, ' ')
test_session.expire_all()
assert Video.find_by_file_group_id(test_session, video.file_group_id).file_group.title == 'Old'
@pytest.mark.asyncio
async def test_video_update_api(async_client, test_session, video_factory, wrol_mode_fixture):
video = video_factory(with_info_json={'title': 'Old'})
test_session.commit()
request, response = await async_client.put(f'/api/videos/{video.file_group_id}',
content=json.dumps({'title': 'Edited'}))
assert response.status == 200, response.json
assert response.json['file_group']['title'] == 'Edited'
assert response.json['file_group']['id'] == video.file_group_id
request, response = await async_client.put(f'/api/videos/{video.file_group_id}', content=json.dumps({'title': ''}))
assert response.status == 400
request, response = await async_client.put('/api/videos/999999', content=json.dumps({'title': 'x'}))
assert response.status == 404
await wrol_mode_fixture(True)
request, response = await async_client.put(f'/api/videos/{video.file_group_id}',
content=json.dumps({'title': 'Nope'}))
assert response.status == 403
await wrol_mode_fixture(False)

View file

@ -29,6 +29,20 @@ def video_get(request: Request, file_group_id: int):
return json_response({'file_group': video, 'prev': previous_video, 'next': next_video})
@video_bp.put('/<file_group_id:int>')
@openapi.description('Edit a Video\'s details. Written to its info json; the Video is validated again.')
@openapi.body({'application/json': schema.VideoUpdateRequest})
@openapi.response(HTTPStatus.OK, schema.VideoResponse)
@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
@validate(schema.VideoUpdateRequest)
@wrol_mode_check
async def video_update(_: Request, file_group_id: int, body: schema.VideoUpdateRequest):
lib.update_video(file_group_id, body.title)
video, previous_video, next_video = lib.get_video_for_app(file_group_id)
return json_response({'file_group': video, 'prev': previous_video, 'next': next_video})
@video_bp.get('/<file_group_id:int>/comments')
@openapi.description('Get Video comments')
@openapi.response(HTTPStatus.OK, schema.VideoCommentsResponse)

View file

@ -50,6 +50,35 @@ def get_video(file_group_id: int) -> Video:
return video
def update_video(file_group_id: int, title: str) -> 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.
A later re-download of the Video's metadata will overwrite the info json, and this edit with
it.
@raise UnknownVideo: if the Video can not be found
@raise ValidationError: when the title is empty
"""
from wrolpi.errors import ValidationError
title = (title or '').strip()
if not title:
raise ValidationError('Video title cannot be empty')
with get_db_session(commit=True) as session:
video = Video.find_by_file_group_id(session, file_group_id)
info_json = video.get_info_json() or dict()
# `extract_video_info_json` prefers fulltitle over title; keep the two in agreement.
info_json['title'] = title
info_json['fulltitle'] = title
# Keep the file as it was, other than the title.
video.replace_info_json(info_json, clean=False)
# Re-derive the title (and search text) from the file just written.
video.validate(session)
video.flush()
return video
VIDEO_ORDERS = {
# fg.id (the rowid) is the pagination tiebreaker: it is present in every index, so these
# orders stay index-only. LOWER(fg.primary_path) forced SQLite to read every (large) row.