FileBrowser files can be tagged. A Tag icon is displayed on each file row in the browser.

This commit is contained in:
Roland 2023-04-01 11:52:41 -06:00
parent 655e944960
commit ae2b6daa33
No known key found for this signature in database
GPG key ID: 9D6DBD3ACF11AB93
29 changed files with 356 additions and 219 deletions

View file

@ -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'),

View file

@ -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;
}

View file

@ -77,22 +77,19 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
export default function App() {
const statusValue = useStatusInterval();
const tagsValue = useTags();
useEventsInterval();
return <ThemeWrapper>
<TagsContext.Provider value={tagsValue}>
<FilePreviewWrapper>
{/* Context and style to handle switching between mobile/computer. */}
<style>{mediaStyles}</style>
{/* Toasts can be on any page. */}
<SemanticToastContainer position='top-right'/>
<MediaContextProvider>
<StatusContext.Provider value={statusValue}>
<RouterProvider router={router}/>
</StatusContext.Provider>
</MediaContextProvider>
</FilePreviewWrapper>
</TagsContext.Provider>
<FilePreviewWrapper>
{/* Context and style to handle switching between mobile/computer. */}
<style>{mediaStyles}</style>
{/* Toasts can be on any page. */}
<SemanticToastContainer position='top-right'/>
<MediaContextProvider>
<StatusContext.Provider value={statusValue}>
<RouterProvider router={router}/>
</StatusContext.Provider>
</MediaContextProvider>
</FilePreviewWrapper>
</ThemeWrapper>
}

View file

@ -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) {

View file

@ -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 <Grid>
<Grid.Row>
<Grid.Column mobile={2} computer={1}>
<TagsModal fileGroup={fileGroup} onClick={onClick}/>
<AddTagsButton fileGroup={fileGroup} afterTag={afterTag}/>
</Grid.Column>
<Grid.Column mobile={13} computer={14}>
<TagsLinkGroup tagNames={fileGroup['tags']}/>
@ -334,7 +346,13 @@ export const TagsDisplay = ({fileGroup, onClick}) => {
</Grid>
}
export const TagsDashboard = () => {
export const TagsDisplay = ({fileGroup, afterTag, ...props}) => {
return <TagsProvider>
<Display fileGroup={fileGroup} afterTag={afterTag} {...props}/>
</TagsProvider>
}
const Dashboard = () => {
const {tagNames, TagsLinkGroup} = React.useContext(TagsContext);
let availableTagsGroup = <SLoader active inline/>;
@ -351,3 +369,17 @@ export const TagsDashboard = () => {
<EditTagsModal/>
</Segment>
}
export const TagsDashboard = () => {
return <TagsProvider>
<Dashboard/>
</TagsProvider>
}
export const TagsProvider = ({children}) => {
const value = useTagsInterval();
return <TagsContext.Provider value={value}>
{children}
</TagsContext.Provider>
}

View file

@ -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.');
}
}

View file

@ -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 <Pagination
activePage={activePage}
boundaryRange={1}
onPageChange={handlePageChange}
size={size}
siblingRange={2}
totalPages={totalPages}
firstItem={showFirstAndLast ? undefined : null}
lastItem={showFirstAndLast ? undefined : null}
/>
return <>
<Media at='mobile'>
<Pagination
activePage={activePage}
boundaryRange={1}
onPageChange={handlePageChange}
size={size}
siblingRange={2}
totalPages={totalPages}
firstItem={showFirstAndLast ? undefined : null}
lastItem={showFirstAndLast ? undefined : null}
/>
</Media>
<Media greaterThanOrEqual='tablet'>
<Pagination
activePage={activePage}
boundaryRange={1}
onPageChange={handlePageChange}
size={size}
siblingRange={5}
totalPages={totalPages}
firstItem={showFirstAndLast ? undefined : null}
lastItem={showFirstAndLast ? undefined : null}
/>
</Media>
</>
}
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 <Form>
<Toggle
label={on ? 'Downloading Enabled' : 'Downloading Disabled'}

