mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
FileBrowser files can be tagged. A Tag icon is displayed on each file row in the browser.
This commit is contained in:
parent
655e944960
commit
ae2b6daa33
29 changed files with 356 additions and 219 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -287,6 +287,8 @@ async def ebook_modeler():
|
|||
if PYTEST:
|
||||
raise
|
||||
|
||||
session.commit()
|
||||
|
||||
if processed < 10:
|
||||
# Did not reach limit, no more books.
|
||||
break
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue