From ae2b6daa33f194d228319b7c75ffa17ccf083b2c Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 1 Apr 2023 11:52:41 -0600 Subject: [PATCH] FileBrowser files can be tagged. A Tag icon is displayed on each file row in the browser. --- alembic/versions/3b6918aeca4b_file_group.py | 2 - app/src/App.css | 3 +- app/src/App.js | 25 ++++---- app/src/Dashboard.js | 2 +- app/src/Tags.js | 52 ++++++++++++---- app/src/api.js | 38 ++++++++++-- app/src/components/Common.js | 53 +++++++++++++---- app/src/components/Common.test.js | 56 ----------------- app/src/components/FileBrowser.js | 24 +++++--- app/src/components/FilePreview.js | 44 +++++++++++--- app/src/components/VideoPlayer.js | 2 +- app/src/hooks/customHooks.js | 37 ------------ modules/archive/__init__.py | 2 + modules/videos/__init__.py | 3 + modules/videos/api.py | 10 ---- modules/videos/common.py | 2 +- modules/videos/downloader.py | 24 ++++---- modules/videos/lib.py | 2 + modules/videos/models.py | 9 +-- modules/videos/test/test_api.py | 6 +- modules/videos/test/test_common.py | 11 ++-- modules/videos/video/test/test_api.py | 2 +- wrolpi/downloader.py | 4 ++ wrolpi/files/api.py | 27 ++++++++- wrolpi/files/ebooks.py | 2 + wrolpi/files/lib.py | 66 +++++++++++++++------ wrolpi/files/pdfs.py | 5 +- wrolpi/files/schema.py | 11 +++- wrolpi/files/test/test_api.py | 51 ++++++++++++++-- 29 files changed, 356 insertions(+), 219 deletions(-) diff --git a/alembic/versions/3b6918aeca4b_file_group.py b/alembic/versions/3b6918aeca4b_file_group.py index 35b645de..39509d91 100644 --- a/alembic/versions/3b6918aeca4b_file_group.py +++ b/alembic/versions/3b6918aeca4b_file_group.py @@ -99,7 +99,6 @@ def upgrade(): session.execute(''' INSERT INTO tag (name, color) VALUES ('Automotive', '#353535'), - ('Child Care', '#0a4003'), ('Clothing', '#9858C5'), ('Comms', '#ECD71C'), ('Cooking', '#00A04A'), @@ -127,7 +126,6 @@ def upgrade(): ('Schooling', '#6d752e'), ('Security', '#333333'), ('Shelter', '#625641'), - ('Social Decay', '#674c09'), ('Social', '#490c73'), ('Survival', '#A18A62'), ('Tools', '#f0d730'), diff --git a/app/src/App.css b/app/src/App.css index 0201b28c..a8ef5483 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -29,8 +29,7 @@ pre.wrap-text { top: 4px; right: 4px; color: black; - background: #eee; - opacity: 0.8; + background: #ffffff; padding: 0px 4px; border-radius: 3px; } diff --git a/app/src/App.js b/app/src/App.js index 2f92032e..d9a9127e 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -77,22 +77,19 @@ const router = createBrowserRouter(createRoutesFromElements( - - - {/* Context and style to handle switching between mobile/computer. */} - - {/* Toasts can be on any page. */} - - - - - - - - + + {/* Context and style to handle switching between mobile/computer. */} + + {/* Toasts can be on any page. */} + + + + + + + } diff --git a/app/src/Dashboard.js b/app/src/Dashboard.js index 20b582cd..110fe44f 100644 --- a/app/src/Dashboard.js +++ b/app/src/Dashboard.js @@ -12,7 +12,7 @@ import {Divider, Icon, Message} from "semantic-ui-react"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; import {refreshFiles} from "./api"; import _ from "lodash"; -import {TagsDashboard} from "./Tags"; +import {TagsDashboard, TagsProvider} from "./Tags"; function FlagsMessages({flags}) { if (!flags) { diff --git a/app/src/Tags.js b/app/src/Tags.js index 16bbf79f..0c1e1d2c 100644 --- a/app/src/Tags.js +++ b/app/src/Tags.js @@ -25,6 +25,7 @@ import {contrastingColor, HelpPopup} from "./components/Common"; import {Segment} from "./components/Theme"; import _ from "lodash"; import {HexColorPicker} from "react-colorful"; +import {useRecurringTimeout} from "./hooks/customHooks"; export const TagsContext = React.createContext({ NameToTagLabel: null, @@ -117,6 +118,15 @@ export function useTags() { return {tags, tagNames, NameToTagLabel, TagsGroup, TagsLinkGroup, fetchTags, findTagByName} } +export const useTagsInterval = () => { + const tagsValue = useTags(); + const {fetchTags} = tagsValue; + + useRecurringTimeout(fetchTags, 10_000); + + return tagsValue +} + function EditTagLabel({tag, onDelete, onEdit}) { const {NameToTagLabel} = React.useContext(TagsContext); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); @@ -238,7 +248,7 @@ function EditTagsModal() { } -export function TagsModal({fileGroup, onClick}) { +export function AddTagsButton({fileGroup, afterTag}) { const {tags: usedTags} = fileGroup; const {tags: availableTags, TagsGroup} = React.useContext(TagsContext); const [open, setOpen] = useState(false); @@ -248,10 +258,11 @@ export function TagsModal({fileGroup, onClick}) { setLoading(true); try { await addTag(fileGroup, name); - if (onClick) { - await onClick(); - } console.debug('Added tag'); + if (afterTag) { + console.debug('Calling afterTag'); + await afterTag(); + } } finally { setLoading(false); } @@ -261,10 +272,11 @@ export function TagsModal({fileGroup, onClick}) { setLoading(true); try { await removeTag(fileGroup, name); - if (onClick) { - await onClick(); - } console.debug('Removed tag'); + if (afterTag) { + console.debug('Calling afterTag'); + await afterTag(); + } } finally { setLoading(false); } @@ -312,7 +324,7 @@ export function TagsModal({fileGroup, onClick}) { export const taggedImageLabel = {corner: 'left', icon: 'tag', color: 'green'}; -export const TagsDisplay = ({fileGroup, onClick}) => { +const Display = ({fileGroup, afterTag}) => { const {TagsLinkGroup} = React.useContext(TagsContext); if (!fileGroup || !TagsLinkGroup) { @@ -322,7 +334,7 @@ export const TagsDisplay = ({fileGroup, onClick}) => { return - + @@ -334,7 +346,13 @@ export const TagsDisplay = ({fileGroup, onClick}) => { } -export const TagsDashboard = () => { +export const TagsDisplay = ({fileGroup, afterTag, ...props}) => { + return + + +} + +const Dashboard = () => { const {tagNames, TagsLinkGroup} = React.useContext(TagsContext); let availableTagsGroup = ; @@ -351,3 +369,17 @@ export const TagsDashboard = () => { } + +export const TagsDashboard = () => { + return + + +} + +export const TagsProvider = ({children}) => { + const value = useTagsInterval(); + + return + {children} + +} diff --git a/app/src/api.js b/app/src/api.js index 6121257e..ceb371ed 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -681,9 +681,18 @@ export async function getTags() { } export async function addTag(fileGroup, name) { - const {id} = fileGroup; + const body = {tag_name: name}; + + const {id, primary_path, path} = fileGroup; + if (id) { + body['file_group_id'] = id; + } else if (primary_path) { + body['file_group_primary_path'] = primary_path; + } else if (path) { + body['file_group_primary_path'] = path; + } + const uri = `${API_URI}/files/tag`; - const body = {file_group_id: id, tag_name: name}; let response = await apiPost(uri, body); if (response.status !== 201) { console.error('Failed to add tag'); @@ -691,9 +700,18 @@ export async function addTag(fileGroup, name) { } export async function removeTag(fileGroup, name) { - const {id} = fileGroup; + const body = {tag_name: name}; + + const {id, primary_path, path} = fileGroup; + if (id) { + body['file_group_id'] = id; + } else if (primary_path) { + body['file_group_primary_path'] = primary_path; + } else if (path) { + body['file_group_primary_path'] = path; + } + const uri = `${API_URI}/files/untag`; - const body = {file_group_id: id, tag_name: name}; let response = await apiPost(uri, body) if (response.status !== 204) { console.error('Failed to remove tag'); @@ -740,3 +758,15 @@ export async function deleteTag(id, name) { }) } } + +export async function fetchFile(path) { + const uri = `${API_URI}/files/file`; + const body = {file: path}; + const response = await apiPost(uri, body); + if (response.status === 200) { + const content = await response.json(); + return content['file']; + } else { + console.error('Unable to fetch file dict! See client logs.'); + } +} \ No newline at end of file diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 9e2fce49..78a39e56 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -2,11 +2,12 @@ import React, {useContext, useEffect, useState} from "react"; import {Card, Container, IconGroup, Input, Modal, Pagination} from 'semantic-ui-react'; import {Link, NavLink, useNavigate} from "react-router-dom"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; -import {useDirectories, useDownloaders, useHotspot, useSettings, useThrottle} from "../hooks/customHooks"; +import {useDirectories, useHotspot, useSettings, useThrottle} from "../hooks/customHooks"; import {Media, StatusContext, ThemeContext} from "../contexts/contexts"; import {Button, CardIcon, darkTheme, Form, Header, Icon, lightTheme, Menu, Popup, Statistic} from "./Theme"; import {FilePreviewContext} from "./FilePreview"; import _ from "lodash"; +import {killDownloads, startDownloads} from "../api"; export const API_URI = process.env && process.env.REACT_APP_API_URI ? process.env.REACT_APP_API_URI : `${window.location.protocol}//${window.location.host}/api`; export const VIDEOS_API = `${API_URI}/videos`; @@ -20,16 +21,32 @@ export function Paginator({activePage, onPageChange, totalPages, showFirstAndLas onPageChange(activePage); } - return + return <> + + + + + + + } export function divmod(x, y) { @@ -616,7 +633,19 @@ export function Toggle({label, checked, disabled, onChange, icon}) { } export function DisableDownloadsToggle() { - let {on, setDownloads} = useDownloaders(); + const {status, fetchStatus} = React.useContext(StatusContext); + const {downloads} = status; + + const setDownloads = async (enable) => { + if (enable) { + await startDownloads(); + } else { + await killDownloads(); + } + await fetchStatus(); + } + + const on = downloads && downloads['disabled'] === false; return
({useDownloaders: jest.fn()})); - -test('DownloadsToggle is off when loading', async () => { - useDownloaders.mockImplementation(() => ({on: null})); - - const {container} = render(); - let toggle = screen.getByRole('checkbox'); - expect(toggle).not.toBeChecked(); - // Disabled while loading. - expect(toggle).toHaveClass('disabled'); - // Text is "disabled" when downloading is On. - const label = getByTestId(container, 'toggle-label'); - expect(label).toHaveTextContent('Downloading Disabled'); -}); - -test('DownloadsToggle is off if downloading is Off', async () => { - useDownloaders.mockImplementation(() => ({on: false})); - - const {container} = render(); - const toggle = screen.getByRole('checkbox'); - expect(toggle).not.toBeChecked(); - // Not disabled when loaded. - expect(toggle).not.toHaveClass('disabled'); - // Text is "disabled" when downloading is On. - const label = getByTestId(container, 'toggle-label'); - expect(label).toHaveTextContent('Downloading Disabled'); -}); - -test('DownloadsToggle is on if downloading is On', async () => { - useDownloaders.mockImplementation(() => ({on: true})); - - const {container} = render(); - const toggle = screen.getByRole('checkbox'); - expect(toggle).toBeChecked(); - // Not disabled when loaded. - expect(toggle).not.toHaveClass('disabled'); - // Text is "enabled" when downloading is On. - const label = getByTestId(container, 'toggle-label'); - expect(label).toHaveTextContent('Downloading Enabled'); -}); - -test('DownloadsToggle click', async () => { - const mock = jest.fn(); - useDownloaders.mockImplementation(() => ({on: true, setDownloads: mock})); - render(); - - // Downloads is toggled when DownloadsToggle is clicked. - const toggle = screen.getByRole('checkbox'); - user.click(toggle); - expect(mock).toHaveBeenCalled(); - expect(mock).toHaveBeenLastCalledWith(false); -}) diff --git a/app/src/components/FileBrowser.js b/app/src/components/FileBrowser.js index 38b872a4..bef109fe 100644 --- a/app/src/components/FileBrowser.js +++ b/app/src/components/FileBrowser.js @@ -1,12 +1,12 @@ import {Button, Icon, Placeholder} from "./Theme"; import {Checkbox, Confirm, PlaceholderLine, TableCell, TableFooter, TableHeaderCell, TableRow} from "semantic-ui-react"; import {FileIcon, humanFileSize} from "./Common"; -import React from "react"; -import {deleteFile} from "../api"; +import React, {useEffect} from "react"; +import {deleteFile, fetchFile} from "../api"; import _ from 'lodash'; import {SortableTable} from "./SortableTable"; import {useBrowseFiles} from "../hooks/customHooks"; -import {DirectoryRefreshButton, FilesRefreshButton} from "./Files"; +import {DirectoryRefreshButton, FileRowTagIcon, FilesRefreshButton} from "./Files"; import {FilePreviewContext} from "./FilePreview"; function depthIndentation(path) { @@ -58,6 +58,7 @@ function File({file, onFileClick, selectedPath, onSelect}) { onFileClick(file)} className='file-path'> {depthIndentation(path)} + {/*null size to make icon the correct size*/} {name} @@ -96,11 +97,20 @@ export function FileBrowser() { const {browseFiles, openFolders, setOpenFolders, fetchFiles} = useBrowseFiles(); - const headers = [{key: 'select', text: ''}, { - key: 'path', text: 'Path', sortBy: i => i['path'].toLowerCase() - }, {key: 'size', text: 'Size', sortBy: 'size'},]; + const headers = [ + {key: 'select', text: ''}, + {key: 'path', text: 'Path', sortBy: i => i['path'].toLowerCase()}, + {key: 'size', text: 'Size', sortBy: 'size'}, + ]; - const {setPreviewFile} = React.useContext(FilePreviewContext); + const {setPreviewFile, setCallbacks} = React.useContext(FilePreviewContext); + + useEffect(() => { + // Fetch files after tagging. + setCallbacks([fetchFiles]); + + return () => setCallbacks(null); + }, []); const onSelect = (path) => { if (path.endsWith('/') && selectedPath === path) { diff --git a/app/src/components/FilePreview.js b/app/src/components/FilePreview.js index 150d5e4c..dc124c4d 100644 --- a/app/src/components/FilePreview.js +++ b/app/src/components/FilePreview.js @@ -10,9 +10,10 @@ import { ModalContent, ModalHeader } from "semantic-ui-react"; -import {TagsDisplay} from "../Tags"; +import {TagsDisplay, TagsProvider} from "../Tags"; import {Media} from "../contexts/contexts"; import {encodeMediaPath} from "./Common"; +import {fetchFile} from "../api"; function getMediaPathURL(previewFile) { if (previewFile['primary_path']) { @@ -126,7 +127,11 @@ function getAudioModal(previewFile) { } export const FilePreviewContext = React.createContext({ - previewFile: null, setPreviewFile: null, previewModal: null, setPreviewModal: null, + previewFile: null, + setPreviewFile: null, + previewModal: null, + setPreviewModal: null, + setCallbacks: null, }); const MAXIMUM_TEXT_SIZE = 5_000_000; @@ -134,15 +139,38 @@ const MAXIMUM_TEXT_SIZE = 5_000_000; export function FilePreviewWrapper({children}) { const [previewFile, setPreviewFile] = React.useState(null); const [previewModal, setPreviewModal] = React.useState(null); + const [callbacks, setCallbacks] = React.useState(null); - const value = { - previewFile, setPreviewFile, previewModal, setPreviewModal, + const localFetchFile = async () => { + // Get the file again with its Tags. + const {path, primary_path} = previewFile; + try { + const file = await fetchFile(primary_path ?? path); + setPreviewFile(file); + } finally { + await handleCallbacks(); + } }; + const handleClose = () => { + setPreviewModal(null); + setPreviewFile(null); + } + + const handleCallbacks = async () => { + for (const callbacksKey in callbacks) { + try { + await callbacks[callbacksKey](); + } catch (e) { + console.error(e); + } + } + } + function setModalContent(content, url, downloadURL) { const openButton = Open; - const closeButton = setPreviewFile(null)}>Close; - const tagsDisplay = ; + const closeButton = Close; + const tagsDisplay = ; let downloadButton; if (downloadURL) { downloadButton = @@ -153,7 +181,7 @@ export function FilePreviewWrapper({children}) { setPreviewModal( setPreviewModal(null)} + onClose={handleClose} > {content} @@ -222,6 +250,8 @@ export function FilePreviewWrapper({children}) { } }, [previewFile]); + const value = {previewFile, setPreviewFile, previewModal, setPreviewModal, setCallbacks}; + return {children} {previewModal} diff --git a/app/src/components/VideoPlayer.js b/app/src/components/VideoPlayer.js index 2036ac66..e3eec993 100644 --- a/app/src/components/VideoPlayer.js +++ b/app/src/components/VideoPlayer.js @@ -210,7 +210,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { - + diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 62c5bd05..ffe571f1 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -531,37 +531,6 @@ export const useDownloads = () => { return {onceDownloads, recurringDownloads, fetchDownloads} } -export const useDownloaders = () => { - const [on, setOn] = useState(null); - const [downloaders, setDownloaders] = useState(null); - - const fetchDownloaders = async () => { - try { - let data = await getDownloaders(); - setDownloaders(data['downloaders']); - setOn(!data['manager_disabled']); - } catch (e) { - console.error(e); - } - } - - useEffect(() => { - fetchDownloaders(); - }, []); - - const localSetDownloads = async (on) => { - setOn(null); - if (on) { - await startDownloads(); - } else { - await killDownloads(); - } - await fetchDownloaders(); - } - - return {on, setOn, downloaders, setDownloads: localSetDownloads}; -} - export const useThrottle = () => { const [on, setOn] = useState(null); @@ -669,12 +638,6 @@ export const useStatus = () => { setStatus(s); } catch (e) { console.error(e); - toast({ - type: 'error', - title: 'Unexpected server response', - description: 'Could not get server status', - time: 5000, - }); } } diff --git a/modules/archive/__init__.py b/modules/archive/__init__.py index 27c67876..6ff10ebb 100644 --- a/modules/archive/__init__.py +++ b/modules/archive/__init__.py @@ -179,6 +179,8 @@ async def archive_modeler(): # Even if indexing fails, we mark it as indexed. We won't retry indexing this. file_group.indexed = True + session.commit() + if processed < 20: # Did not reach limit, do not query again. break diff --git a/modules/videos/__init__.py b/modules/videos/__init__.py index 4f6b27d0..35c45c8e 100644 --- a/modules/videos/__init__.py +++ b/modules/videos/__init__.py @@ -45,6 +45,8 @@ async def video_modeler(): file_group.indexed = True + session.commit() + if processed < VIDEO_PROCESSING_LIMIT: # Did not reach limit, do not query again. break @@ -54,6 +56,7 @@ async def video_modeler(): # Sleep to catch cancel. await asyncio.sleep(0) + @register_refresh_cleanup @limit_concurrent(1) def video_cleanup(): diff --git a/modules/videos/api.py b/modules/videos/api.py index 6b8e3f73..a327f11d 100644 --- a/modules/videos/api.py +++ b/modules/videos/api.py @@ -26,16 +26,6 @@ add_blueprint(bp) logger = logger.getChild(__name__) -@content_bp.post('/download/') -@openapi.description('Update a channel catalog, download any missing videos') -@openapi.response(HTTPStatus.OK, schema.StreamResponse) -@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse) -@wrol_mode_check -def download(_, channel_id: int = None): - channel_lib.download_channel(channel_id) - return response.empty() - - @content_bp.post('/tag') @openapi.definition( description='Associate a Video with a Tag', diff --git a/modules/videos/common.py b/modules/videos/common.py index dae9a2b7..5cf3f929 100644 --- a/modules/videos/common.py +++ b/modules/videos/common.py @@ -215,7 +215,7 @@ def check_for_video_corruption(video_path: Path) -> bool: return corrupt -def update_view_counts(channel_id: int): +async def update_view_counts(channel_id: int): """Update view_count for all Videos in a channel using its info_json file.""" with get_db_session() as session: channel: Channel = session.query(Channel).filter_by(id=channel_id).one() diff --git a/modules/videos/downloader.py b/modules/videos/downloader.py index dd4b22a6..8555022f 100755 --- a/modules/videos/downloader.py +++ b/modules/videos/downloader.py @@ -14,7 +14,7 @@ from yt_dlp.extractor import YoutubeTabIE # noqa from yt_dlp.utils import UnsupportedError, DownloadError from wrolpi.cmd import YT_DLP_BIN -from wrolpi.common import logger, get_media_directory, escape_file_name, resolve_generators +from wrolpi.common import logger, get_media_directory, escape_file_name, resolve_generators, background_task from wrolpi.dates import now from wrolpi.db import get_db_session from wrolpi.db import optional_session @@ -438,7 +438,7 @@ def update_channel_catalog(channel: Channel, info: dict): It is expected that any missing videos will be downloaded later. """ - logger.info(f'Downloading video list for {channel.name} at {channel.url} This may take several minutes.') + logger.info(f'Downloading video list for {channel} at {channel.url}') # Resolve all entries to dictionaries. entries = info['entries'] = list(info['entries']) @@ -452,17 +452,8 @@ def update_channel_catalog(channel: Channel, info: dict): if entry['title'] == 'Uploads': logger.info('Youtube-DL gave back a list of URLs, found the "Uploads" URL and using it.') info = extract_info(entry['url']) - entries = info['entries'] = list(info['entries']) break - # This is all the source id's that are currently available. - try: - all_source_ids = {i['id'] for i in entries} - except KeyError as e: - logger.warning(f'No ids for entries! Was the channel update successful? Is the channel URL correct?') - logger.warning(f'entries: {entries}') - raise KeyError('No id key for entry!') from e - with get_db_session(commit=True) as session: # Get the channel in this new context. channel: Channel = session.query(Channel).filter_by(id=channel.id).one() @@ -478,8 +469,10 @@ def update_channel_catalog(channel: Channel, info: dict): with info_json_path.open('wt') as fh: json.dump(info, fh, indent=2) + logger.info(f'Finished downloading video list for {channel} found {len(entries)} videos') + # Update all view counts using the latest from the Channel's info_json. - update_view_counts(channel_id) + background_task(update_view_counts(channel_id)) def _find_all_missing_videos(channel_id: id) -> List[Tuple]: @@ -552,8 +545,11 @@ async def find_all_missing_videos(channel_id: int = None): logger.warning(f'Video {channel.name} / {source_id} is not in {channel.name} info_json') continue - if not match_regex or (match_regex and missing_video['title'] and match_regex.match(missing_video['title'])): - # No title match regex, or the title matches the regex. + if not match_regex: + # No title match regex, yield all videos. + yield video_id, source_id, missing_video + if match_regex and missing_video['title'] and match_regex.match(missing_video['title']): + # Title matches the regex. yield video_id, source_id, missing_video diff --git a/modules/videos/lib.py b/modules/videos/lib.py index c37a0a57..798f2f3c 100644 --- a/modules/videos/lib.py +++ b/modules/videos/lib.py @@ -95,6 +95,8 @@ def validate_video(video: Video, channel_generate_poster: bool): video.channel_id = get_channel_id_by_source_id(source_id=video_info_json.channel_source_id) video_path = video.video_path + if not video_path: + logger.error(f'{video} does not have video_path!') if video_path and (not video.file_group.title or not video.upload_date or not video.source_id): # Video is missing things that can be extracted from the video file name. diff --git a/modules/videos/models.py b/modules/videos/models.py index 8e889150..77f3179d 100644 --- a/modules/videos/models.py +++ b/modules/videos/models.py @@ -43,7 +43,7 @@ class Video(ModelHelper, Base): v = None if self.video_path: v = self.video_path.relative_to(get_media_directory()) - return f'