diff --git a/alembic/versions/36cae041ad31_files.py b/alembic/versions/36cae041ad31_files.py
new file mode 100644
index 00000000..8e69db38
--- /dev/null
+++ b/alembic/versions/36cae041ad31_files.py
@@ -0,0 +1,53 @@
+"""Files
+
+Revision ID: 36cae041ad31
+Revises: b97c1313f986
+Create Date: 2022-02-01 11:05:57.605541
+
+"""
+import os
+
+from alembic import op
+from sqlalchemy.orm import Session
+
+# revision identifiers, used by Alembic.
+revision = '36cae041ad31'
+down_revision = 'b97c1313f986'
+branch_labels = None
+depends_on = None
+
+DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
+
+
+def upgrade():
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ session.execute('''CREATE TABLE file (
+ id BIGSERIAL PRIMARY KEY,
+ path TEXT NOT NULL,
+ title TEXT,
+ mimetype TEXT,
+ size INTEGER,
+ modification_datetime TIMESTAMPTZ,
+ textsearch tsvector GENERATED ALWAYS AS (
+ setweight(to_tsvector('english'::regconfig, title), 'A'::"char")
+ ) stored,
+ idempotency TEXT
+ )''')
+ session.execute('CREATE INDEX file_path_idx ON file(path)')
+ session.execute('CREATE INDEX file_mimetype_idx ON file(textsearch)')
+ session.execute('CREATE INDEX file_textsearch_idx ON file(textsearch)')
+ if not DOCKERIZED:
+ session.execute('ALTER TABLE public.file OWNER TO wrolpi')
+
+
+def downgrade():
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ session.execute('DROP INDEX IF EXISTS file_textsearch_idx')
+ session.execute('DROP INDEX IF EXISTS file_mimetype_idx')
+ session.execute('DROP INDEX IF EXISTS file_path_idx')
+
+ session.execute('DROP TABLE IF EXISTS file')
diff --git a/app/src/api.js b/app/src/api.js
index bcb80594..0cf521b2 100644
--- a/app/src/api.js
+++ b/app/src/api.js
@@ -1,4 +1,4 @@
-import {API_URI, ARCHIVES_API, DEFAULT_LIMIT, OTP_API, VIDEOS_API} from "./components/Common";
+import {API_URI, ARCHIVES_API, DEFAULT_LIMIT, OTP_API, secondsToDateTime, VIDEOS_API} from "./components/Common";
import {toast} from 'react-semantic-toasts';
function timeoutPromise(ms, promise) {
@@ -303,8 +303,8 @@ export async function deleteArchive(archive_id) {
export async function searchArchives(offset, limit, domain = null, searchStr = null) {
// Build a search query to retrieve a list of videos from the API
- offset = offset || 0;
- limit = limit || DEFAULT_LIMIT;
+ offset = parseInt(offset || 0);
+ limit = parseInt(limit || DEFAULT_LIMIT);
let body = {offset, limit};
if (domain) {
body['domain'] = domain;
@@ -352,19 +352,16 @@ export async function postDownload(urls, downloader) {
return response;
}
-
export async function killDownload(download_id) {
let response = await apiPost(`${API_URI}/download/${download_id}/kill`);
return response;
}
-
export async function killDownloads() {
let response = await apiPost(`${API_URI}/download/kill`);
return response;
}
-
export async function startDownloads() {
let response = await apiPost(`${API_URI}/download/enable`);
return response;
@@ -375,10 +372,45 @@ export async function getDownloaders() {
return await response.json();
}
+const replaceFileDatetimes = (files) => {
+ for (let i = 0; i < files.length; i++) {
+ let file = files[i]['modified'];
+ if (file['modified']) {
+ files[i]['modified'] = secondsToDateTime(file['modified']);
+ }
+ }
+ return files;
+}
+
+export async function filesSearch(offset, limit, searchStr) {
+ const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit)};
+ const response = await apiPost(`${API_URI}/files/search`, body);
+
+ if (response.status === 200) {
+ let data = await response.json();
+ let [files, total] = [data['files'], data['totals']['files']];
+ files = replaceFileDatetimes(files);
+ return [files, total];
+ } else {
+ toast({
+ type: 'error',
+ title: 'Unable to search files',
+ description: 'Cannot search files. See server logs.',
+ time: 5000,
+ });
+ }
+}
+
+export async function refreshFiles() {
+ return await apiPost(`${API_URI}/files/refresh`);
+}
+
export async function getFiles(directories) {
let body = {directories: directories || []};
let response = await apiPost(`${API_URI}/files`, body);
- return await response.json();
+ let {files} = await response.json();
+ files = replaceFileDatetimes(files);
+ return files;
}
export async function deleteFile(file) {
diff --git a/app/src/components/Admin.js b/app/src/components/Admin.js
index 22687488..811c10f2 100644
--- a/app/src/components/Admin.js
+++ b/app/src/components/Admin.js
@@ -443,9 +443,9 @@ class Downloads extends React.Component {
export default function Admin(props) {
const links = [
- {text: 'Downloads', to: '/admin', exact: true},
- {text: 'Settings', to: '/admin/settings'},
- {text: 'WROL Mode', to: '/admin/wrol'},
+ {text: 'Downloads', to: '/admin', exact: true, key: 'admin'},
+ {text: 'Settings', to: '/admin/settings', key: 'settings'},
+ {text: 'WROL Mode', to: '/admin/wrol', key: 'wrol'},
];
return (
diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js
index 78add301..c36de3fb 100644
--- a/app/src/components/Archive.js
+++ b/app/src/components/Archive.js
@@ -1,10 +1,11 @@
import React, {useState} from "react";
import {Card, Confirm, Container, Icon, Image, Placeholder} from "semantic-ui-react";
-import Paginator, {
+import {
CardGroupCentered,
ClearButton,
ExternalLink,
PageContainer,
+ Paginator,
SearchInput,
TabLinks,
textEllipsis,
@@ -202,9 +203,8 @@ export function ArchivesList({archives, searchStr, syncArchive, localDeleteArchi
}
export function Archives() {
- const {archivesData, setPage, totalPages, searchStr, activePage, setSearchStr, domain, setDomain, search} =
- useArchives();
- const {archives} = archivesData;
+ const {archives, setPage, totalPages, searchStr, activePage, setSearchStr, domain, setDomain, search} =
+ useArchives(20);
const syncArchive = async (url) => {
await postDownload(url, 'archive');
@@ -237,11 +237,7 @@ export function Archives() {
setDomain={setDomain}
/>
>
)
@@ -323,9 +319,9 @@ function Domains() {
export function ArchiveRoute(props) {
const links = [
- {text: 'Archives', to: '/archive', exact: true},
- {text: 'Domains', to: '/archive/domains'},
- {text: 'Manage', to: '/archive/manage'},
+ {text: 'Archives', to: '/archive', exact: true, key: 'archive'},
+ {text: 'Domains', to: '/archive/domains', key: 'domains'},
+ {text: 'Manage', to: '/archive/manage', key: 'manage'},
];
return
diff --git a/app/src/components/Common.js b/app/src/components/Common.js
index 5ee771a6..cd6f2e8a 100644
--- a/app/src/components/Common.js
+++ b/app/src/components/Common.js
@@ -12,7 +12,7 @@ export const ARCHIVES_API = `${API_URI}/archive`;
export const OTP_API = `${API_URI}/otp`;
export const DEFAULT_LIMIT = 20;
-export default class Paginator extends React.Component {
+export default class OldPaginator extends React.Component {
state = {
boundaryRange: 1,
siblingRange: 2,
@@ -54,6 +54,25 @@ export default class Paginator extends React.Component {
}
}
+export function Paginator({activePage, onPageChange, totalPages, showFirstAndLast}) {
+ const handlePageChange = (e, {activePage}) => {
+ onPageChange(activePage);
+ }
+
+ return (
+
+ )
+}
+
export function secondsToDuration(video) {
let duration = video.duration;
let hours = Math.floor(duration / 3600);
@@ -582,6 +601,7 @@ export function TabLinks({links}) {
className={'item'}
exact={link.exact || false}
style={{padding: '1em'}}
+ key={link.key}
>
{link.text}
diff --git a/app/src/components/Files.js b/app/src/components/Files.js
index 149d8329..e40f5949 100644
--- a/app/src/components/Files.js
+++ b/app/src/components/Files.js
@@ -2,8 +2,10 @@ import React from "react";
import FileBrowser from 'react-keyed-file-browser';
import {Button, Confirm, Header, Icon, Modal} from "semantic-ui-react";
import 'react-keyed-file-browser/dist/react-keyed-file-browser.css';
-import {deleteFile, getFiles} from "../api";
-import {humanFileSize, PageContainer, secondsToDateTime} from "./Common";
+import {deleteFile, refreshFiles} from "../api";
+import {Paginator, humanFileSize, PageContainer, SearchInput, TabLinks} from "./Common";
+import {useBrowseFiles, useSearchFiles} from "../hooks/customHooks";
+import {Route} from "react-router-dom";
const icons = {
File: ,
@@ -13,107 +15,142 @@ const icons = {
PDF: ,
};
-class Files extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- deleteOpen: false,
- files: [],
- openFolders: [],
- previewOpen: false,
- selectedFile: null,
- };
- }
+export function Files() {
+ const {searchFiles, setPage, totalPages, activePage, searchStr, setSearchStr} = useSearchFiles(50);
+ const {browseFiles, setOpenFolders, fetchFiles} = useBrowseFiles();
- async componentDidMount() {
- await this.fetchFiles();
- }
+ const [deleteOpen, setDeleteOpen] = React.useState(false);
+ const [previewOpen, setPreviewOpen] = React.useState(false);
+ const [selectedFile, setSelectedFile] = React.useState(null);
- fetchFiles = async () => {
- let {files} = await getFiles(this.state.openFolders);
- for (let i = 0; i < files.length; i++) {
- files[i]['modified'] = secondsToDateTime(files[i]['modified']);
- }
- this.setState({files: files});
- }
-
- handleFolderChange = async (file, browserProps) => {
+ const handleFolderChange = async (file, browserProps) => {
let openFolders = Object.keys(browserProps.openFolders);
- this.setState({openFolders}, this.fetchFiles);
+ setOpenFolders(openFolders);
}
- onDownloadFile = async () => {
+ const onDownloadFile = async () => {
// Open file in new tab/window.
- window.open(`/media/${this.state.selectedFile.key}`);
+ window.open(`/media/${selectedFile.key}`);
}
- onSelectFile = async (file) => {
- this.setState({selectedFile: file}, this.openPreview);
+ const onSelectFile = async (file) => {
+ setSelectedFile(file);
+ openPreview();
}
- onDeleteFile = async () => {
- await deleteFile(this.state.selectedFile.key);
- await this.fetchFiles();
- this.closePreview();
+ const onDeleteFile = async () => {
+ await deleteFile(selectedFile.key);
+ await fetchFiles();
+ closePreview();
}
- openPreview = () => {
- this.setState({previewOpen: true});
+ const openPreview = () => {
+ setPreviewOpen(true);
}
- closePreview = () => {
- this.setState({previewOpen: false, selectedFile: null, deleteOpen: false});
+ const closePreview = () => {
+ setPreviewOpen(false);
+ setSelectedFile(null);
+ setDeleteOpen(false);
}
- openDelete = () => {
- this.setState({deleteOpen: true});
+ const openDelete = () => {
+ setDeleteOpen(true);
}
- closeDelete = () => {
- this.setState({deleteOpen: false});
+ const closeDelete = () => {
+ setDeleteOpen(false);
}
- render() {
- return <>
- {this.state.selectedFile &&
-
- File Preview
-
- {this.state.selectedFile.name}
- {humanFileSize(this.state.selectedFile.size)}
-
-
-
-
-
-
- }
- <>>} // Hide the preview that the 3rd party provided.
- />
- >
+ let selectedModal = <>>;
+ if (selectedFile) {
+ selectedModal = (
+
+ File Preview
+
+
+ {humanFileSize(selectedFile.size)}
+
+
+
+
+
+
+ );
}
+
+ let browser;
+ let pagination = null;
+ if (searchStr) {
+ browser = ( <>>} // Hide the preview that the 3rd party provided.
+ />);
+ pagination = (
+ );
+ } else {
+ browser = ( <>>} // Hide the preview that the 3rd party provided.
+ />);
+ }
+
+ return <>
+
+ {selectedModal}
+ {browser}
+ {pagination}
+ >
+}
+
+function ManageFiles() {
+ return (<>
+
+
+ >)
}
export function FilesRoute(props) {
+ const links = [
+ {text: 'Files', to: '/files', exact: true, key: 'files'},
+ {text: 'Manage', to: '/files/manage', exact: true, key: 'manage'},
+ ];
+
return (
-
+
+
+
);
}
diff --git a/app/src/components/Inventory.js b/app/src/components/Inventory.js
index a9039744..b87501e3 100644
--- a/app/src/components/Inventory.js
+++ b/app/src/components/Inventory.js
@@ -1090,8 +1090,8 @@ class InventoryAddList extends React.Component {
export function InventoryRoute(props) {
const links = [
- {text: 'List', to: '/inventory', exact: true},
- {text: 'Summary', to: '/inventory/summary'},
+ {text: 'List', to: '/inventory', exact: true, key: 'inventory'},
+ {text: 'Summary', to: '/inventory/summary', key: 'summary'},
];
return (
diff --git a/app/src/components/Nav.js b/app/src/components/Nav.js
index 18e00ea4..1725e888 100644
--- a/app/src/components/Nav.js
+++ b/app/src/components/Nav.js
@@ -5,17 +5,17 @@ import {Dropdown, Menu, Responsive} from "semantic-ui-react";
const responsiveWidth = 500;
const links = [
- {text: 'Videos', to: '/videos'},
- {text: 'Inventory', to: '/inventory'},
- {text: 'Archive', to: '/archive'},
- {text: 'Files', to: '/files'},
+ {text: 'Videos', to: '/videos', key: 'videos'},
+ {text: 'Inventory', to: '/inventory', key: 'inventory'},
+ {text: 'Archive', to: '/archive', key: 'archive'},
+ {text: 'Files', to: '/files', key: 'files'},
{
- text: 'Apps', links: [
+ key: 'apps', text: 'Apps', links: [
{to: '/apps/otp', text: 'One Time Pad', exact: true},
]
},
];
-const admin = {to: '/admin', text: 'Admin', exact: true};
+const admin = {to: '/admin', text: 'Admin', exact: true, key: 'admin'};
const rightLinks = [admin,];
const collapsedLinks = links.concat([admin,]);
@@ -67,22 +67,20 @@ export function NavBar() {
{/*Show the links in a menu when on desktop*/}
{links.map((link) => {
return (
-
+
)
})}
- {rightLinks.map((link) => {
- return ()
- })}
+ {rightLinks.map((link) => )}
{/*Show the menu items in a dropdown when on mobile*/}
- {collapsedLinks.map((link) => {
- return ()
- })}
+ {collapsedLinks.map((link) =>
+
+ )}
diff --git a/app/src/components/Videos.js b/app/src/components/Videos.js
index 06bf309b..f068f252 100644
--- a/app/src/components/Videos.js
+++ b/app/src/components/Videos.js
@@ -1,6 +1,6 @@
import React from 'react';
import {Link, Route} from "react-router-dom";
-import Paginator, {
+import OldPaginator, {
changePageHistory,
DEFAULT_LIMIT,
defaultSearchOrder,
@@ -401,7 +401,7 @@ class Videos extends React.Component {
if (totalPages) {
pagination = (
-
{
@@ -68,15 +68,26 @@ export const useDomains = () => {
return [domains, total];
}
-export const useArchives = (defaultLimit = 20) => {
- const [archivesData, setArchives] = useState({archives: null, total: 0});
- const [totalPages, setTotalPages] = useState(0);
- const [activePage, setActivePage] = useState(1);
-
- let [offset, setOffset] = useSearchParam('o');
+export const useArchives = ({defaultLimit = 20}) => {
let [limit, setLimit] = useSearchParam('l', defaultLimit);
- let [domain, setDomain] = useSearchParam('domain');
+ let [offset, setOffset] = useSearchParam('o');
let [searchStr, setSearchStr] = useSearchParam('q');
+ let [domain, setDomain] = useSearchParam('domain');
+
+ const [archives, setArchives] = useState(null);
+ const [totalPages, setTotalPages] = useState(0);
+ const [activePage, setActivePage] = useState((offset / limit) + 1);
+
+ const search = async () => {
+ setArchives(null);
+ const [archives, total] = await searchArchives(offset, limit, domain, searchStr);
+ setTotalPages(Math.floor(total / limit) + 1);
+ setArchives(archives);
+ }
+
+ useEffect(() => {
+ search();
+ }, [offset, limit, domain, searchStr]);
const setPage = (i) => {
i = parseInt(i);
@@ -85,20 +96,8 @@ export const useArchives = (defaultLimit = 20) => {
setActivePage(i);
}
- const search = async (term) => {
- setArchives({archives: null, total: 0});
- setTotalPages(0);
- const [archives, total] = await searchArchives(offset, limit, domain, term);
- setTotalPages(Math.floor(total / limit) + 1);
- setArchives({archives, total});
- }
-
- useEffect(() => {
- search(searchStr);
- }, [offset, limit, domain, searchStr]);
-
return {
- archivesData,
+ archives,
totalPages,
setTotalPages,
offset,
@@ -130,3 +129,52 @@ export const useVersion = () => {
return version;
}
+
+export const useSearchFiles = ({defaultLimit = 50}) => {
+ let [limit, setLimit] = useSearchParam('l', defaultLimit);
+ let [offset, setOffset] = useSearchParam('o');
+ let [searchStr, setSearchStr] = useSearchParam('q');
+
+ const [searchFiles, setSearchFiles] = useState([]);
+ const [totalPages, setTotalPages] = useState(0);
+ const [activePage, setActivePage] = useState(1);
+
+ const localSearchFiles = async () => {
+ setSearchFiles([]);
+ setTotalPages(0);
+ if (searchStr) {
+ let [files, total] = await filesSearch(offset, limit, searchStr);
+ setSearchFiles(files);
+ setTotalPages(Math.floor(total / limit) + 1);
+ }
+ }
+
+ useEffect(() => {
+ localSearchFiles();
+ }, [searchStr, limit, offset, activePage]);
+
+ const setPage = (i) => {
+ i = parseInt(i);
+ let l = parseInt(limit);
+ setOffset((l * i) - l);
+ setActivePage(i);
+ }
+
+ return {searchFiles, totalPages, limit, setLimit, setOffset, searchStr, setSearchStr, activePage, setPage};
+}
+
+export const useBrowseFiles = () => {
+ const [browseFiles, setBrowseFiles] = useState([]);
+ const [openFolders, setOpenFolders] = useState([]);
+
+ const fetchFiles = async () => {
+ const files = await getFiles(openFolders);
+ setBrowseFiles(files);
+ }
+
+ useEffect(() => {
+ fetchFiles();
+ }, [openFolders])
+
+ return {browseFiles, openFolders, setOpenFolders, fetchFiles};
+}
diff --git a/modules/files/api.py b/modules/files/api.py
index daa296bc..45c78f92 100644
--- a/modules/files/api.py
+++ b/modules/files/api.py
@@ -6,7 +6,7 @@ from sanic import response
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
-from wrolpi.common import get_media_directory
+from wrolpi.common import get_media_directory, api_param_limiter
from wrolpi.errors import InvalidFile
from wrolpi.root_api import get_blueprint, json_response
from . import lib, schema
@@ -61,3 +61,21 @@ async def delete_file(_: Request, body: schema.DeleteRequest):
raise InvalidFile('file cannot be empty')
lib.delete_file(body.file)
return response.empty()
+
+
+files_limit_limiter = api_param_limiter(100)
+files_offset_limiter = api_param_limiter(100, 0)
+
+
+@bp.post('/refresh')
+@openapi.description('Find and index all files')
+async def refresh(_: Request):
+ lib.refresh_files()
+ return response.empty()
+
+
+@bp.post('/search')
+@validate(schema.FilesSearchRequest)
+async def search_files(_: Request, body: schema.FilesSearchRequest):
+ files, total = lib.search(body.search_str, body.limit, body.offset)
+ return json_response(dict(files=files, totals=dict(files=total)))
diff --git a/modules/files/lib.py b/modules/files/lib.py
index 6224be4e..ac5d3c80 100644
--- a/modules/files/lib.py
+++ b/modules/files/lib.py
@@ -1,8 +1,24 @@
+import asyncio
+import re
+from functools import wraps
from pathlib import Path
from typing import List
+from uuid import uuid4
-from wrolpi.common import get_media_directory, wrol_mode_check
+import magic
+import psycopg2
+from sqlalchemy.orm import Session
+
+from modules.files.models import File
+from wrolpi.common import get_media_directory, wrol_mode_check, walk, chunks, logger
+from wrolpi.dates import from_timestamp
+from wrolpi.db import get_db_session, get_db_curs, get_ranked_models
from wrolpi.errors import InvalidFile
+from wrolpi.vars import PYTEST
+
+logger = logger.getChild(__name__)
+
+__all__ = ['list_files', 'delete_file', 'split_file_name', 'upsert_file', 'refresh_files', 'search']
def filter_parent_directories(directories: List[Path]) -> List[Path]:
@@ -56,3 +72,101 @@ def delete_file(file: str):
if file.is_dir() or not file.is_file():
raise InvalidFile(f'Invalid file {file}')
file.unlink()
+
+
+FILE_NAME_REGEX = re.compile(r'[_ ]')
+
+
+def split_file_name(path: Path) -> List[str]:
+ """
+ Split a file name into words.
+
+ >>> split_file_name(Path('foo.mp4'))
+ ['foo']
+ >>> split_file_name(Path('foo bar.mp4'))
+ ['foo', 'bar']
+ """
+ words = [str(i) for i in FILE_NAME_REGEX.split(path.stem)]
+ return words
+
+
+def upsert_file(path: Path, session: Session) -> File:
+ """Update/insert a File in the DB. Gather metadata about it."""
+ file = session.query(File).filter_by(path=path).one_or_none()
+ if not file:
+ file = File(path=path)
+ session.add(file)
+ if not file.mimetype:
+ file.mimetype = magic.from_file(str(path), mime=True)
+ if not file.title:
+ file.title = split_file_name(path)
+ if not file.size or not file.modification_datetime:
+ stat = path.stat()
+ file.size = stat.st_size
+ file.modification_datetime = file.modification_datetime or from_timestamp(stat.st_mtime)
+
+ return file
+
+
+def _refresh_files():
+ """Find and index all files"""
+ logger.info('Refreshing Files')
+ # Mark all files as unverified. Any records that are unverified after refresh will be deleted.
+ with get_db_curs(commit=True) as curs:
+ curs.execute('UPDATE file SET idempotency=null')
+
+ paths = filter(lambda i: i.is_file(), walk(get_media_directory()))
+ idempotency = str(uuid4())
+ for chunk in chunks(paths, 20):
+ with get_db_session(commit=True) as session:
+ for path in chunk:
+ file = upsert_file(path, session)
+ file.idempotency = idempotency
+
+ # Remove any records where the file no longer exists.
+ with get_db_curs(commit=True) as curs:
+ curs.execute('DELETE FROM file WHERE idempotency IS NULL')
+
+ logger.info('Done refreshing Files')
+
+
+@wraps(_refresh_files)
+def refresh_files():
+ """Schedule a refresh task if not testing. If testing, do a synchronous refresh."""
+ if PYTEST:
+ return _refresh_files()
+
+ async def _():
+ return _refresh_files()
+
+ asyncio.create_task(_())
+
+
+def search(search_str: str, limit: int, offset: int):
+ """
+ Search the "title" of each file. The title is the words in the file's stem.
+ """
+ with get_db_curs() as curs:
+ stmt = '''
+ SELECT id, ts_rank_cd(textsearch, websearch_to_tsquery(%(search_str)s)), COUNT(*) OVER() AS total
+ FROM file
+ WHERE textsearch @@ websearch_to_tsquery(%(search_str)s)
+ OFFSET %(offset)s LIMIT %(limit)s
+ '''
+ params = dict(search_str=search_str, offset=offset, limit=limit)
+ curs.execute(stmt, params)
+
+ try:
+ results = list(map(dict, curs.fetchall()))
+ except psycopg2.ProgrammingError:
+ # No files.
+ return [], 0
+
+ total = results[0]['total'] if results else 0
+ ranked_ids = [i['id'] for i in results]
+
+ with get_db_session() as session:
+ results = get_ranked_models(ranked_ids, File, session)
+ results = [i.__json__() for i in results]
+
+ return results, total
diff --git a/modules/files/models.py b/modules/files/models.py
new file mode 100644
index 00000000..09ee70c8
--- /dev/null
+++ b/modules/files/models.py
@@ -0,0 +1,36 @@
+from sqlalchemy import Integer, Column, String, Computed
+from sqlalchemy.orm import deferred
+
+from wrolpi.common import Base, ModelHelper, get_media_directory, tsvector
+from wrolpi.dates import TZDateTime
+from wrolpi.media_path import MediaPathType
+
+
+class File(ModelHelper, Base):
+ __tablename__ = 'file'
+ id = Column(Integer, primary_key=True)
+ path = Column(MediaPathType)
+
+ idempotency = Column(String)
+ mimetype = Column(String)
+ modification_datetime = Column(TZDateTime)
+ size = Column(Integer)
+ title = deferred(Column(String)) # used by textsearch column
+
+ textsearch = deferred(
+ Column(tsvector, Computed('''to_tsvector('english'::regconfig, title)''')))
+
+ def __repr__(self):
+ return f''
+
+ def __json__(self):
+ path = self.path.path.relative_to(get_media_directory())
+ d = dict(
+ id=self.id,
+ mimetype=self.mimetype,
+ modified=self.modification_datetime, # React browser expects this.
+ path=path,
+ size=self.size,
+ key=path,
+ )
+ return d
diff --git a/modules/files/schema.py b/modules/files/schema.py
index 8952834c..af2bf73b 100644
--- a/modules/files/schema.py
+++ b/modules/files/schema.py
@@ -1,5 +1,5 @@
from dataclasses import dataclass, field
-from typing import List
+from typing import List, Optional
@dataclass
@@ -10,3 +10,10 @@ class FilesRequest:
@dataclass
class DeleteRequest:
file: str
+
+
+@dataclass
+class FilesSearchRequest:
+ search_str: str
+ limit: Optional[int] = 20
+ offset: Optional[int] = 0
diff --git a/modules/files/test/test_api.py b/modules/files/test/test_api.py
index 0fb1ea41..3931282b 100644
--- a/modules/files/test/test_api.py
+++ b/modules/files/test/test_api.py
@@ -6,6 +6,8 @@ from unittest import mock
import pytest
from wrolpi.errors import API_ERRORS, WROLModeEnabled
+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):
@@ -114,3 +116,44 @@ def test_delete_wrol_mode(test_client):
request, response = test_client.post('/api/files/delete', content=json.dumps({'file': 'foo'}))
assert response.status_code == HTTPStatus.FORBIDDEN
assert response.json['code'] == API_ERRORS[WROLModeEnabled]['code']
+
+
+def do_search(test_client, search_str, total, expected):
+ content = json.dumps({'search_str': search_str})
+ request, response = test_client.post('/api/files/search', content=content)
+ assert response.json['totals']['files'] == total
+ for file, expected in zip_longest(response.json['files'], expected):
+ assert_dict_contains(file, expected)
+ # FileBrowser in React requires a key. We use the path.
+ assert file['path'] == file['key']
+
+
+def test_files_search(test_session, test_client, make_files_structure):
+ # You can search an empty directory.
+ do_search(test_client, 'nothing', 0, [])
+
+ # Create files in the temporary directory. Add some contents so the mimetype can be tested.
+ files = [
+ 'foo_is_the_name.txt',
+ 'archives/bar.txt',
+ 'baz.mp4',
+ 'baz two.mp4'
+ ]
+ foo, bar, baz, baz2 = make_files_structure(files)
+ foo.write_text('foo contents')
+ bar.write_text('the bar contents')
+ baz.write_bytes((PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4').read_bytes())
+ baz2.write_bytes((PROJECT_DIR / 'test/big_buck_bunny_720p_1mb.mp4').read_bytes())
+
+ # Refresh so files can be searched.
+ request, response = test_client.post('/api/files/refresh')
+ assert response.status_code == HTTPStatus.NO_CONTENT
+
+ do_search(test_client, 'foo', 1, [dict(path='foo_is_the_name.txt', mimetype='text/plain', size=12)])
+ do_search(test_client, 'bar', 1, [dict(path='archives/bar.txt', mimetype='text/plain', size=16)])
+ do_search(test_client, 'baz', 2, [
+ dict(path='baz two.mp4', mimetype='video/mp4', size=1055736),
+ dict(path='baz.mp4', mimetype='video/mp4', size=1055736),
+ ])
+ do_search(test_client, 'two', 1, [dict(path='baz two.mp4', mimetype='video/mp4', size=1055736)])
+ do_search(test_client, 'nothing', 0, [])
diff --git a/modules/files/test/test_lib.py b/modules/files/test/test_lib.py
index 1a53bb29..a15862d5 100644
--- a/modules/files/test/test_lib.py
+++ b/modules/files/test/test_lib.py
@@ -1,9 +1,12 @@
from pathlib import Path
+from typing import List, Iterable
import pytest
from modules.files import lib
+from modules.files.models import File
from wrolpi.errors import InvalidFile
+from wrolpi.media_path import MediaPathType
def test_list_files(make_files_structure, test_directory):
@@ -122,3 +125,47 @@ def test_delete_file(make_files_structure, test_directory):
lib.delete_file('does not exist')
assert (test_directory / 'baz').is_dir()
+
+
+@pytest.mark.parametrize(
+ 'path,expected',
+ [
+ ('foo.mp4', ['foo']),
+ ('foo bar.txt', ['foo', 'bar']),
+ ('foo_bar.mp4', ['foo', 'bar']),
+ ('foo-bar', ['foo-bar']),
+ ('123 foo bar', ['123', 'foo', 'bar']),
+ ('123foo bar', ['123foo', 'bar']),
+ ]
+)
+def test_split_file_name(path, expected):
+ assert lib.split_file_name(Path(path)) == expected
+
+
+def test_refresh_files(test_session, make_files_structure, test_directory):
+ files = make_files_structure([
+ 'foo.txt',
+ 'bar.txt',
+ 'baz.txt',
+ ])
+ foo, bar, baz = files
+
+ def get_relative_strs(files_: Iterable[MediaPathType]) -> List[str]:
+ return sorted([str(i.path.path.relative_to(test_directory)) for i in files_])
+
+ lib.refresh_files()
+ results = test_session.query(File)
+ assert get_relative_strs(results) == ['bar.txt', 'baz.txt', 'foo.txt']
+
+ baz.unlink()
+
+ lib.refresh_files()
+ results = test_session.query(File)
+ assert get_relative_strs(results) == ['bar.txt', 'foo.txt']
+
+ foo.unlink()
+ bar.unlink()
+
+ lib.refresh_files()
+ results = test_session.query(File)
+ assert get_relative_strs(results) == []
diff --git a/modules/videos/captions.py b/modules/videos/captions.py
index 5b60d83b..8f098c1a 100755
--- a/modules/videos/captions.py
+++ b/modules/videos/captions.py
@@ -1,5 +1,4 @@
#! /usr/bin/env python3
-import pathlib
from pathlib import Path
from typing import Generator, Union
@@ -7,14 +6,12 @@ import srt
import webvtt
from wrolpi.common import logger
-from wrolpi.media_path import MediaPath
-from .models import Video
+
+__all__ = ['get_captions']
def get_caption_text(caption_path: Union[str, Path]) -> Generator:
- """
- Return all text from each caption of a caption file.
- """
+ """Return all text from each caption of a caption file."""
if str(caption_path).endswith('vtt'):
for caption in webvtt.read(caption_path):
text = str(caption.text).strip()
@@ -38,15 +35,8 @@ def get_unique_caption_lines(caption_path: Union[str, Path]) -> Generator:
yield line
-def get_video_captions(video: Video):
- """
- Parse video captions from the video's captions file.
- """
- if isinstance(video.caption_path, MediaPath):
- caption_path = video.caption_path.path
- else:
- caption_path = pathlib.Path(video.caption_path)
-
+def get_captions(caption_path: Path):
+ """Parse video captions from the video's captions file."""
try:
lines = get_unique_caption_lines(str(caption_path))
block = '\n'.join(lines)
diff --git a/modules/videos/lib.py b/modules/videos/lib.py
index f6610a84..f6cb4fab 100644
--- a/modules/videos/lib.py
+++ b/modules/videos/lib.py
@@ -14,7 +14,7 @@ from wrolpi.dates import from_timestamp, Seconds
from wrolpi.db import get_db_curs, get_db_session, optional_session
from wrolpi.media_path import MediaPath
from wrolpi.vars import PYTEST
-from .captions import get_video_captions
+from .captions import get_captions
from .common import generate_video_paths, remove_duplicate_video_paths, apply_info_json, import_videos_config, \
get_video_duration, is_valid_poster, convert_image, generate_video_poster
from .models import Channel, Video
@@ -188,7 +188,7 @@ def validate_video(video: Video, channel_generate_poster: bool):
# Video duration was not in the info json, use ffprobe.
video.duration = get_video_duration(video_path)
if not video.caption and video.caption_path:
- video.caption = get_video_captions(video)
+ video.caption = get_captions(video.caption_path.path)
if not video.size or not video.modification_datetime:
stat = video_path.stat()
diff --git a/modules/videos/test/test_captions.py b/modules/videos/test/test_captions.py
index 894e80d1..3dbc4cdb 100644
--- a/modules/videos/test/test_captions.py
+++ b/modules/videos/test/test_captions.py
@@ -1,3 +1,4 @@
+import shutil
from pathlib import Path
from modules.videos import captions
@@ -46,11 +47,11 @@ class TestCaption(TestAPI):
video1 = Video(title='scream', caption_path=str(self.vtt_path1))
session.add(video1)
session.commit()
- video1.caption = captions.get_video_captions(video1)
+ video1.caption = captions.get_captions(video1.caption_path.path)
video2 = Video(title='bar', caption_path=str(self.vtt_path2))
session.add(video2)
session.commit()
- video2.caption = captions.get_video_captions(video2)
+ video2.caption = captions.get_captions(video2.caption_path.path)
session.commit()
session.flush()
@@ -91,3 +92,13 @@ class TestCaption(TestAPI):
# Matches video2.title
select_textsearch('bar')
self.assertEqual(curs.fetchall(), [[2, ]])
+
+
+def test_bad_caption(test_directory):
+ """A caption file that cannot be parsed is ignored."""
+ bad_caption_file = PROJECT_DIR / 'test/bad_caption.en.vtt'
+ test_file = test_directory / 'bad_caption.en.vtt'
+ shutil.copy(bad_caption_file, test_file)
+
+ result = captions.get_captions(test_file)
+ assert result is None
diff --git a/requirements.txt b/requirements.txt
index 9e681a9d..e5954ff3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,6 +7,7 @@ Pint==0.18
psycopg2-binary==2.9.3
pytest-asyncio==0.17.2
pytest==6.2.5
+python-magic==0.4.24
pytz==2021.3
PyYAML==6.0
requests==2.27.1
@@ -15,7 +16,7 @@ sanic-testing==0.8.2
sanic==21.12.1
selenium==4.1.0
SQLAlchemy==1.3.22
-srt==3.5.0
+srt==3.5.1
websockets==10.1
webvtt-py==0.4.6
yt-dlp==2022.1.21
diff --git a/test/bad_caption.en.vtt b/test/bad_caption.en.vtt
new file mode 100644
index 00000000..463b74ef
--- /dev/null
+++ b/test/bad_caption.en.vtt
@@ -0,0 +1,8 @@
+WEBVTT
+Kind: captions
+Language: en
+
+00:00:00.070 --> 00:00:06.380
+this caption is bad
+
+00:05:08.650 --> 00:05:11.26
diff --git a/wrolpi/common.py b/wrolpi/common.py
index 19134a0a..9056e473 100644
--- a/wrolpi/common.py
+++ b/wrolpi/common.py
@@ -638,3 +638,11 @@ def zig_zag(low: ZIG_TYPE, high: ZIG_TYPE) -> Generator[ZIG_TYPE, None, None]:
if num >= high:
divisor *= 2
num = low + (diff / divisor)
+
+
+def walk(path: Path) -> Generator[Path, None, None]:
+ """Walk a directory structure yielding all files and directories."""
+ for path in path.iterdir():
+ yield path
+ if path.is_dir():
+ yield from walk(path)
diff --git a/wrolpi/test/common.py b/wrolpi/test/common.py
index a93bc3dd..efbdd4a5 100644
--- a/wrolpi/test/common.py
+++ b/wrolpi/test/common.py
@@ -76,19 +76,7 @@ class PytestCase:
@classmethod
def assertDictContains(cls, d1: dict, d2: dict): # noqa
- if hasattr(d1, '__dict__'):
- d1 = d1.__dict__
- if hasattr(d2, '__dict__'):
- d2 = d2.__dict__
-
- for k2 in d2.keys():
- assert d1, f'dict 1 is empty: {d1}'
- assert d2, f'dict 1 is empty: {d2}'
- assert k2 in d1, f'dict 1 does not contain {k2}'
- if isinstance(d1[k2], dict):
- cls.assertDictContains(d1[k2], d2[k2])
- else:
- assert d1[k2] == d2[k2], f'{k2} of value "{d1[k2]}" does not equal {d2[k2]} in dict 1'
+ assert_dict_contains(d1, d2)
def assertError(self, response, http_status: int, code=None): # noqa
self.assertEqual(response.status_code, http_status)
@@ -217,3 +205,19 @@ async def get_all_ws_messages(ws) -> List[dict]:
break
messages.append(json.loads(message))
return messages
+
+
+def assert_dict_contains(d1: dict, d2: dict):
+ if hasattr(d1, '__dict__'):
+ d1 = d1.__dict__
+ if hasattr(d2, '__dict__'):
+ d2 = d2.__dict__
+
+ for k2 in d2.keys():
+ assert d1, f'dict 1 is empty: {d1}'
+ assert d2, f'dict 1 is empty: {d2}'
+ assert k2 in d1, f'dict 1 does not contain {k2}'
+ if isinstance(d1[k2], dict):
+ assert_dict_contains(d1[k2], d2[k2])
+ else:
+ assert d1[k2] == d2[k2], f'{k2} of value "{d1[k2]}" does not equal {d2[k2]} in dict 1'