diff --git a/app/src/api.js b/app/src/api.js
index a08959fa..b56dfcae 100644
--- a/app/src/api.js
+++ b/app/src/api.js
@@ -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};
diff --git a/app/src/components/VideoEditMenu.js b/app/src/components/VideoEditMenu.js
index 4593bd5e..2020cf3f 100644
--- a/app/src/components/VideoEditMenu.js
+++ b/app/src/components/VideoEditMenu.js
@@ -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})
}
+/**
+ * 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
+ Edit Video
+
+
+
+
+
+
+
+
+}
+
/**
* 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,
+ }
+ onClick={() => setEditOpen(true)}
+ disabled={!!wrolModeEnabled}
+ >
+ Edit...
+
}
onClick={handleRefresh}
@@ -334,6 +409,13 @@ export function VideoEditMenu({videoFile, video, onRefresh, onTranscodeComplete,
+ setEditOpen(false)}
+ fileGroupId={videoFile?.id}
+ videoFile={videoFile}
+ onSaved={onSaved}
+ />
({
@@ -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(
+ ,
+ );
+
+ 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();
+ 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();
+ 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();
+ 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 () => {
diff --git a/app/src/components/VideoPlayer.js b/app/src/components/VideoPlayer.js
index ba01fb7f..c2d9e9ab 100644
--- a/app/src/components/VideoPlayer.js
+++ b/app/src/components/VideoPlayer.js
@@ -495,6 +495,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
onRefresh={handleRefresh}
onTranscodeComplete={fetchVideo}
onDelete={async () => await handleDeleteVideo(videoFile.id)}
+ onSaved={fetchVideo}
/>
diff --git a/modules/videos/schema.py b/modules/videos/schema.py
index 0a8423c1..21c0cdca 100644
--- a/modules/videos/schema.py
+++ b/modules/videos/schema.py
@@ -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.
diff --git a/modules/videos/test/test_video_edit.py b/modules/videos/test/test_video_edit.py
new file mode 100644
index 00000000..fa8b3805
--- /dev/null
+++ b/modules/videos/test/test_video_edit.py
@@ -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)
diff --git a/modules/videos/video/api.py b/modules/videos/video/api.py
index 03c0a5ed..c7e584c0 100644
--- a/modules/videos/video/api.py
+++ b/modules/videos/video/api.py
@@ -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('/')
+@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('//comments')
@openapi.description('Get Video comments')
@openapi.response(HTTPStatus.OK, schema.VideoCommentsResponse)
diff --git a/modules/videos/video/lib.py b/modules/videos/video/lib.py
index b7424835..174d230a 100644
--- a/modules/videos/video/lib.py
+++ b/modules/videos/video/lib.py
@@ -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.