View file

@ -1,58 +1,2 @@
import React from "react";
import {getByTestId, render, screen} from '@testing-library/react';
import {DisableDownloadsToggle} from "./Common";
import {useDownloaders} from "../hooks/customHooks";
import user from '@testing-library/user-event';
jest.mock('../hooks/customHooks', () => ({useDownloaders: jest.fn()}));
test('DownloadsToggle is off when loading', async () => {
useDownloaders.mockImplementation(() => ({on: null}));
const {container} = render(<DisableDownloadsToggle/>);
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(<DisableDownloadsToggle/>);
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(<DisableDownloadsToggle/>);
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(<DisableDownloadsToggle/>);
// Downloads is toggled when DownloadsToggle is clicked.
const toggle = screen.getByRole('checkbox');
user.click(toggle);
expect(mock).toHaveBeenCalled();
expect(mock).toHaveBeenLastCalledWith(false);
})

View file

@ -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}) {
</TableCell>
<TableCell onClick={() => onFileClick(file)} className='file-path'>
{depthIndentation(path)}
<FileRowTagIcon file={file}/>
{/*null size to make icon the correct size*/}
<FileIcon file={file} size={null}/>
{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) {

View file

@ -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 = <SButton color='blue' as='a' href={url}>Open</SButton>;
const closeButton = <SButton onClick={() => setPreviewFile(null)}>Close</SButton>;
const tagsDisplay = <TagsDisplay fileGroup={previewFile}/>;
const closeButton = <SButton onClick={handleClose}>Close</SButton>;
const tagsDisplay = <TagsDisplay fileGroup={previewFile} afterTag={localFetchFile}/>;
let downloadButton;
if (downloadURL) {
downloadButton = <SButton color='yellow' as='a' href={downloadURL} floated='left'>
@ -153,7 +181,7 @@ export function FilePreviewWrapper({children}) {
setPreviewModal(<Modal closeIcon
size='fullscreen'
open={true}
onClose={() => setPreviewModal(null)}
onClose={handleClose}
>
{content}
<ModalActions>
@ -222,6 +250,8 @@ export function FilePreviewWrapper({children}) {
}
}, [previewFile]);
const value = {previewFile, setPreviewFile, previewModal, setPreviewModal, setCallbacks};
return <FilePreviewContext.Provider value={value}>
{children}
{previewModal}

View file

@ -210,7 +210,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
</Segment>
<Segment>
<TagsDisplay fileGroup={videoFile} onClick={fetchVideo}/>
<TagsDisplay fileGroup={videoFile} afterTag={fetchVideo}/>
</Segment>
<Tab menu={tabMenu} panes={tabPanes}/>

View file

@ -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,
});
}
}

View file

@ -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

View file

@ -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():

View file

@ -26,16 +26,6 @@ add_blueprint(bp)
logger = logger.getChild(__name__)
@content_bp.post('/download/<channel_id:int>')
@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',

View file

@ -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()

View file

@ -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

View file

@ -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.

View file

@ -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'<Video id={self.id} title={repr(self.file_group.name)} path={v} channel={self.channel_id} ' \
return f'<Video id={self.id} title={repr(self.file_group.title)} path={v} channel={self.channel_id} ' \
f'source_id={repr(self.source_id)}>'
def __json__(self) -> dict:
@ -60,6 +60,7 @@ class Video(ModelHelper, Base):
channel=channel,
channel_id=self.channel_id,
description=self.file_group.c_text or self.get_video_description(),
duration=self.duration,
id=self.id,
info_json_file=self.info_json_file,
info_json_path=self.info_json_path,
@ -446,18 +447,18 @@ class Channel(ModelHelper, Base):
self_id = self.id
# Refresh all files within this channel's directory first.
await refresh_files(self.directory, send_events=send_events)
await refresh_files([self.directory], send_events=send_events)
# Apply any info_json (update view counts) second.
from modules.videos.common import update_view_counts
if PYTEST:
update_view_counts(self_id)
await update_view_counts(self_id)
self.refreshed = True
else:
# Perform info_json in background task. Channel will be marked as refreshed after this completes.
async def _():
update_view_counts(self_id)
await update_view_counts(self_id)
with get_db_session(commit=True) as session:
channel: Channel = session.query(Channel).filter(Channel.id == self_id).one()
channel.refreshed = True

View file

@ -149,15 +149,15 @@ def test_channels_with_videos(test_session, test_client, test_directory, channel
def test_api_download_channel(test_session, test_client, simple_channel):
"""A Channel download (a catalog update) can be triggered via the API."""
# simple_channel does not have a download record.
request, response = test_client.post(f'/api/videos/download/{simple_channel.id}')
request, response = test_client.post(f'/api/videos/channels/download/{simple_channel.id}')
assert response.status_code == HTTPStatus.BAD_REQUEST, response.json
assert 'not have a download' in response.json['message']
# Add a download frequency to the channel, this should also create a download.
simple_channel.update(dict(download_frequency=DownloadFrequency.daily))
test_session.commit()
request, response = test_client.post(f'/api/videos/download/{simple_channel.id}')
assert response.status_code == HTTPStatus.NO_CONTENT
request, response = test_client.post(f'/api/videos/channels/download/{simple_channel.id}')
assert response.status_code == HTTPStatus.NO_CONTENT, response.json
def test_search_videos_file(test_client, test_session, test_directory, video_with_search_factory, assert_video_search):

View file

@ -81,7 +81,8 @@ def test_is_valid_poster(ext, expected, test_directory):
assert is_valid_poster(image_path) == expected, f'is_valid_poster({image_path}) should be {expected}'
def test_update_view_count(test_session, channel_factory, video_factory):
@pytest.mark.asyncio
async def test_update_view_count(test_session, channel_factory, video_factory):
def check_view_counts(view_counts):
for source_id, view_count in view_counts.items():
video = test_session.query(Video).filter_by(source_id=source_id).one()
@ -101,22 +102,22 @@ def test_update_view_count(test_session, channel_factory, video_factory):
check_view_counts({'vid1': None, 'vid2': None, 'vid3': None, 'vid4': None})
# Channel 1 is updated, the other channels are left alone.
update_view_counts(channel1.id)
await update_view_counts(channel1.id)
check_view_counts({'vid1': 10, 'vid2': None, 'vid3': None, 'vid4': None})
# Channel 2 is updated, the other channels are left alone. The 'bad_id' video is ignored.
update_view_counts(channel2.id)
await update_view_counts(channel2.id)
check_view_counts({'vid1': 10, 'vid2': 11, 'vid3': None, 'vid4': None})
# All videos are updated.
update_view_counts(channel3.id)
await update_view_counts(channel3.id)
check_view_counts({'vid1': 10, 'vid2': 11, 'vid3': 13, 'vid4': 14})
# An outdated view count will be overwritten.
vid = test_session.query(Video).filter_by(id=1).one()
vid.view_count = 8
check_view_counts({'vid1': 8, 'vid2': 11, 'vid3': 13, 'vid4': 14})
update_view_counts(channel1.id)
await update_view_counts(channel1.id)
check_view_counts({'vid1': 10, 'vid2': 11, 'vid3': 13, 'vid4': 14})

View file

@ -148,7 +148,7 @@ def test_wrol_mode(test_directory, simple_channel, simple_video, wrol_mode_fixtu
_, resp = api_app.test_client.post('/api/files/refresh')
assert resp.status_code == HTTPStatus.FORBIDDEN
assert resp.json['code'] == API_ERRORS[WROLModeEnabled]['code']
_, resp = api_app.test_client.post(f'/api/videos/download/{simple_channel.id}')
_, resp = api_app.test_client.post(f'/api/videos/channels/download/{simple_channel.id}')
assert resp.status_code == HTTPStatus.FORBIDDEN
assert resp.json['code'] == API_ERRORS[WROLModeEnabled]['code']

View file

@ -623,6 +623,8 @@ class DownloadManager:
if len(self.data['processing_domains']) >= len(self.workers):
return
logger.debug(f' queue_downloads {self.data["processing_domains"]=}')
# Find download whose domain isn't already being downloaded.
new_downloads = list(session.query(Download).filter(
Download.status == 'new',
@ -632,6 +634,8 @@ class DownloadManager:
Download.frequency,
Download.id)) # noqa
count = 0
new_download_ids = [i.id for i in new_downloads]
logger.debug(f' queue_downloads {new_download_ids=}')
for download in new_downloads:
download.manager = self # Assign this Download to this manager.
domain = download.domain

View file

@ -26,6 +26,17 @@ async def get_files(_: Request, body: schema.FilesRequest):
return json_response({'files': files})
@bp.post('/file')
@openapi.definition(
summary='Get the dict of one file',
body=schema.FileRequest,
)
@validate(schema.FileRequest)
async def get_file(_: Request, body: schema.FileRequest):
file = lib.get_file_dict(body.file)
return json_response({'file': file})
@bp.post('/delete')
@openapi.definition(
summary='Delete a single file. Returns an error if WROL Mode is enabled.',
@ -67,6 +78,7 @@ async def refresh_progress(request: Request):
progress=progress,
))
@bp.post('/search')
@openapi.definition(
summary='Search Files',
@ -100,12 +112,23 @@ def post_directories(_, body: schema.DirectoriesRequest):
@bp.post('/tag')
@validate(schema.TagFileGroupPost)
def post_tag_file_group(_, body: schema.TagFileGroupPost):
lib.add_file_group_tag(body.file_group_id, body.tag_name)
if not body.tag_id and not body.tag_name:
return json_response(
dict(error='tag_id and tag_name cannot both be empty'),
HTTPStatus.BAD_REQUEST,
)
if not body.file_group_id and not body.file_group_primary_path:
return json_response(
dict(error='file_group_primary_path and file_group_id cannot both be empty'),
HTTPStatus.BAD_REQUEST,
)
lib.add_file_group_tag(body.file_group_id, body.file_group_primary_path, body.tag_name, body.tag_id)
return response.empty(HTTPStatus.CREATED)
@bp.post('/untag')
@validate(schema.TagFileGroupPost)
def post_untag_file_group(_, body: schema.TagFileGroupPost):
lib.remove_file_group_tag(body.file_group_id, body.tag_name)
lib.remove_file_group_tag(body.file_group_id, body.file_group_primary_path, body.tag_name, body.tag_id)
return response.empty(HTTPStatus.NO_CONTENT)

View file

@ -287,6 +287,8 @@ async def ebook_modeler():
if PYTEST:
raise
session.commit()
if processed < 10:
# Did not reach limit, no more books.
break

View file

@ -44,18 +44,32 @@ __all__ = ['list_directories_contents', 'delete_file', 'split_path_stem_and_suff
'get_mimetype', 'split_file_name_words', 'get_primary_file']
@cachetools.func.ttl_cache(10_000, 30.0)
def _get_file_dict(file: pathlib.Path,
directories_cache: str, # Used to cache by requested directories.
) -> Dict:
@optional_session
def get_file_tag_names(file: pathlib.Path, session: Session = None) -> List[str]:
"""Returns all Tag names for the provided file path."""
tags = session.query(Tag) \
.join(TagFile, Tag.id == TagFile.tag_id) \
.join(FileGroup, FileGroup.id == TagFile.file_group_id) \
.filter(FileGroup.primary_path == str(file))
names = sorted([tag.name for tag in tags], key=lambda i: i.lower())
return names
def _get_file_dict(file: pathlib.Path) -> Dict:
media_directory = get_media_directory()
return dict(
path=file.relative_to(media_directory),
size=file.stat().st_size,
mimetype=get_mimetype(file),
tags=get_file_tag_names(file),
)
def get_file_dict(file: str) -> Dict:
media_directory = get_media_directory()
return _get_file_dict(media_directory / file)
@cachetools.func.ttl_cache(10_000, 30.0)
def _get_directory_dict(directory: pathlib.Path,
directories_cache: str, # Used to cache by requested directories.
@ -78,7 +92,7 @@ def _get_recursive_directory_dict(directory: pathlib.Path, directories: List[pat
elif path.is_dir():
children[f'{path.name}/'] = _get_directory_dict(path, directories_cache)
else:
children[path.name] = _get_file_dict(path, directories_cache)
children[path.name] = _get_file_dict(path)
d['children'] = children
return d
@ -109,7 +123,7 @@ def list_directories_contents(directories_: List[str]) -> Dict:
if path.is_dir():
paths[f'{path.name}/'] = _get_recursive_directory_dict(path, directories)
else:
paths[path.name] = _get_file_dict(path, str(directories))
paths[path.name] = _get_file_dict(path)
return paths
@ -750,28 +764,46 @@ def group_files_by_stem(files: List[pathlib.Path], pre_sorted: bool = False) ->
yield group
def _get_tag(file_group_id: int, tag_name: str, session: Session):
file_group: FileGroup = session.query(FileGroup).filter_by(id=file_group_id).one_or_none()
if not file_group:
raise UnknownFile(f'Cannot find FileGroup with id {file_group_id}')
def _get_tag_and_file_group(file_group_id: int, file_group_primary_path: str, tag_name: str, tag_id: int,
session: Session):
if file_group_id:
file_group: FileGroup = session.query(FileGroup).filter_by(id=file_group_id).one_or_none()
if not file_group:
raise UnknownFile(f'Cannot find FileGroup with id {file_group_id}')
elif file_group_primary_path:
path = str(get_media_directory() / file_group_primary_path)
file_group: FileGroup = session.query(FileGroup).filter_by(primary_path=path).one_or_none()
if not file_group:
raise UnknownFile(f'Cannot find FileGroup with primary_path {file_group_primary_path}')
else:
raise UnknownFile(f'Cannot find FileGroup without id or primary_path')
tag: Tag = Tag.find_by_name(tag_name, session)
if not tag:
raise UnknownTag(f'Cannot find Tag with name {tag_name}')
if tag_id:
tag: Tag = session.query(Tag).filter_by(id=tag_id).one_or_none()
if not tag:
raise UnknownTag(f'Cannot find Tag with id {tag_id}')
elif tag_name:
tag: Tag = Tag.find_by_name(tag_name, session)
if not tag:
raise UnknownTag(f'Cannot find Tag with name {tag_name}')
else:
raise UnknownTag('Cannot find Tag without id or name')
return file_group, tag
@optional_session(commit=True)
def add_file_group_tag(file_group_id: int, tag_name: str, session: Session = None) -> TagFile:
file_group, tag = _get_tag(file_group_id, tag_name, session)
def add_file_group_tag(file_group_id: int, file_group_primary_path: str, tag_name: str, tag_id: int,
session: Session = None) -> TagFile:
file_group, tag = _get_tag_and_file_group(file_group_id, file_group_primary_path, tag_name, tag_id, session)
tag_file = file_group.add_tag(tag, session)
return tag_file
@optional_session(commit=True)
def remove_file_group_tag(file_group_id: int, tag_name: str, session: Session = None):
file_group, tag = _get_tag(file_group_id, tag_name, session)
def remove_file_group_tag(file_group_id: int, file_group_primary_path: str, tag_name: str, tag_id: int,
session: Session = None):
file_group, tag = _get_tag_and_file_group(file_group_id, file_group_primary_path, tag_name, tag_id, session)
file_group.remove_tag(tag, session)

View file

@ -135,8 +135,9 @@ async def pdf_modeler():
# Sleep to catch cancel.
await asyncio.sleep(0)
if processed:
logger.debug(f'pdf_modeler processed {processed} PDFs')
logger.info(f'pdf_modeler processed {processed} PDFs')
session.commit()
if processed < PDF_PROCESSING_LIMIT:
# Did not reach limit, do not query again.

View file

@ -7,6 +7,11 @@ class FilesRequest:
directories: List[str] = field(default_factory=list)
@dataclass
class FileRequest:
file: str
@dataclass
class DeleteRequest:
file: str
@ -39,5 +44,7 @@ class DirectoriesResponse:
@dataclass
class TagFileGroupPost:
file_group_id: int
tag_name: str
tag_name: Optional[str] = None
tag_id: Optional[int] = None
file_group_id: Optional[int] = None
file_group_primary_path: Optional[str] = None

View file

@ -7,10 +7,12 @@ import pytest
from wrolpi.errors import API_ERRORS, WROLModeEnabled
from wrolpi.files import lib as files_lib
from wrolpi.files.models import FileGroup
from wrolpi.tags import TagFile
from wrolpi.test.common import assert_dict_contains
from wrolpi.vars import PROJECT_DIR
def test_list_files_api(test_client, make_files_structure, test_directory):
def test_list_files_api(test_session, test_client, make_files_structure, test_directory):
files = [
'archives/bar.txt',
'archives/baz/bar.txt',
@ -29,10 +31,7 @@ def test_list_files_api(test_client, make_files_structure, test_directory):
assert not response.json.get('errors')
# The first dict is the media directory.
children = response.json['files']
assert children == expected_files
# Clear caches before next call.
files_lib._get_file_dict.cache_clear()
files_lib._get_directory_dict.cache_clear()
assert_dict_contains(children, expected_files)
# Requesting no directories results in the top-level results.
expected = {
@ -263,3 +262,45 @@ def test_file_statistics(test_session, test_client, test_directory, example_pdf,
'video_count': 1,
'zip_count': 0,
}
def test_file_group_tag_by_primary_path(test_session, test_client, test_directory, example_singlefile, tag_factory,
insert_file_group):
singlefile = FileGroup.from_paths(test_session, example_singlefile)
tag1 = tag_factory()
tag2 = tag_factory()
test_session.commit()
# FileGroup can be tagged with its primary_path.
content = dict(file_group_primary_path=str(singlefile.primary_path.relative_to(test_directory)), tag_name=tag1.name)
request, response = test_client.post('/api/files/tag', content=json.dumps(content))
assert response.status_code == HTTPStatus.CREATED
assert test_session.query(TagFile).count() == 1
# FileGroup can be tagged with its id.
content = dict(file_group_id=singlefile.id, tag_name=tag2.name)
request, response = test_client.post('/api/files/tag', content=json.dumps(content))
assert response.status_code == HTTPStatus.CREATED
assert test_session.query(TagFile).count() == 2
# FileGroup can be untagged with its primary_path.
content = dict(file_group_id=singlefile.id, tag_id=tag1.id)
request, response = test_client.post('/api/files/untag', content=json.dumps(content))
assert response.status_code == HTTPStatus.NO_CONTENT
assert test_session.query(TagFile).count() == 1
# FileGroup can be untagged with its id.
content = dict(file_group_id=singlefile.id, tag_name=tag2.name)
request, response = test_client.post('/api/files/untag', content=json.dumps(content))
assert response.status_code == HTTPStatus.NO_CONTENT
assert test_session.query(TagFile).count() == 0
def test_file_group_tag(test_client):
request, response = test_client.post('/api/files/tag', content=json.dumps(dict(tag_id=1)))
assert response.status_code == HTTPStatus.BAD_REQUEST
assert 'file_group_id' in response.json['error']
request, response = test_client.post('/api/files/tag', content=json.dumps(dict(file_group_id=1)))
assert response.status_code == HTTPStatus.BAD_REQUEST
assert 'tag_id' in response.json['error']