You haven't tagged any videos as favorite.
;
+ } else {
+ // default empty body.
+ body = No videos retrieved. Have you downloaded videos yet?
;
+ }
+ } else if (videos) {
+ body = ;
+ }
+
+ let pagination = null;
+ if (totalPages) {
+ pagination = (
+
+ );
+ }
+
+ let clearSearchButton = (
+
+ Search: {queryStr}
+
+
+ );
+
+ return (
+
+
+
+
+ {title}
+ {
+ channel &&
+ <>
+
+
+
+
+
+ >
+ }
+
+ {queryStr && clearSearchButton}
+
+
+
+
+
+
+
+
+
+ {body}
+
+ {pagination}
+
+ )
+ }
+}
+
+class ManageTabs extends React.Component {
+ render() {
+ const panes = [
+ {menuItem: 'Manage', render: () => },
+ {menuItem: 'Statistics', render: () => },
+ ]
+ return (
+
+
+
+ )
+ }
+}
+
+export class VideosRoute extends React.Component {
+
+ render() {
+ return (
+ <>
+
+
+ }
+ />
+
+
+ }/>
+
+
+
+
+
+ }
+ />
+
+
+
+ >
+ )
+ }
+}
+
+class Statistics extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ videos: null,
+ historical: null,
+ channels: null,
+ };
+ this.videoNames = [
+ {key: 'videos', label: 'Downloaded Videos'},
+ {key: 'favorites', label: 'Favorite Videos'},
+ {key: 'sum_size', label: 'Total Size'},
+ {key: 'max_size', label: 'Largest Video'},
+ {key: 'week', label: 'Downloads Past Week'},
+ {key: 'month', label: 'Downloads Past Month'},
+ {key: 'year', label: 'Downloads Past Year'},
+ {key: 'sum_duration', label: 'Total Duration'},
+ ];
+ this.historicalNames = [
+ {key: 'average_count', label: 'Average Monthly Downloads'},
+ {key: 'average_size', label: 'Average Monthly Usage'},
+ ];
+ this.channelNames = [
+ {key: 'channels', label: 'Channels'},
+ ];
+ }
+
+ async componentDidMount() {
+ await this.fetchStatistics();
+ }
+
+ async fetchStatistics() {
+ let stats = await getStatistics();
+ stats.videos.sum_duration = secondsToString(stats.videos.sum_duration);
+ stats.videos.sum_size = humanFileSize(stats.videos.sum_size, true);
+ stats.videos.max_size = humanFileSize(stats.videos.max_size, true);
+ stats.historical.average_size = humanFileSize(stats.historical.average_size, true);
+ this.setState({...stats});
+ }
+
+ buildSegment(title, names, stats) {
+ return
+
+
+ {names.map(
+ ({key, label}) =>
+
+ {stats[key]}
+ {label}
+
+ )}
+
+
+ }
+
+ render() {
+ if (this.state.videos) {
+ return (
+ <>
+ {this.buildSegment('Videos', this.videoNames, this.state.videos)}
+ {this.buildSegment('Historical Video', this.historicalNames, this.state.historical)}
+ {this.buildSegment('Channels', this.channelNames, this.state.channels)}
+ >
+ )
+ } else {
+ return
+ }
+ }
+}
diff --git a/app/src/index.css b/app/src/index.css
new file mode 100644
index 00000000..dae270b0
--- /dev/null
+++ b/app/src/index.css
@@ -0,0 +1,33 @@
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
+ "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
+ monospace;
+}
+
+html, body, #root {
+ height: 100%;
+ margin: 0;
+}
+
+.vwrapper, html, body {
+ height: 100%;
+ margin: 0;
+}
+
+.vwrapper {
+ display: flex;
+ flex-direction: column;
+}
+
+.full-page {
+ flex: 2;
+ display: flex;
+}
diff --git a/app/src/index.js b/app/src/index.js
new file mode 100644
index 00000000..f8538d88
--- /dev/null
+++ b/app/src/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import './index.css';
+import App from './App';
+import * as serviceWorker from './serviceWorker';
+import {BrowserRouter} from "react-router-dom";
+
+ReactDOM.render(
+
+
+ ,
+ document.getElementById('root')
+);
+
+// If you want your app to work offline and load faster, you can change
+// unregister() to register() below. Note this comes with some pitfalls.
+// Learn more about service workers: https://bit.ly/CRA-PWA
+serviceWorker.unregister();
diff --git a/app/src/logo.svg b/app/src/logo.svg
new file mode 100644
index 00000000..2e5df0d3
--- /dev/null
+++ b/app/src/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/src/serviceWorker.js b/app/src/serviceWorker.js
new file mode 100644
index 00000000..ac8920b4
--- /dev/null
+++ b/app/src/serviceWorker.js
@@ -0,0 +1,135 @@
+// This optional code is used to register a service worker.
+// register() is not called by default.
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on subsequent visits to a page, after all the
+// existing tabs open on the page have been closed, since previously cached
+// resources are updated in the background.
+
+// To learn more about the benefits of this model and instructions on how to
+// opt-in, read https://bit.ly/CRA-PWA
+
+const isLocalhost = Boolean(
+ window.location.hostname === 'localhost' ||
+ // [::1] is the IPv6 localhost address.
+ window.location.hostname === '[::1]' ||
+ // 127.0.0.1/8 is considered localhost for IPv4.
+ window.location.hostname.match(
+ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
+ )
+);
+
+export function register(config) {
+ if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+ // The URL constructor is available in all browsers that support SW.
+ const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
+ if (publicUrl.origin !== window.location.origin) {
+ // Our service worker won't work if PUBLIC_URL is on a different origin
+ // from what our page is served on. This might happen if a CDN is used to
+ // serve assets; see https://github.com/facebook/create-react-app/issues/2374
+ return;
+ }
+
+ window.addEventListener('load', () => {
+ const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+ if (isLocalhost) {
+ // This is running on localhost. Let's check if a service worker still exists or not.
+ checkValidServiceWorker(swUrl, config);
+
+ // Add some additional logging to localhost, pointing developers to the
+ // service worker/PWA documentation.
+ navigator.serviceWorker.ready.then(() => {
+ console.log(
+ 'This web app is being served cache-first by a service ' +
+ 'worker. To learn more, visit https://bit.ly/CRA-PWA'
+ );
+ });
+ } else {
+ // Is not localhost. Just register service worker
+ registerValidSW(swUrl, config);
+ }
+ });
+ }
+}
+
+function registerValidSW(swUrl, config) {
+ navigator.serviceWorker
+ .register(swUrl)
+ .then(registration => {
+ registration.onupdatefound = () => {
+ const installingWorker = registration.installing;
+ if (installingWorker == null) {
+ return;
+ }
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === 'installed') {
+ if (navigator.serviceWorker.controller) {
+ // At this point, the updated precached content has been fetched,
+ // but the previous service worker will still serve the older
+ // content until all client tabs are closed.
+ console.log(
+ 'New content is available and will be used when all ' +
+ 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
+ );
+
+ // Execute callback
+ if (config && config.onUpdate) {
+ config.onUpdate(registration);
+ }
+ } else {
+ // At this point, everything has been precached.
+ // It's the perfect time to display a
+ // "Content is cached for offline use." message.
+ console.log('Content is cached for offline use.');
+
+ // Execute callback
+ if (config && config.onSuccess) {
+ config.onSuccess(registration);
+ }
+ }
+ }
+ };
+ };
+ })
+ .catch(error => {
+ console.error('Error during service worker registration:', error);
+ });
+}
+
+function checkValidServiceWorker(swUrl, config) {
+ // Check if the service worker can be found. If it can't reload the page.
+ fetch(swUrl)
+ .then(response => {
+ // Ensure service worker exists, and that we really are getting a JS file.
+ const contentType = response.headers.get('content-type');
+ if (
+ response.status === 404 ||
+ (contentType != null && contentType.indexOf('javascript') === -1)
+ ) {
+ // No service worker found. Probably a different app. Reload the page.
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister().then(() => {
+ window.location.reload();
+ });
+ });
+ } else {
+ // Service worker found. Proceed as normal.
+ registerValidSW(swUrl, config);
+ }
+ })
+ .catch(() => {
+ console.log(
+ 'No internet connection found. App is running in offline mode.'
+ );
+ });
+}
+
+export function unregister() {
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister();
+ });
+ }
+}
diff --git a/app/src/static/wrolpi.css b/app/src/static/wrolpi.css
new file mode 100644
index 00000000..6fd0b1e5
--- /dev/null
+++ b/app/src/static/wrolpi.css
@@ -0,0 +1,4 @@
+textarea.otp {
+ width: 100%;
+ color: #333;
+}
diff --git a/config/example.yaml b/config/example.yaml
new file mode 100644
index 00000000..8f68f448
--- /dev/null
+++ b/config/example.yaml
@@ -0,0 +1,28 @@
+# This is an example config for your WROLPi. Do NOT modify this file, rather, copy this file to local.yaml and
+# modify that file.
+media_directory: ./test # this should be an absolute directory, for example, /media/8TB
+
+downloader:
+ # Most of these names were decided by Youtube-DL project, we pass these to any calls to that project.
+ continue_dl: true
+ dateafter: 19900101
+ file_name_format: "%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s"
+ format: 22/135/18/43
+ nooverwrites: true
+ quiet: false
+ writeautomaticsub: true
+ writeinfojson: true
+ writesubtitles: true
+ writethumbnail: true
+ youtube_include_dash_manifest: false
+
+channels:
+ # These are channels or playlists that will be downloaded. You can configure them in the UI, or in your local.yaml.
+ wrolpi:
+ name: WROLPi # the pretty name for the channel
+ url: https://www.youtube.com/channel/UC4t8bw1besFTyjW7ZBCOIrw/videos
+ directory: videos/wrolpi # relative to `media_directory`
+
+timezone: America/Boise
+min_download_frequency: 14400 # 4 hours
+max_download_frequency: 43200 # 12 hours
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 00000000..d2e0ba61
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,6 @@
+# Import conftest for test fixtures
+import os
+
+from wrolpi.conftest import * # noqa
+
+os.environ['DB_PORT'] = '54321'
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..1834ea55
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,72 @@
+# Development containers, these should only be used if you are developing.
+version: '3'
+services:
+ db:
+ image: postgres:12
+ environment:
+ - POSTGRES_DB=wrolpi
+ - POSTGRES_PASSWORD=wrolpi
+ ports:
+ - '54321:5432'
+ healthcheck:
+ test: [ 'CMD-SHELL', 'pg_isready -U postgres' ]
+
+ api:
+ depends_on:
+ - db
+ build:
+ context: .
+ dockerfile: docker/api/Dockerfile
+ volumes:
+ - './main.py:/opt/wrolpi/main.py'
+ - './wrolpi:/opt/wrolpi/wrolpi'
+ - './modules:/opt/wrolpi/modules'
+ - './config:/opt/wrolpi/config'
+ - './test:/opt/wrolpi/test'
+ - './alembic:/opt/wrolpi/alembic'
+ - './alembic.ini:/opt/wrolpi/alembic.ini'
+ ports:
+ - ${REACT_APP_API-127.0.0.1:8081}:8081
+ command: '-vv api --host 0.0.0.0'
+ healthcheck:
+ test: [ 'CMD-SHELL', 'curl http://127.0.0.1:8081/api/echo' ]
+ user: '${UID-1000}:${GID-1000}'
+
+ archive:
+ build:
+ context: .
+ dockerfile: docker/archive/Dockerfile
+ ports:
+ - '8083:8080'
+ healthcheck:
+ test: [ 'CMD-SHELL', 'curl http://0.0.0.0:8080/' ]
+
+ app:
+ depends_on:
+ - api
+ build:
+ context: .
+ dockerfile: docker/app/Dockerfile
+ ports:
+ - '8082:8082'
+ volumes:
+ - './app:/app'
+ command: 'yarn start'
+ stdin_open: true
+ healthcheck:
+ test: [ 'CMD-SHELL', 'curl http://127.0.0.1:3000' ]
+ user: '${UID-1000}:${GID-1000}'
+
+ web:
+ depends_on:
+ - app
+ image: nginx:latest
+ ports:
+ - ${WEB_HOST-0.0.0.0}:${WEB_PORT-8080}:80
+ volumes:
+ - './nginx.conf:/etc/nginx/nginx.conf'
+ - './icon.ico:/etc/nginx/favicon.ico'
+ - './icon.png:/etc/nginx/icon.png'
+ - './test:/opt/media'
+ healthcheck:
+ test: [ 'CMD-SHELL', 'curl http://127.0.0.1:80' ]
diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile
new file mode 100644
index 00000000..1e412d0a
--- /dev/null
+++ b/docker/api/Dockerfile
@@ -0,0 +1,18 @@
+FROM python:3.9-buster
+ENV DOCKER=true
+WORKDIR /opt/wrolpi
+
+# Update dependencies for the services install.
+RUN apt update
+RUN apt install -y ffmpeg wkhtmltopdf
+
+# Install WROLPi
+COPY main.py /opt/wrolpi/
+COPY config /opt/wrolpi/config
+COPY wrolpi /opt/wrolpi/wrolpi
+COPY modules /opt/wrolpi/modules
+COPY requirements.txt /opt/wrolpi/requirements.txt
+RUN pip3 install -r /opt/wrolpi/requirements.txt
+
+ENTRYPOINT [ "python3", "/opt/wrolpi/main.py"]
+CMD ["api", "--host", "0.0.0.0" ]
diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile
new file mode 100644
index 00000000..94266af1
--- /dev/null
+++ b/docker/app/Dockerfile
@@ -0,0 +1,20 @@
+# base image
+FROM node:13-buster
+
+WORKDIR /app
+ENV PATH /app/node_modules/.bin:$PATH
+
+# install and cache app dependencies
+COPY app/public /app/public
+COPY app/src /app/src
+COPY app/package.json /app/
+RUN rm -rf /app/node_modules
+# Increase network timeout because a Raspberry Pi is slow.
+RUN yarn --silent --network-timeout 100000
+
+# Create the production build
+RUN yarn run build
+RUN yarn global add serve
+
+# Run the production build
+CMD ["serve", "-s", "build"]
diff --git a/docker/archive/Dockerfile b/docker/archive/Dockerfile
new file mode 100644
index 00000000..b88a36dd
--- /dev/null
+++ b/docker/archive/Dockerfile
@@ -0,0 +1,19 @@
+FROM capsulecode/singlefile
+
+USER root
+WORKDIR /app
+
+# Install Python for main.py
+ENV PYTHONUNBUFFERED=1
+RUN apk add --update --no-cache python3 python3-dev && ln -sf python3 /usr/bin/python
+RUN python3 -m ensurepip
+RUN pip3 install --no-cache --upgrade pip setuptools
+
+# Install the Python wrapper and it's requirements.
+COPY docker/archive /app
+RUN pip3 install -r /app/requirements.txt
+
+# Install Readability too
+RUN npm install -g 'git+https://github.com/pirate/readability-extractor'
+
+ENTRYPOINT [ "python3", "/app/main.py"]
diff --git a/docker/archive/main.py b/docker/archive/main.py
new file mode 100644
index 00000000..aae4d431
--- /dev/null
+++ b/docker/archive/main.py
@@ -0,0 +1,111 @@
+#! /usr/bin/env python3
+"""
+This file is a simple Python/Sanic wrapper around the single-file CLI command.
+"""
+import base64
+import json
+import logging
+import subprocess
+import tempfile
+
+from sanic import Sanic, response
+from sanic.request import Request
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+ch = logging.StreamHandler()
+formatter = logging.Formatter('[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s')
+ch.setFormatter(formatter)
+logger.addHandler(ch)
+
+app = Sanic('archive')
+
+index_html = '''
+
+
+This is the WROLPi archiving service.
+
+ This is not meant to be used directly, it should be called by the
+ archive module.
+
+
+
+'''
+
+
+@app.get('/')
+async def index(_):
+ return response.html(index_html)
+
+
+async def call_single_file(url) -> bytes:
+ """
+ Call the CLI command for SingleFile.
+
+ See https://github.com/gildas-lormeau/SingleFile
+ """
+ logger.info(f'archiving {url}')
+ cmd = ['/usr/src/app/node_modules/single-file/cli/single-file', url,
+ '--browser-executable-path', '/usr/bin/chromium-browser', '--browser-args', '["--no-sandbox"]',
+ '--dump-content']
+ logger.debug(f'archive cmd: {cmd}')
+ output = subprocess.check_output(cmd)
+ logger.debug(f'done archiving for {url}')
+ return output
+
+
+async def extract_readability(path: str, url: str) -> dict:
+ """
+ Call the CLI command for readability-extractor.
+
+ See https://github.com/ArchiveBox/readability-extractor
+ """
+ logger.info(f'readability for {url}')
+ cmd = ['readability-extractor', path, url]
+ logger.debug(f'readability cmd: {cmd}')
+ output = subprocess.check_output(cmd)
+ output = json.loads(output)
+ logger.debug(f'done readability for {url}')
+ return output
+
+
+async def take_screenshot(url: str) -> bytes:
+ cmd = ['/usr/bin/chromium-browser', '--headless', '--disable-gpu', '--no-sandbox', '--screenshot',
+ '--window-size=1280,720', url]
+ logger.info(f'Screenshot: {url}')
+ logger.debug(f'Screenshot cmd: {cmd}')
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ try:
+ subprocess.check_output(cmd, cwd=tmp_dir)
+ except Exception as e:
+ logger.error(f'Failed to screenshot {url}', exc_info=True)
+ return b''
+
+ try:
+ with open(f'{tmp_dir}/screenshot.png', 'rb') as fh:
+ png = fh.read()
+ return base64.b64encode(png)
+ except FileNotFoundError:
+ return b''
+
+
+@app.post('/json')
+async def post_archive(request: Request):
+ url = request.json['url']
+ singlefile = await call_single_file(url)
+ screenshot = await take_screenshot(url)
+ with tempfile.NamedTemporaryFile('wb') as fh:
+ fh.write(singlefile)
+ readability = await extract_readability(fh.name, url)
+
+ ret = dict(
+ url=url,
+ singlefile=singlefile.decode(),
+ readability=readability,
+ screenshot=screenshot.decode(),
+ )
+ return response.json(ret)
+
+
+if __name__ == '__main__':
+ app.run('0.0.0.0', 8080, workers=4)
diff --git a/docker/archive/requirements.txt b/docker/archive/requirements.txt
new file mode 100644
index 00000000..5396f7f7
--- /dev/null
+++ b/docker/archive/requirements.txt
@@ -0,0 +1 @@
+sanic==20.12.0
diff --git a/icon.ico b/icon.ico
new file mode 100755
index 00000000..18e0acb7
Binary files /dev/null and b/icon.ico differ
diff --git a/icon.png b/icon.png
new file mode 100755
index 00000000..2a6e1a7f
Binary files /dev/null and b/icon.png differ
diff --git a/install.sh b/install.sh
new file mode 100755
index 00000000..5d923053
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,118 @@
+#! /usr/bin/env bash
+# This script will install WROLPi to `/opt/wrolpi` on a fresh/empty Raspberry Pi. It is expected to be run once to
+# install, and any subsequent runs will update WROLPi. This script assumes it will be run as the `root` user.
+
+Help() {
+ # Display Help
+ echo "Install WROLPi onto this Raspberry Pi."
+ echo
+ echo "Syntax: install.sh [-h] [-b BRANCH]"
+ echo "options:"
+ echo "h Print this help."
+ echo "b Install from this git BRANCH."
+ echo
+}
+
+BRANCH="master"
+while getopts ":hb:" option; do
+ case $option in
+ h) # display Help
+ Help
+ exit
+ ;;
+ b)
+ BRANCH="${OPTARG}"
+ ;;
+ *) # invalid argument(s)
+ echo "Error: Invalid option"
+ exit 1
+ ;;
+ esac
+done
+
+set -x
+set -e
+
+# Check that WROLPi directory exists, and contains wrolpi.
+[ -d /opt/wrolpi ] && [ ! -d /opt/wrolpi/wrolpi ] && echo "/opt/wrolpi exists but does not contain wrolpi!" && exit 2
+
+# Install yarn repos
+curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | sudo tee /usr/share/keyrings/yarnkey.gpg >/dev/null
+echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian stable main" |
+ tee /etc/apt/sources.list.d/yarn.list
+# Install npm repos.
+curl -fsSL https://deb.nodesource.com/setup_14.x | bash -
+# Update if we haven't updated in the last day.
+[ -z "$(find -H /var/lib/apt/lists -maxdepth 0 -mtime -1)" ] && apt update
+# Install dependencies
+apt install -y git apt-transport-https ca-certificates curl gnupg-agent gcc libpq-dev software-properties-common \
+ postgresql-12 nginx-full nginx-doc python3.8-minimal python3.8-dev python3.8-doc python3.8-venv \
+ ffmpeg wkhtmltopdf hostapd nodejs yarn
+
+# Get the latest WROLPi code
+git --version
+git clone https://github.com/lrnselfreliance/wrolpi.git /opt/wrolpi || :
+(cd /opt/wrolpi && git checkout "${BRANCH}" && git pull) || exit 3
+
+# Setup the virtual environment that main.py expects
+pip3 --version || (
+ # If no pip, install pip
+ curl https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py &&
+ python3 /tmp/get-pip.py
+)
+python3 -m venv /opt/wrolpi/venv
+. /opt/wrolpi/venv/bin/activate
+
+# Install python requirements files
+pip3 install -r /opt/wrolpi/requirements.txt
+
+# Build React app
+[ ! -f /usr/local/bin/serve ] && npm -g install serve
+cd /opt/wrolpi/app
+yarn --network-timeout 10000
+yarn run build
+yarn global add serve
+
+# Configure PostgreSQL
+sudo -u postgres psql -c '\l' | grep wrolpi || (
+ sudo -u postgres createuser wrolpi &&
+ sudo -u postgres psql -c "alter user postgres password 'wrolpi'" &&
+ sudo -u postgres psql -c "alter user wrolpi password 'wrolpi'" &&
+ sudo -u postgres createdb -O wrolpi wrolpi
+)
+# Initialize the WROLPi database.
+(cd /opt/wrolpi && /opt/wrolpi/venv/bin/python3 /opt/wrolpi/main.py db upgrade)
+
+# Install the WROLPi nginx config over the default nginx config.
+cp /opt/wrolpi/nginx.conf /etc/nginx/nginx.conf
+/usr/sbin/nginx -s reload
+
+# Create the WROLPi user
+grep wrolpi /etc/passwd || useradd -d /opt/wrolpi wrolpi -s "$(command -v bash)"
+chown -R wrolpi:wrolpi /opt/wrolpi
+
+# Install the systemd services
+cp /opt/wrolpi/systemd/wrolpi-api.service /etc/systemd/system/
+cp /opt/wrolpi/systemd/wrolpi-app.service /etc/systemd/system/
+cp /opt/wrolpi/systemd/wrolpi.target /etc/systemd/system/
+/usr/bin/systemctl daemon-reload
+systemctl enable wrolpi-api.service
+systemctl enable wrolpi-app.service
+
+# Configure the hotspot.
+bash /opt/wrolpi/scripts/hotspot.sh
+
+set +x
+
+ip=$(hostname -i | cut -d' ' -f1)
+
+echo "
+
+WROLPi has successfully been installed!
+
+Start the WROLPi services using:
+
+ # sudo systemctl start wrolpi.target
+
+then navigate to: http://${ip}
+"
diff --git a/join_discord.png b/join_discord.png
new file mode 100644
index 00000000..028c3232
Binary files /dev/null and b/join_discord.png differ
diff --git a/main.py b/main.py
new file mode 100755
index 00000000..c6722b4e
--- /dev/null
+++ b/main.py
@@ -0,0 +1,119 @@
+#! /usr/bin/env python3
+import argparse
+import asyncio
+import inspect
+import logging
+import sys
+
+import pytz
+
+from wrolpi import root_api, BEFORE_STARTUP_FUNCTIONS
+from wrolpi.common import logger, get_config
+from wrolpi.dates import set_timezone
+from wrolpi.vars import PROJECT_DIR, MODULES_DIR
+
+logger = logger.getChild('wrolpi-main')
+
+
+def db_main(args):
+ """
+ Handle database migrations. Currently this uses Alembic, supported commands are "upgrade" and "downgrade".
+ """
+ from alembic.config import Config
+ from alembic import command
+ from wrolpi.db import uri
+
+ config = Config(PROJECT_DIR / 'alembic.ini')
+ # Overwrite the Alembic config, the is usually necessary when running in a docker container.
+ config.set_main_option('sqlalchemy.url', uri)
+
+ logger.warning(f'DB URI: {uri}')
+
+ if args.command == 'upgrade':
+ command.upgrade(config, 'head')
+ elif args.command == 'downgrade':
+ command.downgrade(config, '-1')
+ else:
+ print(f'Unknown DB command: {args.command}')
+ return 2
+
+ return 0
+
+
+async def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-v', '--verbose', action='count')
+
+ sub_commands = parser.add_subparsers(title='sub-commands', dest='sub_commands')
+
+ # Add the API parser, this will allow the user to specify host/port etc.
+ api_parser = sub_commands.add_parser('api')
+ root_api.init_parser(api_parser)
+
+ # DB Parser for running Alembic migrations
+ db_parser = sub_commands.add_parser('db')
+ db_parser.add_argument('command', help=f'Supported commands: upgrade, downgrade')
+
+ args = parser.parse_args()
+ logger.warning(f'Starting with: {sys.argv}')
+
+ await set_log_level(args)
+
+ # Run DB migrations before anything else.
+ if args.sub_commands == 'db':
+ return db_main(args)
+
+ # Set the Timezone
+ config = get_config()
+ if 'timezone' in config:
+ tz = pytz.timezone(config['timezone'])
+ set_timezone(tz)
+
+ # Import the API in every module. Each API should attach itself to `root_api`.
+ try:
+ modules = [i.name for i in MODULES_DIR.iterdir() if i.is_dir() and not i.name.startswith('_')]
+ for module in modules:
+ module = f'modules.{module}.api'
+ logger.debug(f'Importing {module}')
+ __import__(module, globals(), locals(), [], 0)
+ except ImportError as e:
+ logger.fatal('No modules could be found!', exc_info=e)
+ raise
+
+ if not modules:
+ raise Exception('No modules could be found!')
+
+ # Run the startup functions
+ for func in BEFORE_STARTUP_FUNCTIONS:
+ try:
+ logger.debug(f'Calling {func} before startup.')
+ coro = func()
+ if inspect.iscoroutine(coro):
+ await coro
+ except Exception as e:
+ logger.warning(f'Startup {func} failed!', exc_info=e)
+
+ # Run the API
+ return root_api.main(args)
+
+
+async def set_log_level(args):
+ """
+ Set the level at the root logger so all children that have been created (or will be created) share the same level.
+ """
+ root_logger = logging.getLogger()
+ if args.verbose == 1:
+ root_logger.setLevel(logging.INFO)
+ elif args.verbose and args.verbose >= 2:
+ root_logger.setLevel(logging.DEBUG)
+
+ # Always warn about the log level so we know what will be logged
+ effective_level = logger.getEffectiveLevel()
+ level_name = logging.getLevelName(effective_level)
+ logger.warning(f'Logging level: {level_name}')
+
+
+if __name__ == '__main__':
+ loop = asyncio.get_event_loop()
+ result = loop.run_until_complete(main())
+ sys.exit(result)
diff --git a/modules/__init__.py b/modules/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/archive/__init__.py b/modules/archive/__init__.py
new file mode 100644
index 00000000..2d48cc88
--- /dev/null
+++ b/modules/archive/__init__.py
@@ -0,0 +1,6 @@
+from wrolpi.common import logger
+from .api import bp # noqa
+
+PRETTY_NAME = 'Archive'
+
+logger = logger.getChild(__name__)
diff --git a/modules/archive/api.py b/modules/archive/api.py
new file mode 100644
index 00000000..d1a698de
--- /dev/null
+++ b/modules/archive/api.py
@@ -0,0 +1,76 @@
+import asyncio
+from http import HTTPStatus
+
+from requests import Request
+from sanic import response
+
+from wrolpi.common import logger, wrol_mode_check
+from wrolpi.root_api import get_blueprint, json_response
+from wrolpi.schema import validate_doc, JSONErrorResponse
+from . import lib
+from .schema import RetrieveURLsRequest, RetrieveURLsResponse, PostArchiveRequest
+
+NAME = 'archive'
+
+bp = get_blueprint('Archive', '/api/archive')
+
+logger = logger.getChild(__name__)
+
+
+@bp.post('/')
+@validate_doc(
+ 'Archive a website',
+ PostArchiveRequest,
+)
+@wrol_mode_check
+async def post_archive(_: Request, data: dict):
+ url = data['url']
+ lib.new_archive(url)
+ return response.empty()
+
+
+@bp.delete('/')
+@validate_doc(
+ 'Delete a website record',
+ responses=[
+ (HTTPStatus.NOT_FOUND, JSONErrorResponse),
+ ]
+)
+@wrol_mode_check
+async def delete_url(_: Request, url_id: int):
+ lib.delete_url(url_id)
+ return response.empty()
+
+
+@bp.post('/search')
+@validate_doc(
+ 'Get a list of URLs',
+ RetrieveURLsRequest,
+ RetrieveURLsResponse,
+)
+async def search_archives(_: Request, data: dict):
+ try:
+ limit = abs(int(data.get('limit', 20)))
+ offset = abs(int(data.get('offset', 0)))
+ domain = data.get('domain')
+ except Exception as e:
+ logger.error(f'Bad request', exc_info=e)
+ return response.json(dict(error='bad request'), HTTPStatus.BAD_REQUEST)
+
+ urls = lib.get_urls(limit, offset, domain)
+ count = lib.get_url_count(domain)
+ ret = dict(urls=urls, totals=dict(urls=count))
+ return json_response(ret)
+
+
+@bp.post(':refresh')
+@wrol_mode_check
+async def refresh_archives(_: Request):
+ asyncio.ensure_future(lib.refresh_archives())
+ return response.empty()
+
+
+@bp.get('/domains')
+async def fetch_domains(_: Request):
+ domains = lib.get_domains()
+ return json_response({'domains': domains, 'totals': {'domains': len(domains)}})
diff --git a/modules/archive/lib.py b/modules/archive/lib.py
new file mode 100644
index 00000000..14916675
--- /dev/null
+++ b/modules/archive/lib.py
@@ -0,0 +1,451 @@
+import asyncio
+import base64
+import json
+import pathlib
+import re
+from itertools import groupby
+from typing import Iterator
+from urllib.parse import urlparse
+
+import requests
+from bs4 import BeautifulSoup
+from sqlalchemy.orm import Session
+
+from modules.archive.models import URL, Domain, Archive
+from wrolpi.common import get_media_directory, logger, chunks
+from wrolpi.dates import now, strptime_ms
+from wrolpi.db import get_db_session, get_db_curs
+from wrolpi.errors import InvalidDomain, UnknownURL, PendingArchive, InvalidArchive
+from wrolpi.vars import DATETIME_FORMAT_MS
+
+logger = logger.getChild(__name__)
+
+ARCHIVE_SERVICE = 'http://archive:8080'
+
+
+def get_archive_directory() -> pathlib.Path:
+ return get_media_directory() / 'archive'
+
+
+def extract_domain(url):
+ parsed = urlparse(url)
+ return parsed.netloc
+
+
+def get_domain_directory(url: str) -> pathlib.Path:
+ """
+ Get the archive directory for a particular domain.
+ """
+ domain = extract_domain(url)
+ directory = get_archive_directory() / domain
+ if directory.is_dir():
+ return directory
+ elif directory.exists():
+ raise FileNotFoundError(f'Domain directory {directory} is already a file')
+
+ directory.mkdir()
+ return directory
+
+
+def get_new_archive_files(url: str):
+ """
+ Create a list of archive files using a shared name schema. Raise an error if any of them exist.
+ """
+ directory = get_domain_directory(url)
+ dt = now().strftime(DATETIME_FORMAT_MS)
+
+ singlefile_path = directory / f'{dt}.html'
+ readability_path = directory / f'{dt}-readability.html'
+ readability_txt_path = directory / f'{dt}-readability.txt'
+ readability_json_path = directory / f'{dt}-readability.json'
+ screenshot_path = directory / f'{dt}.png'
+
+ ret = (singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path)
+
+ for path in ret:
+ if path.exists():
+ raise FileExistsError(f'New archive file already exists: {path}')
+
+ return ret
+
+
+def request_archive(url: str):
+ """
+ Send a request to the archive service to archive the URL.
+ """
+ data = {'url': url}
+ try:
+ resp = requests.post(f'{ARCHIVE_SERVICE}/json', json=data)
+ except Exception as e:
+ logger.error('Error when requesting archive', exc_info=e)
+ raise
+ singlefile = resp.json()['singlefile']
+ readability = resp.json()['readability']
+ screenshot = resp.json()['screenshot']
+
+ if not screenshot:
+ raise Exception('singlefile response was empty!')
+
+ if screenshot:
+ screenshot = base64.b64decode(screenshot)
+ return singlefile, readability, screenshot
+
+
+def is_pending_archive(url: str) -> bool:
+ with get_db_session() as session:
+ url = session.query(URL).filter_by(url=url).one_or_none()
+ if url and url.latest.status == 'pending':
+ return True
+ return False
+
+
+def new_archive(url: str, sync: bool = False):
+ """
+ Request archiving of the provided URL. Store the returned files in their domain's directory.
+
+ :param url: The URL to archive.
+ :param sync: Perform the archiving process synchronously for testing.
+ """
+ # Check that the archive files are available.
+ get_new_archive_files(url)
+
+ if is_pending_archive(url):
+ raise PendingArchive()
+
+ with get_db_session(commit=True) as session:
+ domain, url_ = get_or_create_domain_and_url(session, url)
+
+ archive = Archive(
+ url_id=url_.id,
+ domain_id=domain.id,
+ status='pending',
+ archive_datetime=now(),
+ )
+ session.add(archive)
+ session.flush()
+ archive_id = archive.id
+
+ url_.latest_id = archive_id
+ url_.latest_datetime = now()
+
+ if sync:
+ return _do_archive(url, archive_id)
+ else:
+ # Run the real archive process in the future.
+ asyncio.ensure_future(do_archive(url, archive_id))
+
+ return archive
+
+
+def _do_archive(url: str, archive_id: int):
+ """
+ Perform the real archive request to the archiving service. Store the resulting data into files. Update the Archive
+ in the DB.
+ """
+ try:
+ singlefile, readability, screenshot = request_archive(url)
+
+ singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path = \
+ get_new_archive_files(url)
+
+ # Store the single-file HTML in it's own file.
+ with singlefile_path.open('wt') as fh:
+ fh.write(singlefile)
+ if screenshot:
+ with screenshot_path.open('wb') as fh:
+ fh.write(screenshot)
+ else:
+ screenshot_path = None
+
+ # Store the Readability into separate files. This allows the user to view text-only or html articles.
+ title = None
+ if readability:
+ title = readability.get('title')
+
+ # Write the readability parts to their own files. Write what is left after pops to the JSON file.
+ with readability_path.open('wt') as fh:
+ fh.write(readability.pop('content'))
+ with readability_txt_path.open('wt') as fh:
+ fh.write(readability.pop('textContent'))
+ else:
+ readability_path = readability_txt_path = None
+
+ # Always write a JSON file that contains at least the URL.
+ readability = readability or {}
+ # Use the Readability title, or try and extract one from singlefile.
+ if not title and singlefile:
+ title = get_title_from_html(singlefile, url=url)
+ readability['title'] = title
+ readability['url'] = url
+ with readability_json_path.open('wt') as fh:
+ fh.write(json.dumps(readability))
+
+ with get_db_session(commit=True) as session:
+ archive = session.query(Archive).filter_by(id=archive_id).one()
+ archive.status = 'complete'
+ archive.archive_datetime = now()
+ archive.title = title
+ archive.singlefile_path = singlefile_path
+ archive.readability_path = readability_path
+ archive.readability_json_path = readability_json_path
+ archive.readability_txt_path = readability_txt_path
+ archive.screenshot_path = screenshot_path
+ # Update the latest for easy viewing.
+ archive.url.latest_id = archive.id
+ archive.url.latest_datetime = archive.archive_datetime
+
+ return archive
+ except Exception:
+ with get_db_session(commit=True) as session:
+ archive = session.query(Archive).filter_by(id=archive_id).one()
+ archive.status = 'failed'
+ raise
+
+
+async def do_archive(url: str, archive_id: int):
+ _do_archive(url, archive_id)
+
+
+def get_or_create_domain_and_url(session, url):
+ """
+ Get/create the Domain for this archive.
+ """
+ domain_ = extract_domain(url)
+ domain = session.query(Domain).filter_by(domain=domain_).one_or_none()
+ if not domain:
+ domain = Domain(domain=domain_, directory=str(get_domain_directory(url)))
+ session.add(domain)
+ session.flush()
+ url_ = session.query(URL).filter_by(url=url).one_or_none()
+ if not url_:
+ url_ = URL(url=url, domain_id=domain.id)
+ session.add(url_)
+ session.flush()
+ return domain, url_
+
+
+def get_title_from_html(html: str, url: str = None) -> str:
+ """
+ Try and get the title from the
+ """
+ soup = BeautifulSoup(html, features='html.parser')
+ try:
+ return soup.title.string
+ except Exception: # noqa
+ logger.info(f'Unable to extract title {url}')
+
+
+def get_domain(session, domain: str) -> Domain:
+ domain_ = session.query(Domain).filter_by(domain=domain).one_or_none()
+ if not domain_:
+ raise InvalidDomain(f'Invalid domain: {domain}')
+ return domain_
+
+
+def get_urls(limit: int = 20, offset: int = 0, domain: str = ''):
+ with get_db_session() as session:
+ if domain:
+ domain_ = get_domain(session, domain)
+ urls = domain_.urls[offset:offset + limit]
+ urls = sorted(urls, key=lambda i: i.latest_datetime)[::-1]
+ else:
+ urls = session.query(URL) \
+ .order_by(URL.latest_datetime.desc()) \
+ .limit(limit) \
+ .offset(offset) \
+ .all()
+ urls = [i.dict() for i in urls]
+ return urls
+
+
+def get_url_count(domain: str = '') -> int:
+ """
+ Get count of all URLs. Or, get count of all attached to a specific domain string.
+ """
+ with get_db_session() as session:
+ domain_id = None
+ if domain:
+ domain_id = get_domain(session, domain).id
+
+ with get_db_curs() as curs:
+ stmt = 'SELECT COUNT(*) FROM url'
+ params = {}
+ if domain_id:
+ stmt = f'{stmt} WHERE domain_id = %(domain_id)s'
+ params['domain_id'] = domain_id
+ curs.execute(stmt, params)
+ count = int(curs.fetchone()[0])
+ return count
+
+
+def delete_url(url_id: int):
+ """
+ Delete a URL record, all it's Archives and files.
+ """
+ with get_db_session() as session:
+ url: URL = session.query(URL).filter_by(id=url_id).one_or_none()
+ if not url:
+ raise UnknownURL(f'Unknown url with id: {url_id}')
+
+ # Delete any files associated with this URL.
+ for archive in url.archives:
+ archive.unlink()
+
+ with get_db_session(commit=True) as session:
+ session.query(URL).filter_by(id=url_id).delete()
+
+
+def group_archive_files(files: Iterator[pathlib.Path]) -> groupby:
+ """
+ Group archive files by their timestamp.
+ """
+ # groupby requires the files to be sorted.
+ files = sorted(files)
+ # Group archive files by their datetime at the beginning of the file.
+ groups = groupby(files, key=lambda i: i.name[:26])
+ for dt, files in groups:
+ try:
+ dt = strptime_ms(dt)
+ except ValueError:
+ logger.info(f'Ignoring invalid archives of {dt=}')
+ continue
+
+ # Sort the files into their respective slots.
+ singlefile_path = readability_path = readability_txt_path = readability_json_path = screenshot_path = None
+ file = None
+ for file in files:
+ if file.name.endswith('-readability.html'):
+ readability_path = file
+ elif file.name.endswith('.html'):
+ singlefile_path = file
+ elif file.name.endswith('.png') or file.name.endswith('.jpg') or file.name.endswith('.jpeg'):
+ screenshot_path = file
+ elif file.name.endswith('-readability.json'):
+ readability_json_path = file
+ elif file.name.endswith('-readability.txt'):
+ readability_txt_path = file
+
+ if not singlefile_path:
+ logger.warning(f'Archive does not have a singlefile html! Ignoring. {file}')
+ continue
+ if not readability_json_path:
+ logger.warning(f'Archive does not have a json file! Ignoring. {file}')
+ continue
+
+ yield dt, (singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path)
+
+
+ARCHIVE_MATCHER = re.compile(r'\d{4}-\d\d-\d\d (\d\d:){2}\d\d\.\d{6}.*$')
+ARCHIVE_SUFFIXES = {'.txt', '.html', '.json', '.png', '.jpg', '.jpeg'}
+
+
+def is_archive_file(path: pathlib.Path) -> bool:
+ """
+ Archive files are expected to start with the following: %Y-%m-%d %H:%M:%S.%f
+ they must have one of the following suffixes: .txt, .html, .json, .png, .jpg, .jpeg
+ """
+ return path.is_file() and path.suffix.lower() in ARCHIVE_SUFFIXES and bool(ARCHIVE_MATCHER.match(path.name))
+
+
+def _refresh_archives():
+ """
+ Search the Archives directory for archive files, update the database if new files are found.
+ """
+ archive_directory = get_archive_directory()
+ for domain_directory in filter(lambda i: i.is_dir(), archive_directory.iterdir()):
+ logger.debug(f'Refreshing directory: {domain_directory}')
+ archives_files = filter(is_archive_file, domain_directory.iterdir())
+ archive_groups = group_archive_files(archives_files)
+ archive_count = 0
+ for chunk in chunks(archive_groups, 20):
+ archive_count += 1
+ with get_db_session(commit=True) as session:
+ for dt, files in chunk:
+ print('_refresh_archives', dt)
+ upsert_archive(dt, files, session)
+
+ if archive_count:
+ logger.info(f'Inserted/updated {archive_count} archives')
+
+ cleanup_domains_urls()
+
+
+def cleanup_domains_urls():
+ """
+ Delete any URLs/Domains without Archives.
+ """
+ with get_db_curs(commit=True) as curs:
+ stmt = '''
+ DELETE FROM url WHERE id NOT IN (
+ select distinct url_id from archive
+ ) RETURNING url.id
+ '''
+ curs.execute(stmt)
+ urls = list(map(dict, curs.fetchall()))
+ stmt = '''
+ DELETE FROM domains WHERE id NOT IN (
+ select distinct domain_id from url
+ ) RETURNING domains.id
+ '''
+ curs.execute(stmt)
+ domains = list(map(dict, curs.fetchall()))
+ logger.info(f'Deleted {len(urls)} URLS and {len(domains)} Domains')
+
+
+async def refresh_archives():
+ _refresh_archives()
+
+
+def upsert_archive(dt: str, files, session: Session):
+ """
+ Get or create an Archive and it's URL/Domain. If it already exists, update it with these new files.
+ """
+ singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path = files
+ # Extract the URL from the JSON. Fail if this is not possible.
+ try:
+ with readability_json_path.open() as fh:
+ json_contents = json.load(fh)
+ url = json_contents['url']
+ except Exception as e:
+ raise InvalidArchive() from e
+
+ domain, url_ = get_or_create_domain_and_url(session, url)
+ logger.debug(f'Upsert url={url_.url}')
+ # Get the existing Archive, or create a new one.
+ archive = session.query(Archive).filter_by(singlefile_path=singlefile_path).one_or_none()
+ if not archive:
+ # No archive matches this singlefile_path, create a new one.
+ archive = Archive(
+ url_id=url_.id,
+ domain_id=domain.id,
+ )
+ session.add(archive)
+ session.flush()
+
+ # Update the archive with the files that we have.
+ archive.status = 'complete',
+ archive.archive_datetime = dt
+ archive.singlefile_path = singlefile_path
+ archive.readability_path = readability_path
+ archive.readability_txt_path = readability_txt_path
+ archive.readability_json_path = readability_json_path
+ archive.screenshot_path = screenshot_path
+ archive_id = archive.id
+
+ if not url_.latest_datetime or url_.latest_datetime < dt:
+ url_.latest_id = archive_id
+ url_.latest_datetime = archive.archive_datetime
+
+
+def get_domains():
+ with get_db_curs() as curs:
+ stmt = '''
+ SELECT domains.domain AS domain, COUNT(u.id) AS url_count
+ FROM domains
+ LEFT JOIN url u on domains.id = u.domain_id
+ GROUP BY domains.domain
+ ORDER BY domains.domain
+ '''
+ curs.execute(stmt)
+ domains = list(map(dict, curs.fetchall()))
+ return domains
diff --git a/modules/archive/models.py b/modules/archive/models.py
new file mode 100644
index 00000000..e6fb550d
--- /dev/null
+++ b/modules/archive/models.py
@@ -0,0 +1,89 @@
+from typing import Generator
+
+from sqlalchemy import Column, Integer, String, ForeignKey
+from sqlalchemy.orm import relationship
+from sqlalchemy.orm.collections import InstrumentedList
+
+from wrolpi.common import ModelHelper, Base
+from wrolpi.dates import TZDateTime
+from wrolpi.media_path import MediaPathType
+
+
+class Archive(Base, ModelHelper):
+ __tablename__ = 'archive'
+ id = Column(Integer, primary_key=True)
+
+ url_id = Column(Integer, ForeignKey('url.id', ondelete='cascade'))
+ url = relationship('URL', primaryjoin='Archive.url_id==URL.id')
+ domain_id = Column(Integer, ForeignKey('domains.id'))
+ domain = relationship('Domain', primaryjoin='Archive.domain_id==Domain.id')
+
+ singlefile_path = Column(MediaPathType)
+ readability_path = Column(MediaPathType)
+ readability_json_path = Column(MediaPathType)
+ readability_txt_path = Column(MediaPathType)
+ screenshot_path = Column(MediaPathType)
+
+ title = Column(String)
+ archive_datetime = Column(TZDateTime)
+ status = Column(String)
+
+ def __repr__(self):
+ return f''
+
+ def my_paths(self) -> Generator:
+ if self.singlefile_path:
+ yield self.singlefile_path.path
+ if self.readability_path:
+ yield self.readability_path.path
+ if self.readability_json_path:
+ yield self.readability_json_path.path
+ if self.readability_txt_path:
+ yield self.readability_txt_path.path
+ if self.screenshot_path:
+ yield self.screenshot_path.path
+
+ def unlink(self):
+ """
+ Remove any files in this archive.
+ """
+ # Don't fail if a file is missing, it could have been deleted manually.
+ for path in self.my_paths():
+ path.unlink(missing_ok=True)
+
+
+class URL(Base, ModelHelper):
+ __tablename__ = 'url'
+ id = Column(Integer, primary_key=True)
+
+ url = Column(String, unique=True)
+
+ archives: InstrumentedList = relationship('Archive', primaryjoin='URL.id==Archive.url_id')
+ latest_id = Column(Integer, ForeignKey('archive.id'))
+ latest = relationship('Archive', primaryjoin='URL.latest_id==Archive.id')
+ latest_datetime = Column(TZDateTime)
+ domain_id = Column(Integer, ForeignKey('domains.id'))
+ domain = relationship('Domain', primaryjoin='URL.domain_id==Domain.id')
+
+ def __repr__(self):
+ return f''
+
+ def dict(self) -> dict:
+ d = super().dict()
+ d['latest'] = self.latest.dict() if self.latest else None
+ d['domain'] = self.domain.dict()
+ d['archives'] = [i.dict() for i in self.archives]
+ return d
+
+
+class Domain(Base, ModelHelper):
+ __tablename__ = 'domains' # plural to avoid conflict
+ id = Column(Integer, primary_key=True)
+
+ domain = Column(String)
+ directory = Column(MediaPathType)
+
+ urls: InstrumentedList = relationship('URL', primaryjoin='Domain.id==URL.domain_id', order_by='URL.latest_datetime')
+
+ def __repr__(self):
+ return f''
diff --git a/modules/archive/schema.py b/modules/archive/schema.py
new file mode 100644
index 00000000..b3524236
--- /dev/null
+++ b/modules/archive/schema.py
@@ -0,0 +1,44 @@
+from sanic_openapi import doc
+
+
+class RetrieveURLsRequest:
+ limit = doc.Integer()
+ offset = doc.Integer()
+ domain = doc.String()
+
+
+class ArchiveDict:
+ id = doc.Integer()
+ url_id = doc.Integer()
+ domain_id = doc.Integer()
+ singlefile_path = doc.String()
+ readability_path = doc.String()
+ readability_json_path = doc.String()
+ readability_txt_path = doc.String()
+ screenshot_path = doc.String()
+ title = doc.String()
+ archive_datetime = doc.DateTime()
+
+
+class DomainDict:
+ id = doc.Integer()
+ domain = doc.String()
+
+
+class URLDict:
+ id = doc.Integer()
+ url = doc.String()
+ latest_id = doc.Integer()
+ latest_datetime = doc.DateTime()
+ domain_id = doc.Integer()
+ domain = DomainDict
+ latest = ArchiveDict
+ archives = doc.List(ArchiveDict)
+
+
+class RetrieveURLsResponse:
+ urls = doc.List(URLDict)
+
+
+class PostArchiveRequest:
+ url = doc.String(required=True)
diff --git a/modules/archive/test/__init__.py b/modules/archive/test/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/archive/test/test_lib.py b/modules/archive/test/test_lib.py
new file mode 100644
index 00000000..5a3cc959
--- /dev/null
+++ b/modules/archive/test/test_lib.py
@@ -0,0 +1,330 @@
+import json
+import pathlib
+
+import mock
+
+from modules.archive.lib import new_archive, get_or_create_domain_and_url, get_urls, get_url_count, delete_url, \
+ _refresh_archives, get_domains
+from modules.archive.models import Archive, URL, Domain
+from wrolpi.common import get_media_directory
+from wrolpi.db import get_db_session
+from wrolpi.errors import InvalidDomain, UnknownURL
+from wrolpi.media_path import MediaPath
+from wrolpi.root_api import CustomJSONEncoder
+from wrolpi.test.common import TestAPI, wrap_test_db, wrap_media_directory
+
+
+def make_fake_request_archive(readability=True, screenshot=True, title=True):
+ def fake_request_archive(_):
+ singlefile = '\ntest single-file\nジにてこちら\nsome title '
+ r = dict(
+ content=f'test readability content',
+ textContent='test readability textContent',
+ title='ジにてこちら' if title else None,
+ ) if readability else None
+ s = b'screenshot data' if screenshot else None
+ return singlefile, r, s
+
+ return fake_request_archive
+
+
+class TestArchive(TestAPI):
+
+ def setUp(self) -> None:
+ super().setUp()
+ (pathlib.Path(self.tmp_dir.name) / 'archive').mkdir(exist_ok=True)
+
+ @wrap_test_db
+ def test_new_archive(self):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
+ archive1 = new_archive('https://example.com', sync=True)
+ # Everything is filled out.
+ self.assertIsInstance(archive1, Archive)
+ self.assertIsNotNone(archive1.archive_datetime)
+ self.assertIsInstance(archive1.singlefile_path, MediaPath)
+ self.assertIsInstance(archive1.readability_path, MediaPath)
+ self.assertIsInstance(archive1.readability_txt_path, MediaPath)
+ self.assertIsInstance(archive1.screenshot_path, MediaPath)
+ self.assertEqual(archive1.title, 'ジにてこちら')
+ self.assertIsNotNone(archive1.url)
+ self.assertIsNotNone(archive1.domain)
+
+ # The actual files were dumped and read correctly.
+ with open(archive1.singlefile_path.path) as fh:
+ self.assertEqual(fh.read(), '\ntest single-file\nジにてこちら\nsome title ')
+ with open(archive1.readability_path.path) as fh:
+ self.assertEqual(fh.read(), 'test readability content')
+ with open(archive1.readability_txt_path.path) as fh:
+ self.assertEqual(fh.read(), 'test readability textContent')
+ with open(archive1.readability_json_path.path) as fh:
+ self.assertEqual(json.load(fh), {'title': 'ジにてこちら', 'url': 'https://example.com'})
+
+ archive2 = new_archive('https://example.com')
+ # URL and Domain are reused.
+ self.assertEqual(archive1.url, archive2.url)
+ self.assertEqual(archive1.domain, archive2.domain)
+
+ @wrap_test_db
+ def test_no_screenshot(self):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive(screenshot=False)):
+ archive = new_archive('https://example.com', sync=True)
+ self.assertIsInstance(archive.singlefile_path, MediaPath)
+ self.assertIsInstance(archive.readability_path, MediaPath)
+ # Screenshot was empty
+ self.assertIsNone(archive.screenshot_path)
+
+ @wrap_test_db
+ def test_no_readability(self):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive(readability=False)):
+ archive = new_archive('https://example.com', sync=True)
+ self.assertIsInstance(archive.singlefile_path, MediaPath)
+ self.assertIsInstance(archive.screenshot_path, MediaPath)
+ # Readability empty
+ self.assertIsNone(archive.readability_path)
+ self.assertIsNone(archive.readability_txt_path)
+
+ @wrap_test_db
+ def test_dict(self):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
+ d = new_archive('https://example.com', sync=True).dict()
+ self.assertIsInstance(d, dict)
+ json.dumps(d, cls=CustomJSONEncoder)
+
+ @wrap_test_db
+ def test_relationships(self):
+ with get_db_session(commit=True) as session:
+ domain, url = get_or_create_domain_and_url(session, 'https://wrolpi.org:443')
+ archive = Archive(
+ singlefile_path=f'{self.tmp_dir.name}/wrolpi.org:443/foo',
+ title='bar',
+ url_id=url.id,
+ domain_id=domain.id,
+ )
+ session.add(archive)
+ session.flush()
+
+ url.latest_id = archive.id
+
+ self.assertEqual(archive.domain, domain)
+ self.assertEqual(archive.url, url)
+
+ # Relationships are added in the dict() method.
+ self.assertDictContains(
+ url.dict(),
+ dict(
+ id=1,
+ url='https://wrolpi.org:443',
+ latest_id=1,
+ latest=dict(singlefile_path=self.tmp_path / 'wrolpi.org:443/foo'),
+ domain_id=1,
+ domain=dict(directory=self.tmp_path / 'archive/wrolpi.org:443', domain='wrolpi.org:443'),
+ )
+ )
+
+ @wrap_test_db
+ def test_get_urls(self):
+ # No URLs yet.
+ urls = get_urls()
+ self.assertEqual([], urls)
+
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive(readability=False)):
+ # One set of duplicate URLs
+ new_archive('https://wrolpi.org/one', sync=True)
+ new_archive('https://wrolpi.org/one', sync=True)
+
+ # Unique URLs
+ new_archive('https://wrolpi.org/two', sync=True)
+ new_archive('https://wrolpi.org/three', sync=True)
+ new_archive('https://example.com/one', sync=True)
+
+ urls = get_urls()
+ # There are only 4 because one set is duplicate.
+ self.assertEqual(len(urls), 4)
+ self.assertEqual(
+ ['https://example.com/one', 'https://wrolpi.org/three',
+ 'https://wrolpi.org/two', 'https://wrolpi.org/one'],
+ [i['url'] for i in urls],
+ )
+
+ # Only one URL for this domain.
+ urls = get_urls(domain='example.com')
+ self.assertEqual(len(urls), 1)
+ self.assertEqual(urls[0]['url'], 'https://example.com/one')
+
+ # Bad domain requested.
+ self.assertRaises(InvalidDomain, get_urls, domain='bad_domain.com')
+
+ # Limit to 3, but with an offset of 2 there are only 2.
+ urls = get_urls(3, 2)
+ self.assertEqual(['https://wrolpi.org/two', 'https://wrolpi.org/one'], [i['url'] for i in urls])
+
+ # First two of this domain.
+ urls = get_urls(2, 0, 'wrolpi.org')
+ self.assertEqual(['https://wrolpi.org/two', 'https://wrolpi.org/one'], [i['url'] for i in urls])
+ # Last two of this domain, but there is only 1.
+ urls = get_urls(2, 2, 'wrolpi.org')
+ self.assertEqual(['https://wrolpi.org/three'], [i['url'] for i in urls])
+
+ @wrap_test_db
+ def test_validate_paths(self):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
+ archive = new_archive('https://example.com')
+ try:
+ with get_db_session(commit=True):
+ archive.singlefile_path = 'asdf'
+ except ValueError as e:
+ self.assertIn('relative', str(e), f'Relative path error was not raised')
+
+ @wrap_test_db
+ def test_get_url_count(self):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
+ self.assertRaises(InvalidDomain, get_url_count, 'bad domain')
+
+ self.assertEqual(get_url_count(), 0)
+ new_archive('https://example.com', sync=True)
+ self.assertEqual(get_url_count(), 1)
+ new_archive('https://example.com', sync=True)
+ self.assertEqual(get_url_count(), 1)
+ new_archive('https://example.org', sync=True)
+ self.assertEqual(get_url_count(), 2)
+ new_archive('https://example.org', sync=True)
+ self.assertEqual(get_url_count('example.org'), 1)
+
+ @wrap_test_db
+ def test_delete_url(self):
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
+ archive = new_archive('https://example.com', sync=True)
+
+ with get_db_session() as session:
+ urls = session.query(URL).all()
+ self.assertEqual(len(urls), 1)
+ archives = session.query(Archive).all()
+ self.assertEqual(len(archives), 1)
+
+ self.assertIsNotNone(archive.singlefile_path)
+ singlefile_path = archive.singlefile_path.path
+ self.assertTrue(singlefile_path.is_file())
+
+ url_id = archive.url.id
+
+ # Delete the URL, all archives and all files.
+ delete_url(url_id)
+ self.assertFalse(singlefile_path.exists())
+
+ # Can't delete the same URL twice.
+ self.assertRaises(UnknownURL, delete_url, url_id)
+
+ with get_db_session() as session:
+ urls = session.query(URL).all()
+ self.assertEqual(len(urls), 0)
+
+ # Bad ID
+ self.assertRaises(UnknownURL, delete_url, 123)
+
+ @wrap_test_db
+ def test_get_title_from_html(self):
+ with wrap_media_directory():
+ with mock.patch('modules.archive.lib.request_archive', make_fake_request_archive()):
+ archive = new_archive('example.com', sync=True)
+ self.assertEqual(archive.title, 'ジにてこちら')
+
+ def fake_request_archive(_):
+ singlefile = '\ntest single-file\nジにてこちら\nsome title '
+ r = dict(
+ content=f'test readability content',
+ textContent='test readability textContent',
+ )
+ s = b'screenshot data'
+ return singlefile, r, s
+
+ with mock.patch('modules.archive.lib.request_archive', fake_request_archive):
+ archive = new_archive('example.com', sync=True)
+ self.assertEqual(archive.title, 'some title')
+
+ def fake_request_archive(_):
+ singlefile = ''
+ r = dict(
+ content=f'missing a title',
+ textContent='',
+ )
+ s = b'screenshot data'
+ return singlefile, r, s
+
+ with mock.patch('modules.archive.lib.request_archive', fake_request_archive):
+ archive = new_archive('example.com', sync=True)
+ self.assertIsNone(archive.title)
+
+ @wrap_test_db
+ def test_refresh_archives(self):
+ with wrap_media_directory():
+ with get_db_session() as session:
+ urls = session.query(URL).count()
+ self.assertEqual(urls, 0)
+
+ archive_directory = get_media_directory() / 'archive'
+ archive_directory.mkdir()
+
+ # Make some test files to refresh.
+ example = archive_directory / 'example.com'
+ example.mkdir()
+ (example / '2021-10-05 16:20:10.346823.html').touch()
+ (example / '2021-10-05 16:20:10.346823.png').touch()
+ (example / '2021-10-05 16:20:10.346823-readability.txt').touch()
+ (example / '2021-10-05 16:20:10.346823-readability.json').touch()
+ (example / '2021-10-05 16:20:10.346823-readability.html').touch()
+ with (example / '2021-10-05 16:20:10.346823-readability.json').open('wt') as fh:
+ json.dump({'url': 'foo'}, fh)
+
+ # These should log an error because they are missing the singlefile path.
+ (example / '2021-10-05 16:20:10.346824-readability.html').touch()
+ (example / '2021-10-05 16:20:10.346825-readability.json').touch()
+ # These are not archives and should be ignored.
+ (example / '2021-10-05 16:20:10.346826-something.html').touch()
+ (example / 'random file').touch()
+ (example / '2021-10-05 16:20:10.346827-something.html').mkdir()
+ (example / '2021-10-05 16:20:10 invalid date').touch()
+
+ # This single archive is found.
+ _refresh_archives()
+ with get_db_session() as session:
+ self.assertEqual(session.query(URL).count(), 1)
+ self.assertEqual(session.query(Archive).count(), 1)
+
+ url: URL = session.query(URL).one()
+ archive: Archive = session.query(Archive).one()
+ self.assertEqual(url.latest, archive)
+ # latest_datetime is set, the timestamp in the file name is assumed to be UTC.
+ self.assertEqual(str(url.latest_datetime), '2021-10-05 22:20:10.346823-06:00')
+
+ # Running the refresh does not result in a new archive.
+ _refresh_archives()
+ with get_db_session() as session:
+ self.assertEqual(session.query(URL).count(), 1)
+ self.assertEqual(session.query(Archive).count(), 1)
+
+ @wrap_test_db
+ def test_refresh_archives_delete_empty(self):
+ """
+ Domains or URLs with no archives should be deleted during refresh.
+ """
+ with wrap_media_directory():
+ (get_media_directory() / 'archive').mkdir()
+ with get_db_session(commit=True) as session:
+ # These should not be deleted.
+ domain, url = get_or_create_domain_and_url(session, 'https://example.com')
+ archive = Archive(singlefile_path='foo', url_id=url.id)
+ session.add(archive)
+
+ # These should be deleted.
+ domain = Domain(domain='bar')
+ session.add(domain)
+ session.flush()
+ session.add(URL(url='bar', domain_id=domain.id))
+
+ self.assertEqual(len(get_urls()), 2)
+ self.assertEqual(len(get_domains()), 2)
+
+ _refresh_archives()
+
+ self.assertEqual(len(get_urls()), 1)
+ self.assertEqual(len(get_domains()), 1)
diff --git a/modules/inventory/__init__.py b/modules/inventory/__init__.py
new file mode 100644
index 00000000..64c647f3
--- /dev/null
+++ b/modules/inventory/__init__.py
@@ -0,0 +1,33 @@
+import wrolpi
+from wrolpi.db import get_db_session
+from .api import bp
+from .inventory import logger, DEFAULT_CATEGORIES, DEFAULT_INVENTORIES
+from .models import Item, Inventory
+
+INVENTORY_INITIALIZED = False
+
+
+@wrolpi.before_startup
+def init(force=False):
+ """
+ Initialize inventory categories, but only if none already exist. Initializes the inventories, but only if none
+ already exist.
+ """
+ global INVENTORY_INITIALIZED
+ if INVENTORY_INITIALIZED and force is False:
+ return
+
+ logger.info('Initializing inventory')
+
+ with get_db_session(commit=True) as session:
+ if session.query(Item).count() == 0:
+ for subcategory, category in DEFAULT_CATEGORIES:
+ item = Item(subcategory=subcategory, category=category)
+ session.add(item)
+
+ if session.query(Inventory).count() == 0:
+ for name in DEFAULT_INVENTORIES:
+ inv = Inventory(name=name)
+ session.add(inv)
+
+ INVENTORY_INITIALIZED = True
diff --git a/modules/inventory/api.py b/modules/inventory/api.py
new file mode 100644
index 00000000..dd25d89d
--- /dev/null
+++ b/modules/inventory/api.py
@@ -0,0 +1,131 @@
+from http import HTTPStatus
+
+from sanic import response
+from sanic.request import Request
+
+from wrolpi.common import run_after, remove_dict_value_whitespace
+from wrolpi.errors import ValidationError
+from wrolpi.root_api import get_blueprint, json_response
+from wrolpi.schema import validate_doc
+from .common import get_inventory_by_category, get_inventory_by_subcategory, get_inventory_by_name, \
+ save_inventories_file
+from .inventory import get_items, save_item, delete_items, \
+ get_inventories, save_inventory, delete_inventory, update_inventory, update_item, get_categories, get_brands
+from .schema import ItemPostRequest, InventoryPostRequest, InventoryPutRequest, ItemPutRequest
+
+NAME = 'inventory'
+
+bp = get_blueprint('Inventory', '/api/inventory')
+
+
+@bp.get('/categories')
+def _get_categories(_: Request):
+ categories = get_categories()
+ return json_response(dict(categories=categories))
+
+
+@bp.get('/brands')
+def _get_brands(_: Request):
+ brands = get_brands()
+ return json_response(dict(brands=brands))
+
+
+@bp.get('/')
+def inventories_get(_: Request):
+ inventories = get_inventories()
+ return json_response(dict(inventories=inventories))
+
+
+@bp.get('/')
+def inventory_get(_: Request, inventory_id: int):
+ by_category = get_inventory_by_category(inventory_id)
+ by_subcategory = get_inventory_by_subcategory(inventory_id)
+ by_name = get_inventory_by_name(inventory_id)
+ return json_response(dict(by_category=by_category, by_subcategory=by_subcategory, by_name=by_name))
+
+
+@bp.post('/')
+@validate_doc(
+ 'Save a new inventory',
+ consumes=InventoryPostRequest,
+)
+@run_after(save_inventories_file)
+def post_inventory(_: Request, data: dict):
+ # Cleanup the whitespace.
+ data = remove_dict_value_whitespace(data)
+
+ save_inventory(data)
+ return response.empty(HTTPStatus.CREATED)
+
+
+@bp.put('/')
+@validate_doc(
+ 'Update an inventory',
+ consumes=InventoryPutRequest,
+)
+@run_after(save_inventories_file)
+def put_inventory(_: Request, inventory_id: int, data: dict):
+ # Cleanup the whitespace.
+ data = remove_dict_value_whitespace(data)
+
+ update_inventory(inventory_id, data)
+ return response.empty()
+
+
+@bp.delete('/')
+@run_after(save_inventories_file)
+def inventory_delete(_: Request, inventory_id: int):
+ delete_inventory(inventory_id)
+ return response.empty()
+
+
+@bp.get('//item')
+@validate_doc(
+ "Get all items from an inventory.",
+)
+def items_get(_: Request, inventory_id: int):
+ items = get_items(inventory_id)
+ return json_response({'items': items})
+
+
+@bp.post('//item')
+@validate_doc(
+ "Save an item into it's inventory.",
+ consumes=ItemPostRequest,
+)
+@run_after(save_inventories_file)
+def post_item(_: Request, inventory_id: int, data: dict):
+ # Cleanup the whitespace.
+ data = remove_dict_value_whitespace(data)
+
+ save_item(inventory_id, data)
+ return response.empty()
+
+
+@bp.put('/item/')
+@validate_doc(
+ "Update an item",
+ consumes=ItemPutRequest,
+)
+@run_after(save_inventories_file)
+def put_item(_: Request, item_id: int, data: dict):
+ # Cleanup the whitespace.
+ data = remove_dict_value_whitespace(data)
+
+ update_item(item_id, data)
+ return response.empty()
+
+
+@bp.delete('/item/')
+@validate_doc(
+ 'Delete items from an inventory.',
+)
+@run_after(save_inventories_file)
+def item_delete(_: Request, item_ids: str):
+ try:
+ item_ids = [int(i) for i in item_ids.split(',')]
+ except ValueError:
+ raise ValidationError('Could not parse item_ids')
+
+ delete_items(item_ids)
+ return response.empty()
diff --git a/modules/inventory/common.py b/modules/inventory/common.py
new file mode 100644
index 00000000..7b4e1b05
--- /dev/null
+++ b/modules/inventory/common.py
@@ -0,0 +1,182 @@
+from decimal import Decimal
+from functools import partial
+from pathlib import Path
+from typing import List, Tuple
+
+import yaml
+from pint import Quantity
+
+from wrolpi import before_startup
+from wrolpi.common import logger, Base
+from wrolpi.db import get_db_session
+from wrolpi.errors import NoInventories, InventoriesVersionMismatch
+from wrolpi.vars import CONFIG_DIR
+from .inventory import unit_registry, get_items, get_inventories, increment_inventories_version, \
+ get_inventories_version
+from .models import Inventory, Item
+
+MY_DIR: Path = Path(__file__).parent
+
+logger = logger.getChild(__name__)
+
+
+def sum_by_key(items: List[Base], key: callable):
+ """
+ Sum the total size of each item by the provided key function. Returns a dict containing the key, and the total_size
+ for that key. Combine total of like units. This means ounces and pounds will be in the same total.
+ """
+ summed = dict()
+ for item in items:
+ k = key(item)
+ item_size, count, unit = item.item_size, item.count, unit_registry(item.unit)
+ key_dim = (k, unit.dimensionality)
+
+ total_size = item_size * count * unit
+
+ try:
+ summed[key_dim] += total_size
+ except KeyError:
+ summed[key_dim] = total_size
+
+ summed = {k: compact_unit(v) for k, v in summed.items()}
+ return summed
+
+
+def get_inventory_by_keys(keys: Tuple, inventory_id: int):
+ items = get_items(inventory_id)
+
+ summed = sum_by_key(items, lambda i: tuple(getattr(i, k) or '' for k in keys))
+
+ inventory = []
+ for key, quantity in sorted(summed.items(), key=lambda i: i[0][0]):
+ quantity = cleanup_quantity(quantity)
+ total_size, units = quantity.to_tuple()
+ unit = units[0][0]
+
+ d = dict(total_size=total_size, unit=unit)
+ d.update(dict(zip(keys, key[0])))
+ inventory.append(d)
+
+ return inventory
+
+
+get_inventory_by_category = partial(get_inventory_by_keys, ('category',))
+get_inventory_by_subcategory = partial(get_inventory_by_keys, ('category', 'subcategory'))
+get_inventory_by_name = partial(get_inventory_by_keys, ('brand', 'name'))
+INVENTORY_UNITS = {
+ ('ounce', 1): (16, unit_registry.pound),
+ ('pound', 1): (2000, unit_registry.ton),
+}
+UNIT_PRECISION = 5
+
+
+def compact_unit(quantity: unit_registry.Quantity) -> unit_registry.Quantity:
+ """
+ Convert a Quantity to it's more readable format. Such as 2000 pounds to 1 ton.
+ """
+ number, (units,) = quantity.to_tuple()
+ next_unit = INVENTORY_UNITS.get(units)
+ if not next_unit:
+ # No units after this one, return as is.
+ return round(quantity, UNIT_PRECISION)
+
+ max_quantity, next_unit = next_unit
+ if number >= max_quantity:
+ # Number is too high for this unit, move to the next unit.
+ return compact_unit(quantity.to(next_unit))
+
+ # No units were necessary, return as is.
+ quantity = round(quantity, UNIT_PRECISION)
+ return quantity
+
+
+def quantity_to_tuple(quantity: unit_registry.Quantity) -> Tuple[Decimal, Quantity]:
+ decimal, (units,) = quantity.to_tuple()
+ unit, _ = units
+ quantity, unit = round(decimal, UNIT_PRECISION), unit_registry(unit)
+ return quantity, unit
+
+
+def cleanup_quantity(quantity: Quantity) -> Quantity:
+ """
+ Remove trailing zeros from a Quantity.
+ """
+ num, unit = quantity_to_tuple(quantity)
+ num = round(num, UNIT_PRECISION)
+ num = str(num).rstrip('0').rstrip('.')
+ return Decimal(num) * unit
+
+
+DEFAULT_SAVE_PATH = CONFIG_DIR / 'inventories.yaml'
+
+
+def save_inventories_file(path: str = None):
+ """
+ Write all inventories and their respective items to a YAML file.
+ """
+ path: Path = Path(path) if path else DEFAULT_SAVE_PATH
+
+ inventories = []
+ for inventory in get_inventories():
+ inventories.append(inventory.dict())
+
+ if not inventories:
+ raise NoInventories('No Inventories are in the database!')
+
+ with increment_inventories_version() as version:
+ if path.is_file():
+ # Check that we aren't overwriting our inventories with empty inventories.
+ with open(path, 'rt') as fh:
+ old = yaml.load(fh, Loader=yaml.Loader)
+ if old and not inventories:
+ raise FileExistsError(f'Refusing to overwrite non-empty inventories.yaml with empty inventories.'
+ f' {path}')
+
+ if old and old.get('version') > version:
+ raise InventoriesVersionMismatch(
+ f'Inventories config version is {old["version"]} but DB version is {get_inventories_version()}')
+
+ with open(path, 'wt') as fh:
+ contents = dict(
+ version=version,
+ inventories=inventories,
+ )
+
+ yaml.dump(contents, fh)
+
+
+@before_startup
+def import_inventories_file(path: str = None):
+ path: Path = Path(path) if path else DEFAULT_SAVE_PATH
+
+ if not path.is_file():
+ logger.warning(f'No inventories config file at {path}')
+ return
+
+ with open(path, 'rt') as fh:
+ contents = yaml.load(fh, Loader=yaml.Loader)
+
+ if not contents or 'inventories' not in contents:
+ raise ValueError('Inventories file does not contain the expected "inventories" list.')
+
+ inventories = get_inventories()
+ inventories_names = {i.name for i in inventories}
+ new_inventories = [i for i in contents['inventories'] if i['name'] not in inventories_names]
+ with get_db_session(commit=True) as session:
+ for inventory in new_inventories:
+ items = inventory['items']
+ inventory = Inventory(
+ name=inventory['name'],
+ created_at=inventory['created_at'],
+ deleted_at=inventory['deleted_at'],
+ )
+ session.add(inventory)
+ # Get the Inventory from the DB so we can use it's ID.
+ session.flush()
+ session.refresh(inventory)
+
+ for item in items:
+ del item['inventory_id']
+ item = Item(inventory_id=inventory.id, **item)
+ item.inventory_id = inventory.id
+ session.add(item)
diff --git a/modules/inventory/inventory.py b/modules/inventory/inventory.py
new file mode 100644
index 00000000..ccf80f84
--- /dev/null
+++ b/modules/inventory/inventory.py
@@ -0,0 +1,214 @@
+import contextlib
+from functools import partial
+from operator import itemgetter
+from typing import List, Tuple
+
+import pint
+import psycopg2
+from sqlalchemy.orm import Session
+from sqlalchemy.orm.exc import NoResultFound
+
+from wrolpi.common import logger, Base
+from wrolpi.db import get_db_session, get_db_curs
+from .models import Inventory, Item, InventoriesVersion
+
+logger = logger.getChild(__name__)
+
+unit_registry = pint.UnitRegistry()
+
+DEFAULT_CATEGORIES = [
+ ('salt', 'cooking ingredients'),
+ ('dry milk', 'dairy'),
+ ('evaporated milk', 'dairy'),
+ ('freeze dried', 'dairy'),
+ ('shortening', 'fats'),
+ ('vegetable oil', 'fats'),
+ ('canned', 'fruits'),
+ ('dehydrated', 'fruits'),
+ ('freeze-dried', 'fruits'),
+ ('corn meal', 'grains'),
+ ('flour', 'grains'),
+ ('oats', 'grains'),
+ ('pasta', 'grains'),
+ ('rice', 'grains'),
+ ('wheat', 'grains'),
+ ('dry beans', 'legumes'),
+ ('lentils', 'legumes'),
+ ('canned', 'meats'),
+ ('dried', 'meats'),
+ ('freeze dried', 'meals'),
+ ('brown sugar', 'sugars'),
+ ('corn syrup', 'sugars'),
+ ('honey', 'sugars'),
+ ('juice mix', 'sugars'),
+ ('white sugar', 'sugars'),
+ ('canned', 'vegetables'),
+ ('dehydrated', 'vegetables'),
+ ('freeze dried', 'vegetables'),
+ ('water', 'water'),
+ ('bottled', 'water'),
+ ('barrel', 'water'),
+]
+
+DEFAULT_INVENTORIES = [
+ 'Food Storage',
+]
+
+# Categories are sorted by category/sub-category.
+sort_categories = partial(sorted, key=itemgetter(1, 0))
+
+
+def get_inventories() -> List[Base]:
+ with get_db_session() as session:
+ inventories = session.query(Inventory).filter(
+ Inventory.deleted_at == None, # noqa
+ ).order_by(
+ Inventory.viewed_at.desc(),
+ ).all()
+ inventories = list(inventories)
+ return inventories
+
+
+IGNORED_INVENTORY_KEYS = {'viewed_at', 'created_at', 'deleted_at'}
+
+
+def _remove_conflicting_deleted_inventory(inventory: dict, session: Session):
+ """
+ Remove any Inventories that share the inventory's name, but are marked as deleted.
+ """
+ inventories = session.query(Inventory).filter(
+ Inventory.deleted_at != None, # noqa
+ Inventory.name == inventory['name'],
+ ).all()
+ for i in inventories:
+ session.query(Item).filter_by(inventory_id=i.id).delete()
+ session.query(Inventory).filter_by(id=i.id).delete()
+
+
+def save_inventory(inventory):
+ inventory = {k: v for k, v in inventory.items() if k not in IGNORED_INVENTORY_KEYS}
+
+ # Cleanup the whitespace.
+ inventory['name'] = inventory['name'].strip()
+
+ with get_db_session(commit=True) as session:
+ _remove_conflicting_deleted_inventory(inventory, session)
+ session.add(Inventory(**inventory))
+
+
+def update_inventory(inventory_id: int, inventory: dict):
+ inventory = {k: v for k, v in inventory.items() if k not in IGNORED_INVENTORY_KEYS}
+
+ # Cleanup the whitespace.
+ inventory['name'] = inventory['name'].strip()
+
+ with get_db_session(commit=True) as session:
+ _remove_conflicting_deleted_inventory(inventory, session)
+
+ i = session.query(Inventory).filter_by(id=inventory_id).one()
+ for key, value in inventory.items():
+ setattr(i, key, value)
+
+
+def delete_inventory(inventory_id: int):
+ with get_db_session(commit=True) as session:
+ inventory = session.query(Inventory).filter_by(id=inventory_id).one()
+ inventory.delete()
+
+
+def get_categories() -> List[Tuple[str, str]]:
+ """
+ Get all distinct sets of subcategory and category.
+ """
+ categories = DEFAULT_CATEGORIES.copy()
+ with get_db_curs() as curs:
+ curs.execute('SELECT DISTINCT subcategory, category FROM item ORDER BY 1, 2')
+ try:
+ db_categories = curs.fetchall()
+ db_categories = [tuple(i) for i in db_categories]
+ categories = set(categories + db_categories)
+ except psycopg2.ProgrammingError:
+ # No categories in DB.
+ pass
+
+ return sort_categories(categories)
+
+
+def get_brands() -> List[Tuple[int, str]]:
+ with get_db_curs() as curs:
+ curs.execute("SELECT DISTINCT brand FROM item WHERE brand IS NOT NULL AND brand != '' ORDER BY 1")
+ try:
+ brands = curs.fetchall()
+ except psycopg2.ProgrammingError:
+ # No brands
+ return []
+ return list(brands)
+
+
+def get_items(inventory_id: int) -> List[Base]:
+ """
+ Get all Items in an Inventory, except deleted items.
+ """
+ with get_db_session() as session:
+ results = session.query(Item).filter(
+ Item.inventory_id == inventory_id,
+ Item.deleted_at == None, # noqa
+ ).all()
+ return results
+
+
+def save_item(inventory_id: int, item: dict):
+ with get_db_session(commit=True) as session:
+ item = Item(inventory_id=inventory_id, **item)
+ session.add(item)
+
+
+def update_item(item_id: int, item: dict):
+ with get_db_session(commit=True) as session:
+ i = session.query(Item).filter_by(id=item_id).one()
+ del item['id']
+ del item['created_at']
+ for key, value in item.items():
+ setattr(i, key, value)
+
+
+def delete_items(items_ids: List[int]):
+ with get_db_curs(commit=True) as curs:
+ curs.execute('UPDATE item SET deleted_at=current_timestamp WHERE id = ANY(%s)', (items_ids,))
+
+
+def get_inventories_version():
+ """
+ The inventory_version table contains a single row with the Inventories version integer.
+ """
+ with get_db_session() as session:
+ try:
+ version = session.query(InventoriesVersion).one()
+ return version.version
+ except NoResultFound:
+ # No version is saved yet.
+ pass
+
+
+def get_next_inventories_version():
+ """
+ Get the current inventories version, increment it if there is one.
+ """
+ version = get_inventories_version()
+ if version:
+ return version + 1
+ return 1
+
+
+@contextlib.contextmanager
+def increment_inventories_version():
+ """
+ Context manager that will increment the Inventories Version by 1 when exiting.
+ """
+ version = get_next_inventories_version()
+ yield version
+ with get_db_session(commit=True) as session:
+ if session.query(InventoriesVersion).count() == 0:
+ session.add(InventoriesVersion(version=version))
+ else:
+ session.query(InventoriesVersion).one().version = version
diff --git a/modules/inventory/models.py b/modules/inventory/models.py
new file mode 100644
index 00000000..0b285bc6
--- /dev/null
+++ b/modules/inventory/models.py
@@ -0,0 +1,62 @@
+from sqlalchemy import Column, Integer, String, Date, ForeignKey, DECIMAL
+from sqlalchemy.orm import relationship
+from sqlalchemy.orm.collections import InstrumentedList
+
+from wrolpi.common import Base, ModelHelper
+from wrolpi.dates import now, TZDateTime
+
+
+class Item(Base, ModelHelper):
+ __tablename__ = 'item'
+ id = Column(Integer, primary_key=True)
+
+ brand = Column(String)
+ category = Column(String)
+ count = Column(DECIMAL)
+ created_at = Column(TZDateTime, default=now)
+ deleted_at = Column(TZDateTime)
+ expiration_date = Column(Date)
+ item_size = Column(DECIMAL)
+ name = Column(String)
+ purchase_date = Column(Date)
+ serving = Column(Integer)
+ subcategory = Column(String)
+ unit = Column(String)
+
+ inventory_id = Column(Integer, ForeignKey('inventory.id'))
+ inventory = relationship('Inventory', primaryjoin="Item.inventory_id==Inventory.id", back_populates='items')
+
+ def __repr__(self):
+ return f'- '
+
+
+class Inventory(Base, ModelHelper):
+ __tablename__ = 'inventory'
+ id = Column(Integer, primary_key=True)
+
+ name = Column(String, unique=True)
+ viewed_at = Column(TZDateTime)
+ created_at = Column(TZDateTime, default=now)
+ deleted_at = Column(TZDateTime)
+
+ items: InstrumentedList = relationship('Item', foreign_keys='Item.inventory_id')
+
+ def __repr__(self):
+ return f'
'
+
+ def dict(self):
+ d = super().dict()
+ d['items'] = [i.dict() for i in self.items]
+ return d
+
+ def delete(self):
+ """
+ Delete all Items of this Inventory. Then delete this inventory.
+ """
+ self.deleted_at = now()
+
+
+class InventoriesVersion(Base, ModelHelper):
+ __tablename__ = 'inventories_version'
+ version = Column(Integer, primary_key=True)
diff --git a/modules/inventory/schema.py b/modules/inventory/schema.py
new file mode 100644
index 00000000..a9198c0c
--- /dev/null
+++ b/modules/inventory/schema.py
@@ -0,0 +1,37 @@
+from sanic_openapi import doc
+
+
+class ItemPostRequest:
+ brand = doc.String()
+ name = doc.String(required=True)
+ item_size = doc.Float(required=True)
+ unit = doc.String(required=True)
+ count = doc.Float(required=True)
+ category = doc.String()
+ subcategory = doc.String()
+ expiration_date = doc.Date()
+
+
+class ItemPutRequest:
+ id = doc.Integer() # Ignored in favor of URL
+ brand = doc.String()
+ name = doc.String(required=True)
+ item_size = doc.Float(required=True)
+ unit = doc.String(required=True)
+ count = doc.Float(required=True)
+ serving = doc.Float()
+ category = doc.String()
+ subcategory = doc.String()
+ expiration_date = doc.Date()
+ purchase_date = doc.Date()
+ created_at = doc.DateTime()
+ deleted_at = doc.DateTime()
+ inventory_id = doc.Integer(required=True)
+
+
+class InventoryPostRequest:
+ name = doc.String(required=True)
+
+
+class InventoryPutRequest:
+ name = doc.String(required=True)
diff --git a/modules/inventory/test/__init__.py b/modules/inventory/test/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/inventory/test/test_inventory.py b/modules/inventory/test/test_inventory.py
new file mode 100644
index 00000000..c5011e49
--- /dev/null
+++ b/modules/inventory/test/test_inventory.py
@@ -0,0 +1,335 @@
+import os
+import tempfile
+from decimal import Decimal
+from itertools import zip_longest
+from typing import List, Iterable
+
+import pytest
+import sqlalchemy
+import yaml
+from pint import Quantity
+
+from wrolpi.common import Base
+from wrolpi.db import get_db_session
+from wrolpi.errors import NoInventories, InventoriesVersionMismatch
+from wrolpi.test.common import PytestCase
+from .. import init
+from ..common import sum_by_key, get_inventory_by_category, get_inventory_by_subcategory, get_inventory_by_name, \
+ compact_unit, cleanup_quantity, save_inventories_file, import_inventories_file
+from ..inventory import unit_registry, \
+ get_inventories, save_inventory, update_inventory, \
+ delete_inventory, get_categories
+from ..models import Item, Inventory
+
+TEST_ITEMS_COLUMNS = (
+ 'inventory_id',
+ 'brand',
+ 'name',
+ 'item_size',
+ 'unit',
+ 'count',
+ 'category',
+ 'subcategory',
+ 'deleted_at',
+ 'serving',
+ 'expiration_date',
+ 'purchase_date',
+)
+TEST_ITEMS_TUPLES = [
+ (1, 'Wheaters', 'Red Wheat', 500, 'oz', 1, 'grains', 'wheat'),
+ (1, 'Wheaters', 'Red Wheat', 55, 'pounds', 2, 'grains', 'wheat'),
+ (1, 'Ricey', 'White Rice', 8, 'pounds', 1, 'grains', 'rice'),
+ (1, 'Chewy', 'Chicken Breast', 16, 'oz', 8, 'meats', 'canned'),
+ (1, 'Chewy', 'Beef', 16, 'oz', 12, 'meats', 'canned'),
+ (1, 'Vibrant', 'Peaches', 24, 'oz', 2, 'fruits', 'canned'),
+ (1, 'Vibrant', 'Pineapple', Decimal('22.3'), 'oz', 1, 'fruits', 'canned'),
+ # These are in the same category, but have conflicting unit types. (gallon vs pound)
+ (1, 'White', 'Vinegar', 1, 'gallon', 1, 'cooking ingredients', 'vinegar'),
+ (1, 'Salty', 'Salt', 1, 'pound', 1, 'cooking ingredients', 'salt'),
+ # This item is deleted, and should always be ignored.
+ (1, 'deleted', 'deleted', Decimal('1'), 'oz', 1, 'fruits', 'canned', '2020-01-01'),
+]
+TEST_ITEMS = [dict(zip(TEST_ITEMS_COLUMNS, i)) for i in TEST_ITEMS_TUPLES]
+
+
+def extract_items(lst: List[Base], keys: Iterable[str]) -> List[Base]:
+ extracted = []
+ for i in lst:
+ extracted.append({j: getattr(i, j) for j in keys})
+
+ return extracted
+
+
+class TestInventory(PytestCase):
+
+ @staticmethod
+ def prepare() -> None:
+ """
+ This exists rather than setUp because the DB is wrapped.
+ """
+ init(force=True)
+
+ with get_db_session(commit=True) as session:
+ for item in TEST_ITEMS:
+ item = Item(**item)
+ session.add(item)
+
+ def test_get_categories(self, test_session):
+ self.prepare()
+
+ categories = get_categories()
+ self.assertGreater(len(categories), 0)
+
+ def test_get_inventories(self, test_session):
+ self.prepare()
+
+ inventories = get_inventories()
+ dates = extract_items(inventories, {'created_at', 'deleted_at', 'viewed_at'})
+ self.assertItemsTruthyOrFalsey(dates, [{'created_at': True, 'deleted_at': False, 'viewed_at': False}])
+ self.assertEqual(inventories[0].id, 1)
+ self.assertEqual(inventories[0].name, 'Food Storage')
+
+ def test_inventory1(self, test_session):
+ self.prepare()
+
+ # Insert a new Inventory.
+ inventory = {
+ 'name': 'New Inventory',
+ 'viewed_at': 'asdf', # This should be ignored.
+ }
+ save_inventory(inventory)
+
+ with get_db_session() as session:
+ i1, i2 = session.query(Inventory).order_by(Inventory.name).all()
+ self.assertDictContains(i2, {'name': 'New Inventory', 'viewed_at': None})
+
+ # Inventories cannot share a name.
+ self.assertRaises(sqlalchemy.exc.IntegrityError, save_inventory, inventory)
+
+ # Insert a second inventory.
+ inventory['name'] = 'Super Inventory'
+ save_inventory(inventory)
+
+ # Cannot update the name to a name that is already used.
+ with get_db_session() as session:
+ i = session.query(Inventory).filter_by(name='Super Inventory').one()
+ inventory['name'] = 'New Inventory'
+ self.assertRaises(sqlalchemy.exc.IntegrityError, update_inventory, i.id, inventory)
+
+ # Add some items to "New Inventory"
+ with get_db_session(commit=True) as session:
+ before_item_count = session.query(Item).count()
+ session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
+ session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
+ session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
+ session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
+
+ # You can rename a inventory to a conflicting name, if the other inventory is marked as deleted.
+ with get_db_session(commit=True) as session:
+ delete_inventory(2)
+ # Check that the items from the deleted Inventory were not deleted, YET.
+ self.assertEqual(before_item_count + 4, session.query(Item).count())
+
+ i = session.query(Inventory).filter_by(name='Super Inventory').one()
+ inventory['name'] = 'New Inventory'
+ update_inventory(i.id, inventory)
+
+ # Check that the items from the deleted Inventory were really deleted.
+ self.assertEqual(before_item_count, session.query(Item).count())
+
+ def test_get_inventory_by_category(self, test_session):
+ self.prepare()
+
+ expected = [
+ dict(category='cooking ingredients', total_size=Decimal('1'), unit='gallon'),
+ dict(category='cooking ingredients', total_size=Decimal('1'), unit='pound'),
+ dict(category='fruits', total_size=Decimal('4.39375'), unit='pound'),
+ dict(category='grains', total_size=Decimal('149.25'), unit='pound'),
+ dict(category='meats', total_size=Decimal('20'), unit='pound'),
+ ]
+
+ inventory = get_inventory_by_category(1)
+
+ for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
+ self.assertEqual(i, j, f'category inventory {idx} is not equal')
+
+ def test_get_inventory_by_subcategory(self, test_session):
+ self.prepare()
+
+ expected = [
+ dict(category='cooking ingredients', subcategory='salt', total_size=Decimal('1'), unit='pound'),
+ dict(category='cooking ingredients', subcategory='vinegar', total_size=Decimal('1'), unit='gallon'),
+ dict(category='fruits', subcategory='canned', total_size=Decimal('4.39375'), unit='pound'),
+ dict(category='grains', subcategory='rice', total_size=Decimal('8'), unit='pound'),
+ dict(category='grains', subcategory='wheat', total_size=Decimal('141.25'), unit='pound'),
+ dict(category='meats', subcategory='canned', total_size=Decimal('20'), unit='pound'),
+ ]
+
+ inventory = get_inventory_by_subcategory(1)
+
+ for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
+ self.assertEqual(i, j, f'subcategory inventory {idx} is not equal')
+
+ def test_get_inventory_by_name(self, test_session):
+ self.prepare()
+
+ expected = [
+ dict(brand='Chewy', name='Beef', total_size=Decimal('12'), unit='pound'),
+ dict(brand='Chewy', name='Chicken Breast', total_size=Decimal('8'), unit='pound'),
+ dict(brand='Ricey', name='White Rice', total_size=Decimal('8'), unit='pound'),
+ dict(brand='Salty', name='Salt', total_size=Decimal('1'), unit='pound'),
+ dict(brand='Vibrant', name='Peaches', total_size=Decimal('3'), unit='pound'),
+ dict(brand='Vibrant', name='Pineapple', total_size=Decimal('1.39375'), unit='pound'),
+ dict(brand='Wheaters', name='Red Wheat', total_size=Decimal('141.25'), unit='pound'),
+ dict(brand='White', name='Vinegar', total_size=Decimal('1'), unit='gallon'),
+ ]
+
+ inventory = get_inventory_by_name(1)
+
+ for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
+ self.assertEqual(i, j, f'named inventory {idx} is not equal')
+
+ def test_inventories_file(self, test_session):
+ self.prepare()
+
+ with tempfile.NamedTemporaryFile() as tf:
+ # Can't import an empty file.
+ self.assertRaises(ValueError, import_inventories_file, tf.name)
+
+ save_inventories_file(tf.name)
+
+ # Clear out the DB so the import will be tested
+ with get_db_session(commit=True) as session:
+ session.query(Item).delete()
+ session.query(Inventory).delete()
+
+ import_inventories_file(tf.name)
+
+ inventories = get_inventories()
+ self.assertEqual(len(inventories), 1)
+ # ID has increased because we did not reset the sequence when deleting from the table.
+ self.assertEqual(inventories[0].id, 2)
+ self.assertEqual(inventories[0].name, 'Food Storage')
+
+ # All items in the DB match those in the test list, except for the "deleted" item.
+ non_deleted_items = [i for i in inventories[0].items if i.deleted_at is None]
+ self.assertEqual(len(non_deleted_items), len(TEST_ITEMS) - 1)
+ test_items = {(i['name'], i['brand'], i['count']) for i in TEST_ITEMS[:-1]}
+ db_items = {(i.name, i.brand, i.count) for i in non_deleted_items}
+ self.assertEqual(test_items, db_items)
+
+ def test_inventories_version(self, test_session):
+ """
+ You can't save over a newer version of an inventory.
+ """
+ self.prepare()
+ with tempfile.NamedTemporaryFile(delete=False) as tf:
+ pass
+
+ try:
+ with open(tf.name, 'rt') as fh:
+ # Version is set to 1 on first save.
+ save_inventories_file(tf.name)
+ inventories_contents = yaml.load(fh, Loader=yaml.Loader)
+ self.assertEqual(inventories_contents['version'], 1)
+
+ with open(tf.name, 'rt') as fh:
+ # Version is incremented when saving.
+ save_inventories_file(tf.name)
+ inventories_contents = yaml.load(fh, Loader=yaml.Loader)
+ self.assertEqual(inventories_contents['version'], 2)
+
+ with open(tf.name, 'wt') as fh:
+ # Version is greater than what will be saved.
+ inventories_contents['version'] = 5
+ yaml.dump(inventories_contents, fh)
+
+ self.assertRaises(InventoriesVersionMismatch, save_inventories_file, tf.name)
+
+ finally:
+ os.unlink(tf.name)
+
+ def test_no_inventories(self, test_session, tmp_path):
+ """
+ Can't save empty inventories.
+ """
+ try:
+ save_inventories_file(tmp_path.name)
+ except NoInventories:
+ pass
+
+
+def quantity_to_string(quantity: Quantity) -> str:
+ quantity = cleanup_quantity(quantity)
+ num, (units,) = quantity.to_tuple()
+ (unit, _) = units
+ return f'{num} {unit}'
+
+
+@pytest.mark.parametrize(
+ 'quantity,expected',
+ [
+ (Decimal(5) * unit_registry.ounce, '5 ounce'),
+ (Decimal(16) * unit_registry.ounce, '1 pound'),
+ (Decimal(500) * unit_registry.pound, '500 pound'),
+ (Decimal(2000) * unit_registry.pound, '1 ton'),
+ (Decimal(128000) * unit_registry.ounce, '4 ton'),
+ ]
+)
+def test_compact_unit(quantity, expected):
+ # Round the result so we don't have to specify all those zeros for the test definition.
+ assert quantity_to_string(compact_unit(quantity)) == expected
+
+
+pound, oz, gram, gallon = unit_registry('pound'), unit_registry('oz'), unit_registry('gram'), unit_registry('gallon')
+pound, oz, gram, gallon = pound.units, oz.units, gram.units, gallon.units
+mass = pound.dimensionality
+length = gallon.dimensionality
+
+
+@pytest.mark.parametrize(
+ 'items,expected',
+ [
+ (
+ # No conversion is necessary.
+ [{'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'}],
+ {(('grains',), oz.dimensionality): Decimal('1') * oz},
+ ),
+ (
+ # The larger of the units is what is returned.
+ [
+ {'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'},
+ {'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'lbs'},
+ ],
+ {(('grains',), pound.dimensionality): Decimal('1.0625') * pound},
+ ),
+ (
+ # Items are summed by category.
+ [
+ {'category': 'grains', 'count': Decimal('8'), 'item_size': Decimal('24'), 'unit': 'oz'},
+ {'category': 'grains', 'count': Decimal('2'), 'item_size': Decimal('45'), 'unit': 'lbs'},
+ {'category': 'fruits', 'count': Decimal('4'), 'item_size': Decimal('1'), 'unit': 'gram'},
+ ],
+ {
+ (('grains',), pound.dimensionality): Decimal('102') * pound,
+ (('fruits',), gram.dimensionality): Decimal('4') * gram,
+ },
+ ),
+ (
+ [
+ {'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'),
+ 'unit': 'gallon'},
+ {'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'),
+ 'unit': 'quart'},
+ {'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'},
+ ],
+ {
+ (('cooking ingredients',), gallon.dimensionality): Decimal('1.25') * gallon,
+ (('cooking ingredients',), oz.dimensionality): Decimal('1') * oz,
+ },
+ ),
+ ]
+)
+def test_sum_by_key(items, expected):
+ items = [Item(**i) for i in items]
+ assert sum_by_key(items, lambda i: (i.category,)) == expected
diff --git a/modules/map/__init__.py b/modules/map/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/map/api.py b/modules/map/api.py
new file mode 100644
index 00000000..7d4866d8
--- /dev/null
+++ b/modules/map/api.py
@@ -0,0 +1,42 @@
+import asyncio
+from http import HTTPStatus
+from pathlib import Path
+from urllib.parse import urlparse
+
+from sanic import Blueprint, response
+
+from modules.map.schema import PBFPostRequest, PBFPostResponse
+from wrolpi.common import create_websocket_feed
+from wrolpi.schema import validate_doc
+
+NAME = 'map'
+
+api_bp = Blueprint('Map', url_prefix='/map')
+
+download_queue, download_event = create_websocket_feed('pbf_progress', '/feeds/pbf_progress', api_bp)
+
+
+@api_bp.route('/pbf', methods=['POST'])
+@validate_doc(
+ summary='Queue a PBF file for download and processing',
+ consumes=PBFPostRequest,
+ produces=PBFPostResponse,
+)
+def pbf_post(request, data: dict):
+ pbf_url = data.get('pbf_url')
+ parsed = urlparse(pbf_url)
+ if not parsed.scheme or not parsed.netloc or not parsed.path:
+ return response.json({'error': 'Invalid PBF url'}, HTTPStatus.BAD_REQUEST)
+
+ # Get the size of the PBF file. Also, check that it is accessible.
+ try:
+ size, filename = get_http_file_info(pbf_url)
+ except LookupError:
+ # Couldn't get the size of the file, probably doesn't exist
+ return response.json({'error': 'Failed to get file size. Does it exists?', 'url': pbf_url},
+ HTTPStatus.NOT_FOUND)
+
+ destination = Path('/tmp') / filename
+ coro = download_file(pbf_url, size, destination)
+ asyncio.ensure_future(coro)
+ return response.json({'success': 'File download started'})
diff --git a/modules/map/common.py b/modules/map/common.py
new file mode 100644
index 00000000..06e23848
--- /dev/null
+++ b/modules/map/common.py
@@ -0,0 +1,3 @@
+from pathlib import Path
+
+MY_DIR: Path = Path(__file__).parent
diff --git a/modules/map/schema.py b/modules/map/schema.py
new file mode 100644
index 00000000..f0aa1347
--- /dev/null
+++ b/modules/map/schema.py
@@ -0,0 +1,9 @@
+from sanic_openapi import doc
+
+
+class PBFPostRequest:
+ pbf_url = doc.String()
+
+
+class PBFPostResponse:
+ success = doc.String()
diff --git a/modules/map/test/__init__.py b/modules/map/test/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/otp/__init__.py b/modules/otp/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/otp/api.py b/modules/otp/api.py
new file mode 100644
index 00000000..7e860a25
--- /dev/null
+++ b/modules/otp/api.py
@@ -0,0 +1,50 @@
+import random
+from http import HTTPStatus
+from string import ascii_letters
+
+from sanic import response
+from sanic.request import Request
+
+from wrolpi.root_api import get_blueprint
+from wrolpi.schema import validate_doc
+from .common import generate_html, generate_pdf, encrypt_otp, decrypt_otp
+from .schema import EncryptOTPRequest, DecryptOTPRequest
+
+bp = get_blueprint('OTP', '/api/otp')
+
+
+@bp.post(':encrypt_otp')
+@validate_doc(
+ summary='Encrypt a message with OTP',
+ consumes=EncryptOTPRequest,
+)
+async def post_encrypt_otp(_: Request, data: dict):
+ data = encrypt_otp(data['otp'], data['plaintext'])
+ return response.json(data)
+
+
+@bp.post(':decrypt_otp')
+@validate_doc(
+ summary='Decrypt a message with OTP',
+ consumes=DecryptOTPRequest,
+)
+async def post_decrypt_otp(_: Request, data: dict):
+ data = decrypt_otp(data['otp'], data['ciphertext'])
+ return response.json(data)
+
+
+@bp.get('/html')
+async def get_new_otp(_: Request):
+ return response.html(generate_html())
+
+
+@bp.get('/pdf')
+async def get_new_otp_pdf(_: Request):
+ random_name = ''.join(random.choice(ascii_letters) for _ in range(8))
+ filename = f'one-time-pad-{random_name}.pdf'
+ headers = {
+ 'Content-type': 'application/pdf',
+ 'Content-Disposition': f'attachment;filename={filename}'
+ }
+ contents = generate_pdf()
+ return response.raw(contents, HTTPStatus.OK, headers)
diff --git a/modules/otp/common.py b/modules/otp/common.py
new file mode 100644
index 00000000..f5d4eb47
--- /dev/null
+++ b/modules/otp/common.py
@@ -0,0 +1,143 @@
+import secrets
+from functools import partial
+from typing import Dict
+
+import pdfkit
+
+from wrolpi.common import chunks, remove_whitespace
+from wrolpi.errors import InvalidOTP, InvalidPlaintext, InvalidCiphertext
+
+# These are the only characters we support.
+OTP_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+# The number of characters in each group, separated by spaces.
+GROUP_SIZE = 5
+# The groups in each row of a message, separated by newlines. This is small to allow for mobile phones.
+GROUP_COUNT = 5
+
+
+def format_message(message: str, group_count: int = GROUP_COUNT) -> str:
+ """
+ Formats messages in the style of a One-Time-Pad. Usually 5 characters per group.
+
+ >>> format_message('SOMEMESSAGE')
+ 'SOMEM ESSAG E'
+ """
+ groups = [''.join(i) for i in chunks(message, GROUP_SIZE)]
+ rows = chunks(groups, group_count)
+ message = '\n'.join(' '.join(group for group in groups) for groups in rows)
+ return message
+
+
+generate_char = partial(secrets.choice, OTP_CHARS)
+
+
+def generate_message():
+ """
+ Create a OTP message. Format it for ease of use.
+ """
+ # Use 20 messages per line because this page will be printed.
+ return format_message(''.join(generate_char() for _ in range(400)), 20)
+
+
+PAGE_HTML = '''
+
+One Time Pad - Unique, just for you
+
+
+{messages}
+
+
+Print this page and distribute the copies (along with the One Time Pad Cheat Sheet
+https://lrnsr.co/aY6m ) to all members of your group that you trust to receive your encrypted messages. Every
+person must have their OWN copy of this "One Time Pad" to encrypt and decrypt messages.
+
+Use each message ONLY ONCE. Cut off and burn each message from this paper as it is used.
+
+If you want more One Time Pads, simply go here to refresh the page:
+https://learningselfreliance.com/one_time_pad . The server will generate a unique page just for you. This page
+is not stored on the server, and cannot be retrieved once you close this window!
+
+To learn how to use this page, please visit: https://lrnsr.co/H7Za
+
+
+'''
+
+
+def generate_html() -> str:
+ """
+ Create an HTML One-Time Pad page. This page will have instructions on how to use the OTP.
+ """
+ messages = [generate_message() for _ in range(9)]
+ messages = '\n\n'.join(f'MESSAGE {i} {j} ' for i, j in enumerate(messages, 1))
+ return PAGE_HTML.format(messages=messages)
+
+
+def generate_pdf():
+ # Use False to return bytes, rather than saving to file.
+ return pdfkit.from_string(generate_html(), False)
+
+
+def validate_message(otp, error_class):
+ otp = otp.upper()
+ otp = remove_whitespace(otp)
+
+ if not all(i in OTP_CHARS for i in otp):
+ raise error_class()
+
+ return otp
+
+
+CHARS_LEN = len(OTP_CHARS)
+
+
+def encrypt_char(plaintext: str, otp: str) -> str:
+ calculated = (OTP_CHARS.index(plaintext) + OTP_CHARS.index(otp)) % CHARS_LEN
+ return OTP_CHARS[calculated]
+
+
+def decrypt_char(ciphertext: str, otp: str) -> str:
+ calculated = (OTP_CHARS.index(ciphertext) - OTP_CHARS.index(otp)) % CHARS_LEN
+ return OTP_CHARS[calculated]
+
+
+def encrypt_otp(otp: str, plaintext: str) -> Dict[str, str]:
+ """
+ Encrypt a plaintext message using an OTP.
+ """
+ otp = validate_message(otp, InvalidOTP)
+ plaintext = validate_message(plaintext, InvalidPlaintext)
+
+ if len(plaintext) > len(otp):
+ raise InvalidPlaintext('Plaintext is longer than OTP')
+
+ ciphertext = ''.join(encrypt_char(p, o) for p, o in zip(plaintext, otp))
+
+ data = dict(
+ ciphertext=format_message(ciphertext),
+ otp=format_message(otp),
+ plaintext=format_message(plaintext),
+ )
+
+ return data
+
+
+def decrypt_otp(otp: str, ciphertext: str) -> Dict[str, str]:
+ """
+ Decrypt an encrypted message that was encrypted with an OTP.
+ """
+ otp = validate_message(otp, InvalidOTP)
+ ciphertext = validate_message(ciphertext, InvalidCiphertext)
+
+ if len(ciphertext) > len(otp):
+ raise InvalidCiphertext('Ciphertext is longer than OTP')
+
+ plaintext = ''.join(decrypt_char(c, o) for c, o in zip(ciphertext, otp))
+ data = dict(
+ plaintext=format_message(plaintext),
+ ciphertext=format_message(ciphertext),
+ otp=format_message(otp),
+ )
+ return data
diff --git a/modules/otp/schema.py b/modules/otp/schema.py
new file mode 100644
index 00000000..c939502d
--- /dev/null
+++ b/modules/otp/schema.py
@@ -0,0 +1,11 @@
+from sanic_openapi import doc
+
+
+class EncryptOTPRequest:
+ otp = doc.String(required=True)
+ plaintext = doc.String(required=True)
+
+
+class DecryptOTPRequest:
+ otp = doc.String(required=True)
+ ciphertext = doc.String(required=True)
diff --git a/modules/otp/test/__init__.py b/modules/otp/test/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/otp/test/test_otp.py b/modules/otp/test/test_otp.py
new file mode 100644
index 00000000..6f694e25
--- /dev/null
+++ b/modules/otp/test/test_otp.py
@@ -0,0 +1,89 @@
+import pytest
+
+from modules.otp.common import format_message, encrypt_otp, decrypt_otp
+
+
+@pytest.mark.parametrize(
+ 'otp,plaintext,ciphertext',
+ [
+ ('', '', ''),
+ ('A', 'A', 'A'),
+ ('B', 'B', 'C'),
+ ('9', '9', '8'),
+ ('W', 'H', '3'),
+ ('W', 'H', '3'),
+ ('W8JD7', 'HELLO', '3CUOL'),
+ ('XMCKL', 'HELLO', '4QNVZ'),
+ (
+ # This message says: TEST THE ENCRYPTION OF A LONG MESSAGE THE SPACES SHOULDNT MATTER
+ # The message is shorter than the OTP, this is fine. The ciphertext should be the same
+ # length as the message.
+ 'V4YPT RCJLZ QZVSD 0U2E6 OOKNU\nZDH8F 4XQ76 OR6EK J0TRG 0G0HU\nS4BUJ',
+ 'TESTT HEENC RYPTI ONOFA LONGM\nESSAG ETHES PACES SHOUL DNTMA\nTTER',
+ 'E8G8C YGNY1 7NABL E7GJ6 Z2XT6\n3VZ8L 8GXBO 3R8I2 177BR 3TJTU\nBNFB'
+ ),
+ ],
+)
+def test_encrypt_otp(otp, plaintext, ciphertext):
+ result = encrypt_otp(otp, plaintext)
+ expected = dict(
+ otp=otp,
+ plaintext=plaintext,
+ ciphertext=ciphertext,
+ )
+ assert result == expected
+
+ # Whitespace is ignored
+ plaintext = f'\t {plaintext}'
+ otp = f'\n {otp} '
+ result = encrypt_otp(otp, plaintext)
+ expected = dict(
+ otp=otp.strip(),
+ plaintext=plaintext.strip(),
+ ciphertext=ciphertext,
+ )
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ 'otp,ciphertext,plaintext',
+ [
+ ('', '', ''),
+ ('A', 'A', 'A'),
+ ('B', 'C', 'B'),
+ ('W', '3', 'H'),
+ ('9', '8', '9'),
+ ('W8JD7', '3CUOL', 'HELLO'),
+ ('XMCKL', '4QNVZ', 'HELLO'),
+ (
+ 'V4YPT RCJLZ QZVSD 0U2E6 OOKNU\nZDH8F 4XQ76 OR6EK J0TRG 0G0HU\nS4BUJ',
+ 'E8G8C YGNY1 7NABL E7GJ6 Z2XT6\n3VZ8L 8GXBO 3R8I2 177BR 3TJTU\nBNFB',
+ 'TESTT HEENC RYPTI ONOFA LONGM\nESSAG ETHES PACES SHOUL DNTMA\nTTER',
+ ),
+ ],
+)
+def test_decrypt_otp(otp, ciphertext, plaintext):
+ result = decrypt_otp(otp, ciphertext)
+ expected = dict(
+ otp=otp,
+ plaintext=plaintext,
+ ciphertext=ciphertext,
+ )
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ 'message,expected',
+ [
+ ('ABC', 'ABC'),
+ ('ABCDEFG', 'ABCDE FG'),
+ ('SOMEMESSAGE', 'SOMEM ESSAG E'),
+ (
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890',
+ 'ABCDE FGHIJ KLMNO PQRST UVWXY\nZ0123 45678 90',
+ ),
+ ]
+)
+def test_format_message(message, expected):
+ result = format_message(message)
+ assert result == expected
diff --git a/modules/videos/__init__.py b/modules/videos/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/videos/api.py b/modules/videos/api.py
new file mode 100644
index 00000000..212c0f73
--- /dev/null
+++ b/modules/videos/api.py
@@ -0,0 +1,215 @@
+import asyncio
+import random
+from functools import wraps
+from http import HTTPStatus
+from multiprocessing import Event
+
+import requests
+from sanic import Blueprint, response
+from sanic.request import Request
+
+from wrolpi import before_startup
+from wrolpi.common import create_websocket_feed, get_sanic_url, \
+ wrol_mode_check, ProgressReporter, get_config
+from wrolpi.common import logger
+from wrolpi.root_api import add_blueprint, json_response
+from wrolpi.schema import validate_doc, JSONErrorResponse
+from .channel.api import channel_bp
+from .downloader import update_channels, download_all_missing_videos
+from .lib import process_video_meta_data, _refresh_videos, get_statistics, get_download_history
+from .schema import StreamResponse, \
+ FavoriteRequest, FavoriteResponse, VideosStatisticsResponse, DownloadHistoryResponse
+from .video.api import video_bp
+from .video.lib import set_video_favorite
+
+content_bp = Blueprint('Video Content', '/api/videos')
+bp = Blueprint('Videos', '/api/videos').group(
+ content_bp, # view and manage video content and settings
+ channel_bp, # view and manage channels
+ video_bp, # view videos
+)
+add_blueprint(bp)
+
+logger = logger.getChild(__name__)
+
+refresh_queue, refresh_event = create_websocket_feed('refresh', '/feeds/refresh', content_bp)
+
+
+@content_bp.post(':refresh')
+@content_bp.post(':refresh/')
+@validate_doc(
+ summary='Search for videos that have previously been downloaded and stored.',
+ produces=StreamResponse,
+ responses=[
+ (HTTPStatus.BAD_REQUEST, JSONErrorResponse),
+ ],
+)
+@wrol_mode_check
+async def refresh(_, link: str = None):
+ refresh_logger = logger.getChild('refresh')
+ stream_url = get_sanic_url(scheme='ws', path='/api/videos/feeds/refresh')
+
+ # Only one refresh can run at a time
+ if refresh_event.is_set():
+ return response.json({'error': 'Refresh already running', 'stream_url': stream_url}, HTTPStatus.CONFLICT)
+
+ refresh_event.set()
+
+ async def do_refresh():
+ try:
+ refresh_logger.info('refresh started')
+
+ channel_links = [link] if link else None
+ await refresh_videos(channel_links)
+
+ refresh_logger.info('refresh complete')
+ except Exception as e:
+ refresh_queue.put({'error': 'Refresh failed. See server logs.', 'message': str(e)})
+ raise
+ finally:
+ refresh_event.clear()
+
+ coro = do_refresh()
+ asyncio.ensure_future(coro)
+ refresh_logger.debug('do_refresh scheduled')
+ return response.json({'code': 'stream-started', 'stream_url': stream_url})
+
+
+download_queue, download_event = create_websocket_feed('download', '/feeds/download', content_bp)
+
+
+@content_bp.post(':download')
+@content_bp.post(':download/')
+@validate_doc(
+ summary='Update channel catalogs, download any missing videos',
+ produces=StreamResponse,
+ responses=[
+ (HTTPStatus.BAD_REQUEST, JSONErrorResponse)
+ ],
+)
+@wrol_mode_check
+async def download(_, link: str = None):
+ download_logger = logger.getChild('download')
+
+ stream_url = get_sanic_url(scheme='ws', path='/api/videos/feeds/download')
+ # Only one download can run at a time
+ if download_event.is_set():
+ return response.json({'error': 'download already running', 'stream_url': stream_url}, HTTPStatus.CONFLICT)
+ if refresh_event.is_set():
+ return response.json({'error': 'Refresh is running. Cannot download.'})
+
+ download_event.set()
+
+ async def do_download():
+ reporter = ProgressReporter(download_queue, 2)
+ reporter.set_progress_total(0, 1)
+ reporter.send_progress(0, 1, 'Download started')
+
+ try:
+ update_channels(reporter, link)
+ download_logger.info('Updated all channel catalogs')
+ download_all_missing_videos(reporter, link)
+ reporter.finish(1, 'All videos have been downloaded')
+
+ # Fill in any missing data for all videos.
+ reporter.message(0, 'Processing and cleaning files')
+ process_video_meta_data()
+ reporter.finish(0, 'Processing and cleaning complete')
+
+ download_logger.info('download complete')
+ except Exception as e:
+ logger.fatal(f'Download failed: {e}')
+ reporter.error(0, 'Download failed. See server logs.')
+ raise
+ finally:
+ download_event.clear()
+
+ coro = do_download()
+ asyncio.ensure_future(coro)
+ download_logger.debug('do_download scheduled')
+ return response.json({'code': 'stream-started', 'stream_url': stream_url})
+
+
+@wraps(_refresh_videos)
+async def refresh_videos(channel_links: list = None):
+ return _refresh_videos(refresh_queue, channel_links=channel_links)
+
+
+@content_bp.post(':favorite')
+@validate_doc(
+ summary='Toggle the favorite flag on a video',
+ consumes=FavoriteRequest,
+ produces=FavoriteResponse,
+ responses=[
+ (HTTPStatus.BAD_REQUEST, JSONErrorResponse)
+ ]
+)
+async def favorite(_: Request, data: dict):
+ _favorite = set_video_favorite(data['video_id'], data['favorite'])
+ ret = {'video_id': data['video_id'], 'favorite': _favorite}
+ return json_response(ret, HTTPStatus.OK)
+
+
+@content_bp.get('/statistics')
+@validate_doc(
+ summary='Retrieve video statistics',
+ produces=VideosStatisticsResponse,
+)
+async def statistics(_: Request):
+ ret = await get_statistics()
+ return json_response(ret, HTTPStatus.OK)
+
+
+@content_bp.get('/download_history')
+@validate_doc(
+ summary='Retrieve video download history',
+ produces=DownloadHistoryResponse,
+)
+async def download_history(_: Request):
+ history = await get_download_history()
+ ret = dict(history=history)
+ return json_response(ret, HTTPStatus.OK)
+
+
+MIN_DOWNLOAD_FREQUENCY = 60 * 60 * 4 # 4 hours
+MAX_DOWNLOAD_FREQUENCY = 60 * 60 * 12 # 12 hours
+PERIODIC_DOWNLOAD_EVENT = Event()
+
+
+async def _periodic_download(min_download_frequency: int, max_download_frequency: int):
+ """
+ Wait some amount of time, download, then schedule the next download.
+ """
+ sleep_seconds = random.randint(min_download_frequency, max_download_frequency)
+ logger.debug(f'Waiting {sleep_seconds} seconds before next periodic download')
+ await asyncio.sleep(sleep_seconds)
+
+ url = get_sanic_url(path='/api/videos:download')
+ resp = requests.post(url)
+ if resp.status_code != HTTPStatus.OK and resp.status_code != HTTPStatus.CONFLICT:
+ # Download failed and wasn't already running.
+ logger.warning(f'Periodic download failed with status_code={resp.status_code}')
+ logger.warning(f'Periodic download response={resp}')
+
+ # Schedule the next download
+ asyncio.ensure_future(_periodic_download(min_download_frequency, max_download_frequency))
+
+
+@before_startup
+def start_periodic_download():
+ """
+ Periodically download the videos.
+ """
+ if not PERIODIC_DOWNLOAD_EVENT.is_set():
+ PERIODIC_DOWNLOAD_EVENT.set()
+ logger.info(f'Starting periodic download')
+
+ config = get_config()
+ try:
+ min_download_frequency = int(config.get('min_download_frequency', MIN_DOWNLOAD_FREQUENCY))
+ max_download_frequency = int(config.get('max_download_frequency', MAX_DOWNLOAD_FREQUENCY))
+ except Exception:
+ logger.fatal(f'Failed to get min/max download frequencies from config!', exc_info=True)
+ raise
+
+ asyncio.ensure_future(_periodic_download(min_download_frequency, max_download_frequency))
diff --git a/modules/videos/captions.py b/modules/videos/captions.py
new file mode 100755
index 00000000..bd18d93a
--- /dev/null
+++ b/modules/videos/captions.py
@@ -0,0 +1,74 @@
+#! /usr/bin/env python3
+from pathlib import Path
+from typing import Generator, List, Union
+
+import srt
+import webvtt
+
+from wrolpi.common import logger, chunks
+from wrolpi.db import get_db_session
+from wrolpi.errors import UnknownCaptionFile
+from .common import get_absolute_video_caption
+from .models import Video
+
+
+def get_caption_text(caption_path: Union[str, Path]) -> Generator:
+ """
+ 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()
+ yield text
+ else:
+ with open(caption_path, 'rt') as fh:
+ contents = fh.read()
+ for subtitle in srt.parse(contents):
+ yield subtitle.content
+
+
+def get_unique_caption_lines(caption_path: Union[str, Path]) -> Generator:
+ """
+ Return all unique lines from each caption of a caption file.
+ """
+ last_line = None
+ for text in get_caption_text(caption_path):
+ for line in text.split('\n'):
+ if line and line != last_line:
+ last_line = line
+ yield line
+
+
+def process_captions(video: Video):
+ """
+ Parse and insert captions for a video record.
+ """
+ caption_path = get_absolute_video_caption(video)
+ if not caption_path.exists():
+ raise UnknownCaptionFile()
+ try:
+ lines = get_unique_caption_lines(str(caption_path))
+ block = '\n'.join(lines)
+ video.caption = block
+ return
+ except UnicodeDecodeError:
+ # Failed to decode the caption file
+ # TODO handle this error
+ pass
+ except webvtt.errors.MalformedFileError:
+ # File format is broken
+ pass
+ except webvtt.errors.MalformedCaptionError:
+ # Captions form is broken somehow
+ pass
+
+ logger.debug(f'Failed to parse caption file {caption_path}')
+
+
+async def insert_bulk_captions(video_ids: List[int]):
+ for video_ids in chunks(video_ids, 10):
+ with get_db_session(commit=True) as session:
+ for idx, video_id in enumerate(video_ids):
+ video = session.query(Video).filter_by(id=video_id).one()
+ process_captions(video)
+ logger.debug(f'Inserted {len(video_ids)} captions')
diff --git a/modules/videos/channel/__init__.py b/modules/videos/channel/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/videos/channel/api.py b/modules/videos/channel/api.py
new file mode 100644
index 00000000..bc0ce555
--- /dev/null
+++ b/modules/videos/channel/api.py
@@ -0,0 +1,104 @@
+import asyncio
+from http import HTTPStatus
+
+from sanic import response, Blueprint
+from sanic.request import Request
+
+from wrolpi.common import logger, wrol_mode_check, get_relative_to_media_directory, make_media_directory
+from wrolpi.root_api import json_response
+from wrolpi.errors import UnknownDirectory
+from wrolpi.schema import validate_doc, JSONErrorResponse
+from .lib import get_minimal_channels, delete_channel, update_channel, get_channel, create_channel
+from ..schema import ChannelsResponse, ChannelResponse, ChannelPostRequest, \
+ ChannelPostResponse, ChannelPutRequest, SuccessResponse
+
+channel_bp = Blueprint('Channel', url_prefix='/api/videos/channels')
+
+logger = logger.getChild(__name__)
+
+
+@channel_bp.get('/')
+@validate_doc(
+ summary='Get a list of all Channels',
+ produces=ChannelsResponse,
+)
+async def get_channels(_: Request):
+ channels = await get_minimal_channels()
+ return json_response({'channels': channels})
+
+
+@channel_bp.route('/', methods=['GET', 'OPTIONS'])
+@validate_doc(
+ summary='Get a Channel',
+ produces=ChannelResponse,
+ responses=(
+ (HTTPStatus.NOT_FOUND, JSONErrorResponse),
+ ),
+)
+def channel_get(_: Request, link: str):
+ channel = get_channel(link)
+ channel.pop('info_json')
+ return json_response({'channel': channel})
+
+
+@channel_bp.post('/')
+@validate_doc(
+ summary='Insert a Channel',
+ consumes=ChannelPostRequest,
+ responses=(
+ (HTTPStatus.CREATED, ChannelPostResponse),
+ (HTTPStatus.BAD_REQUEST, JSONErrorResponse),
+ ),
+)
+@wrol_mode_check
+def channel_post(_: Request, data: dict):
+ try:
+ data['directory'] = get_relative_to_media_directory(data['directory'])
+ except UnknownDirectory:
+ if data['mkdir']:
+ make_media_directory(data['directory'])
+ data['directory'] = get_relative_to_media_directory(data['directory'])
+ else:
+ raise
+
+ channel = create_channel(data)
+
+ # Refresh the videos asynchronously
+ from ..api import refresh_videos
+ coro = refresh_videos([channel['link']])
+ asyncio.ensure_future(coro)
+
+ return response.json({'success': 'Channel created successfully'}, HTTPStatus.CREATED,
+ {'Location': f'/api/videos/channels/{channel["link"]}'})
+
+
+@channel_bp.put('/')
+@channel_bp.patch('/')
+@validate_doc(
+ summary='Update a Channel',
+ consumes=ChannelPutRequest,
+ produces=SuccessResponse,
+ responses=(
+ (HTTPStatus.NOT_FOUND, JSONErrorResponse),
+ (HTTPStatus.BAD_REQUEST, JSONErrorResponse),
+ ),
+)
+@wrol_mode_check
+def channel_update(_: Request, link: str, data: dict):
+ channel = update_channel(data, link)
+ return response.raw('', HTTPStatus.NO_CONTENT,
+ headers={'Location': f'/api/videos/channels/{channel.link}'})
+
+
+@channel_bp.delete('/')
+@validate_doc(
+ summary='Delete a Channel',
+ produces=SuccessResponse,
+ responses=(
+ (HTTPStatus.NOT_FOUND, JSONErrorResponse),
+ ),
+)
+@wrol_mode_check
+def channel_delete(_: Request, link: str):
+ delete_channel(link)
+ return response.raw('', HTTPStatus.NO_CONTENT)
diff --git a/modules/videos/channel/lib.py b/modules/videos/channel/lib.py
new file mode 100644
index 00000000..51913e1d
--- /dev/null
+++ b/modules/videos/channel/lib.py
@@ -0,0 +1,168 @@
+from typing import List, Dict
+
+from sqlalchemy.orm.exc import NoResultFound
+
+from wrolpi.common import sanitize_link, run_after, get_relative_to_media_directory, make_media_directory
+from wrolpi.db import get_db_session, get_db_curs
+from wrolpi.errors import UnknownChannel, UnknownDirectory, APIError, ValidationError
+from ..common import check_for_channel_conflicts
+from ..lib import save_channels_config
+from ..models import Channel
+
+DEFAULT_DOWNLOAD_FREQUENCY = 60 * 60 * 24 * 7 # weekly
+DEFAULT_DOWNLOAD_TIMEOUT = 60.0 * 10.0 # Ten minutes
+
+
+async def get_minimal_channels() -> List[dict]:
+ """
+ Get the minimum amount of information necessary about all channels.
+ """
+ with get_db_curs() as curs:
+ # Get all channels, even if they don't have videos.
+ stmt = '''
+ SELECT
+ c.id, name, link, directory, url, download_frequency, info_date, next_download
+ FROM
+ channel AS c
+ ORDER BY LOWER(name)
+ '''
+ curs.execute(stmt)
+ channels = list(map(dict, curs.fetchall()))
+
+ video_counts = await get_channels_video_count()
+
+ for channel in channels:
+ channel_id = channel['id']
+ channel['video_count'] = video_counts[channel_id]
+
+ return channels
+
+
+async def get_channels_video_count() -> Dict[int, int]:
+ """
+ Add video counts to all channels
+ """
+ with get_db_curs() as curs:
+ stmt = 'SELECT id FROM channel'
+ curs.execute(stmt)
+ # Get all channel IDs, start them with a count of 0.
+ video_counts = {int(i['id']): 0 for i in curs.fetchall()}
+
+ stmt = '''
+ SELECT
+ c.id, COUNT(v.id) AS video_count
+ FROM
+ channel AS c
+ LEFT JOIN video AS v ON v.channel_id = c.id
+ WHERE
+ v.video_path IS NOT NULL
+ GROUP BY 1
+ '''
+ curs.execute(stmt)
+ # Replace all the counts of those channels with videos.
+ video_counts.update({int(i['id']): int(i['video_count']) for i in curs.fetchall()})
+ return video_counts
+
+
+@run_after(save_channels_config)
+def delete_channel(link):
+ with get_db_session(commit=True) as session:
+ try:
+ channel = session.query(Channel).filter_by(link=link).one()
+ except NoResultFound:
+ raise UnknownChannel()
+
+ channel.delete_with_videos()
+
+
+@run_after(save_channels_config)
+def update_channel(data, link):
+ with get_db_session(commit=True) as session:
+ try:
+ channel = session.query(Channel).filter_by(link=link).one()
+ except NoResultFound:
+ raise UnknownChannel()
+
+ # Only update directory if it was empty
+ if data.get('directory') and not channel.directory:
+ try:
+ data['directory'] = get_relative_to_media_directory(data['directory'])
+ except UnknownDirectory:
+ if data['mkdir']:
+ make_media_directory(data['directory'])
+ data['directory'] = get_relative_to_media_directory(data['directory'])
+ else:
+ raise
+
+ if 'directory' in data:
+ data['directory'] = str(data['directory'])
+
+ if 'download_frequency' in data:
+ try:
+ data['download_frequency'] = int(data['download_frequency'])
+ except ValueError:
+ raise APIError('Invalid download frequency')
+
+ if data.get('match_regex') in ('None', 'null'):
+ data['match_regex'] = None
+
+ # Verify that the URL/Name/Link aren't taken
+ check_for_channel_conflicts(
+ session,
+ id_=channel.id,
+ url=data.get('url'),
+ name=data.get('name'),
+ link=data.get('link'),
+ directory=data.get('directory'),
+ )
+
+ # Apply the changes now that we've OK'd them
+ channel.update(data)
+
+ return channel
+
+
+def get_channel(link) -> dict:
+ """
+ Get a Channel by it's `link`. Raise UnknownChannel if it does not exist.
+ """
+ with get_db_session() as session:
+ try:
+ channel = session.query(Channel).filter_by(link=link).one()
+ except NoResultFound:
+ raise UnknownChannel()
+ return channel.dict()
+
+
+@run_after(save_channels_config)
+def create_channel(data: dict) -> dict:
+ """
+ Create a new Channel. Check for conflicts with existing Channels.
+ """
+ with get_db_session(commit=True) as session:
+ try:
+ # Verify that the URL/Name/Link aren't taken
+ check_for_channel_conflicts(
+ session,
+ url=data.get('url'),
+ name=data['name'],
+ link=sanitize_link(data['name']),
+ directory=str(data['directory']),
+ )
+ except APIError as e:
+ raise ValidationError() from e
+
+ channel = Channel(
+ name=data['name'],
+ url=data.get('url'),
+ match_regex=data.get('match_regex'),
+ link=sanitize_link(data['name']),
+ directory=str(data['directory']),
+ download_frequency=data.get('download_frequency', DEFAULT_DOWNLOAD_FREQUENCY),
+ )
+ session.add(channel)
+ session.commit()
+ session.flush()
+ session.refresh(channel)
+
+ return channel.dict()
diff --git a/modules/videos/channel/test/__init__.py b/modules/videos/channel/test/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/videos/channel/test/test_api.py b/modules/videos/channel/test/test_api.py
new file mode 100644
index 00000000..aea45baf
--- /dev/null
+++ b/modules/videos/channel/test/test_api.py
@@ -0,0 +1,468 @@
+import json
+import pathlib
+import tempfile
+from datetime import timedelta
+from http import HTTPStatus
+from queue import Empty
+
+import mock
+import pytest
+
+from modules.videos.api import refresh_queue
+from modules.videos.lib import upsert_video
+from modules.videos.models import Channel, Video
+from modules.videos.test.common import create_channel_structure
+from wrolpi.dates import now
+from wrolpi.db import get_db_session
+from wrolpi.errors import UnknownFile
+from wrolpi.root_api import api_app
+from wrolpi.test.common import wrap_test_db, get_all_messages_in_queue, TestAPI
+
+
+class TestVideoAPI(TestAPI):
+
+ @wrap_test_db
+ @create_channel_structure(
+ {
+ 'Foo': ['vid1.mp4'],
+ 'Bar': ['vid2.mp4'],
+ 'Baz': [],
+ }
+ )
+ def test_get_channels(self, tempdir):
+ request, response = api_app.test_client.get('/api/videos/channels')
+ assert response.status_code == HTTPStatus.OK
+ # Channels are sorted by name
+ self.assertDictContains(response.json['channels'][0], {'id': 2, 'name': 'Bar', 'video_count': 1})
+ self.assertDictContains(response.json['channels'][1], {'id': 3, 'name': 'Baz', 'video_count': 0})
+ self.assertDictContains(response.json['channels'][2], {'id': 1, 'name': 'Foo', 'video_count': 1})
+
+ @wrap_test_db
+ def test_channel(self):
+ with tempfile.TemporaryDirectory() as media_dir, \
+ mock.patch('wrolpi.common.get_media_directory', lambda: pathlib.Path(media_dir)):
+ channel_directory = tempfile.TemporaryDirectory(dir=media_dir).name
+ pathlib.Path(channel_directory).mkdir()
+ new_channel = dict(
+ directory=channel_directory,
+ match_regex='asdf',
+ name='Example Channel 1',
+ url='https://example.com/channel1',
+ )
+
+ # Channel doesn't exist
+ request, response = api_app.test_client.get('/api/videos/channels/examplechannel1')
+ assert response.status_code == HTTPStatus.NOT_FOUND, f'Channel exists: {response.json}'
+
+ # Create it
+ request, response = api_app.test_client.post('/api/videos/channels', data=json.dumps(new_channel))
+ assert response.status_code == HTTPStatus.CREATED
+ location = response.headers['Location']
+ request, response = api_app.test_client.get(location)
+ created = response.json['channel']
+ self.assertIsNotNone(created)
+ self.assertIsNotNone(created['id'])
+
+ # Channel directory should be relative to the media directory
+ assert not pathlib.Path(created['directory']).is_absolute(), \
+ f'Channel directory is absolute: {created["directory"]}'
+
+ # Get the link that was computed
+ new_channel['link'] = response.json['channel']['link']
+ assert new_channel['link']
+
+ # Can't create it again
+ request, response = api_app.test_client.post('/api/videos/channels', data=json.dumps(new_channel))
+ assert response.status_code == HTTPStatus.BAD_REQUEST
+
+ # Update it
+ new_channel['name'] = 'Example Channel 2'
+ new_channel['directory'] = str(new_channel['directory'])
+ request, response = api_app.test_client.put(location, data=json.dumps(new_channel))
+ assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
+ request, response = api_app.test_client.get(location)
+ assert response.status_code == HTTPStatus.OK
+ self.assertDictContains(response.json['channel'], {
+ 'id': 1,
+ 'name': 'Example Channel 2',
+ 'directory': channel_directory,
+ 'match_regex': 'asdf',
+ 'url': 'https://example.com/channel1',
+ })
+
+ # Patch it
+ patch = {'name': 'new name'}
+ request, response = api_app.test_client.patch(location, data=json.dumps(patch))
+ assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
+ request, response = api_app.test_client.get(location)
+ assert response.status_code == HTTPStatus.OK
+ self.assertDictContains(response.json['channel'], {
+ 'id': 1,
+ 'name': 'new name',
+ 'directory': channel_directory,
+ 'match_regex': 'asdf',
+ 'url': 'https://example.com/channel1',
+ })
+
+ # Can't update channel that doesn't exist
+ request, response = api_app.test_client.put('/api/videos/channels/doesnt_exist',
+ data=json.dumps(new_channel))
+ assert response.status_code == HTTPStatus.NOT_FOUND
+
+ # Delete the new channel
+ request, response = api_app.test_client.delete(location)
+ assert response.status_code == HTTPStatus.NO_CONTENT
+
+ # Cant delete it again
+ request, response = api_app.test_client.delete(location)
+ assert response.status_code == HTTPStatus.NOT_FOUND
+
+ @wrap_test_db
+ def test_channel_conflicts(self):
+ with tempfile.TemporaryDirectory() as media_dir, \
+ mock.patch('wrolpi.common.get_media_directory', lambda: pathlib.Path(media_dir)):
+ channel_directory = tempfile.TemporaryDirectory(dir=media_dir).name
+ pathlib.Path(channel_directory).mkdir()
+ new_channel = dict(
+ directory=channel_directory,
+ match_regex='asdf',
+ name='Example Channel 1',
+ url='https://example.com/channel1',
+ )
+
+ def _post_channel(channel):
+ return api_app.test_client.post('/api/videos/channels', data=json.dumps(channel))
+
+ # Create it
+ request, response = _post_channel(new_channel)
+ assert response.status_code == HTTPStatus.CREATED
+
+ # Name is an exact match
+ channel_directory2 = tempfile.TemporaryDirectory(dir=media_dir).name
+ pathlib.Path(channel_directory2).mkdir()
+ new_channel = dict(
+ directory=channel_directory2,
+ name='Example Channel 1',
+ )
+ request, response = _post_channel(new_channel)
+ assert response.status_code == HTTPStatus.BAD_REQUEST
+ assert response.json == {'error': 'Could not validate the contents of the request', 'code': 10,
+ 'cause': {'error': 'The channel name is already taken.', 'code': 5}}
+
+ # Name matches when converted to link
+ channel_directory2 = tempfile.TemporaryDirectory(dir=media_dir).name
+ pathlib.Path(channel_directory2).mkdir()
+ new_channel = dict(
+ directory=channel_directory2,
+ name='Example channel 1',
+ )
+ request, response = _post_channel(new_channel)
+ assert response.status_code == HTTPStatus.BAD_REQUEST
+ assert response.json == {'error': 'Could not validate the contents of the request', 'code': 10,
+ 'cause': {'code': 11, 'error': 'Channel link already used by another channel'}}
+
+ # Directory was already used
+ new_channel = dict(
+ directory=channel_directory,
+ name='name is fine',
+ )
+ request, response = _post_channel(new_channel)
+ assert response.status_code == HTTPStatus.BAD_REQUEST
+ assert response.json == {'error': 'Could not validate the contents of the request', 'code': 10,
+ 'cause': {'code': 7, 'error': 'The directory is already used by another channel.'}}
+
+ # URL is already used
+ channel_directory3 = tempfile.TemporaryDirectory(dir=media_dir).name
+ pathlib.Path(channel_directory3).mkdir()
+ new_channel = dict(
+ directory=channel_directory3,
+ name='name is fine',
+ url='https://example.com/channel1',
+ )
+ request, response = _post_channel(new_channel)
+ assert response.status_code == HTTPStatus.BAD_REQUEST
+ assert response.json == {'error': 'Could not validate the contents of the request', 'code': 10,
+ 'cause': {'code': 6, 'error': 'The URL is already used by another channel.'}}
+
+ @wrap_test_db
+ def test_channel_empty_url_doesnt_conflict(self):
+ """Two channels with empty URLs shouldn't conflict"""
+ with tempfile.TemporaryDirectory() as media_dir, \
+ mock.patch('wrolpi.common.get_media_directory', lambda: pathlib.Path(media_dir)):
+ channel_directory = tempfile.TemporaryDirectory(dir=media_dir).name
+ pathlib.Path(channel_directory).mkdir()
+
+ new_channel = {
+ 'name': 'Fooz',
+ 'directory': channel_directory,
+ }
+ request, response = api_app.test_client.post('/api/videos/channels', data=json.dumps(new_channel))
+ assert response.status_code == HTTPStatus.CREATED, response.json
+ location = response.headers['Location']
+
+ channel_directory2 = tempfile.TemporaryDirectory(dir=media_dir).name
+ pathlib.Path(channel_directory2).mkdir()
+ new_channel = {
+ 'name': 'Barz',
+ 'directory': channel_directory2,
+ }
+ request, response = api_app.test_client.post('/api/videos/channels', data=json.dumps(new_channel))
+ assert response.status_code == HTTPStatus.CREATED, response.json
+ assert location != response.headers['Location']
+
+ @wrap_test_db
+ def test_refresh_videos(self):
+ # There should be no messages until a refresh is called.
+ pytest.raises(Empty, refresh_queue.get_nowait)
+
+ # Setup a fake channel directory.
+ with get_db_session() as session, \
+ tempfile.TemporaryDirectory() as channel_dir:
+ channel_path = pathlib.Path(channel_dir)
+
+ # Files in subdirectories should be found and handled properly.
+ subdir = channel_path / 'subdir'
+ subdir.mkdir()
+
+ # These are the types of files that will be found first.
+ vid1 = subdir / 'channel name_20000101_abcdefghijk_title.mp4'
+ vid1.touch()
+ vid2 = channel_path / 'channel name_20000102_bcdefghijkl_title.webm'
+ vid2.touch()
+
+ # This video is named the same as vid1, except for the file extension. Its possible that this was
+ # downloaded later, or maybe the old video format has fallen out of favor. WROLPi should ignore this
+ # duplicate file.
+ vid1_alt = subdir / 'channel name_20000101_abcdefghijk_title.webm'
+ vid1_alt.touch()
+
+ # These files are associated with the video files above, and should be found "near" them.
+ poster1 = subdir / 'channel name_20000101_abcdefghijk_title.jpg'
+ poster1.touch()
+ poster2 = channel_path / 'channel name_20000102_bcdefghijkl_title.jpg'
+ poster2.touch()
+
+ # Create a channel, associate videos with it.
+ channel = Channel(directory=channel_dir, link='foo', name='foo')
+ session.add(channel)
+ session.flush()
+ session.refresh(channel)
+ video1 = upsert_video(session, vid1, channel)
+ video2 = upsert_video(session, vid2, channel)
+ session.commit()
+ self.assertEqual({i.video_path for i in channel.videos},
+ {pathlib.Path('subdir/' + vid1.name), pathlib.Path(vid2.name)})
+
+ # Poster files were found.
+ self.assertEqual(video1.poster_path, pathlib.Path('subdir/' + poster1.name))
+ self.assertEqual(video2.poster_path, pathlib.Path(poster2.name))
+
+ # Add a bogus file, this should be removed during the refresh
+ self.assertNotIn('foo', {i.video_path for i in channel.videos})
+ session.add(Video(video_path='foo', channel_id=channel.id))
+ session.flush()
+ session.refresh(channel)
+ self.assertIn('foo', {str(i.video_path) for i in channel.videos})
+ self.assertEqual(len(channel.videos), 3)
+
+ # Add a video that isn't in the DB, it should be found and any meta files associated with it
+ vid3 = pathlib.Path(channel_path / 'channel name_20000103_cdefghijklm_title.flv')
+ vid3.touch()
+ description3 = pathlib.Path(channel_path / 'channel name_20000103_cdefghijklm_title.description')
+ description3.touch()
+
+ # An orphan meta-file should be ignored. This shouldn't show up anywhere. But, it shouldn't be deleted.
+ poster3 = pathlib.Path(channel_path / 'channel name_20000104_defghijklmn_title.jpg')
+ poster3.touch()
+
+ # Finally, call the refresh. Again, it should remove the "foo" video, then discover this 3rd video
+ # file and it's description.
+ api_app.test_client.post('/api/videos:refresh')
+
+ # Bogus file was removed
+ self.assertNotIn('foo', {i.video_path for i in channel.videos})
+
+ # Final channel video list we built
+ expected = {
+ ('subdir/' + vid1.name, 'subdir/' + poster1.name, None), # in a subdirectory, no description
+ (vid2.name, poster2.name, None), # no description
+ (vid3.name, None, description3.name), # no poster
+ }
+
+ def str_or_none(i):
+ return str(i) if i else None
+
+ self.assertEqual(
+ {(str_or_none(i.video_path), str_or_none(i.poster_path), str_or_none(i.description_path))
+ for i in channel.videos},
+ expected
+ )
+
+ assert poster3.is_file(), 'Orphan jpg file was deleted!'
+
+ # During the refresh process, messages are pushed to a queue, make sure there are messages there
+ messages = get_all_messages_in_queue(refresh_queue)
+ assert 'refresh-started' in [i.get('code') for i in messages]
+
+ @wrap_test_db
+ def test_get_channel_videos(self):
+ with get_db_session(commit=True) as session:
+ channel1 = Channel(name='Foo', link='foo')
+ channel2 = Channel(name='Bar', link='bar')
+ session.add(channel1)
+ session.add(channel2)
+ session.flush()
+ session.refresh(channel1)
+ session.refresh(channel2)
+
+ # Channels don't have videos yet
+ d = dict(channel_link=channel1.link)
+ request, response = api_app.test_client.post(f'/api/videos/search', data=json.dumps(d))
+ assert response.status_code == HTTPStatus.OK
+ assert len(response.json['videos']) == 0
+
+ with get_db_session(commit=True) as session:
+ vid1 = Video(title='vid1', channel_id=channel2.id, video_path='foo')
+ vid2 = Video(title='vid2', channel_id=channel1.id, video_path='foo')
+ session.add(vid1)
+ session.add(vid2)
+
+ # Videos are gotten by their respective channels
+ request, response = api_app.test_client.post(f'/api/videos/search', data=json.dumps(d))
+ assert response.status_code == HTTPStatus.OK
+ assert len(response.json['videos']) == 1
+ assert response.json['totals']['videos'] == 1
+ self.assertDictContains(response.json['videos'][0], dict(id=2, title='vid2', channel_id=channel1.id))
+
+ d = dict(channel_link=channel2.link)
+ request, response = api_app.test_client.post(f'/api/videos/search', data=json.dumps(d))
+ assert response.status_code == HTTPStatus.OK
+ assert len(response.json['videos']) == 1
+ self.assertDictContains(response.json['videos'][0], dict(id=1, title='vid1', channel_id=channel2.id))
+
+ @wrap_test_db
+ def test_get_video(self):
+ """
+ Test that you get can information about a video. Test that video file can be gotten.
+ """
+
+ def raise_unknown_file(_):
+ raise UnknownFile()
+
+ with get_db_session(commit=True) as session, \
+ mock.patch('modules.videos.common.get_absolute_video_info_json', raise_unknown_file):
+ channel = Channel(name='Foo', link='foo')
+ session.add(channel)
+ session.flush()
+ session.refresh(channel)
+ now_ = now()
+ session.add(Video(title='vid1', channel_id=channel.id, upload_date=now_))
+ session.add(Video(title='vid2', channel_id=channel.id, upload_date=now_ + timedelta(seconds=1)))
+
+ # Test that a 404 is returned when no video exists
+ _, response = api_app.test_client.get('/api/videos/video/10')
+ assert response.status_code == HTTPStatus.NOT_FOUND, response.json
+ assert response.json == {'code': 1, 'api_error': 'The video could not be found.', 'message': ''}
+
+ # Get the video info we inserted
+ _, response = api_app.test_client.get('/api/videos/video/1')
+ assert response.status_code == HTTPStatus.OK, response.json
+ self.assertDictContains(response.json['video'], {'title': 'vid1'})
+
+ # The next video is included.
+ self.assertIsNone(response.json['prev'])
+ self.assertDictContains(response.json['next'], {'title': 'vid2'})
+
+ @wrap_test_db
+ def test_get_channel_videos_pagination(self):
+ with get_db_session(commit=True) as session:
+ channel1 = Channel(name='Foo', link='foo')
+ session.add(channel1)
+ session.flush()
+ session.refresh(channel1)
+
+ for i in range(50):
+ session.add(Video(title=f'Foo.Video{i}', channel_id=channel1.id, video_path='foo'))
+
+ channel2 = Channel(name='Bar', link='bar')
+ session.add(channel2)
+ session.flush()
+ session.refresh(channel2)
+ session.add(Video(title='vid2', channel_id=channel2.id, video_path='foo'))
+
+ # Get first, second, third, and empty pages of videos.
+ tests = [
+ # (offset, video_count)
+ (0, 20),
+ (20, 20),
+ (40, 10),
+ (50, 0),
+ ]
+ last_ids = []
+ for offset, video_count in tests:
+ d = dict(channel_link=channel1.link, order_by='id', offset=offset)
+ _, response = api_app.test_client.post(f'/api/videos/search', data=json.dumps(d))
+ assert response.status_code == HTTPStatus.OK
+ assert len(response.json['videos']) == video_count
+ current_ids = [i['id'] for i in response.json['videos']]
+ assert current_ids != last_ids, f'IDs are unchanged current_ids={current_ids}'
+ last_ids = current_ids
+
+ @wrap_test_db
+ def test_video_search(self):
+ """
+ Test that videos can be searched and that their order is by their textsearch rank.
+ """
+ # These captions have repeated letters so they will be higher in the ranking
+ videos = [
+ ('1', 'b b b b e d d'),
+ ('2', '2 b b b d'),
+ ('3', 'b b'),
+ ('4', 'b e e'),
+ ('5', ''),
+ ]
+ with get_db_session(commit=True) as session:
+ for title, caption in videos:
+ session.add(Video(title=title, caption=caption, video_path='foo'))
+
+ def do_search(search_str, limit=20):
+ d = json.dumps({'search_str': search_str, 'limit': limit})
+ _, resp = api_app.test_client.post('/api/videos/search', data=d)
+ return resp
+
+ def search_is_as_expected(resp, expected):
+ assert resp.status_code == HTTPStatus.OK
+ response_ids = [i['id'] for i in resp.json['videos']]
+ assert response_ids == expected
+ assert resp.json['totals']['videos'] == len(expected)
+
+ # Repeated runs should return the same result
+ for _ in range(2):
+ # Only videos with a b are returned, ordered by the amount of b's
+ response = do_search('b')
+ search_is_as_expected(response, [1, 2, 3, 4])
+
+ # Only two captions have e
+ response = do_search('e')
+ search_is_as_expected(response, [4, 1])
+
+ # Only two captions have d
+ response = do_search('d')
+ search_is_as_expected(response, [1, 2])
+
+ # 5 can be gotten by it's title
+ response = do_search('5')
+ search_is_as_expected(response, [5])
+
+ # only video 1 has e and d
+ response = do_search('e d')
+ search_is_as_expected(response, [1])
+
+ # video 1 and 4 have b and e, but 1 has more
+ response = do_search('b e')
+ search_is_as_expected(response, [1, 4])
+
+ # Check totals are correct even with a limit
+ response = do_search('b', 2)
+ assert [i['id'] for i in response.json['videos']] == [1, 2]
+ assert response.json['totals']['videos'] == 4
diff --git a/modules/videos/channel/test/test_channel.py b/modules/videos/channel/test/test_channel.py
new file mode 100644
index 00000000..34b3d695
--- /dev/null
+++ b/modules/videos/channel/test/test_channel.py
@@ -0,0 +1,21 @@
+import pathlib
+import unittest
+
+from modules.videos.models import Channel
+from modules.videos.test.common import create_channel_structure
+from wrolpi.db import get_db_session
+from wrolpi.test.common import wrap_test_db
+
+
+class TestChannel(unittest.TestCase):
+
+ @wrap_test_db
+ @create_channel_structure(
+ {
+ 'Foo': ['vid1.mp4'],
+ }
+ )
+ def test_channel(self, tempdir):
+ with get_db_session() as session:
+ channel = session.query(Channel).one()
+ self.assertIsInstance(channel.directory, pathlib.Path)
diff --git a/modules/videos/common.py b/modules/videos/common.py
new file mode 100644
index 00000000..1ae65acd
--- /dev/null
+++ b/modules/videos/common.py
@@ -0,0 +1,533 @@
+import json
+import os
+import pathlib
+import subprocess
+import tempfile
+from functools import partial
+from pathlib import Path
+from typing import Union, Tuple, List, Set, Iterable
+
+import PIL
+from PIL import Image
+from sqlalchemy.orm import Session
+
+from wrolpi import before_startup
+from wrolpi.common import sanitize_link, logger, CONFIG_PATH, get_config, iterify, chunks, get_media_directory, \
+ get_absolute_media_path, minimize_dict
+from wrolpi.db import get_db_session, get_db_curs
+from wrolpi.errors import UnknownFile, UnknownDirectory, ChannelNameConflict, ChannelURLConflict, \
+ ChannelLinkConflict, ChannelDirectoryConflict
+from wrolpi.vars import DOCKERIZED, DEFAULT_FILE_PERMISSIONS
+from .models import Channel, Video
+
+logger = logger.getChild(__name__)
+
+REQUIRED_OPTIONS = ['name', 'directory']
+MINIMUM_CHANNEL_KEYS = {'id', 'name', 'directory', 'url', 'video_count', 'link'}
+MINIMUM_INFO_JSON_KEYS = {'description', 'view_count'}
+MINIMUM_VIDEO_KEYS = {'id', 'title', 'upload_date', 'duration', 'channel', 'channel_id', 'favorite', 'size',
+ 'poster_path', 'caption_path', 'video_path', 'info_json', 'channel', 'viewed', 'source_id'}
+VIDEO_QUERY_LIMIT = 20
+VIDEO_QUERY_MAX_LIMIT = 100
+# These are the supported video formats. These are in order of their preference.
+VIDEO_EXTENSIONS = ('mp4', 'ogg', 'webm', 'flv')
+
+
+def load_downloader_config() -> dict:
+ config = get_config()
+ downloader = config['downloader']
+ return downloader
+
+
+def load_channels_config() -> dict:
+ config = get_config()
+ channels = config['channels']
+ return channels
+
+
+def get_allowed_limit(limit: int) -> int:
+ """
+ Return the video limit int if it was passed, unless it is over the maximum limit. If no limit is provided, return
+ the default limit.
+ """
+ if limit:
+ return min(int(limit), VIDEO_QUERY_MAX_LIMIT)
+ return VIDEO_QUERY_LIMIT
+
+
+class ConfigError(Exception):
+ pass
+
+
+@before_startup
+def import_videos_config():
+ """Import channel settings to the DB. Existing channels will be updated."""
+ try:
+ config = load_channels_config()
+
+ with get_db_session(commit=True) as session:
+ for section in config:
+ for option in (i for i in REQUIRED_OPTIONS if i not in config[section]):
+ raise ConfigError(f'Channel "{section}" is required to have "{option}"')
+
+ name, directory = config[section]['name'], config[section]['directory']
+
+ link = sanitize_link(name)
+ matches = session.query(Channel).filter(Channel.link == link)
+ if not matches.count():
+ # Channel not yet in the DB, add it
+ channel = Channel(link=link)
+ else:
+ channel = matches.one()
+
+ # Only name and directory are required
+ channel.name = name
+ channel.directory = str(directory)
+
+ channel.calculate_duration = config[section].get('calculate_duration')
+ channel.download_frequency = config[section].get('download_frequency')
+ channel.generate_posters = config[section].get('generate_posters')
+ channel.match_regex = config[section].get('match_regex')
+ channel.skip_download_videos = list(set(config[section].get('skip_download_videos', {})))
+ channel.url = config[section].get('url')
+
+ session.add(channel)
+
+ # Set favorite Videos of this Channel.
+ favorites = config[section].get('favorites', {})
+ if favorites:
+ videos = session.query(Video).filter(Video.source_id.in_(favorites.keys()))
+ for video in videos:
+ video.favorite = favorites[video.source_id]['favorite']
+ except Exception:
+ logger.warning('Failed to load channels config!', exc_info=True)
+
+
+VALID_VIDEO_KINDS = {'video', 'caption', 'poster', 'description', 'info_json'}
+
+
+def get_absolute_video_path(video: Video, kind: str = 'video') -> Path:
+ if kind not in VALID_VIDEO_KINDS:
+ raise Exception(f'Unknown video path kind {kind}')
+ directory = get_absolute_media_path(video.channel.directory)
+ path = getattr(video, f'{kind}_path')
+ if directory and path:
+ return directory / path
+ raise UnknownFile()
+
+
+get_absolute_video_caption = partial(get_absolute_video_path, kind='caption')
+get_absolute_video_poster = partial(get_absolute_video_path, kind='poster')
+get_absolute_video_description = partial(get_absolute_video_path, kind='description')
+get_absolute_video_info_json = partial(get_absolute_video_path, kind='info_json')
+
+
+def get_absolute_video_files(video: Video) -> List[Path]:
+ """
+ Get all video files that exist.
+ """
+ getters = [
+ get_absolute_video_description,
+ get_absolute_video_caption,
+ get_absolute_video_poster,
+ get_absolute_video_info_json,
+ get_absolute_video_path,
+ ]
+
+ def _get():
+ for getter in getters:
+ try:
+ yield getter(video)
+ except UnknownFile:
+ pass
+
+ return list(_get())
+
+
+def get_video_info_json(video) -> Union[dict, None]:
+ """Get the info_json object from a video's meta-file. Return an empty dict if not possible."""
+ if not video.channel or not video.channel.directory:
+ return
+
+ try:
+ path = get_absolute_video_info_json(video)
+ if path.exists():
+ with open(str(path), 'rb') as fh:
+ contents = json.load(fh)
+ return contents
+ except UnknownFile:
+ return
+ except UnknownDirectory:
+ return
+
+
+def any_extensions(filename: str, extensions: Iterable = ()):
+ """
+ Return True only if the file name ends with any of the possible extensions.
+ Matches lower or upper case of the extension.
+ """
+ return any(filename.lower().endswith(ext) for ext in extensions)
+
+
+match_video_extensions = partial(any_extensions, extensions=VIDEO_EXTENSIONS)
+
+
+def generate_video_paths(directory: Union[str, pathlib.Path], relative_to=None) -> Tuple[str, pathlib.Path]:
+ """
+ Generate a list of video paths in the provided directory.
+ """
+ directory = pathlib.Path(directory)
+
+ for child in directory.iterdir():
+ if child.is_file() and match_video_extensions(child.name):
+ child = child.absolute()
+ if relative_to:
+ yield child.relative_to(relative_to)
+ else:
+ yield child
+ elif child.is_dir():
+ yield from generate_video_paths(child)
+
+
+@iterify(set)
+def remove_duplicate_video_paths(paths: Iterable[Path]) -> Set[Path]:
+ """
+ Remove any duplicate paths from a given list. Duplicate is defined as any file that shares the EXACT same
+ name as another file, but with a different extension. If a duplicate is found, only yield one of the paths. The
+ path that will be yielded will be whichever is first in the VIDEO_EXTESIONS tuple.
+
+ i.e.
+ >>> remove_duplicate_video_paths([Path('one.mp4'), Path('two.mp4'), Path('one.ogg')])
+ {'one.mp4, 'two.mp4'}
+ """
+ new_paths = {}
+
+ # Group all paths by their name, but without their extension.
+ for path in set(paths):
+ name, _, _ = path.name.rpartition(path.suffix)
+ try:
+ new_paths[name].append(path)
+ except KeyError:
+ new_paths[name] = [path]
+
+ # Yield back the first occurrence of the preferred format for each video.
+ for name, paths in new_paths.items():
+ if len(paths) == 1:
+ # This should be the most common case. Most videos will only have one format.
+ yield paths[0]
+ else:
+ path_strings = [i.name for i in paths]
+ for ext in VIDEO_EXTENSIONS:
+ try:
+ index = path_strings.index(f'{name}.{ext}')
+ yield paths[index]
+ break
+ except ValueError:
+ # That extension is not in the paths.
+ pass
+ else:
+ # Somehow no format was found, yield back the first one. This is probably caused by an unexpected video
+ # format in the paths.
+ yield sorted(paths)[0]
+
+
+def check_for_channel_conflicts(session: Session, id_=None, url=None, name=None, link=None, directory=None):
+ """
+ Search for any channels that conflict with the provided args, raise a relevant exception if any conflicts are found.
+ """
+ if not any([id_, url, name, link, directory]):
+ raise Exception('Cannot search for channel with no arguments')
+
+ logger.debug(f'Checking for channel conflicts: id={id_} url={url} name={name} link={link} directory={directory}')
+
+ # A channel can't conflict with itself
+ if id_:
+ base_where = session.query(Channel).filter(Channel.id != id_)
+ else:
+ base_where = session.query(Channel)
+
+ if url:
+ conflicts = base_where.filter(Channel.url == url)
+ if list(conflicts):
+ raise ChannelURLConflict()
+ if name:
+ conflicts = base_where.filter(Channel.name == name)
+ if list(conflicts):
+ raise ChannelNameConflict()
+ if link:
+ conflicts = base_where.filter(Channel.link == link)
+ if list(conflicts):
+ raise ChannelLinkConflict()
+ if directory:
+ conflicts = base_where.filter(Channel.directory == directory)
+ if list(conflicts):
+ raise ChannelDirectoryConflict()
+
+
+def verify_config():
+ """
+ Check that the local.yaml config file exists. Check some basic expectations about it's structure.
+ """
+ if not pathlib.Path(CONFIG_PATH).exists():
+ raise Exception(f'local.yaml does not exist, looked here: {CONFIG_PATH}')
+
+ media_directory = get_media_directory()
+ error = None
+ if not media_directory.is_absolute():
+ error = f'Media directory is not absolute! {media_directory} '
+ elif not media_directory.exists():
+ error = f'Media directory does not exist! {media_directory} '
+
+ if error and DOCKERIZED:
+ error += 'Have you mounted your media directory in docker-compose.yml?'
+ elif error:
+ error += 'Have you updated your local.yaml with the media_directory?'
+
+ if error:
+ raise Exception(error)
+
+
+def get_matching_directories(path: Union[str, Path]) -> List[str]:
+ """
+ Return a list of directory strings that start with the provided path. If the path is a directory, return it's
+ subdirectories, if the directory contains no subdirectories, return the directory.
+ """
+ path = str(path)
+
+ if os.path.isdir(path):
+ # The provided path is a directory, return it's subdirectories, or itself if no subdirectories exist
+ paths = [os.path.join(path, i) for i in os.listdir(path)]
+ paths = sorted(i for i in paths if os.path.isdir(i))
+ if len(paths) == 0:
+ return [path]
+ return paths
+
+ head, tail = os.path.split(path)
+ paths = os.listdir(head)
+ paths = [os.path.join(head, i) for i in paths]
+ pattern = path.lower()
+ paths = sorted(i for i in paths if os.path.isdir(i) and i.lower().startswith(pattern))
+
+ return paths
+
+
+def replace_extension(path: pathlib.Path, new_ext) -> pathlib.Path:
+ """Swap the extension of a file's path.
+
+ Example:
+ >>> foo = pathlib.Path('foo.bar')
+ >>> replace_extension(foo, 'baz')
+ 'foo.baz'
+ """
+ parent = path.parent
+ existing_ext = path.suffix
+ path = str(path)
+ name, _, _ = path.rpartition(existing_ext)
+ path = pathlib.Path(str(parent / name) + new_ext)
+ return path
+
+
+def generate_video_poster(video_path: Path):
+ """
+ Create a poster (aka thumbnail) next to the provided video_path.
+ """
+ poster_path = replace_extension(video_path, '.jpg')
+ cmd = ['/usr/bin/ffmpeg', '-n', '-i', str(video_path), '-f', 'mjpeg', '-vframes', '1', '-ss', '00:00:05.000',
+ str(poster_path)]
+ try:
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ logger.info(f'Generated poster at {poster_path}')
+ except subprocess.CalledProcessError as e:
+ logger.warning(f'FFMPEG poster generation failed with stdout: {e.stdout.decode()}')
+ logger.warning(f'FFMPEG poster generation failed with stdout: {e.stderr.decode()}')
+ raise
+
+
+async def generate_bulk_posters(video_ids: List[int]):
+ """
+ Generate all posters for the provided videos. Update the video object with the new jpg file location. Do not
+ clobber existing jpg files.
+ """
+ logger.info(f'Generating {len(video_ids)} video posters')
+ for video_ids in chunks(video_ids, 10):
+ with get_db_session(commit=True) as session:
+ videos = session.query(Video).filter(Video.id.in_(video_ids))
+ for video in videos:
+ video_path = get_absolute_video_path(video)
+
+ poster_path = replace_extension(video_path, '.jpg')
+ if not poster_path.exists():
+ generate_video_poster(video_path)
+ channel_dir = get_absolute_media_path(video.channel.directory)
+ poster_path = poster_path.relative_to(channel_dir)
+ video.poster_path = str(poster_path)
+ logger.info('Done generating video posters')
+
+
+def convert_image(existing_path: Path, destination_path: Path, ext: str = 'jpeg'):
+ """
+ Convert an image from one format to another. Remove the existing image file. This will safely overwrite an image
+ if the existing path is the same as the destination path.
+ """
+ with tempfile.NamedTemporaryFile(dir=destination_path.parent, delete=False) as fh:
+ img = Image.open(existing_path).convert('RGB')
+ img.save(fh.name, ext)
+
+ existing_path.unlink()
+ os.rename(fh.name, destination_path)
+ os.chmod(destination_path, DEFAULT_FILE_PERMISSIONS)
+
+
+def is_valid_poster(poster_path: Path) -> bool:
+ """Return True only if poster file exists, and is of a JPEG format."""
+ if poster_path.is_file():
+ try:
+ img = Image.open(poster_path)
+ return img.format == 'JPEG'
+ except PIL.UnidentifiedImageError:
+ logger.error(f'Failed to identify poster: {poster_path}', exc_info=True)
+ pass
+
+ return False
+
+
+def bulk_validate_posters(video_ids: List[int]):
+ """
+ Replace all posters for the provided videos if a video's poster is not a JPEG format.
+ """
+ logger.info(f'Validating {len(video_ids)} video posters')
+ for video_ids in chunks(video_ids, 10):
+ for video_id in video_ids:
+ with get_db_session(commit=True) as session:
+ video = session.query(Video).filter_by(id=video_id).one()
+ channel = video.channel
+
+ poster_path: Path = get_absolute_video_poster(video)
+ new_poster_path = poster_path.with_suffix('.jpg')
+
+ if poster_path != new_poster_path and new_poster_path.exists():
+ # Destination JPEG already exists (it may have the wrong format), lets overwrite it with a valid
+ # JPEG.
+ poster_path.unlink()
+ poster_path = new_poster_path
+
+ if not is_valid_poster(poster_path):
+ # Poster is not valid, convert it and place it in the new location.
+ try:
+ convert_image(poster_path, new_poster_path)
+ logger.info(f'Converted invalid poster {poster_path} to {new_poster_path}')
+ except Exception:
+ logger.error(f'Failed to convert invalid poster {poster_path} to {new_poster_path}',
+ exc_info=True)
+ else:
+ logger.debug(f'Poster was already valid: {new_poster_path}')
+
+ channel_dir = get_absolute_media_path(channel.directory)
+
+ # Update the video with the new poster path. Mark it as validated.
+ video.poster_path = str(new_poster_path.relative_to(channel_dir))
+ video.validated_poster = True
+ logger.info('Done validating video posters.')
+
+
+def get_video_duration(video_path: Path) -> int:
+ """
+ Get the duration of a video in seconds. Do this using ffprobe.
+ """
+ cmd = ['/usr/bin/ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of',
+ 'default=noprint_wrappers=1:nokey=1', str(video_path)]
+
+ try:
+ proc = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ except subprocess.CalledProcessError as e:
+ logger.warning(f'FFPROBE failed to get duration with stdout: {e.stdout.decode()}')
+ logger.warning(f'FFPROBE failed to get duration with stderr: {e.stderr.decode()}')
+ raise
+ stdout = proc.stdout.decode()
+ duration = int(float(stdout.strip()))
+ return duration
+
+
+async def get_bulk_video_info_json(video_ids: List[int]):
+ """
+ Get and save the info_json data for each video provided.
+ """
+ logger.info(f'Getting {len(video_ids)} video info_json meta data.')
+ for video_ids in chunks(video_ids, 10):
+ with get_db_session(commit=True) as session:
+ for video_id in video_ids:
+ video = session.query(Video).filter_by(id=video_id).one()
+ logger.debug(f'Getting video info_json data: {video}')
+ video_path = get_absolute_video_path(video)
+
+ try:
+ info_json = video.get_info_json()
+ video.view_count = info_json.get('view_count') if info_json else None
+ video.duration = info_json.get('duration') if info_json else get_video_duration(video_path)
+ except Exception:
+ logger.warning(f'Unable to get meta data of {video}', exc_info=True)
+ logger.info('Done getting video info_json meta data.')
+
+
+async def get_bulk_video_size(video_ids: List[int]):
+ """
+ Get and save the size for each video provided.
+ """
+ logger.info(f'Getting {len(video_ids)} video sizes.')
+ for video_ids in chunks(video_ids, 10):
+ with get_db_session(commit=True) as session:
+ for video_id in video_ids:
+ video = session.query(Video).filter_by(id=video_id).one()
+ logger.debug(f'Getting video size: {video.id} {video.video_path}')
+ video_path = get_absolute_video_path(video)
+
+ size = video_path.stat().st_size
+ video.size = size
+ logger.info('Done getting video sizes')
+
+
+def update_view_count(channel_id: int):
+ """
+ Update view_count for all Videos in a channel.
+ """
+ with get_db_session() as session:
+ channel = session.query(Channel).filter_by(id=channel_id).one()
+ info = channel.info_json
+
+ if not info:
+ logger.info(f'No info_json for channel {channel.name}')
+ return
+
+ view_counts = [{'id': i['id'], 'view_count': i['view_count']} for i in info['entries']]
+ logger.info(f'Updating {len(view_counts)} view counts for channel {channel.name}')
+ view_counts_str = json.dumps(view_counts)
+
+ with get_db_curs(commit=True) as curs:
+ stmt = '''
+ WITH source AS (select * from json_to_recordset(%s::json) as (id text, view_count int))
+ UPDATE video
+ SET view_count = s.view_count
+ FROM source as s
+ WHERE source_id=s.id AND channel_id=%s
+ '''
+ curs.execute(stmt, (view_counts_str, channel_id))
+
+
+minimize_channel = partial(minimize_dict, keys=MINIMUM_CHANNEL_KEYS)
+minimize_video_info_json = partial(minimize_dict, keys=MINIMUM_INFO_JSON_KEYS)
+_minimize_video = partial(minimize_dict, keys=MINIMUM_VIDEO_KEYS)
+
+
+def minimize_video(video: dict) -> dict:
+ """
+ Return a Video dictionary that contains only the key/values typically used. Minimize the Channel and info_json,
+ if they are present.
+ """
+ video = _minimize_video(video)
+
+ if video.get('channel'):
+ video['channel'] = minimize_channel(video['channel'])
+ if video.get('info_json'):
+ video['info_json'] = minimize_video_info_json(video['info_json'])
+
+ return video
diff --git a/modules/videos/downloader.py b/modules/videos/downloader.py
new file mode 100755
index 00000000..b7e65fe9
--- /dev/null
+++ b/modules/videos/downloader.py
@@ -0,0 +1,309 @@
+#! /usr/bin/env python3
+import pathlib
+import re
+from queue import Queue
+from random import shuffle
+from typing import Tuple, List, Dict
+
+from sqlalchemy import or_
+from sqlalchemy.orm.exc import NoResultFound
+from youtube_dl import YoutubeDL
+
+from wrolpi.common import logger, ProgressReporter, run_after, get_absolute_media_path
+from wrolpi.dates import now, today
+from wrolpi.db import get_db_session, get_db_curs
+from wrolpi.errors import UnknownChannel, ChannelURLEmpty
+from .common import load_downloader_config, update_view_count
+from .lib import save_channels_config, upsert_video
+from .models import Video, Channel
+
+logger = logger.getChild(__name__)
+ydl_logger = logger.getChild('youtube-dl')
+
+YDL = YoutubeDL()
+YDL.params['logger'] = ydl_logger
+YDL.add_default_info_extractors()
+
+
+def update_channel(channel=None, link: str = None):
+ """
+ Connect to the Channel's host website and pull a catalog of all videos. Insert any new videos into the DB.
+
+ It is expected that any missing videos will be downloaded later.
+ """
+ with get_db_session() as session:
+ if not channel:
+ channel = session.query(Channel).filter_by(link=link).one()
+
+ logger.info(f'Downloading video list for {channel.name} at {channel.url} This may take several minutes.')
+ info = YDL.extract_info(channel.url, download=False, process=False)
+ if 'url' in info:
+ url = info['url']
+ info = YDL.extract_info(url, download=False, process=False)
+
+ # Resolve all entries to dictionaries.
+ entries = info['entries'] = list(info['entries'])
+
+ # Youtube-DL may hand back a list of URLs, lets use the "Uploads" URL, if available.
+ try:
+ entries[0]['id']
+ except Exception:
+ for entry in entries:
+ if entry['title'] == 'Uploads':
+ logger.info('Youtube-DL gave back a list of URLs, found the "Uploads" URL and using it.')
+ info = YDL.extract_info(entry['url'], download=False, process=False)
+ 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 = session.query(Channel).filter_by(id=channel.id).one()
+
+ channel.info_json = info
+ channel.info_date = now()
+ channel.increment_next_download()
+
+ with get_db_curs() as curs:
+ # Insert any new videos.
+ query = 'SELECT source_id FROM video WHERE channel_id=%s AND source_id IS NOT NULL'
+ curs.execute(query, (channel.id,))
+ known_source_ids = {i[0] for i in curs.fetchall()}
+
+ new_source_ids = all_source_ids.difference(known_source_ids)
+
+ logger.info(f'Got {len(new_source_ids)} new videos for channel {channel.name}')
+ channel_id = channel.id
+ for source_id in new_source_ids:
+ session.add(Video(source_id=source_id, channel_id=channel_id))
+
+ # Update all view counts using the latest from the Channel's info_json.
+ update_view_count(channel_id)
+
+
+def update_channels(reporter: ProgressReporter, link: str = None):
+ """Update all information for each channel. (No downloads performed)"""
+
+ with get_db_session() as session:
+ if session.query(Channel).count() == 0:
+ raise UnknownChannel('No channels exist yet')
+
+ if link:
+ try:
+ channel = session.query(Channel).filter_by(link=link).one()
+ except NoResultFound:
+ raise UnknownChannel(f'No channel with link: {link}')
+ channels = [channel, ]
+ else:
+ channels = session.query(Channel).filter(
+ Channel.url != None, # noqa
+ Channel.url != '',
+ or_(
+ Channel.next_download == None, # noqa
+ Channel.next_download <= today(),
+ )
+ ).all()
+
+ if len(channels) == 0:
+ logger.warning(f'All channels are up to date')
+
+ reporter.set_progress_total(0, len(channels))
+ reporter.send_progress(0, 0, f'{len(channels)} channels scheduled for update')
+
+ # Randomize downloading of channels.
+ shuffle(channels)
+
+ logger.debug(f'Getting info for {len(channels)} channels')
+ for idx, channel in enumerate(channels):
+ reporter.send_progress(0, idx, f'Getting video list for {channel.name}')
+ try:
+ update_channel(channel)
+ except Exception:
+ logger.critical(f'Unable to get video list for {channel.name}', exc_info=True)
+ continue
+
+ if channels:
+ reporter.send_progress(0, len(channels), 'Done downloading video lists')
+ else:
+ reporter.finish(0, 'Done downloading video lists')
+
+
+def _find_all_missing_videos(link: str = None) -> List[Tuple]:
+ """
+ Get all Video entries which don't have the required media files (i.e. hasn't been downloaded). Restrict to a
+ single channel if "link" is provided.
+ """
+ with get_db_curs() as curs:
+ # Get all channels by default.
+ where = ''
+ params = ()
+
+ if link:
+ # Restrict by channel when link is provided.
+ query = 'SELECT id FROM channel WHERE link = %s'
+ curs.execute(query, (link,))
+ channel_id = curs.fetchall()[0][0]
+ where = 'AND channel_id = %s'
+ params = (channel_id,)
+
+ query = f'''
+ SELECT
+ video.id, video.source_id, video.channel_id
+ FROM
+ video
+ LEFT JOIN channel ON channel.id = video.channel_id
+ WHERE
+ channel.url IS NOT NULL
+ AND channel.url != ''
+ AND source_id IS NOT NULL
+ {where}
+ AND channel_id IS NOT NULL
+ AND (video_path IS NULL OR video_path = '' OR poster_path IS NULL OR poster_path = '')
+ '''
+ curs.execute(query, params)
+ missing_videos = list(curs.fetchall())
+ return missing_videos
+
+
+def find_all_missing_videos(link: str = None) -> Tuple[dict, dict]:
+ """
+ Find all videos that don't have a video file, but are found in the DB (taken from the channel's info_json).
+
+ Yields a Channel Dict object, our Video id, and the "entry" of the video from the channel's info_json['entries'].
+ """
+ with get_db_session() as session:
+ if link:
+ try:
+ channel = session.query(Channel).filter_by(link=link).one()
+ except NoResultFound:
+ raise UnknownChannel(f'No channel with link: {link}')
+ if not channel.url:
+ raise ChannelURLEmpty('No URL for this channel')
+ channels = [channel, ]
+ else:
+ channels = session.query(Channel).filter(Channel.info_json != None).all() # noqa
+
+ # Get all channels while in the db context.
+ channels = list(channels)
+
+ channels = {i.id: i for i in channels}
+
+ match_regexen = {i: re.compile(j.match_regex) for i, j in channels.items() if j.match_regex}
+
+ # Convert the channel video entries into a form that allows them to be quickly retrieved without searching through
+ # the entire entries list.
+ channels_entries = {}
+ for id_, channel in channels.items():
+ channels_entries[id_] = {i['id']: i for i in channel.info_json['entries']}
+
+ missing_videos = _find_all_missing_videos(link)
+
+ for id_, source_id, channel_id in missing_videos:
+ channel = channels[channel_id]
+
+ if channel.skip_download_videos and source_id in channel.skip_download_videos:
+ # This video has been marked to skip.
+ continue
+
+ try:
+ missing_video = channels_entries[channel_id][source_id]
+ except KeyError:
+ logger.warning(f'Video {id_} / {source_id} is not in {channel.name} info_json')
+ continue
+
+ match_regex: re.compile = match_regexen.get(channel_id)
+ 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.
+ yield channel, id_, missing_video
+
+
+def download_video(channel: Channel, video: Dict) -> Tuple[pathlib.Path, Dict]:
+ """
+ Download a video (and associated posters/etc) to it's channel's directory.
+
+ :param channel:
+ :param video: A YoutubeDL info entry dictionary
+ :return:
+ """
+ # YoutubeDL expects specific options, add onto the default options
+ config = load_downloader_config()
+ options = dict(config)
+ directory = get_absolute_media_path(channel.directory)
+ options['outtmpl'] = f'{directory}/{config["file_name_format"]}'
+
+ ydl = YoutubeDL(options)
+ ydl.add_default_info_extractors()
+ source_id = video['id']
+ url = f'https://www.youtube.com/watch?v={source_id}'
+ entry = ydl.extract_info(url, download=True, process=True)
+ final_filename = ydl.prepare_filename(entry)
+ final_filename = pathlib.Path(final_filename)
+ return final_filename, entry
+
+
+UNRECOVERABLE_ERRORS = {
+ '404: Not Found',
+ 'requires payment',
+ 'Content Warning',
+ 'Did not get any data blocks',
+ 'Sign in',
+ 'This live stream recording is not available.',
+ 'members-only content',
+}
+
+
+def _skip_download(error):
+ """Return True if the error is unrecoverable and the video should be skipped in the future."""
+ error_str = str(error)
+ for msg in UNRECOVERABLE_ERRORS:
+ if msg in error_str:
+ return True
+ return False
+
+
+@run_after(save_channels_config)
+def download_all_missing_videos(reporter: ProgressReporter, link: str = None):
+ """Find any videos identified by the info packet that haven't yet been downloaded, download them."""
+ missing_videos = list(find_all_missing_videos(link))
+ reporter.set_progress_total(1, len(missing_videos))
+ reporter.message(1, f'Found {len(missing_videos)} missing videos.')
+
+ for idx, (channel, id_, missing_video) in enumerate(missing_videos):
+ reporter.send_progress(1, idx, f'Downloading {channel.name}: {missing_video["title"]}')
+ try:
+ video_path, info_json = download_video(channel, missing_video)
+ except Exception as e:
+ logger.warning(f'Failed to download "{missing_video["title"]}"', exc_info=e)
+ if _skip_download(e):
+ # The video failed to download, and the error will never be fixed. Skip it forever.
+ source_id = missing_video.get('id')
+ logger.warning(f'Adding video "{source_id}" to skip list for this channel. WROLPi will not '
+ f'attempt to download it again.')
+
+ with get_db_session(commit=True) as session:
+ channel = session.query(Channel).filter_by(id=channel.id).one()
+ channel.add_video_to_skip_list(source_id)
+
+ reporter.error(1, f'Failed to download "{missing_video["title"]}", see server logs...')
+ continue
+
+ with get_db_session(commit=True) as session:
+ upsert_video(session, video_path, channel, id_=id_, info_json=info_json)
+
+ reporter.finish(1, 'All videos are downloaded')
+
+
+def main(args=None):
+ """Find and download any missing videos. Parse any arguments passed by the cmd-line."""
+ q = Queue()
+ reporter = ProgressReporter(q, 2)
+ update_channels(reporter)
+ download_all_missing_videos(reporter)
+ return 0
diff --git a/modules/videos/lib.py b/modules/videos/lib.py
new file mode 100644
index 00000000..9d3d0c08
--- /dev/null
+++ b/modules/videos/lib.py
@@ -0,0 +1,433 @@
+import asyncio
+import json
+import pathlib
+import re
+from multiprocessing.queues import Queue
+from typing import Tuple
+from uuid import uuid1
+
+from sqlalchemy.orm import Session
+
+from wrolpi.common import ProgressReporter, save_settings_config, get_absolute_media_path
+from wrolpi.common import logger
+from wrolpi.db import get_db_curs, get_db_session
+from .captions import insert_bulk_captions, process_captions
+from .common import generate_bulk_posters, get_bulk_video_info_json, get_bulk_video_size, \
+ generate_video_paths, remove_duplicate_video_paths, bulk_validate_posters, \
+ update_view_count
+from .models import Channel, Video
+
+logger = logger.getChild(__name__)
+
+
+def refresh_channel_video_captions() -> bool:
+ with get_db_curs() as curs:
+ query = 'SELECT id FROM video WHERE caption IS NULL AND caption_path IS NOT NULL'
+ curs.execute(query)
+ missing_captions = [i for (i,) in curs.fetchall()]
+
+ if missing_captions:
+ coro = insert_bulk_captions(missing_captions)
+ asyncio.ensure_future(coro)
+ logger.info('Scheduled insert_bulk_captions()')
+ return True
+ else:
+ logger.info('No missing captions to process.')
+ return False
+
+
+def refresh_channel_generate_posters() -> bool:
+ with get_db_curs() as curs:
+ query = 'SELECT id FROM video WHERE video_path IS NOT NULL AND poster_path IS NULL'
+ curs.execute(query)
+ missing_posters = [i for (i,) in curs.fetchall()]
+
+ if missing_posters:
+ coro = generate_bulk_posters(missing_posters)
+ asyncio.ensure_future(coro)
+ logger.info('Scheduled generate_bulk_posters()')
+ return True
+ else:
+ logger.info('No missing posters to generate.')
+ return False
+
+
+def convert_invalid_posters() -> bool:
+ """
+ Searches the DB for all videos with an invalid poster type (i.e. webp) and converts them to JPEGs. A video with a
+ valid poster will be marked as such in it's column "validated_poster".
+ """
+ with get_db_curs() as curs:
+ query = "SELECT id FROM video WHERE poster_path IS NOT NULL AND validated_poster = FALSE"
+ curs.execute(query)
+ invalid_posters = [i for (i,) in curs.fetchall()]
+
+ if invalid_posters:
+ async def _():
+ return bulk_validate_posters(invalid_posters)
+
+ coro = _()
+ asyncio.ensure_future(coro)
+ logger.info('Scheduled bulk_replace_invalid_posters()')
+ return True
+ else:
+ logger.info('No invalid posters to replace.')
+ return False
+
+
+def refresh_channel_info_json() -> bool:
+ """
+ Fill in Video columns that are extracted from the info_json.
+ """
+ with get_db_curs() as curs:
+ query = 'SELECT id FROM video WHERE video_path IS NOT NULL AND ' \
+ '(duration IS NULL OR view_count IS NULL)'
+ curs.execute(query)
+ missing_duration = [i for (i,) in curs.fetchall()]
+
+ if missing_duration:
+ coro = get_bulk_video_info_json(missing_duration)
+ asyncio.ensure_future(coro)
+ logger.info('Scheduled get_bulk_video_duration()')
+ return True
+ else:
+ logger.info('No videos missing duration.')
+ return False
+
+
+def refresh_channel_calculate_size() -> bool:
+ with get_db_curs() as curs:
+ query = 'SELECT id FROM video WHERE video_path IS NOT NULL AND size IS NULL'
+ curs.execute(query)
+ missing_size = [i for (i,) in curs.fetchall()]
+
+ if missing_size:
+ coro = get_bulk_video_size(missing_size)
+ asyncio.ensure_future(coro)
+ logger.info('Scheduled get_bulk_video_size()')
+ return True
+ else:
+ logger.info('No videos missing size.')
+ return False
+
+
+def process_video_meta_data():
+ """
+ Search for any videos missing meta data, fill in that data.
+ """
+ refresh_channel_video_captions()
+ refresh_channel_generate_posters()
+ convert_invalid_posters()
+ refresh_channel_info_json()
+ refresh_channel_calculate_size()
+
+
+def refresh_channel_videos(channel: Channel, reporter: ProgressReporter):
+ """
+ Find all video files in a channel's directory. Add any videos not in the DB to the DB.
+ """
+ # This function is hard to predict, so we will simply progress in chunks :(
+ reporter.set_progress_total(1, 7)
+ reporter.send_progress(1, 0, 'Preparing channel.')
+
+ # Set the idempotency key so we can remove any videos not touched during this search
+ with get_db_curs(commit=True) as curs:
+ curs.execute('UPDATE video SET idempotency=NULL WHERE channel_id=%s', (channel.id,))
+
+ reporter.send_progress(1, 1, 'Finding all videos, checking for duplicates.')
+
+ idempotency = str(uuid1())
+ directory = get_absolute_media_path(channel.directory)
+
+ # A set of absolute paths that exist in the file system
+ possible_new_paths = generate_video_paths(directory)
+ possible_new_paths = remove_duplicate_video_paths(possible_new_paths)
+
+ reporter.send_progress(1, 2, 'Matching all videos to the database.')
+
+ # Update all videos that match the current video paths
+ relative_new_paths = [str(i.relative_to(directory)) for i in possible_new_paths]
+ with get_db_curs(commit=True) as curs:
+ query = 'UPDATE video SET idempotency = %s WHERE channel_id = %s AND video_path = ANY(%s) RETURNING video_path'
+ curs.execute(query, (idempotency, channel.id, relative_new_paths))
+ existing_paths = {i for (i,) in curs.fetchall()}
+
+ reporter.send_progress(1, 3)
+
+ # Get the paths for any video not yet in the DB
+ # (paths in DB are relative, but we need to pass an absolute path)
+ new_videos = {p for p in possible_new_paths if str(p.relative_to(directory)) not in existing_paths}
+
+ reporter.send_progress(1, 4, f'Inserting {len(new_videos)} new videos.')
+
+ for video_path in new_videos:
+ with get_db_session(commit=True) as session:
+ upsert_video(session, pathlib.Path(video_path), channel, idempotency=idempotency)
+ logger.debug(f'{channel.name}: Added {video_path}')
+
+ reporter.send_progress(1, 5, 'Deleting unnecessary video entries.')
+
+ with get_db_curs(commit=True) as curs:
+ curs.execute('DELETE FROM video WHERE channel_id=%s AND idempotency IS NULL RETURNING id', (channel.id,))
+ deleted_count = len(curs.fetchall())
+
+ if deleted_count:
+ deleted_status = f'Deleted {deleted_count} video records from channel {channel.name}'
+ logger.info(deleted_status)
+
+ logger.info(f'{channel.name}: {len(new_videos)} new videos, {len(existing_paths)} already existed. ')
+
+ reporter.send_progress(1, 6, f'Updating video view counts')
+
+ update_view_count(channel.id)
+
+ reporter.send_progress(1, 7, f'Processed all videos for {channel.name}')
+
+
+def _refresh_videos(q: Queue, channel_links: list = None):
+ """
+ Find any videos in the channel directories and add them to the DB. Delete DB records of any videos not in the
+ file system.
+
+ Yields status updates to be passed to the UI.
+
+ :return:
+ """
+ logger.info('Refreshing video files')
+ with get_db_session() as session:
+ reporter = ProgressReporter(q, 2)
+ reporter.code('refresh-started')
+ reporter.set_progress_total(0, session.query(Channel).count())
+
+ if channel_links:
+ channels = session.query(Channel).filter(Channel.link.in_(channel_links))
+ else:
+ channels = session.query(Channel).all()
+
+ channels = list(channels)
+
+ if not channels and channel_links:
+ raise Exception(f'No channels match links(s): {channel_links}')
+ elif not channels:
+ raise Exception(f'No channels in DB. Have you created any?')
+
+ for idx, channel in enumerate(channels):
+ reporter.send_progress(0, idx, f'Checking {channel.name} directory for new videos')
+ refresh_channel_videos(channel, reporter)
+
+ # Fill in any missing data for all videos.
+ process_video_meta_data()
+
+ reporter.send_progress(0, len(channels), 'All videos refreshed.')
+ reporter.code('refresh-complete')
+
+
+def get_channels_config(session: Session) -> dict:
+ """
+ Create a dictionary that contains all the Channels from the DB.
+ """
+ channels = session.query(Channel).order_by(Channel.link).all()
+ channels = {i.link: i.config_view() for i in channels}
+ return dict(channels=channels)
+
+
+def save_channels_config(session=None):
+ """
+ Pull the Channel information from the DB, save it to the config.
+ """
+ if session:
+ config = get_channels_config(session)
+ else:
+ with get_db_session() as session:
+ config = get_channels_config(session)
+ save_settings_config(config)
+
+
+async def get_statistics():
+ with get_db_curs() as curs:
+ curs.execute('''
+ SELECT
+ -- total videos
+ COUNT(id) AS "videos",
+ -- total videos that are marked as favorite
+ COUNT(id) FILTER (WHERE favorite IS NOT NULL) AS "favorites",
+ -- total videos downloaded over the past week/month/year
+ COUNT(id) FILTER (WHERE upload_date >= current_date - interval '1 week') AS "week",
+ COUNT(id) FILTER (WHERE upload_date >= current_date - interval '1 month') AS "month",
+ COUNT(id) FILTER (WHERE upload_date >= current_date - interval '1 year') AS "year",
+ -- sum of all video lengths in seconds
+ COALESCE(SUM(duration), 0) AS "sum_duration",
+ -- sum of all video file sizes
+ COALESCE(SUM(size), 0)::BIGINT AS "sum_size",
+ -- largest video
+ COALESCE(MAX(size), 0) AS "max_size"
+ FROM
+ video
+ WHERE
+ video_path IS NOT NULL
+ ''')
+ video_stats = dict(curs.fetchone())
+
+ # Get the total videos downloaded every month for the past two years.
+ curs.execute('''
+ SELECT
+ DATE_TRUNC('month', months.a),
+ COUNT(id)::BIGINT,
+ SUM(size)::BIGINT AS "size"
+ FROM
+ generate_series(
+ date_trunc('month', current_date) - interval '2 years',
+ date_trunc('month', current_date) - interval '1 month',
+ '1 month'::interval) AS months(a),
+ video
+ WHERE
+ video.upload_date >= date_trunc('month', months.a)
+ AND video.upload_date < date_trunc('month', months.a) + interval '1 month'
+ AND video.upload_date IS NOT NULL
+ AND video.video_path IS NOT NULL
+ GROUP BY
+ 1
+ ORDER BY
+ 1
+ ''')
+ monthly_videos = [dict(i) for i in curs.fetchall()]
+
+ historical_stats = dict(monthly_videos=monthly_videos)
+ historical_stats['average_count'] = (sum(i['count'] for i in monthly_videos) // len(monthly_videos)) \
+ if monthly_videos else 0
+ historical_stats['average_size'] = (sum(i['size'] for i in monthly_videos) // len(monthly_videos)) \
+ if monthly_videos else 0
+
+ curs.execute('''
+ SELECT
+ COUNT(id) AS "channels"
+ FROM
+ channel
+ ''')
+ channel_stats = dict(curs.fetchone())
+ ret = dict(statistics=dict(
+ videos=video_stats,
+ channels=channel_stats,
+ historical=historical_stats,
+ ))
+ return ret
+
+
+async def get_download_history():
+ """
+ Get the info_date for all channels that have been downloaded. Order by the most recent download first.
+ """
+ query = '''
+ SELECT
+ link, name, info_date
+ FROM
+ channel
+ WHERE
+ info_date IS NOT NULL
+ ORDER BY channel.info_date DESC
+ '''
+ with get_db_curs() as curs:
+ curs.execute(query)
+ results = [dict(i) for i in curs.fetchall()]
+ return results
+
+
+NAME_PARSER = re.compile(r'(.*?)_((?:\d+?)|(?:NA))_(?:(.{11})_)?(.*)\.'
+ r'(jpg|webp|flv|mp4|part|info\.json|description|webm|..\.srt|..\.vtt)')
+
+
+def upsert_video(session: Session, video_path: pathlib.Path, channel: Channel, idempotency: str = None,
+ skip_captions=False,
+ id_: str = None, info_json: dict = None) -> Video:
+ """
+ Insert a video into the DB. Also, find any meta-files near the video file and store them on the video row.
+
+ If id_ is provided, update that entry.
+ """
+ channel_dir = get_absolute_media_path(channel.directory)
+ poster_path, description_path, caption_path, info_json_path = find_meta_files(video_path, relative_to=channel_dir)
+
+ # Video paths should be relative to the channel's directory
+ if video_path.is_absolute():
+ video_path = video_path.relative_to(channel_dir)
+
+ name_match = NAME_PARSER.match(video_path.name)
+ _ = upload_date = source_id = title = ext = None
+ if name_match:
+ _, upload_date, source_id, title, ext = name_match.groups()
+
+ # Make sure the date is a valid date format, if not, leave it blank. Youtube-DL sometimes puts an NA in the date.
+ # We may even get videos that weren't downloaded by WROLPi.
+ if not upload_date or not upload_date.isdigit() or len(upload_date) != 8:
+ logger.debug(f'Could not parse date from filename: {video_path}')
+ upload_date = None
+
+ duration = None
+ if info_json_path:
+ path = (channel_dir / info_json_path).absolute()
+ try:
+ with open(path) as fh:
+ json_contents = json.load(fh)
+ duration = json_contents['duration']
+ except json.decoder.JSONDecodeError:
+ logger.warning(f'Failed to load JSON file to get duration: {path}')
+
+ video_dict = dict(
+ channel_id=channel.id,
+ description_path=str(description_path) if description_path else None,
+ ext=ext,
+ poster_path=str(poster_path) if poster_path else None,
+ source_id=source_id,
+ title=title,
+ upload_date=upload_date,
+ video_path=str(video_path),
+ caption_path=str(caption_path) if caption_path else None,
+ idempotency=idempotency,
+ info_json_path=str(info_json_path) if info_json_path else None,
+ duration=duration,
+ )
+
+ if id_:
+ video = session.query(Video).filter_by(id=id_).one()
+ for key, value in video_dict.items():
+ setattr(video, key, value)
+ else:
+ video = Video(**video_dict)
+
+ session.add(video)
+ session.flush()
+
+ if skip_captions is False and caption_path:
+ # Process captions only when requested
+ process_captions(video)
+
+ return video
+
+
+def find_meta_files(path: pathlib.Path, relative_to=None) -> Tuple[
+ pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]:
+ """
+ Find all files that share a file's full path, except their extensions. It is assumed that file with the
+ same name, but different extension is related to that file. A None will be yielded if the meta file doesn't exist.
+
+ Example:
+ >>> foo = pathlib.Path('foo.bar')
+ >>> find_meta_files(foo)
+ (pathlib.Path('foo.jpg'), pathlib.Path('foo.description'),
+ pathlib.Path('foo.en.vtt'), pathlib.Path('foo.info.json'))
+ """
+ suffix = path.suffix
+ name, suffix, _ = str(path.name).rpartition(suffix)
+ meta_file_exts = (('.jpg', '.webp', '.png'), ('.description',), ('.en.vtt', '.en.srt'), ('.info.json',))
+ for meta_exts in meta_file_exts:
+ for meta_ext in meta_exts:
+ meta_path = path.with_suffix(meta_ext)
+ if meta_path.exists():
+ if relative_to:
+ yield meta_path.relative_to(relative_to)
+ break
+ else:
+ yield meta_path
+ break
+ else:
+ yield None
diff --git a/modules/videos/migrations/20200925_01_irZGd-initial-video-schema.sql b/modules/videos/migrations/20200925_01_irZGd-initial-video-schema.sql
new file mode 100644
index 00000000..d0372887
--- /dev/null
+++ b/modules/videos/migrations/20200925_01_irZGd-initial-video-schema.sql
@@ -0,0 +1,86 @@
+-- Initial video schema.
+-- depends:
+
+CREATE TABLE public.channel
+(
+ id integer NOT NULL,
+ name text,
+ link text,
+ idempotency text,
+ url text,
+ match_regex text,
+ directory text,
+ info_json json,
+ info_date date,
+ skip_download_videos text[],
+ generate_posters boolean DEFAULT true,
+ calculate_duration boolean DEFAULT true,
+ download_frequency integer,
+ next_download date
+);
+
+CREATE SEQUENCE public.channel_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE public.channel_id_seq OWNED BY public.channel.id;
+
+CREATE TABLE public.video
+(
+ id integer NOT NULL,
+ description_path text,
+ ext text,
+ poster_path text,
+ source_id text,
+ title text,
+ video_path text,
+ caption_path text,
+ idempotency text,
+ info_json_path text,
+ channel_id integer,
+ caption text,
+ textsearch tsvector GENERATED ALWAYS AS (to_tsvector('english'::regconfig,
+ ((COALESCE(title, ''::text) || ' '::text) ||
+ COALESCE(caption, ''::text)))) STORED,
+ upload_date timestamp without time zone,
+ duration integer,
+ favorite timestamp without time zone,
+ size bigint,
+ viewed timestamp without time zone,
+ validated_poster boolean DEFAULT false
+);
+
+CREATE SEQUENCE public.video_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE public.video_id_seq OWNED BY public.video.id;
+
+ALTER TABLE ONLY public.channel
+ ALTER COLUMN id SET DEFAULT nextval('public.channel_id_seq'::regclass);
+
+ALTER TABLE ONLY public.video
+ ALTER COLUMN id SET DEFAULT nextval('public.video_id_seq'::regclass);
+
+ALTER TABLE ONLY public.channel
+ ADD CONSTRAINT channel_pkey PRIMARY KEY (id);
+
+CREATE INDEX textsearch_idx ON public.video USING gin (textsearch);
+
+CREATE INDEX video_duration_idx ON public.video USING btree (duration);
+
+CREATE INDEX video_favorite_idx ON public.video USING btree (favorite);
+
+CREATE INDEX video_size_idx ON public.video USING btree (size);
+
+CREATE INDEX video_upload_date_idx ON public.video USING btree (upload_date);
+
+CREATE INDEX video_viewed_idx ON public.video USING btree (viewed);
diff --git a/modules/videos/models.py b/modules/videos/models.py
new file mode 100644
index 00000000..5c9ac131
--- /dev/null
+++ b/modules/videos/models.py
@@ -0,0 +1,332 @@
+import json
+from datetime import timedelta, datetime
+from pathlib import Path
+from typing import Optional
+
+from sqlalchemy import Column, Integer, String, Boolean, JSON, Date, ARRAY, ForeignKey, Computed, types
+from sqlalchemy.orm import relationship, Session
+from sqlalchemy.orm.collections import InstrumentedList
+
+from wrolpi.common import Base, tsvector, ModelHelper, PathColumn, get_absolute_media_path
+from wrolpi.dates import now, today, TZDateTime
+from wrolpi.db import get_db_curs
+from wrolpi.errors import UnknownVideo, NoFrequency, UnknownFile, UnknownDirectory
+
+
+class ChannelPath(types.TypeDecorator):
+ impl = types.String
+
+ def process_bind_param(self, value, dialect):
+ return str(value) if value else None
+
+ def process_result_value(self, value, dialect):
+ if value:
+ return Path(value)
+
+
+class Video(ModelHelper, Base):
+ __tablename__ = 'video'
+ id = Column(Integer, primary_key=True)
+ channel_id = Column(Integer, ForeignKey('channel.id'))
+ channel = relationship('Channel', primaryjoin='Video.channel_id==Channel.id', back_populates='videos')
+ idempotency = Column(String)
+
+ # File paths
+ caption_path = Column(ChannelPath)
+ description_path = Column(ChannelPath)
+ ext = Column(String)
+ info_json_path = Column(ChannelPath)
+ poster_path = Column(ChannelPath)
+ video_path = Column(ChannelPath)
+
+ caption = Column(String)
+ duration = Column(Integer)
+ favorite = Column(TZDateTime)
+ size = Column(Integer)
+ source_id = Column(String)
+ title = Column(String)
+ upload_date = Column(TZDateTime)
+ validated_poster = Column(Boolean, default=False)
+ viewed = Column(TZDateTime)
+ view_count = Column(Integer)
+ textsearch = Column(tsvector, Computed('''to_tsvector('english'::regconfig,
+ ((COALESCE(title, ''::text) || ' '::text) ||
+ COALESCE(caption, ''::text)))'''))
+
+ def __repr__(self):
+ return f''
+
+ def dict(self) -> dict:
+ d = super().dict()
+ if self.channel_id:
+ d['channel'] = self.channel.dict()
+ d['info_json'] = self.get_info_json()
+ return d
+
+ def get_minimize(self) -> dict:
+ """
+ Get a dictionary representation of this Video suitable for sending out the API.
+ """
+ from .common import minimize_video
+ return minimize_video(self.dict())
+
+ def _clear_paths(self):
+ self.caption_path = None
+ self.description_path = None
+ self.ext = None
+ self.info_json_path = None
+ self.poster_path = None
+ self.video_path = None
+
+ def delete(self):
+ """
+ Remove all files related to this video. Add it to it's Channel's skip list.
+
+ Raises: UnknownFile if a the video has no files.
+ """
+ from modules.videos.common import get_absolute_video_files
+ video_files = get_absolute_video_files(self)
+ for path in video_files:
+ try:
+ path.unlink()
+ except FileNotFoundError:
+ pass
+
+ self._clear_paths()
+
+ if not video_files:
+ raise UnknownFile('No video files were deleted')
+
+ self.add_to_skip_list()
+
+ def add_to_skip_list(self):
+ """
+ Add this video to it's Channel's skip list.
+ """
+ self.channel.add_video_to_skip_list(self.source_id)
+
+ def set_favorite(self, favorite: bool) -> Optional[datetime]:
+ self.favorite = now() if favorite else None
+ return self.favorite
+
+ def set_viewed(self):
+ self.viewed = now()
+
+ def get_info_json(self) -> Optional[JSON]:
+ """
+ If this Video has an info_json file, return it's contents. Otherwise, return None.
+ """
+ if not self.channel or not self.channel.directory:
+ return
+
+ try:
+ if self.info_json_path:
+ path = self.channel.get_relative_path(self.info_json_path)
+ with open(path, 'rb') as fh:
+ contents = json.load(fh)
+ return contents
+ except UnknownFile:
+ pass
+ except UnknownDirectory:
+ pass
+
+ def get_video_description(self) -> Optional[str]:
+ """
+ Get the Video description from the file system.
+ """
+ # First try to get description from info_json file.
+ info_json = self.get_info_json()
+ if info_json:
+ description = info_json.get('description')
+ if description:
+ return description
+
+ if self.description_path and self.channel_id:
+ path = self.channel.get_relative_path(self.description_path)
+ if path.exists():
+ with open(path, 'rt') as fh:
+ contents = fh.read()
+ return contents
+
+ def get_surrounding_videos(self):
+ """
+ Get the previous and next videos around this video. The videos must be in the same channel.
+
+ Example:
+ >>> vid1 = Video(id=1, upload_date=10)
+ >>> vid2 = Video(id=2, upload_date=20)
+ >>> vid3 = Video(id=3, upload_date=30)
+ >>> vid4 = Video(id=4)
+
+ >>> vid1.get_surrounding_videos()
+ (None, vid2)
+ >>> vid2.get_surrounding_videos()
+ (vid1, vid3)
+ >>> vid3.get_surrounding_videos()
+ (vid2, None)
+ Video 4 has no upload date, so we can't place it in order.
+ >>> vid4.get_surrounding_videos()
+ (None, None)
+ """
+ session = Session.object_session(self)
+
+ if not self.upload_date:
+ # We can't place a video that has no upload date.
+ return None, None
+
+ with get_db_curs() as curs:
+ query = '''
+ WITH numbered_videos AS (
+ SELECT id,
+ ROW_NUMBER() OVER (ORDER BY upload_date ASC) AS row_number
+ FROM video
+ WHERE
+ channel_id = %(channel_id)s
+ AND upload_date IS NOT NULL
+ )
+
+ SELECT id
+ FROM numbered_videos
+ WHERE row_number IN (
+ SELECT row_number+i
+ FROM numbered_videos
+ CROSS JOIN (SELECT -1 AS i UNION ALL SELECT 0 UNION ALL SELECT 1) n
+ WHERE
+ id = %(video_id)s
+ )
+ '''
+ curs.execute(query, dict(channel_id=self.channel_id, video_id=self.id))
+ results = [i[0] for i in curs.fetchall()]
+
+ # Assign the returned ID's to their respective positions relative to the ID that matches the video_id.
+ previous_id = next_id = None
+ for index, id_ in enumerate(results):
+ if id_ == self.id:
+ if index > 0:
+ previous_id = results[index - 1]
+ if index + 1 < len(results):
+ next_id = results[index + 1]
+ break
+
+ # Fetch the videos by id, if they exist.
+ previous_video = session.query(Video).filter_by(id=previous_id).one() if previous_id else None
+ next_video = session.query(Video).filter_by(id=next_id).one() if next_id else None
+
+ return previous_video, next_video
+
+
+class Channel(ModelHelper, Base):
+ __tablename__ = 'channel'
+ id = Column(Integer, primary_key=True)
+ name = Column(String)
+ link = Column(String, nullable=False)
+ idempotency = Column(String)
+ url = Column(String)
+ match_regex = Column(String)
+ directory = Column(PathColumn)
+ skip_download_videos = Column(ARRAY(String))
+ generate_posters = Column(Boolean)
+ calculate_duration = Column(Boolean)
+ download_frequency = Column(Integer)
+ next_download = Column(Date)
+
+ info_json = Column(JSON)
+ info_date = Column(Date)
+
+ # Will be filled later by directory path relative to the media directory.
+ _directory = None
+
+ videos: InstrumentedList = relationship('Video', primaryjoin='Channel.id==Video.channel_id')
+
+ def __repr__(self):
+ return f''
+
+ def add_video_to_skip_list(self, source_id):
+ if not source_id:
+ raise UnknownVideo(f'Cannot skip video with empty source id: {source_id}')
+
+ skip_download_videos = {i for i in self.skip_download_videos or [] if i}
+ skip_download_videos.add(source_id)
+ self.skip_download_videos = skip_download_videos
+
+ def increment_next_download(self):
+ """
+ Set the next download predictably during the next download iteration.
+
+ For example, two channels that download weekly will need to be downloaded on different days. We want a channel
+ to always be downloaded on it's day. That may be Monday, or Tuesday, etc.
+
+ This is true for all download frequencies (30 days, 90 days, etc.).
+
+ The order that channels will be downloaded/distributed will be by `link`.
+ """
+ if not self.download_frequency:
+ raise NoFrequency(f'{self} does not have a frequency!')
+
+ session = Session.object_session(self)
+
+ # All the channels that share the my frequency.
+ channel_group = session.query(self.__class__).filter_by(download_frequency=self.download_frequency)
+ channel_group = list(channel_group.order_by(self.__class__.link).all())
+
+ # My position in the channel group.
+ index = channel_group.index(self)
+
+ # The seconds between each download.
+ chunk = self.download_frequency // len(channel_group)
+
+ # My next download will be distributed by my frequency and my position.
+ position = chunk * (index + 1)
+ self.next_download = today() + timedelta(seconds=self.download_frequency + position)
+
+ def delete_with_videos(self):
+ """
+ Delete all Video records (but not video files) related to this Channel. Then delete the Channel.
+ """
+ session = Session.object_session(self)
+ # Delete the video records, but not the video files!
+ session.query(Video).filter_by(channel_id=self.id).delete()
+ session.delete(self)
+
+ def update(self, data: dict):
+ for key, value in data.items():
+ setattr(self, key, value)
+
+ def config_view(self) -> dict:
+ """
+ Retrieve the data about this Channel that should be stored in a config file.
+ """
+ config = dict(
+ directory=str(self.directory),
+ match_regex=self.match_regex or '',
+ name=self.name,
+ url=self.url or '',
+ generate_posters=self.generate_posters,
+ calculate_duration=self.calculate_duration,
+ skip_download_videos=self.skip_download_videos or [],
+ download_frequency=self.download_frequency,
+ next_download=self.next_download,
+ favorites={},
+ )
+
+ session = Session.object_session(self)
+ favorites = session.query(Video).filter(Video.favorite != None, Video.channel_id == self.id).all()
+ if favorites:
+ config['favorites'] = {i.source_id: {'favorite': i.favorite} for i in favorites}
+
+ return config
+
+ def get_directory(self):
+ if self._directory:
+ return self._directory
+
+ self._directory = get_absolute_media_path(self.directory)
+ return self._directory
+
+ def get_relative_path(self, path: Path, exists: bool = True):
+ channel_dir = self._directory or self.get_directory()
+ path = channel_dir / path
+ if exists and not path.exists():
+ raise FileNotFoundError(f'Channel path {path} does not exist!')
+ return path
diff --git a/modules/videos/schema.py b/modules/videos/schema.py
new file mode 100644
index 00000000..ef727048
--- /dev/null
+++ b/modules/videos/schema.py
@@ -0,0 +1,182 @@
+from sanic_openapi import doc
+
+from wrolpi.schema import Trinary
+
+
+class ChannelPostRequest:
+ url = doc.String()
+ name = doc.String(description='A short readable name', required=True)
+ match_regex = doc.String(description='Regex that the video title will have to match')
+ directory = doc.String(required=True)
+ mkdir = doc.Boolean()
+ generate_posters = doc.Boolean()
+ calculate_duration = doc.Boolean()
+ download_frequency = doc.Integer()
+
+
+class ChannelPutRequest:
+ url = doc.String()
+ name = doc.String()
+ match_regex = doc.String()
+ link = doc.String()
+ directory = doc.String()
+ generate_posters = doc.Boolean()
+ calculate_duration = doc.Boolean()
+ mkdir = doc.Boolean()
+ download_frequency = doc.Integer()
+
+
+class RefreshRequest:
+ channel_name = doc.String()
+
+
+class SuccessResponse:
+ success = doc.String()
+
+
+class ChannelModel:
+ id = doc.Integer()
+ url = doc.String()
+ name = doc.String()
+ match_regex = doc.String()
+ link = doc.String()
+ directory = doc.String()
+
+
+class ChannelResponse:
+ channel = ChannelModel
+
+
+class ChannelPostResponse:
+ success = doc.String()
+
+
+class ChannelsChannelModel:
+ id = doc.Integer()
+ name = doc.String()
+ link = doc.String()
+
+
+class ChannelsResponse:
+ channels = doc.List(doc.Object(ChannelsChannelModel))
+
+
+class StreamResponse:
+ success = doc.String()
+ stream_url = doc.String()
+
+
+class Video:
+ id = doc.Integer()
+ description_path = doc.String()
+ ext = doc.String()
+ poster_path = doc.String()
+ source_id = doc.String()
+ title = doc.String()
+ upload_date = doc.Date()
+ video_path = doc.String()
+ name = doc.String()
+ caption_path = doc.String()
+ idempotency = doc.String()
+ info_json_path = doc.String()
+ channel_id = doc.Integer()
+
+
+class VideoWithChannel:
+ id = doc.Integer()
+ description_path = doc.String()
+ ext = doc.String()
+ poster_path = doc.String()
+ source_id = doc.String()
+ title = doc.String()
+ upload_date = doc.Date()
+ video_path = doc.String()
+ name = doc.String()
+ caption_path = doc.String()
+ idempotency = doc.String()
+ info_json_path = doc.String()
+ channel_id = doc.Integer()
+
+ channel = ChannelModel
+
+
+class ChannelVideosResponse:
+ videos = doc.List(Video)
+
+
+class ExtendedVideo(Video):
+ info_json = doc.Object(dict)
+
+
+class VideoResponse:
+ video = ExtendedVideo
+
+
+class VideoSearchRequest:
+ search_str = doc.String()
+ channel_link = doc.String()
+ order_by = doc.String()
+ offset = doc.Integer()
+ limit = doc.Integer()
+ favorites = Trinary()
+
+
+class VideoSearchResponse:
+ videos = doc.List(VideoWithChannel)
+ tsquery = doc.String()
+
+
+class PaginationQuery:
+ offset = doc.Integer()
+
+
+class DirectoriesRequest:
+ search_str = doc.String(required=True)
+
+
+class DirectoriesResponse:
+ directories = doc.List(doc.String())
+
+
+class FavoriteRequest:
+ video_id = doc.Integer()
+ favorite = doc.Boolean()
+
+
+class FavoriteResponse:
+ video_id = doc.Integer()
+ favorite = doc.DateTime()
+
+
+class WROLModeRequest:
+ enabled = doc.Boolean(required=True)
+
+
+class VideoStatistics:
+ videos = doc.Integer()
+ favorites = doc.Integer()
+ week = doc.Integer()
+ month = doc.Integer()
+ year = doc.Integer()
+ sum_duration = doc.Integer()
+ sum_size = doc.Integer()
+ max_size = doc.Integer()
+
+
+class ChannelStatistics:
+ channels = doc.Integer()
+
+
+class VideosStatisticsResponse:
+ videos = VideoStatistics
+ channels = ChannelStatistics
+
+
+class DownloadHistory:
+ link = doc.String()
+ name = doc.String()
+ info_date = doc.Date()
+
+
+class DownloadHistoryResponse:
+ history = doc.List(doc.Object(DownloadHistory))
diff --git a/modules/videos/test/__init__.py b/modules/videos/test/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/videos/test/common.py b/modules/videos/test/common.py
new file mode 100644
index 00000000..d13fc1f3
--- /dev/null
+++ b/modules/videos/test/common.py
@@ -0,0 +1,69 @@
+from functools import wraps
+from queue import Queue
+
+from modules.videos.lib import refresh_channel_videos
+from modules.videos.models import Channel
+from wrolpi.common import ProgressReporter, insert_parameter
+from wrolpi.db import get_db_session
+from wrolpi.test.common import wrap_test_db, build_test_directories
+
+
+def create_channel_structure(structure):
+ """
+ Create a directory containing the specified structure of channels and videos. Create DB entries for these
+ channels and videos.
+
+ Example:
+ >>> s = {'channel1': ['vid1.mp4'], 'channel2': ['vid1.mp4', 'vid2.mp4', 'vid2.en.vtt']}
+ >>> create_channel_structure(s)
+
+ Creates directories like so:
+ channel1/vid1.mp4
+ channel2/vid1.mp4
+ channel2/vid2.mp4
+ channel2/vid2.en.vtt
+
+ Channels like so:
+ Channel(name='channel1', directory='channel1')
+ Channel(name='channel2', directory='channel2')
+
+ And, Videos like so:
+ Video(channel_id=1, video_path='vid1.mp4')
+ Video(channel_id=2, video_path='vid1.mp4')
+ Video(channel_id=2, video_path='vid2.mp4', caption_path='vid2.en.vtt')
+ """
+
+ def wrapper(func):
+ @wraps(func)
+ @wrap_test_db
+ def wrapped(*args, **kwargs):
+ # Dummy queue and reporter to receive messages.
+ q = Queue()
+ reporter = ProgressReporter(q, 2)
+
+ # Convert the channel/video structure to a file structure for the test.
+ file_structure = []
+ for channel, paths in structure.items():
+ for path in paths:
+ file_structure.append(f'{channel}/{path}')
+ file_structure.append(f'{channel}/')
+
+ with build_test_directories(file_structure) as tempdir:
+ args, kwargs = insert_parameter(func, 'tempdir', tempdir, args, kwargs)
+
+ with get_db_session(commit=True) as session:
+ for channel in structure:
+ channel_ = Channel(directory=str(tempdir / channel), name=channel, link=channel)
+ session.add(channel_)
+ session.flush()
+ session.refresh(channel_)
+
+ with get_db_session(commit=True) as session:
+ for channel_ in session.query(Channel).all():
+ refresh_channel_videos(channel_, reporter)
+
+ return func(*args, **kwargs)
+
+ return wrapped
+
+ return wrapper
diff --git a/modules/videos/test/test_captions.py b/modules/videos/test/test_captions.py
new file mode 100644
index 00000000..a2c5e6d7
--- /dev/null
+++ b/modules/videos/test/test_captions.py
@@ -0,0 +1,85 @@
+import mock
+
+from modules.videos import captions
+from modules.videos.models import Video
+from wrolpi.db import get_db_session, get_db_curs
+from wrolpi.test.common import wrap_test_db, TestAPI
+from wrolpi.vars import PROJECT_DIR
+
+
+class TestCaption(TestAPI):
+ vtt_path1 = (PROJECT_DIR / 'test/example1.en.vtt').absolute()
+ vtt_path2 = (PROJECT_DIR / 'test/example2.en.vtt').absolute()
+
+ def test_example_en_vtt(self):
+ expected = ['okay welcome to this session this is',
+ 'okay welcome to this session this is',
+ 'okay welcome to this session this is\ncalled the kinetic bunny need to meet',
+ 'called the kinetic bunny need to meet',
+ 'called the kinetic bunny need to meet\nthe virtual bonnie emilio stop this is',
+ 'the virtual bonnie emilio stop this is',
+ 'the virtual bonnie emilio stop this is\njana car who just to give a little bit',
+ 'jana car who just to give a little bit',
+ 'jana car who just to give a little bit\nof background information we come from a',
+ 'of background information we come from a',
+ 'of background information we come from a\ncompany called le code from finland and']
+ self.assertEqual(list(captions.get_caption_text(self.vtt_path1)), expected)
+
+ expected = ['okay welcome to this session this is',
+ 'called the kinetic bunny need to meet',
+ 'the virtual bonnie emilio stop this is',
+ 'jana car who just to give a little bit',
+ 'of background information we come from a',
+ 'company called le code from finland and']
+ self.assertEqual(list(captions.get_unique_caption_lines(self.vtt_path1)), expected)
+
+ @wrap_test_db
+ def test_process_captions(self):
+ with get_db_session(commit=True) as session:
+ video1 = Video(title='scream', caption_path=str(self.vtt_path1))
+ session.add(video1)
+ with mock.patch('modules.videos.captions.get_absolute_video_caption', lambda *a: self.vtt_path1):
+ captions.process_captions(video1)
+ video2 = Video(title='bar', caption_path=str(self.vtt_path2))
+ session.add(video2)
+ with mock.patch('modules.videos.captions.get_absolute_video_caption', lambda *a: self.vtt_path2):
+ captions.process_captions(video2)
+
+ session.flush()
+ session.refresh(video1)
+ session.refresh(video2)
+
+ # Get the video from the DB
+ video1 = session.query(Video).filter_by(id=video1.id).one()
+ self.assertIsNotNone(video1.caption)
+ video2 = session.query(Video).filter_by(id=video2.id).one()
+ self.assertIsNotNone(video2.caption)
+
+ # Search using the tsvector, "sessions" never actually appears in the text, but "session" does
+ with get_db_curs() as curs:
+ def select_textsearch(*args):
+ curs.execute('SELECT id FROM video WHERE textsearch @@ to_tsquery(%s) ORDER BY id', args)
+
+ select_textsearch('sessions')
+ self.assertEqual(curs.fetchall(), [[1, ]])
+ # Matches video1.title and video2.caption
+ select_textsearch('scream')
+ self.assertEqual(curs.fetchall(), [[1, ], [2, ]])
+ # Matches video1.title and video2.caption
+ select_textsearch('scream | sessions')
+ self.assertEqual(curs.fetchall(), [[1, ], [2, ]])
+ # Only matches video1.title
+ select_textsearch('scream & sessions')
+ self.assertEqual(curs.fetchall(), [[1, ]])
+ # Matches neither
+ select_textsearch('scream & sess')
+ self.assertEqual(curs.fetchall(), [])
+ # Matches video2.caption
+ select_textsearch('yawn | sess')
+ self.assertEqual(curs.fetchall(), [[2, ]])
+ # Matches video2.caption
+ select_textsearch('yawn')
+ self.assertEqual(curs.fetchall(), [[2, ]])
+ # Matches video2.title
+ select_textsearch('bar')
+ self.assertEqual(curs.fetchall(), [[2, ]])
diff --git a/modules/videos/test/test_common.py b/modules/videos/test/test_common.py
new file mode 100644
index 00000000..ccfc80dd
--- /dev/null
+++ b/modules/videos/test/test_common.py
@@ -0,0 +1,253 @@
+import pathlib
+import tempfile
+from unittest import mock
+from unittest.mock import Mock
+
+import pytest
+from PIL import Image
+
+from modules.videos.models import Channel, Video
+from modules.videos.test.common import create_channel_structure
+from wrolpi.common import get_absolute_media_path
+from wrolpi.db import get_db_session
+from wrolpi.test.common import ExtendedTestCase, build_test_directories, wrap_test_db
+from ..common import get_matching_directories, convert_image, bulk_validate_posters, remove_duplicate_video_paths, \
+ update_view_count
+
+
+class TestCommon(ExtendedTestCase):
+
+ def test_get_absolute_media_path(self):
+ wrolpi = get_absolute_media_path('videos/wrolpi')
+ assert str(wrolpi).endswith('wrolpi')
+
+ def test_matching_directories(self):
+ structure = [
+ 'foo/qux/',
+ 'Bar/',
+ 'baz/baz'
+ 'barr',
+ 'bazz',
+ ]
+
+ with build_test_directories(structure) as temp_dir:
+ temp_dir = pathlib.Path(temp_dir)
+
+ # No directories have c
+ matches = get_matching_directories(temp_dir / 'c')
+ assert matches == []
+
+ # Get all directories starting with f
+ matches = get_matching_directories(temp_dir / 'f')
+ assert matches == [str(temp_dir / 'foo')]
+
+ # Get all directories starting with b, ignore case
+ matches = get_matching_directories(temp_dir / 'b')
+ assert matches == [str(temp_dir / 'Bar'), str(temp_dir / 'baz')]
+
+ # baz matches, but it has no subdirectories
+ matches = get_matching_directories(temp_dir / 'baz')
+ assert matches == [str(temp_dir / 'baz')]
+
+ # foo is an exact match, return subdirectories
+ matches = get_matching_directories(temp_dir / 'foo')
+ assert matches == [str(temp_dir / 'foo/qux')]
+
+
+@pytest.mark.parametrize(
+ 'paths,expected',
+ (
+ (['1.mp4', ], ['1.mp4', ]),
+ (['1.mp4', '2.mp4'], ['1.mp4', '2.mp4']),
+ (['1.mp4', '1.ogg'], ['1.mp4']),
+ (['1.bad_ext', '1.other_ext'], ['1.bad_ext']),
+ )
+)
+def test_remove_duplicate_video_paths(paths, expected):
+ result = remove_duplicate_video_paths(map(pathlib.Path, paths))
+ assert sorted(result) == sorted(map(pathlib.Path, expected))
+
+
+def test_convert_image():
+ """
+ An image's format can be changed. This tests that convert_image() converts from a WEBP format, to a JPEG format.
+ """
+ foo = Image.new('RGB', (25, 25), color='grey')
+
+ with tempfile.TemporaryDirectory() as tempdir:
+ tempdir = pathlib.Path(tempdir)
+
+ # Save the new image to "foo.webp".
+ existing_path = tempdir / 'foo.webp'
+ foo.save(existing_path)
+ assert Image.open(existing_path).format == 'WEBP'
+
+ destination_path = tempdir / 'foo.jpg'
+ assert not destination_path.is_file()
+
+ # Convert the WEBP to a JPEG. The WEBP image should be removed.
+ convert_image(existing_path, destination_path)
+ assert not existing_path.is_file()
+ assert destination_path.is_file()
+ assert Image.open(destination_path).format == 'JPEG'
+
+
+@pytest.mark.parametrize(
+ '_structure,paths',
+ (
+ (
+ {'channel1': ['vid1.mp4']},
+ [
+ 'channel1/vid1.mp4',
+ ],
+ ),
+ (
+ {'channel1': ['vid1.mp4'], 'channel2': ['vid1.mp4']},
+ [
+ 'channel1/vid1.mp4',
+ 'channel2/vid1.mp4',
+ ],
+ ),
+ (
+ {'channel1': ['vid1.mp4'], 'channel2': ['vid1.mp4', 'vid2.mp4', 'vid2.en.vtt']},
+ [
+ 'channel1/vid1.mp4',
+ 'channel2/vid1.mp4',
+ 'channel2/vid2.mp4',
+ 'channel2/vid2.en.vtt',
+ ],
+ ),
+ )
+)
+def test_create_db_structure(_structure, paths):
+ @create_channel_structure(_structure)
+ def test_func(tempdir):
+ assert isinstance(tempdir, pathlib.Path)
+ for path in paths:
+ path = (tempdir / path)
+ assert path.exists()
+ assert path.is_file()
+
+ with get_db_session() as session:
+ for channel_name in _structure:
+ channel = session.query(Channel).filter_by(name=channel_name).one()
+ assert (tempdir / channel_name).is_dir()
+ assert channel
+ assert channel.directory == tempdir / channel_name
+ assert len(channel.videos) == len([i for i in _structure[channel_name] if i.endswith('mp4')])
+
+ test_func()
+
+
+@wrap_test_db
+@create_channel_structure(
+ {
+ 'channel1': ['vid1.mp4', 'vid1.jpg'],
+ 'channel2': ['vid2.flv', 'vid2.webp'],
+ }
+)
+def test_bulk_replace_invalid_posters(tempdir: pathlib.Path):
+ """
+ Test that when a video has an invalid poster format, we convert it to JPEG.
+ """
+ channel1, channel2 = sorted(tempdir.iterdir())
+ jpg, mp4 = sorted(channel1.iterdir())
+ flv, webp = sorted(channel2.iterdir())
+
+ Image.new('RGB', (25, 25)).save(jpg)
+ Image.new('RGB', (25, 25)).save(webp)
+
+ with open(jpg, 'rb') as jpg_fh, open(webp, 'rb') as webp_fh:
+ # Files are different formats.
+ jpg_fh_contents = jpg_fh.read()
+ webp_fh_contents = webp_fh.read()
+ assert jpg_fh_contents != webp_fh_contents
+ assert Image.open(jpg_fh).format == 'JPEG'
+ assert Image.open(webp_fh).format == 'WEBP'
+
+ with get_db_session() as session:
+ vid1 = session.query(Video).filter_by(poster_path='vid1.jpg').one()
+ assert vid1.validated_poster is False
+
+ vid2 = session.query(Video).filter_by(poster_path='vid2.webp').one()
+ assert vid2.validated_poster is False
+
+ # Convert the WEBP image. convert_image() should only be called once.
+ mocked_convert_image = Mock(wraps=convert_image)
+ with mock.patch('modules.videos.common.convert_image', mocked_convert_image):
+ video_ids = [vid1.id, vid2.id]
+ bulk_validate_posters(video_ids)
+
+ mocked_convert_image.assert_called_once_with(webp, tempdir / 'channel2/vid2.jpg')
+
+ with get_db_session() as session:
+ # Get the video by ID because it's poster is now a JPEG.
+ vid2 = session.query(Video).filter_by(id=vid2.id).one()
+ assert str(vid2.poster_path) == 'vid2.jpg'
+ assert all('webp' not in str(i.poster_path) for i in session.query(Video).all())
+ assert vid2.validated_poster is True
+
+ # Vid1's image was validated, but not converted.
+ vid1 = session.query(Video).filter_by(id=vid1.id).one()
+ assert str(vid1.poster_path) == 'vid1.jpg'
+ assert vid1.validated_poster is True
+
+ # Old webp was removed
+ assert not webp.is_file()
+ new_jpg = tempdir / 'channel2/vid2.jpg'
+ assert new_jpg.is_file()
+ # chmod 644
+ assert new_jpg.stat().st_mode == 0o100644
+ with open(new_jpg, 'rb') as new_jpg_fh:
+ # The converted image is the same as the other JPEG because both are black 25x25 pixel images.
+ assert jpg_fh_contents == new_jpg_fh.read()
+ assert Image.open(new_jpg_fh).format == 'JPEG'
+
+ # Calling convert again has no effect.
+ mocked_convert_image.reset_mock()
+ with mock.patch('modules.videos.common.convert_image', mocked_convert_image):
+ video_ids = [vid1.id, vid2.id]
+ bulk_validate_posters(video_ids)
+
+ mocked_convert_image.assert_not_called()
+
+
+@wrap_test_db
+@create_channel_structure(
+ {
+ 'channel1': ['vid1.mp4', 'vid1.jpg'],
+ 'channel2': ['vid2.mp4'],
+ 'channel3': ['vid3.mp4', 'vid4.mp4'],
+ }
+)
+def test_update_view_count(tempdir: pathlib.Path):
+ def check_view_counts(view_counts):
+ with get_db_session(commit=True) as session:
+ for source_id, view_count in view_counts.items():
+ vid = session.query(Video).filter_by(source_id=source_id).one()
+ assert vid.view_count == view_count
+
+ with get_db_session(commit=True) as session:
+ channel1, channel2, channel3 = session.query(Channel).order_by(Channel.id).all()
+ channel1.info_json = {'entries': [{'id': 'vid1.mp4', 'view_count': 10}]}
+ channel2.info_json = {'entries': [{'id': 'vid2.mp4', 'view_count': 11}, {'id': 'bad_id', 'view_count': 12}]}
+ channel3.info_json = {'entries': [{'id': 'vid3.mp4', 'view_count': 13}, {'id': 'vid4.mp4', 'view_count': 14}]}
+
+ # Use the video_path as a unique source_id
+ for v in session.query(Video).all():
+ v.source_id = str(v.video_path)
+
+ # Check all videos are empty.
+ check_view_counts({'vid1.mp4': None, 'vid2.mp4': None, 'vid3.mp4': None, 'vid4.mp4': None})
+
+ # Channel 1 is updated, the other channels are left alone.
+ update_view_count(channel1.id)
+ check_view_counts({'vid1.mp4': 10, 'vid2.mp4': None, 'vid3.mp4': None, 'vid4.mp4': None})
+
+ # Channel 2 is updated, the other channels are left alone. The 'bad_id' video is ignored.
+ update_view_count(channel2.id)
+ check_view_counts({'vid1.mp4': 10, 'vid2.mp4': 11, 'vid3.mp4': None, 'vid4.mp4': None})
+
+ # All videos are updated.
+ update_view_count(channel3.id)
+ check_view_counts({'vid1.mp4': 10, 'vid2.mp4': 11, 'vid3.mp4': 13, 'vid4.mp4': 14})
diff --git a/modules/videos/test/test_downloader.py b/modules/videos/test/test_downloader.py
new file mode 100644
index 00000000..650ff214
--- /dev/null
+++ b/modules/videos/test/test_downloader.py
@@ -0,0 +1,208 @@
+import time
+from datetime import timedelta
+from queue import Queue
+from unittest import mock
+from unittest.mock import MagicMock
+
+import pytest
+
+from modules.videos.downloader import update_channels, find_all_missing_videos, download_video
+from modules.videos.models import Channel, Video
+from modules.videos.test.common import create_channel_structure
+from wrolpi.common import ProgressReporter
+from wrolpi.dates import today
+from wrolpi.db import get_db_session
+from wrolpi.test.common import wrap_test_db, TestAPI
+
+
+class FakeYDL:
+
+ @staticmethod
+ def extract_info(*a, **kw):
+ return {
+ 'entries': [
+ {'id': 1},
+ ],
+ }
+
+
+@wrap_test_db
+@create_channel_structure(
+ {
+ 'channel1': ['vid1.mp4'],
+ 'channel2': ['vid2.mp4']
+ },
+)
+def test_update_channels(tempdir):
+ """
+ Channels are only updated if their "next_download" has expired, or if they have never been updated.
+ """
+ q = Queue()
+ reporter = ProgressReporter(q, 2)
+
+ with get_db_session(commit=True) as session:
+ channel1, channel2 = session.query(Channel).order_by(Channel.id).all()
+ channel1.url = channel2.url = 'some url'
+
+ with get_db_session() as session:
+ channel1, channel2 = session.query(Channel).order_by(Channel.id).all()
+ with mock.patch('modules.videos.downloader.update_channel') as update_channel:
+ update_channel: MagicMock
+ update_channels(reporter)
+
+ # Both channels would have been updated.
+ assert update_channel.call_count == 2
+ update_channel.assert_any_call(channel1)
+ update_channel.assert_any_call(channel2)
+
+ with get_db_session() as session:
+ channel1, channel2 = session.query(Channel).order_by(Channel.id).all()
+ channel1.next_download = today() + timedelta(days=1)
+ session.commit()
+
+ with mock.patch('modules.videos.downloader.update_channel') as update_channel:
+ update_channel: MagicMock
+ update_channels(reporter)
+
+ # Channel1 is not ready for an update
+ update_channel.assert_called_once()
+ update_channel.assert_called_with(channel2)
+
+ channel1.next_download = today() + timedelta(days=1)
+ channel2.next_download = today() + timedelta(days=1)
+ session.commit()
+
+ with mock.patch('modules.videos.downloader.update_channel') as update_channel:
+ update_channel: MagicMock
+ update_channels(reporter)
+
+ # No channels needed to be updated
+ assert update_channel.call_count == 0
+
+
+class TestDownloader(TestAPI):
+ @wrap_test_db
+ @create_channel_structure(
+ {
+ 'channel1': ['vid1.mp4'],
+ 'channel2': ['vid2.mp4']
+ },
+ )
+ def test_find_all_missing_videos(self, tempdir):
+ with get_db_session(commit=True) as session:
+ channel1, channel2 = session.query(Channel).order_by(Channel.id).all()
+ channel1.url = channel2.url = 'some url'
+ channel1.info_json = {'entries': [{'id': 'foo'}]}
+
+ # No missing videos
+ self.assertEqual([], list(find_all_missing_videos()))
+
+ # Create a video that has no video file.
+ with get_db_session(commit=True) as session:
+ video = Video(title='needs to be downloaded', channel_id=channel1.id, source_id='foo')
+ session.add(video)
+
+ missing = list(find_all_missing_videos())
+ self.assertEqual(len(missing), 1)
+
+ # The video returned is the one we faked.
+ channel, id_, entry = missing[0]
+ self.assertEqual(channel, channel1)
+ # Two videos were created for this test already.
+ self.assertEqual(id_, 3)
+ # The fake entry we added is regurgitated back.
+ self.assertEqual(entry, channel1.info_json['entries'][0])
+
+ @pytest.mark.skip
+ def test_download_timeout(self):
+ class FakeYoutubeDL:
+ def __init__(self, *a, **kw):
+ pass
+
+ def add_default_info_extractors(self):
+ pass
+
+ def extract_info(self, *a, **kw):
+ # Sleep longer than the 1 second timeout.
+ for i in range(10):
+ time.sleep(1)
+
+ with mock.patch('api.timeout.TEST_TIMEOUT', 1), \
+ mock.patch('modules.videos.downloader.YoutubeDL', FakeYoutubeDL), \
+ mock.patch('modules.videos.downloader.get_absolute_media_path'):
+ channel = Channel()
+ self.assertRaises(TimeoutError, download_video, channel, {'id': 1})
+
+ # {key: 'daily', text: 'Daily', value: 86400},
+ # {key: 'weekly', text: 'Weekly', value: 604800},
+ # {key: 'biweekly', text: 'Biweekly', value: 1209600},
+ # {key: '30days', text: '30 Days', value: 2592000},
+ # {key: '90days', text: '90 Days', value: 7776000},
+
+ @wrap_test_db
+ @create_channel_structure(
+ {
+ 'channel1': [], # More channels than days in a week.
+ 'channel2': [],
+ 'channel3': [],
+ 'channel4': [],
+ 'channel5': [],
+ 'channel6': [],
+ 'channel7': [],
+ 'channel8': [],
+ 'channel9': [],
+ },
+ )
+ @mock.patch('modules.videos.downloader.YDL', FakeYDL())
+ def test_update_channel_order(self, tempdir):
+ q = Queue()
+ reporter = ProgressReporter(q, 2)
+
+ # All channels are downloaded weekly.
+ with get_db_session(commit=True) as session:
+ channels = list(session.query(Channel).order_by(Channel.link).all())
+ for channel in channels:
+ channel.download_frequency = 604800 # one week
+ channel.url = 'some url'
+ self.assertEqual(channel.next_download, None)
+
+ # Channels will download on different days the next week. Except where there are not enough days.
+ update_channels(reporter)
+ days = [7, 8, 9, 10, 10, 11, 12, 13, 14]
+ for days_, channel in zip(days, channels):
+ nd, expected = channel.next_download, today() + timedelta(days=days_)
+ self.assertEqual(nd, expected, f'Expected {(nd - expected).days} days for {channel}')
+
+ # Even though all channels aren't updated, their order is the same.
+ with get_db_session(commit=True):
+ channels[0].next_download = None
+ channels[1].next_download = None
+ channels[4].next_download = None
+ channels[8].next_download = None
+ update_channels(reporter)
+ for days_, channel in zip(days, channels):
+ self.assertEqual(channel.next_download, today() + timedelta(days=days_))
+
+ # Multiple frequencies are supported.
+ with get_db_session(commit=True) as session:
+ frequency_map = {
+ 'channel1': 604800, # weekly
+ 'channel2': 604800,
+ 'channel3': 1209600, # bi-weekly
+ 'channel4': 1209600,
+ 'channel5': 2592000, # 30 days
+ 'channel6': 2592000,
+ 'channel7': 2592000,
+ 'channel8': 2592000,
+ 'channel9': 2592000,
+ }
+ for channel in channels:
+ channel.download_frequency = frequency_map[channel.link]
+ channel.next_download = None
+ update_channels(reporter)
+ days = [10, 14, 21, 28,
+ 36, 42, 48, 54, 60 # 6 days apart = 30 days / 5
+ ]
+ for days_, channel in zip(days, channels):
+ nd, expected = channel.next_download, today() + timedelta(days=days_)
+ self.assertEqual(nd, expected, f'Expected {(nd - expected).days} days for {channel}')
diff --git a/modules/videos/test/test_video.py b/modules/videos/test/test_video.py
new file mode 100644
index 00000000..1a2d0f43
--- /dev/null
+++ b/modules/videos/test/test_video.py
@@ -0,0 +1,12 @@
+from modules.videos.models import Video
+from wrolpi.dates import now
+
+
+def test_timezone(test_session):
+ """
+ A Video's dates should have a TimeZone.
+ """
+ video = Video(upload_date=now())
+ test_session.add(video)
+ test_session.commit()
+ assert video.upload_date.tzinfo
diff --git a/modules/videos/video/__init__.py b/modules/videos/video/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/videos/video/api.py b/modules/videos/video/api.py
new file mode 100644
index 00000000..a4537458
--- /dev/null
+++ b/modules/videos/video/api.py
@@ -0,0 +1,91 @@
+from http import HTTPStatus
+
+from sanic import response, Blueprint
+from sanic.request import Request
+
+from wrolpi.common import logger, wrol_mode_check, run_after, get_media_directory, \
+ get_relative_to_media_directory
+from wrolpi.root_api import json_response
+from wrolpi.db import get_db_session
+from wrolpi.errors import ValidationError, InvalidOrderBy
+from wrolpi.schema import validate_doc, JSONErrorResponse
+from .lib import get_video, VIDEO_ORDERS, DEFAULT_VIDEO_ORDER, video_search, get_video_for_app
+from ..common import get_matching_directories, get_allowed_limit, minimize_video
+from ..lib import save_channels_config
+from ..schema import VideoResponse, VideoSearchRequest, VideoSearchResponse, \
+ DirectoriesResponse, DirectoriesRequest
+
+video_bp = Blueprint('Video', '/api/videos')
+
+logger = logger.getChild(__name__)
+
+
+@video_bp.get('/video/')
+@validate_doc(
+ summary='Get Video information',
+ produces=VideoResponse,
+ responses=(
+ (HTTPStatus.NOT_FOUND, JSONErrorResponse),
+ ),
+)
+def video_get(_: Request, video_id: int):
+ video, previous_video, next_video = get_video_for_app(video_id)
+ return json_response({'video': video, 'prev': previous_video, 'next': next_video})
+
+
+@video_bp.post('/search')
+@validate_doc(
+ summary='Search Video titles and captions',
+ consumes=VideoSearchRequest,
+ produces=VideoSearchResponse,
+)
+async def search(_: Request, data: dict):
+ try:
+ search_str = data.get('search_str')
+ channel_link = data.get('channel_link')
+ order_by = data.get('order_by', DEFAULT_VIDEO_ORDER)
+ offset = int(data.get('offset', 0))
+ limit = get_allowed_limit(data.get('limit'))
+ favorites = data.get('favorites', None)
+ except Exception as e:
+ raise ValidationError('Unable to validate search queries') from e
+
+ if order_by not in VIDEO_ORDERS:
+ raise InvalidOrderBy('Invalid order by')
+
+ videos, videos_total = video_search(search_str, offset, limit, channel_link, order_by, favorites)
+
+ ret = {'videos': list(map(minimize_video, videos)), 'totals': {'videos': videos_total}}
+ return json_response(ret)
+
+
+@video_bp.post('/directories')
+@validate_doc(
+ summary='Get all directories that match the search_str, prefixed by the media directory.',
+ consumes=DirectoriesRequest,
+ responses=(
+ (HTTPStatus.NOT_FOUND, JSONErrorResponse),
+ (HTTPStatus.OK, DirectoriesResponse),
+ ),
+)
+def directories(_, data):
+ search_str = str(get_media_directory() / data['search_str'])
+ dirs = get_matching_directories(search_str)
+ dirs = [str(get_relative_to_media_directory(i)) for i in dirs]
+ return response.json({'directories': dirs})
+
+
+@video_bp.delete('/video/')
+@validate_doc(
+ summary='Delete a video',
+ responses=(
+ (HTTPStatus.NOT_FOUND, JSONErrorResponse),
+ ),
+)
+@wrol_mode_check
+@run_after(save_channels_config)
+def video_delete(_: Request, video_id: int):
+ with get_db_session(commit=True) as session:
+ video = get_video(session, video_id)
+ video.delete()
+ return response.raw('', HTTPStatus.NO_CONTENT)
diff --git a/modules/videos/video/lib.py b/modules/videos/video/lib.py
new file mode 100644
index 00000000..e62dfaed
--- /dev/null
+++ b/modules/videos/video/lib.py
@@ -0,0 +1,156 @@
+from datetime import datetime
+from typing import Tuple, Optional, List
+
+import psycopg2
+from sqlalchemy.orm import Session
+from sqlalchemy.orm.exc import NoResultFound
+
+from wrolpi.common import run_after, logger
+from wrolpi.db import get_db_session, get_db_curs
+from wrolpi.errors import UnknownVideo
+from ..lib import save_channels_config
+from ..models import Video
+
+logger.getChild(__name__)
+
+
+def get_video(session: Session, video_id: int) -> Video:
+ try:
+ video = session.query(Video).filter_by(id=video_id).one()
+ return video
+ except NoResultFound:
+ raise UnknownVideo()
+
+
+def get_video_for_app(video_id: int) -> Tuple[dict, Optional[dict], Optional[dict]]:
+ """
+ Get a Video, with it's prev/next videos. Mark the Video as viewed.
+ """
+ with get_db_session(commit=True) as session:
+ video = get_video(session, video_id)
+ video.set_viewed()
+ previous_video, next_video = video.get_surrounding_videos()
+
+ video = video.get_minimize()
+ previous_video = previous_video.get_minimize() if previous_video else None
+ next_video = next_video.get_minimize() if next_video else None
+
+ return video, previous_video, next_video
+
+
+VIDEO_ORDERS = {
+ 'upload_date': 'upload_date ASC, LOWER(video_path) ASC',
+ '-upload_date': 'upload_date DESC NULLS LAST, LOWER(video_path) DESC',
+ 'rank': '2 DESC, LOWER(video_path) DESC',
+ '-rank': '2 ASC, LOWER(video_path) ASC',
+ 'id': 'id ASC',
+ '-id': 'id DESC',
+ 'size': 'size ASC, LOWER(video_path) ASC',
+ '-size': 'size DESC, LOWER(video_path) DESC',
+ 'duration': 'duration ASC, LOWER(video_path) ASC',
+ '-duration': 'duration DESC, LOWER(video_path) DESC',
+ 'favorite': 'favorite ASC, LOWER(video_path) ASC',
+ '-favorite': 'favorite DESC, LOWER(video_path) DESC',
+ 'viewed': 'viewed ASC',
+ '-viewed': 'viewed DESC',
+ 'view_count': 'view_count ASC',
+ '-view_count': 'view_count DESC',
+}
+NO_NULL_ORDERS = {
+ 'viewed': '\nAND viewed IS NOT NULL',
+ '-viewed': '\nAND viewed IS NOT NULL',
+ 'duration': '\nAND duration IS NOT NULL',
+ '-duration': '\nAND duration IS NOT NULL',
+ 'size': '\nAND size IS NOT NULL',
+ '-size': '\nAND size IS NOT NULL',
+ 'view_count': '\nAND view_count IS NOT NULL',
+ '-view_count': '\nAND view_count IS NOT NULL',
+}
+DEFAULT_VIDEO_ORDER = 'rank'
+VIDEO_QUERY_LIMIT = 20
+
+
+def video_search(
+ search_str: str = None,
+ offset: int = None,
+ limit: int = VIDEO_QUERY_LIMIT,
+ channel_link: str = None,
+ order_by: str = None,
+ favorites: bool = None,
+) -> Tuple[List[dict], int]:
+ with get_db_curs() as curs:
+ args = dict(search_str=search_str, offset=offset)
+ channel_where = ''
+ if channel_link:
+ channel_where = 'AND channel_id = (select id from channel where link=%(channel_link)s)'
+ args['channel_link'] = channel_link
+
+ # Filter for/against favorites, if it was provided
+ favorites_where = ''
+ if favorites is not None:
+ favorites_where = f'AND favorite IS {"NOT" if favorites else ""} NULL'
+
+ where = ''
+ if search_str:
+ # A search_str was provided by the user, modify the query to filter by it.
+ columns = 'id, ts_rank_cd(textsearch, websearch_to_tsquery(%(search_str)s)), COUNT(*) OVER() AS total'
+ where = 'AND textsearch @@ websearch_to_tsquery(%(search_str)s)'
+ args['search_str'] = search_str
+ else:
+ # No search_str provided. Get id and total only.
+ columns = 'id, COUNT(*) OVER() AS total'
+
+ # Convert the user-friendly order by into a real order by, restrict what can be interpolated by using the whitelist.
+ order = VIDEO_ORDERS[DEFAULT_VIDEO_ORDER]
+ if order_by:
+ try:
+ order = VIDEO_ORDERS[order_by]
+ except KeyError:
+ raise
+ if order_by in NO_NULL_ORDERS:
+ where += NO_NULL_ORDERS[order_by]
+
+ query = f'''
+ SELECT
+ {columns}
+ FROM video
+ WHERE
+ video_path IS NOT NULL
+ {where}
+ {channel_where}
+ {favorites_where}
+ ORDER BY {order}
+ OFFSET %(offset)s LIMIT {int(limit)}
+ '''.strip()
+ logger.debug(query)
+
+ curs.execute(query, args)
+ try:
+ results = [dict(i) for i in curs.fetchall()]
+ except psycopg2.ProgrammingError:
+ # No videos
+ 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 = []
+ if ranked_ids:
+ results = session.query(Video).filter(Video.id.in_(ranked_ids)).all()
+ results = sorted(results, key=lambda r: ranked_ids.index(r.id))
+
+ results = [i.get_minimize() for i in results]
+
+ return results, total
+
+
+@run_after(save_channels_config)
+def set_video_favorite(video_id: int, favorite: bool) -> Optional[datetime]:
+ """
+ Set the Video.favorite to the current datetime if `favorite` is True, otherwise None.
+ """
+ with get_db_session(commit=True) as session:
+ video = session.query(Video).filter_by(id=video_id).one()
+ favorite = video.set_favorite(favorite)
+
+ return favorite
diff --git a/modules/videos/video/test/__init__.py b/modules/videos/video/test/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/videos/video/test/test_api.py b/modules/videos/video/test/test_api.py
new file mode 100644
index 00000000..fe45fff0
--- /dev/null
+++ b/modules/videos/video/test/test_api.py
@@ -0,0 +1,196 @@
+import pathlib
+from datetime import timedelta
+from http import HTTPStatus
+from json import dumps
+from unittest import mock
+from unittest.mock import MagicMock
+from uuid import uuid4
+
+from modules.videos.models import Channel, Video
+from modules.videos.test.common import create_channel_structure
+from modules.videos.video.lib import get_video_for_app
+from wrolpi.dates import now
+from wrolpi.db import get_db_session
+from wrolpi.errors import API_ERRORS, WROLModeEnabled
+from wrolpi.root_api import api_app
+from wrolpi.test.common import wrap_test_db, TestAPI
+
+
+class TestVideoFunctions(TestAPI):
+
+ @wrap_test_db
+ def test_get_video_prev_next(self):
+ """
+ Test that the previous and next videos will be retrieved when fetching a video.
+ """
+
+ with get_db_session(commit=True) as session:
+ for _ in range(4):
+ session.add(Channel(link=str(uuid4())))
+ channel1, channel2, channel3, channel4 = session.query(Channel).all()
+
+ now_ = now()
+ second = timedelta(seconds=1)
+
+ # The upload_date decides the order of the prev/next videos.
+ session.add(Video(title=f'vid1', channel_id=channel1.id, upload_date=now_))
+ session.add(Video(title=f'vid2', channel_id=channel1.id, upload_date=now_ + second))
+ session.add(Video(title=f'vid3', channel_id=channel2.id, upload_date=now_ + (second * 4)))
+ session.add(Video(title=f'vid4', channel_id=channel1.id, upload_date=now_ + (second * 3)))
+ session.add(Video(title=f'vid5', channel_id=channel2.id, upload_date=now_ + (second * 2)))
+ session.add(Video(title=f'vid6', channel_id=channel2.id, upload_date=now_ + (second * 5)))
+ session.add(Video(title=f'vid7', channel_id=channel1.id))
+ session.add(Video(title=f'vid8', channel_id=channel2.id, upload_date=now_ + (second * 7)))
+ session.add(Video(title=f'vid9', channel_id=channel3.id, upload_date=now_ + (second * 8)))
+ session.add(Video(title=f'vid10', channel_id=channel4.id))
+ session.add(Video(title=f'vid11', channel_id=channel4.id))
+
+ tests = [
+ # Channel 1's videos were inserted in upload_date order.
+ (1, (None, 'vid2')),
+ (2, ('vid1', 'vid4')),
+ (4, ('vid2', None)), # 7 has no upload_date, so it doesn't come after 4.
+ (7, (None, None)), # 7 has no upload_date, so we don't know the order of it.
+ # Channel 3 has only one video.
+ (9, (None, None)),
+ # Channel 2 was inserted out of order.
+ (5, (None, 'vid3')),
+ (3, ('vid5', 'vid6')),
+ (8, ('vid6', None)),
+ # Channel 4's videos have no upload date, so we don't know what is previous/next.
+ (10, (None, None)),
+ ]
+
+ for id_, (prev_title, next_title) in tests:
+ video = session.query(Video).filter_by(id=id_).one()
+ prev_video, next_video = video.get_surrounding_videos()
+
+ if prev_title is None:
+ self.assertIsNone(prev_video)
+ else:
+ self.assertDictContains(prev_video, {'title': prev_title})
+
+ if next_title is None:
+ self.assertIsNone(next_video)
+ else:
+ self.assertDictContains(next_video, {'title': next_title})
+
+ @wrap_test_db
+ @create_channel_structure({
+ 'channel1': ['vid1.mp4']
+ })
+ def test_wrol_mode(self, tempdir):
+ """
+ Many methods are blocked when WROL Mode is enabled.
+ """
+ channel = dumps(dict(name='foo', directory='foo'))
+ favorite = dumps(dict(video_id=1, favorite=True))
+
+ with mock.patch('wrolpi.common.wrol_mode_enabled', lambda: True):
+ # Can't create, update, or delete a channel.
+ _, resp = api_app.test_client.post('/api/videos/channels', data=channel)
+ self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code'])
+ _, resp = api_app.test_client.put('/api/videos/channels/channel1', data=channel)
+ self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code'])
+ _, resp = api_app.test_client.patch('/api/videos/channels/channel1', data=channel)
+ self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code'])
+ _, resp = api_app.test_client.delete('/api/videos/channels/channel1')
+ self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code'])
+
+ # Can't delete a video
+ _, resp = api_app.test_client.delete('/api/videos/video/1')
+ self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code'])
+
+ # Can't refresh or download
+ _, resp = api_app.test_client.post('/api/videos:refresh')
+ self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code'])
+ _, resp = api_app.test_client.post('/api/videos:download')
+ self.assertError(resp, HTTPStatus.FORBIDDEN, API_ERRORS[WROLModeEnabled]['code'])
+
+ # THE REST OF THESE METHODS ARE ALLOWED
+ _, resp = api_app.test_client.post('/api/videos:favorite', data=favorite)
+ self.assertEqual(resp.status_code, HTTPStatus.OK)
+
+ @wrap_test_db
+ @create_channel_structure({
+ 'channel1': [
+ 'vid1.mp4',
+ 'vid1.en.vtt',
+ ],
+ })
+ def test_get_video_for_app(self, tempdir):
+ with get_db_session(commit=True) as session:
+ vid1 = session.query(Video).one()
+
+ vid, prev, next_ = get_video_for_app(vid1.id)
+ self.assertEqual(vid['id'], vid1.id)
+
+ @wrap_test_db
+ @create_channel_structure({
+ 'channel1': [
+ 'vid1.mp4',
+ 'vid1.en.vtt',
+ ],
+ 'channel2': [
+ 'vid2.mp4',
+ 'vid2.info.json',
+ ],
+ })
+ def test_delete_video(self, tempdir: pathlib.Path):
+ with get_db_session(commit=True) as session:
+ channel1 = session.query(Channel).filter_by(name='channel1').one()
+ channel2 = session.query(Channel).filter_by(name='channel2').one()
+ vid1, vid2 = session.query(Video).order_by(Video.video_path).all()
+ vid1.source_id = 'foo'
+ vid2.source_id = 'bar'
+
+ # No videos have been deleted yet.
+ self.assertIsNone(channel1.skip_download_videos)
+ self.assertIsNone(channel2.skip_download_videos)
+ self.assertTrue((tempdir / 'channel1/vid1.mp4').is_file())
+
+ vid1.delete()
+
+ channel1 = session.query(Channel).filter_by(name='channel1').one()
+ # Video was added to skip list.
+ self.assertEqual(len(channel1.skip_download_videos), 1)
+ # Deleting a video leaves it's entry in the DB, but its files are deleted.
+ self.assertEqual(session.query(Video).count(), 2)
+ self.assertFalse((tempdir / 'channel1/vid1.mp4').is_file())
+ self.assertTrue((tempdir / 'channel2/vid2.mp4').is_file())
+
+ vid2.delete()
+
+ self.assertEqual(session.query(Video).count(), 2)
+ self.assertFalse((tempdir / 'channel1/vid1.mp4').is_file())
+ self.assertFalse((tempdir / 'channel2/vid2.mp4').is_file())
+
+ def test_events(self):
+ request, response = api_app.test_client.get('/api/events')
+ self.assertOK(response)
+ self.assertGreater(len(response.json['events']), 1)
+ self.assertFalse(any(i['is_set'] for i in response.json['events']))
+
+ calls = []
+
+ async def fake_refresh_videos(*a, **kw):
+ calls.append((a, kw))
+
+ with mock.patch('modules.videos.api.refresh_videos', fake_refresh_videos), \
+ mock.patch('modules.videos.api.refresh_event') as refresh_event:
+ refresh_event: MagicMock
+
+ # Cannot start a second refresh while one is running.
+ refresh_event.is_set.return_value = True
+ request, response = api_app.test_client.post('/api/videos:refresh')
+ self.assertCONFLICT(response)
+
+ # Refresh is started, a stream is created
+ refresh_event.is_set.return_value = False
+ request, response = api_app.test_client.post('/api/videos:refresh')
+ self.assertOK(response)
+ self.assertEqual(response.json['code'], 'stream-started')
+ stream_url: str = response.json['stream_url']
+ assert stream_url.startswith('ws://')
+ assert calls == [((None,), {})]
+ refresh_event.set.assert_called()
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 00000000..9482d322
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,53 @@
+events {
+
+}
+
+http {
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ server {
+ location /favicon.ico {
+ alias /etc/nginx/favicon.ico;
+ }
+
+ location /apple-touch-icon.png {
+ alias /etc/nginx/icon.png;
+ }
+
+ location / {
+ # React APP
+ proxy_pass http://app:3000;
+ rewrite $1 break;
+ }
+
+ location /sockjs-node {
+ proxy_pass http://app:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ }
+
+ location /api {
+ proxy_pass http://api:8081;
+ rewrite /^api(.*)$ $1 break;
+ }
+
+ location /media {
+ # The media files mounted in the docker volume
+ sendfile on;
+ sendfile_max_chunk 1m;
+
+ tcp_nopush on;
+ tcp_nodelay on;
+ keepalive_timeout 65;
+
+ autoindex on;
+ autoindex_exact_size off;
+ alias /opt/media;
+ }
+ }
+
+}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 00000000..42f139db
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,18 @@
+alembic==1.6.5
+beautifulsoup4==4.10.0
+cachetools==4.2.0
+pdfkit==0.6.1
+Pillow==8.3.2
+Pint==0.16
+psycopg2-binary==2.8.6
+pytz==2021.1
+PyYAML==5.4
+requests==2.26.0
+Sanic-Cors==0.10.0.post3
+sanic-openapi==0.6.2
+sanic==20.12.0
+SQLAlchemy==1.3.22
+srt==3.4.1
+websockets==8.1
+webvtt-py==0.4.6
+youtube-dl==2021.5.16
diff --git a/scripts/hotspot.sh b/scripts/hotspot.sh
new file mode 100644
index 00000000..2b7d7303
--- /dev/null
+++ b/scripts/hotspot.sh
@@ -0,0 +1,57 @@
+#! /usr/bin/env bash
+# Configure the WROLPi hotspot.
+set -x
+set -e
+
+apt install -y network-manager
+
+cat >/etc/netplan/50-cloud-init.yaml <<'EOF'
+# This file is generated from information provided by the datasource. Changes
+# to it will not persist across an instance reboot. To disable cloud-init's
+# network configuration capabilities, write a file
+# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following:
+# network: {config: disabled}
+network:
+ version: 2
+ renderer: NetworkManager
+EOF
+
+netplan generate
+netplan apply
+
+# Initialize the Hotspot config, then overwrite it.
+nmcli con add type wifi ifname wlan0 con-name Hotspot autoconnect yes ssid WROLPi
+cat >/etc/NetworkManager/system-connections/Hotspot.nmconnection <<'EOF'
+[connection]
+id=Hotspot
+uuid=b72ae32e-877c-4559-aaa1-e41a7a9d7bd5
+type=wifi
+interface-name=wlan0
+permissions=
+timestamp=1635780305
+
+[wifi]
+band=bg
+mac-address-blacklist=
+mode=ap
+seen-bssids=DC:A6:32:C7:01:EE;
+ssid=WROLPi
+
+[wifi-security]
+key-mgmt=wpa-psk
+psk=wrolpi hotspot
+
+[ipv4]
+address1=192.168.0.1/24,192.168.0.1
+dns-search=
+method=shared
+
+[ipv6]
+addr-gen-mode=stable-privacy
+dns-search=
+method=auto
+
+[proxy]
+EOF
+
+systemctl restart NetworkManager.service
diff --git a/systemd/wrolpi-api.service b/systemd/wrolpi-api.service
new file mode 100644
index 00000000..b309db0b
--- /dev/null
+++ b/systemd/wrolpi-api.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=WROLPi Python API
+Wants=postgresql.service
+PartOf=wrolpi.target
+
+[Service]
+Environment=PYTHONUNBUFFERED=1
+ExecStart=/opt/wrolpi/venv/bin/python /opt/wrolpi/main.py api
+Group=wrolpi
+User=wrolpi
+WorkingDirectory=/opt/wrolpi/
+
+[Install]
+WantedBy=default.target
diff --git a/systemd/wrolpi-app.service b/systemd/wrolpi-app.service
new file mode 100644
index 00000000..2b377b2f
--- /dev/null
+++ b/systemd/wrolpi-app.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=WROLPi React APP
+Wants=wrolpi-api.service
+PartOf=wrolpi.target
+
+[Service]
+ExecStart=/usr/local/bin/serve -s build
+Group=wrolpi
+User=wrolpi
+WorkingDirectory=/opt/wrolpi/app/
+
+[Install]
+WantedBy=default.target
diff --git a/systemd/wrolpi.target b/systemd/wrolpi.target
new file mode 100644
index 00000000..bcf62ded
--- /dev/null
+++ b/systemd/wrolpi.target
@@ -0,0 +1,3 @@
+[Unit]
+Description=WROLPi services group
+Wants=wrolpi-api.service wrolpi-app.service nginx.service postgresql.service
diff --git a/test/example1.en.vtt b/test/example1.en.vtt
new file mode 100644
index 00000000..fb0fb9b2
--- /dev/null
+++ b/test/example1.en.vtt
@@ -0,0 +1,48 @@
+WEBVTT
+Kind: captions
+Language: en
+
+00:00:00.000 --> 00:00:05.269 align:start position:0%
+
+okay<00:00:01.310> welcome <00:00:02.310> to <00:00:02.429> this <00:00:02.550> session <00:00:04.130> this <00:00:05.130> is
+
+00:00:05.269 --> 00:00:05.279 align:start position:0%
+okay welcome to this session this is
+
+
+00:00:05.279 --> 00:00:07.760 align:start position:0%
+okay welcome to this session this is
+called<00:00:05.879> the <00:00:05.970> kinetic <00:00:06.420> bunny <00:00:06.690> need <00:00:07.319> to <00:00:07.529> meet
+
+00:00:07.760 --> 00:00:07.770 align:start position:0%
+called the kinetic bunny need to meet
+
+
+00:00:07.770 --> 00:00:11.450 align:start position:0%
+called the kinetic bunny need to meet
+the<00:00:07.890> virtual <00:00:08.280> bonnie <00:00:08.519> emilio <00:00:09.380> stop <00:00:10.380> this <00:00:11.340> is
+
+00:00:11.450 --> 00:00:11.460 align:start position:0%
+the virtual bonnie emilio stop this is
+
+
+00:00:11.460 --> 00:00:15.410 align:start position:0%
+the virtual bonnie emilio stop this is
+jana<00:00:11.639> car <00:00:11.880> who <00:00:13.700> just <00:00:14.700> to <00:00:14.759> give <00:00:14.940> a <00:00:14.969> little <00:00:15.030> bit
+
+00:00:15.410 --> 00:00:15.420 align:start position:0%
+jana car who just to give a little bit
+
+
+00:00:15.420 --> 00:00:18.859 align:start position:0%
+jana car who just to give a little bit
+of<00:00:15.529> background <00:00:16.529> information <00:00:17.390> we <00:00:18.390> come <00:00:18.630> from <00:00:18.750> a
+
+00:00:18.859 --> 00:00:18.869 align:start position:0%
+of background information we come from a
+
+
+00:00:18.869 --> 00:00:21.109 align:start position:0%
+of background information we come from a
+company<00:00:19.109> called <00:00:19.199> le <00:00:19.529> code <00:00:19.740> from <00:00:20.550> finland <00:00:20.939> and
+
diff --git a/test/example2.en.vtt b/test/example2.en.vtt
new file mode 100644
index 00000000..d59ce91c
--- /dev/null
+++ b/test/example2.en.vtt
@@ -0,0 +1,22 @@
+WEBVTT
+Kind: captions
+Language: en
+
+00:00:50.680 --> 00:00:52.240
+(YAWNS)
+
+00:01:19.740 --> 00:01:23.760
+(POETIC MUSIC)
+
+00:02:33.080 --> 00:02:34.040
+(MAKES NOISE)
+
+00:02:48.420 --> 00:02:49.380
+(MAKES NOISE)
+
+00:04:14.660 --> 00:04:30.320
+(EPIC MUSIC)
+
+00:06:12.820 --> 00:06:15.100
+(SCREAMS)
+
diff --git a/test/videos/wrolpi/big_buck_bunny_720p_1mb.mp4 b/test/videos/wrolpi/big_buck_bunny_720p_1mb.mp4
new file mode 100644
index 00000000..ed139d6d
Binary files /dev/null and b/test/videos/wrolpi/big_buck_bunny_720p_1mb.mp4 differ
diff --git a/wrolpi/__init__.py b/wrolpi/__init__.py
new file mode 100644
index 00000000..56d15213
--- /dev/null
+++ b/wrolpi/__init__.py
@@ -0,0 +1,9 @@
+BEFORE_STARTUP_FUNCTIONS = []
+
+
+def before_startup(func: callable):
+ """
+ Run a callable before startup of the WROLPi API.
+ """
+ BEFORE_STARTUP_FUNCTIONS.append(func)
+ return func
diff --git a/wrolpi/common.py b/wrolpi/common.py
new file mode 100644
index 00000000..3413de48
--- /dev/null
+++ b/wrolpi/common.py
@@ -0,0 +1,545 @@
+import asyncio
+import collections
+import inspect
+import json
+import logging
+import os
+import pathlib
+import queue
+import re
+import string
+from copy import deepcopy
+from datetime import datetime, date
+from functools import wraps
+from itertools import islice
+from multiprocessing import Event, Queue
+from pathlib import Path
+from typing import Union, Callable, Tuple, Dict, Mapping, List, Iterable
+from urllib.parse import urlunsplit
+
+import yaml
+from cachetools import cached, TTLCache
+from sanic import Blueprint
+from sanic.request import Request
+from sqlalchemy import types
+from sqlalchemy.ext.compiler import compiles
+from sqlalchemy.ext.declarative import declarative_base
+
+from wrolpi.errors import WROLModeEnabled, UnknownDirectory
+from wrolpi.vars import CONFIG_PATH, EXAMPLE_CONFIG_PATH, PUBLIC_HOST, PUBLIC_PORT, LAST_MODIFIED_DATE_FORMAT, \
+ PROJECT_DIR
+
+logger = logging.getLogger(__name__)
+ch = logging.StreamHandler()
+formatter = logging.Formatter('[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s')
+ch.setFormatter(formatter)
+logger.addHandler(ch)
+
+# Base is used for all SQLAlchemy models.
+Base = declarative_base()
+
+
+class ModelHelper:
+
+ def dict(self) -> dict:
+ d = {i.name: getattr(self, i.name) for i in self.__table__.columns} # noqa
+ return d
+
+
+class PathColumn(types.TypeDecorator):
+ impl = types.String
+
+ def process_bind_param(self, value, dialect):
+ if isinstance(value, Path):
+ return value
+ elif value:
+ return str(value)
+
+ def process_result_value(self, value, dialect):
+ if value:
+ return Path(value)
+
+
+class tsvector(types.TypeDecorator):
+ impl = types.UnicodeText
+
+
+@compiles(tsvector, 'postgresql')
+def compile_tsvector(element, compiler, **kw):
+ return 'tsvector'
+
+
+URL_CHARS = string.ascii_lowercase + string.digits
+
+
+def sanitize_link(link: str) -> str:
+ """Remove any non-url safe characters, all will be lowercase."""
+ new_link = ''.join(i for i in str(link).lower() if i in URL_CHARS)
+ return new_link
+
+
+def string_to_boolean(s: str) -> bool:
+ return str(s).lower() in {'true', 't', '1', 'on'}
+
+
+DEFAULT_QUEUE_SIZE = 1000
+QUEUE_TIMEOUT = 10
+
+feed_logger = logger.getChild('ws_feed')
+
+EVENTS = []
+QUEUES = []
+
+
+def create_websocket_feed(name: str, uri: str, blueprint: Blueprint, maxsize: int = DEFAULT_QUEUE_SIZE):
+ """
+ Build the objects needed to run a websocket which will pass on messages from a multiprocessing.Queue.
+
+ :param name: the name that will be reported in the global event feeds
+ :param uri: the Sanic URI that the websocket will listen on
+ :param blueprint: the Sanic Blueprint to attach the websocket to
+ :param maxsize: the maximum size of the Queue
+ :return:
+ """
+ q = Queue(maxsize=maxsize)
+ QUEUES.append(q)
+ event = Event()
+ EVENTS.append((name, event))
+
+ @blueprint.websocket(uri)
+ async def local_websocket(_: Request, ws):
+ feed_logger.info(f'client connected to {ws}')
+ feed_logger.debug(f'event.is_set: {event.is_set()}')
+ any_messages = False
+ while q.qsize() or event.is_set():
+ # Pass along messages from the queue until its empty, or the event is cleared. Give up after 1 second so
+ # the worker can take another request.
+ try:
+ msg = q.get(timeout=QUEUE_TIMEOUT)
+ any_messages = True
+ dump = json.dumps(msg)
+ await ws.send(dump)
+
+ # yield back to the event loop
+ await asyncio.sleep(0)
+ except queue.Empty: # pragma: no cover
+ # No messages yet, try again while event is set
+ pass
+ feed_logger.info(f'loop complete')
+
+ if any_messages is False:
+ await ws.send(json.dumps({'code': 'no-messages'}))
+
+ # No messages left, stream is complete
+ await ws.send(json.dumps({'code': 'stream-complete'}))
+
+ return q, event
+
+
+# The following code is used to consistently construct URLs that will reference this service.
+SANIC_HOST = None
+SANIC_PORT = None
+URL_COMPONENTS = collections.namedtuple('Components', ['scheme', 'netloc', 'path', 'query', 'fragment'])
+
+
+def set_sanic_url_parts(host, port):
+ """
+ Set the global parts of this service's URL. This is used to consistently construct URLs that will reference this
+ service.
+ """
+ global SANIC_HOST
+ global SANIC_PORT
+ SANIC_HOST = host
+ SANIC_PORT = port
+
+
+def get_sanic_url(scheme: str = 'http', path: str = None, query: list = None, fragment: str = None):
+ """
+ Build a URL with the provided parts that references this running service.
+ """
+ host = PUBLIC_HOST or SANIC_HOST
+ port = PUBLIC_PORT or SANIC_PORT
+ components = URL_COMPONENTS(scheme=scheme, netloc=f'{host}:{port}', path=path,
+ query=query, fragment=fragment)
+ unparsed = str(urlunsplit(components))
+ return unparsed
+
+
+def make_progress_calculator(total):
+ """
+ Create a function that calculates the percentage of completion.
+ """
+
+ def progress_calculator(current) -> int:
+ if current >= total:
+ # Progress doesn't make sense, just return 100
+ return 100
+ percent = int((current / total) * 100)
+ return percent
+
+ return progress_calculator
+
+
+class ProgressReporter:
+ """
+ I am used to consistently send messages and progress(s) to a Websocket Feed.
+ """
+
+ def __init__(self, q: Queue, progress_count: int = 1):
+ self.queue: Queue = q
+ self.progresses = [{'percent': 0, 'total': 0, 'value': 0} for _ in range(progress_count)]
+ self.calculators = [lambda _: None for _ in range(progress_count)]
+
+ def _update(self, idx: int, **kwargs):
+ if 'message' in kwargs and kwargs['message'] is None:
+ # Message can't be cleared.
+ kwargs.pop('message')
+ self.progresses[idx].update(kwargs)
+
+ def _send(self, code: str = None):
+ msg = dict(
+ progresses=deepcopy(self.progresses)
+ )
+ if code:
+ msg['code'] = code
+ self.queue.put(msg)
+
+ def message(self, idx: int, msg: str, code: str = None):
+ self._update(idx, message=msg)
+ self._send(code)
+
+ def code(self, code: str):
+ self._send(code)
+
+ def error(self, idx: int, msg: str = None):
+ self.message(idx, msg, 'error')
+
+ def set_progress_total(self, idx: int, total: int):
+ self.progresses[idx]['total'] = total
+ self.calculators[idx] = make_progress_calculator(total)
+
+ def send_progress(self, idx: int, value: int, msg: str = None):
+ kwargs = dict(value=value, percent=self.calculators[idx](value), message=msg)
+ self._update(idx, **kwargs)
+ self._send()
+
+ def finish(self, idx: int, msg: str = None):
+ kwargs = dict(percent=100, message=msg)
+
+ if self.progresses[idx]['total'] == 0:
+ kwargs.update(dict(value=1, total=1))
+ else:
+ kwargs.update(dict(value=self.progresses[idx]['total']))
+
+ self._update(idx, **kwargs)
+ self._send()
+
+
+class FileNotModified(Exception):
+ pass
+
+
+def get_modified_time(path: Union[Path, str]) -> datetime:
+ """
+ Return a datetime object containing the os modification time of the provided path.
+ """
+ modified = datetime.utcfromtimestamp(os.path.getmtime(str(path)))
+ return modified
+
+
+def get_last_modified_headers(request_headers: dict, path: Union[Path, str]) -> dict:
+ """
+ Get a dict containing the Last-Modified header for the provided path. If If-Modified-Since is in the provided
+ request headers, then this will raise a FileNotModified exception, which should be handled by
+ `handle_FileNotModified`.
+ """
+ last_modified = get_modified_time(path)
+
+ modified_since = request_headers.get('If-Modified-Since')
+ if modified_since:
+ modified_since = datetime.strptime(modified_since, LAST_MODIFIED_DATE_FORMAT)
+ if last_modified >= modified_since:
+ raise FileNotModified()
+
+ last_modified = last_modified.strftime(LAST_MODIFIED_DATE_FORMAT)
+ headers = {'Last-Modified': last_modified}
+ return headers
+
+
+def get_example_config() -> dict:
+ config_path = EXAMPLE_CONFIG_PATH
+ with open(str(config_path), 'rt') as fh:
+ config = yaml.load(fh, Loader=yaml.Loader)
+ return dict(config)
+
+
+def get_local_config() -> dict:
+ config_path = CONFIG_PATH
+ with open(str(config_path), 'rt') as fh:
+ config = yaml.load(fh, Loader=yaml.Loader)
+ return dict(config)
+
+
+def get_config() -> dict:
+ try:
+ return get_local_config()
+ except FileNotFoundError:
+ return get_example_config()
+
+
+def combine_dicts(*dicts: dict) -> dict:
+ """
+ Recursively combine dictionaries, preserving the leftmost value.
+
+ >>> a = dict(a='b', c=dict(d='e'))
+ >>> b = dict(a='c', e='f')
+ >>> combine_dicts(a, b)
+ dict(a='b', c=dict(d='e'), e='f')
+ """
+ if len(dicts) == 0:
+ raise IndexError('No dictionaries to iterate through')
+ elif len(dicts) == 1:
+ return dicts[0]
+ a, b = dicts[-2:]
+ c = dicts[:-2]
+ new = {}
+ keys = set(a.keys())
+ keys = keys.union(b.keys())
+ for k in keys:
+ if k in b and k in a and isinstance(b[k], Mapping):
+ value = combine_dicts(a[k], b[k])
+ else:
+ value = a.get(k, b.get(k))
+ new[k] = value
+ if c:
+ return combine_dicts(*c, new)
+ return new
+
+
+def save_settings_config(config=None):
+ """
+ Save new settings to local.yaml, overwriting what is there. This function updates the config file from three
+ sources: the config object argument, the local config, then the example config; in that order.
+ """
+ config = config or {}
+ example_config = get_example_config()
+ # Remove the example channel, that shouldn't be saved to local
+ example_config.pop('channels')
+ try:
+ local_config = get_local_config()
+ except FileNotFoundError:
+ # Local config does not yet exist, lets create it
+ local_config = {}
+
+ if 'channels' in config and 'channels' in local_config:
+ del local_config['channels']
+
+ if ('channels' in config or 'channels' in local_config) and 'channels' in example_config:
+ del example_config['channels']
+
+ new_config = combine_dicts(config, local_config, example_config)
+
+ logger.info(f'Writing config to file: {CONFIG_PATH}')
+ with open(str(CONFIG_PATH), 'wt') as fh:
+ yaml.dump(new_config, fh)
+
+
+@cached(cache=TTLCache(maxsize=1, ttl=30))
+def wrol_mode_enabled() -> bool:
+ """
+ Return the boolean value of the `wrol_mode` in the config.
+ """
+ config = get_config()
+ enabled = config.get('wrol_mode', False)
+ return bool(enabled)
+
+
+def wrol_mode_check(func):
+ """
+ Wraps a function so that it cannot be called when WROL Mode is enabled.
+ """
+
+ @wraps(func)
+ def check(*a, **kw):
+ if wrol_mode_enabled():
+ raise WROLModeEnabled()
+
+ # WROL Mode is not enabled, run the function as normal.
+ result = func(*a, **kw)
+ return result
+
+ return check
+
+
+def insert_parameter(func: Callable, parameter_name: str, item, args: Tuple, kwargs: Dict) -> Tuple[Tuple, Dict]:
+ """
+ Insert a parameter wherever it fits in the Callable's signature.
+ """
+ sig = inspect.signature(func)
+ if parameter_name not in sig.parameters:
+ raise TypeError(f'Function {func} MUST have a {parameter_name} parameter!')
+
+ args = list(args)
+
+ index = list(sig.parameters).index(parameter_name)
+ args.insert(index, item)
+ args = tuple(args)
+
+ return args, kwargs
+
+
+def iterify(kind: type = list):
+ """
+ Convenience function to convert the output of the wrapped function to the type provided.
+ """
+
+ def wrapper(func):
+ @wraps(func)
+ def wrapped(*a, **kw):
+ result = func(*a, **kw)
+ return kind(result)
+
+ return wrapped
+
+ return wrapper
+
+
+dt_or_d = Union[datetime, date]
+
+
+def date_range(start: dt_or_d, end: dt_or_d, steps: int) -> List[dt_or_d]:
+ delta = (end - start) // steps
+ return [start + (delta * i) for i in range(steps)]
+
+
+def chunks(it, size):
+ it = iter(it)
+ return iter(lambda: tuple(islice(it, size)), ())
+
+
+WHITESPACE = re.compile(r'\s')
+
+
+def remove_whitespace(s: str) -> str:
+ return WHITESPACE.sub('', s)
+
+
+def remove_dict_value_whitespace(d: Dict) -> Dict:
+ """
+ Remove the whitespace around a the values in a dictionary. Handles if the value isn't a string.
+ """
+ return {k: v.strip() if hasattr(v, 'strip') else v for k, v in d.items()}
+
+
+def run_after(after: callable, *args, **kwargs) -> callable:
+ """
+ Run the `after` function sometime in the future ofter the wrapped function returns.
+ """
+ if not inspect.iscoroutinefunction(after):
+ synchronous_after = after
+
+ async def after(*a, **kw):
+ return synchronous_after(*a, **kw)
+
+ def wrapper(func: callable):
+ if inspect.iscoroutinefunction(func):
+ @wraps(func)
+ async def wrapped(*a, **kw):
+ results = await func(*a, **kw)
+ coro = after(*args, **kwargs)
+ asyncio.ensure_future(coro)
+ return results
+ else:
+ @wraps(func)
+ def wrapped(*a, **kw):
+ results = func(*a, **kw)
+ coro = after(*args, **kwargs)
+ asyncio.ensure_future(coro)
+ return results
+
+ return wrapped
+
+ return wrapper
+
+
+TEST_MEDIA_DIRECTORY = None
+MEDIA_DIRECTORY = None
+
+
+def set_test_media_directory(path):
+ global TEST_MEDIA_DIRECTORY
+ TEST_MEDIA_DIRECTORY = pathlib.Path(path) if path else None
+
+
+def get_media_directory() -> Path:
+ """
+ Get the media directory configured in local.yaml.
+ """
+ global TEST_MEDIA_DIRECTORY
+ global MEDIA_DIRECTORY
+
+ if isinstance(TEST_MEDIA_DIRECTORY, pathlib.Path):
+ return TEST_MEDIA_DIRECTORY
+
+ if isinstance(MEDIA_DIRECTORY, pathlib.Path):
+ return MEDIA_DIRECTORY
+
+ config = get_config()
+ media_directory = config['media_directory']
+ media_directory = Path(media_directory)
+ if not media_directory.is_absolute():
+ # Media directory is relative. Assume that is relative to the project directory.
+ media_directory = PROJECT_DIR / media_directory
+ MEDIA_DIRECTORY = media_directory.absolute()
+ return MEDIA_DIRECTORY
+
+
+def get_absolute_media_path(path: str) -> Path:
+ """
+ Get the absolute path of file/directory within the config media directory.
+
+ >>> get_media_directory()
+ Path('/media')
+ >>> get_absolute_media_path('videos/blender')
+ Path('/media/videos/blender')
+
+ :raises UnknownDirectory: the directory/path doesn't exist
+ """
+ media_directory = get_media_directory()
+ if not path:
+ raise ValueError(f'Cannot combine empty path with {media_directory}')
+ path = media_directory / path
+ if not path.exists():
+ raise UnknownDirectory(f'path={path}')
+ return path
+
+
+def get_relative_to_media_directory(path: str) -> Path:
+ """
+ Get the path for a file/directory relative to the config media directory.
+
+ >>> get_media_directory()
+ Path('/media')
+ >>> get_relative_to_media_directory('/media/videos/blender')
+ Path('videos/blender')
+
+ :raises UnknownDirectory: the directory/path doesn't exist
+ """
+ absolute = get_absolute_media_path(path)
+ return absolute.relative_to(get_media_directory())
+
+
+def minimize_dict(d: dict, keys: Iterable) -> dict:
+ """
+ Return a new dictionary that contains only the keys provided.
+ """
+ return {k: d[k] for k in set(keys) & d.keys()}
+
+
+def make_media_directory(path: str):
+ """
+ Make a directory relative within the media directory.
+ """
+ media_dir = get_media_directory()
+ path = media_dir / str(path)
+ path.mkdir(parents=True)
diff --git a/wrolpi/conftest.py b/wrolpi/conftest.py
new file mode 100644
index 00000000..e96dc749
--- /dev/null
+++ b/wrolpi/conftest.py
@@ -0,0 +1,32 @@
+"""
+Fixtures for Pytest tests.
+"""
+from unittest import mock
+
+import pytest
+from sqlalchemy.orm import Session
+
+from wrolpi.db import postgres_engine
+from wrolpi.test.common import test_db
+
+
+@pytest.fixture
+def test_session() -> Session:
+ """
+ Pytest Fixture to get a test database session.
+ """
+ test_engine, session = test_db()
+
+ def fake_get_db_context():
+ """Get the testing db"""
+ return test_engine, session
+
+ try:
+ with mock.patch('wrolpi.db._get_db_session', fake_get_db_context):
+ yield session
+ finally:
+ session.rollback()
+ session.close()
+ test_engine.dispose()
+ conn = postgres_engine.connect()
+ conn.execute(f'DROP DATABASE IF EXISTS {test_engine.engine.url.database}')
diff --git a/wrolpi/dates.py b/wrolpi/dates.py
new file mode 100644
index 00000000..80662df4
--- /dev/null
+++ b/wrolpi/dates.py
@@ -0,0 +1,93 @@
+import logging
+from datetime import datetime, date, timezone
+
+import pytz
+from sqlalchemy import types
+
+from wrolpi.errors import InvalidTimezone
+from wrolpi.vars import DEFAULT_TIMEZONE_STR, DATETIME_FORMAT, DATETIME_FORMAT_MS
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_TIMEZONE = pytz.timezone(DEFAULT_TIMEZONE_STR)
+
+
+def set_timezone(tz: pytz.timezone):
+ """
+ Change the global timezone for WROLPi. This does NOT save the config.
+ """
+ global DEFAULT_TIMEZONE
+
+ if not tz:
+ raise InvalidTimezone('Timezone cannot be blank!')
+
+ if isinstance(tz, str):
+ tz = pytz.timezone(tz)
+
+ logger.info(f'Setting timezone: {tz}')
+ DEFAULT_TIMEZONE = tz
+
+
+def utc_now() -> datetime:
+ """
+ Get the current DateTime in UTC. Timezone aware.
+ """
+ return datetime.utcnow().astimezone(pytz.utc)
+
+
+def now(tz: pytz.timezone = None) -> datetime:
+ """
+ Get the current DateTime in the provided timezone. Timezone aware.
+ """
+ tz = tz or DEFAULT_TIMEZONE
+ return datetime.utcnow().astimezone(tz)
+
+
+def local_timezone(dt: datetime) -> datetime:
+ """
+ Convert the DateTime provided to the local Timezone. Timezone aware.
+ """
+ return dt.astimezone(DEFAULT_TIMEZONE)
+
+
+def today() -> date:
+ """
+ Return today's date.
+ """
+ return now().date()
+
+
+def strftime(dt: datetime) -> str:
+ return dt.strftime(DATETIME_FORMAT)
+
+
+def strptime(dt: str) -> datetime:
+ return local_timezone(datetime.strptime(dt, DATETIME_FORMAT))
+
+
+def strftime_ms(dt: datetime) -> str:
+ return dt.strftime(DATETIME_FORMAT_MS)
+
+
+def strptime_ms(dt: str) -> datetime:
+ return local_timezone(datetime.strptime(dt, DATETIME_FORMAT_MS))
+
+
+class TZDateTime(types.TypeDecorator):
+ impl = types.DateTime
+ cache_ok = True
+
+ def process_bind_param(self, value, dialect):
+ if isinstance(value, datetime):
+ if not value.tzinfo:
+ raise TypeError("tzinfo is required")
+ # Store all timestamps as UTC timezone.
+ value = value.astimezone(timezone.utc)
+ elif isinstance(value, str) and '-' in value:
+ value = datetime.fromisoformat(value)
+ return value
+
+ def process_result_value(self, value, dialect):
+ if value is not None:
+ value = local_timezone(value)
+ return value
diff --git a/wrolpi/db.py b/wrolpi/db.py
new file mode 100644
index 00000000..86111858
--- /dev/null
+++ b/wrolpi/db.py
@@ -0,0 +1,100 @@
+from contextlib import contextmanager
+from typing import ContextManager
+
+import psycopg2
+import sqlalchemy.exc
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, Session
+from sqlalchemy.pool import NullPool
+
+from wrolpi.common import logger
+from wrolpi.vars import DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DOCKERIZED, PYTEST
+
+logger = logger.getChild(__name__)
+
+
+def get_db_args(dbname: str = None):
+ db_args = dict(
+ dbname=dbname or DB_NAME,
+ user=DB_USER,
+ password=DB_PASSWORD,
+ host=DB_HOST,
+ port=DB_PORT,
+ )
+ if DOCKERIZED:
+ # Deployed in docker, use the docker database.
+ db_args['user'] = 'postgres'
+ db_args['host'] = 'db'
+ db_args['port'] = 5432
+ elif PYTEST:
+ # Pytest is running but we're not in docker, use the exposed docker container port.
+ db_args['user'] = 'postgres'
+ db_args['port'] = 54321
+
+ return db_args
+
+
+# This engine is used to modify the databases.
+connect_args = dict(application_name='wrolpi_api_super')
+postgres_args = get_db_args('postgres')
+postgres_engine = create_engine('postgresql://{user}:{password}@{host}:{port}/{dbname}'.format(**postgres_args),
+ execution_options={'isolation_level': 'AUTOCOMMIT'}, connect_args=connect_args)
+
+# This engine is used for all normal tasks (except testing).
+db_args = get_db_args()
+connect_args = dict(application_name='wrolpi_api')
+uri = 'postgresql://{user}:{password}@{host}:{port}/{dbname}'.format(**db_args)
+engine = create_engine(uri, poolclass=NullPool, connect_args=connect_args)
+session_maker = sessionmaker(bind=engine)
+
+LOGGED_ARGS = False
+
+
+def _get_db_session():
+ """This function allows the database to be wrapped during testing. See: api.test.common.wrap_test_db"""
+ global LOGGED_ARGS
+ if LOGGED_ARGS is False:
+ # Print the DB args for troubleshooting.
+ LOGGED_ARGS = True
+ logging_args = db_args.copy()
+ logging_args['password'] = '***'
+ logger.debug(f'DB args: {logging_args}')
+
+ session = session_maker()
+ return engine, session
+
+
+@contextmanager
+def get_db_session(commit: bool = False) -> ContextManager[Session]:
+ """Context manager that creates a DB session. This will automatically rollback changes, unless `commit` is True."""
+ local_engine, session = _get_db_session()
+ try:
+ yield session
+ if commit:
+ session.commit()
+ except sqlalchemy.exc.DatabaseError:
+ session.rollback()
+ raise
+ finally:
+ # Rollback only if a transaction hasn't been committed.
+ if session.transaction.is_active:
+ session.rollback()
+
+
+@contextmanager
+def get_db_curs(commit: bool = False):
+ """Context manager that yields a DictCursor to execute raw SQL statements."""
+ local_engine, session = _get_db_session()
+ connection = local_engine.raw_connection()
+ curs = connection.cursor(cursor_factory=psycopg2.extras.DictCursor)
+ try:
+ yield curs
+ if commit:
+ connection.commit()
+ except sqlalchemy.exc.DatabaseError:
+ session.rollback()
+ raise
+ finally:
+ # Rollback only if a transaction hasn't been committed.
+ if session.transaction.is_active:
+ connection.rollback()
diff --git a/wrolpi/errors.py b/wrolpi/errors.py
new file mode 100644
index 00000000..f27875a8
--- /dev/null
+++ b/wrolpi/errors.py
@@ -0,0 +1,275 @@
+from http import HTTPStatus
+
+
+# TODO remove service-specific errors
+
+
+class APIError(Exception):
+ pass
+
+
+class UnknownVideo(APIError):
+ pass
+
+
+class UnknownChannel(APIError):
+ pass
+
+
+class UnknownDirectory(APIError):
+ pass
+
+
+class UnknownFile(APIError):
+ pass
+
+
+class ChannelNameConflict(APIError):
+ pass
+
+
+class ChannelURLConflict(APIError):
+ pass
+
+
+class ChannelDirectoryConflict(APIError):
+ pass
+
+
+class SearchEmpty(APIError):
+ pass
+
+
+class UnknownCaptionFile(APIError):
+ pass
+
+
+class ValidationError(APIError):
+ pass
+
+
+class ChannelLinkConflict(APIError):
+ pass
+
+
+class BadFieldType(APIError):
+ pass
+
+
+class MissingRequiredField(APIError):
+ pass
+
+
+class ExcessJSONFields(APIError):
+ pass
+
+
+class NoBodyContents(APIError):
+ pass
+
+
+class InvalidOrderBy(ValidationError):
+ pass
+
+
+class WROLModeEnabled(APIError):
+ pass
+
+
+class ChannelURLEmpty(APIError):
+ pass
+
+
+class InvalidOTP(APIError):
+ pass
+
+
+class InvalidPlaintext(APIError):
+ pass
+
+
+class InvalidCiphertext(APIError):
+ pass
+
+
+class NoFrequency(APIError):
+ pass
+
+
+class NoInventories(APIError):
+ pass
+
+
+class InventoriesVersionMismatch(APIError):
+ pass
+
+
+class InvalidTimezone(APIError):
+ pass
+
+
+class InvalidDomain(APIError):
+ pass
+
+
+class UnknownURL(APIError):
+ pass
+
+
+class PendingArchive(APIError):
+ pass
+
+
+class InvalidArchive(APIError):
+ pass
+
+
+error_codes = iter(range(1, 1000))
+
+API_ERRORS = {
+ UnknownVideo: {
+ 'code': next(error_codes),
+ 'message': 'The video could not be found.',
+ 'status': HTTPStatus.NOT_FOUND,
+ },
+ UnknownChannel: {
+ 'code': next(error_codes),
+ 'message': 'The channel could not be found by its link.',
+ 'status': HTTPStatus.NOT_FOUND,
+ },
+ UnknownDirectory: {
+ 'code': next(error_codes),
+ 'message': 'The directory does not exist.',
+ 'status': HTTPStatus.NOT_FOUND,
+ },
+ UnknownFile: {
+ 'code': next(error_codes),
+ 'message': 'The file does not exist.',
+ 'status': HTTPStatus.NOT_FOUND,
+ },
+ ChannelNameConflict: {
+ 'code': next(error_codes),
+ 'message': 'The channel name is already taken.',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ ChannelURLConflict: {
+ 'code': next(error_codes),
+ 'message': 'The URL is already used by another channel.',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ ChannelDirectoryConflict: {
+ 'code': next(error_codes),
+ 'message': 'The directory is already used by another channel.',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ SearchEmpty: {
+ 'code': next(error_codes),
+ 'message': 'Search is empty, search_str must have content.',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ UnknownCaptionFile: {
+ 'code': next(error_codes),
+ 'message': 'The caption file could not be found',
+ 'status': HTTPStatus.NOT_FOUND,
+ },
+ ValidationError: {
+ 'code': next(error_codes),
+ 'message': 'Could not validate the contents of the request',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ ChannelLinkConflict: {
+ 'code': next(error_codes),
+ 'message': 'Channel link already used by another channel',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ BadFieldType: {
+ 'code': next(error_codes),
+ 'message': 'Field could not be converted to the required type',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ MissingRequiredField: {
+ 'code': next(error_codes),
+ 'message': 'Missing required field',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ ExcessJSONFields: {
+ 'code': next(error_codes),
+ 'message': 'Extra fields in request',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ NoBodyContents: {
+ 'code': next(error_codes),
+ 'message': 'No content in the body of the request',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ InvalidOrderBy: {
+ 'code': next(error_codes),
+ 'message': 'Invalid order_by',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ WROLModeEnabled: {
+ 'code': next(error_codes),
+ 'message': 'This method is disabled while WROL Mode is enabled.',
+ 'status': HTTPStatus.FORBIDDEN,
+ },
+ ChannelURLEmpty: {
+ 'code': next(error_codes),
+ 'message': 'This channel has no URL',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ InvalidOTP: {
+ 'code': next(error_codes),
+ 'message': 'OTP has invalid characters',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ InvalidPlaintext: {
+ 'code': next(error_codes),
+ 'message': 'Plaintext has invalid characters',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ InvalidCiphertext: {
+ 'code': next(error_codes),
+ 'message': 'Ciphertext has invalid characters',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ NoFrequency: {
+ 'code': next(error_codes),
+ 'message': 'Channel does not have a frequency',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ NoInventories: {
+ 'code': next(error_codes),
+ 'message': 'No Inventories',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ InventoriesVersionMismatch: {
+ 'code': next(error_codes),
+ 'message': 'Inventories version in the DB does not match the inventories config',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ InvalidTimezone: {
+ 'code': next(error_codes),
+ 'message': 'Invalid timezone',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ InvalidDomain: {
+ 'code': next(error_codes),
+ 'message': 'Invalid archive domain',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ UnknownURL: {
+ 'code': next(error_codes),
+ 'message': 'Unknown URL',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ PendingArchive: {
+ 'code': next(error_codes),
+ 'message': 'Archive with that URL is already pending',
+ 'status': HTTPStatus.BAD_REQUEST,
+ },
+ InvalidArchive: {
+ 'code': next(error_codes),
+ 'message': 'The archive is invalid. See server logs.',
+ 'status': HTTPStatus.INTERNAL_SERVER_ERROR,
+ },
+}
diff --git a/wrolpi/media_path.py b/wrolpi/media_path.py
new file mode 100644
index 00000000..c7ec0330
--- /dev/null
+++ b/wrolpi/media_path.py
@@ -0,0 +1,70 @@
+import pathlib
+from typing import Union
+
+from sqlalchemy import types
+
+from wrolpi.common import get_media_directory
+
+PATH_TYPE = Union[str, pathlib.Path]
+
+
+class MediaPath:
+ """
+ Enforce that a given path is within the Media Directory.
+ """
+
+ def __init__(self, path: PATH_TYPE):
+ path = self._validate_path(path)
+ self._path = path.absolute()
+
+ def __repr__(self):
+ return f''
+
+ @classmethod
+ def _validate_path(cls, path: PATH_TYPE):
+ if not path:
+ raise ValueError(f'MediaPath cannot not be empty!')
+
+ path = pathlib.Path(path) if not isinstance(path, pathlib.Path) else path
+ if not path.is_absolute():
+ # Assume the path is relative to the media directory.
+ path = get_media_directory() / path
+ elif not path.is_relative_to(get_media_directory()):
+ # Absolute paths must be within the media directory.
+ raise ValueError(f'Path {path} must be in {get_media_directory()}')
+
+ return path
+
+ def __json__(self):
+ # Always return a relative path to frontend.
+ return str(self.path.relative_to(get_media_directory()))
+
+ @property
+ def path(self) -> pathlib.Path:
+ return self._path
+
+ @path.setter
+ def path(self, path: PATH_TYPE):
+ path = self._validate_path(path)
+ self._path = path.absolute()
+
+ def __eq__(self, other):
+ return self._path == other
+
+
+class MediaPathType(types.TypeDecorator): # noqa
+ impl = types.String
+
+ def process_bind_param(self, value, dialect):
+ if isinstance(value, str) and len(value) == 0:
+ raise ValueError('MediaPath cannot be empty')
+
+ if isinstance(value, pathlib.Path):
+ return str(value.absolute())
+ elif isinstance(value, str) or value is None:
+ return value
+ raise ValueError(f'Invalid MediaPath ({type(value)}): {value}')
+
+ def process_result_value(self, value, dialect):
+ if value:
+ return MediaPath(value)
diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py
new file mode 100644
index 00000000..7c80a2ba
--- /dev/null
+++ b/wrolpi/root_api.py
@@ -0,0 +1,208 @@
+import json
+import logging
+import re
+from datetime import datetime, date
+from decimal import Decimal
+from functools import wraps
+from http import HTTPStatus
+from pathlib import Path
+
+from pytz import UnknownTimeZoneError
+from sanic import Sanic, response, Blueprint
+from sanic.request import Request
+from sanic.response import HTTPResponse
+from sanic_cors import CORS
+from sanic_openapi import swagger_blueprint
+
+from wrolpi.common import EVENTS, set_sanic_url_parts, logger, get_config, wrol_mode_enabled, save_settings_config, \
+ Base
+from wrolpi.dates import set_timezone
+from wrolpi.errors import WROLModeEnabled, InvalidTimezone
+from wrolpi.schema import RegexRequest, RegexResponse, SettingsRequest, SettingsResponse, EchoResponse, \
+ EventsResponse, validate_doc
+
+logger = logger.getChild(__name__)
+
+api_app = Sanic(name='api_app')
+
+DEFAULT_HOST, DEFAULT_PORT = '127.0.0.1', '8081'
+
+# TODO Allow all requests to this webapp during development. This should be restricted later.
+CORS(
+ api_app,
+ expose_headers=[
+ 'Location', # Expose this header so the App can send users to the location of a created object.
+ ],
+ resources={
+ '/*': {'origins': '*'},
+ }
+)
+
+root_api = Blueprint('Root API', url_prefix='/api')
+
+# Attach the Sanic OpenAPI blueprint to the API App. This will generate our API docs.
+BLUEPRINTS = [swagger_blueprint, root_api, ]
+
+
+def get_blueprint(name: str, url_prefix: str) -> Blueprint:
+ """
+ Create a new Sanic blueprint. This will be attached to the app just before run. See `root_api.run_webserver`.
+ """
+ bp = Blueprint(name, url_prefix)
+ add_blueprint(bp)
+ return bp
+
+
+def add_blueprint(bp: Blueprint):
+ BLUEPRINTS.append(bp)
+
+
+def run_webserver(host: str, port: int, workers: int = 8):
+ set_sanic_url_parts(host, port)
+
+ # Attach all blueprints after they have been defined.
+ for bp in BLUEPRINTS:
+ api_app.blueprint(bp)
+
+ # Sanic should match our logging level.
+ debug = logger.level == logging.DEBUG
+ # TODO remove the auto reload when development is stable
+ api_app.run(host, port, workers=workers, debug=debug, auto_reload=True)
+
+
+def init_parser(parser):
+ # Called by WROLPI's main() function
+ parser.add_argument('-H', '--host', default=DEFAULT_HOST, help='What network interface to connect webserver')
+ parser.add_argument('-p', '--port', default=DEFAULT_PORT, type=int, help='What port to connect webserver')
+ parser.add_argument('-w', '--workers', default=4, type=int, help='How many web workers to run')
+
+
+def main(args):
+ run_webserver(args.host, args.port, args.workers)
+ return 0
+
+
+index_html = '''
+
+
+
+ This is a WROLPi API.
+
+
+
+
+'''
+
+
+@api_app.get('/')
+def index(_):
+ return response.html(index_html)
+
+
+@root_api.route('/echo', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
+@validate_doc(
+ summary='Echo whatever is sent to this',
+ produces=EchoResponse,
+ tag='Testing',
+)
+async def echo(request: Request):
+ """
+ Returns a JSON object containing details about the request sent to me.
+ """
+ ret = dict(
+ form=request.form,
+ headers=dict(request.headers),
+ json=request.json,
+ method=request.method,
+ args=request.args,
+ )
+ return response.json(ret)
+
+
+@root_api.get('/events')
+@validate_doc(
+ summary='Get a list of event feeds',
+ produces=EventsResponse,
+)
+def events(_: Request):
+ e = [{'name': name, 'is_set': event.is_set()} for (name, event) in EVENTS]
+ return response.json({'events': e})
+
+
+@root_api.route('/settings', methods=['GET', 'OPTIONS'])
+@validate_doc(
+ summary='Get WROLPi settings',
+ produces=SettingsResponse,
+)
+def get_settings(_: Request):
+ config = dict(get_config())
+ return json_response({'config': config})
+
+
+@root_api.patch('/settings')
+@validate_doc(
+ summary='Update WROLPi settings',
+ consumes=SettingsRequest,
+)
+def update_settings(_: Request, data: dict):
+ if wrol_mode_enabled() and 'wrol_mode' not in data:
+ # Cannot update settings while WROL Mode is enabled, unless you want to disable WROL Mode.
+ raise WROLModeEnabled()
+
+ try:
+ if data.get('timezone'):
+ set_timezone(data['timezone'])
+ except UnknownTimeZoneError:
+ raise InvalidTimezone(f'Invalid timezone: {data["timezone"]}')
+
+ save_settings_config(data)
+
+ return response.raw('', HTTPStatus.NO_CONTENT)
+
+
+@root_api.post('/valid_regex')
+@validate_doc(
+ summary='Check if the regex is valid',
+ consumes=RegexRequest,
+ responses=(
+ (HTTPStatus.OK, RegexResponse),
+ (HTTPStatus.BAD_REQUEST, RegexResponse),
+ )
+)
+def valid_regex(_: Request, data: dict):
+ try:
+ re.compile(data['regex'])
+ return response.json({'valid': True, 'regex': data['regex']})
+ except re.error:
+ return response.json({'valid': False, 'regex': data['regex']}, HTTPStatus.BAD_REQUEST)
+
+
+class CustomJSONEncoder(json.JSONEncoder):
+
+ def default(self, obj):
+ if isinstance(obj, datetime):
+ return obj.timestamp()
+ elif isinstance(obj, date):
+ return datetime(obj.year, obj.month, obj.day).timestamp()
+ elif isinstance(obj, Decimal):
+ return str(obj)
+ elif isinstance(obj, Base):
+ if hasattr(obj, 'dict'):
+ return obj.dict()
+ elif isinstance(obj, Path):
+ return str(obj)
+ elif hasattr(obj, '__json__'):
+ return obj.__json__()
+ return super(CustomJSONEncoder, self).default(obj)
+
+
+@wraps(response.json)
+def json_response(*a, **kwargs) -> HTTPResponse:
+ """
+ Handles encoding date/datetime in JSON.
+ """
+ resp = response.json(*a, **kwargs, cls=CustomJSONEncoder, dumps=json.dumps)
+ return resp
diff --git a/wrolpi/schema.py b/wrolpi/schema.py
new file mode 100644
index 00000000..754f8966
--- /dev/null
+++ b/wrolpi/schema.py
@@ -0,0 +1,195 @@
+import inspect
+from datetime import datetime
+from functools import wraps
+from uuid import UUID
+
+import sanic
+from sanic import response
+from sanic_openapi import doc
+from sanic_openapi.doc import Field
+
+from wrolpi.common import string_to_boolean, logger
+from wrolpi.errors import NoBodyContents, ValidationError, MissingRequiredField, ExcessJSONFields, API_ERRORS, APIError
+from wrolpi.vars import DATE_FORMAT
+
+
+def validate_data(model: type, data: dict):
+ """
+ Convert a JSON object to the model's specification. If the JSON object matches the model's specification, this
+ function will return a python dict of that data. If it doesn't match, this will return a Sanic response object
+ containing an error.
+ """
+ if not data:
+ raise NoBodyContents()
+
+ new_data = {}
+ # Get the public attributes of the model
+ attrs = [i for i in dir(model) if not str(i).startswith('__')]
+ # Convert each json value to it's respective doc field's python type
+ # i.e. "json value" -> doc.String -> str
+ missing_fields = []
+ for attr in attrs:
+ field = getattr(model, attr)
+ try:
+ if isinstance(field, doc.String):
+ new_data[attr] = str(data.pop(attr))
+ elif isinstance(field, doc.Integer):
+ new_data[attr] = int(data.pop(attr))
+ elif isinstance(field, doc.Tuple):
+ new_data[attr] = tuple(data.pop(attr))
+ elif isinstance(field, doc.UUID):
+ new_data[attr] = UUID(data.pop(attr))
+ elif isinstance(field, doc.Boolean):
+ new_data[attr] = string_to_boolean(data.pop(attr))
+ elif isinstance(field, doc.Float):
+ val = data.pop(attr)
+ new_data[attr] = float(val) if val else None
+ elif isinstance(field, doc.Dictionary):
+ new_data[attr] = dict(data.pop(attr))
+ elif isinstance(field, doc.List):
+ new_data[attr] = list(data.pop(attr))
+ elif isinstance(field, Trinary):
+ val = data.pop(attr)
+ if val is not None:
+ val = string_to_boolean(val)
+ new_data[attr] = val
+ elif isinstance(field, doc.Date):
+ val = data.pop(attr)
+ if val:
+ new_data[attr] = datetime.strptime(val, DATE_FORMAT)
+ elif isinstance(field, doc.DateTime):
+ val = data.pop(attr)
+ if val:
+ new_data[attr] = datetime.utcfromtimestamp(val)
+ else:
+ raise ValidationError(f'Bad field type {field} specified in the API model!')
+ except KeyError:
+ if field.required:
+ missing_fields.append(attr)
+
+ if missing_fields:
+ raise MissingRequiredField(f'Missing fields: {missing_fields}')
+
+ if data:
+ raise ExcessJSONFields(f'Extra fields: {data.keys()}')
+
+ return new_data
+
+
+def validate_doc(summary: str = None, consumes=None, produces=None, responses=(), tag: str = None):
+ """
+ Apply Sanic OpenAPI docs to the wrapped route. Perform simple validation on requests.
+ """
+
+ def wrapper(func):
+ @wraps(func)
+ async def wrapped(request, *a, **kw):
+ try:
+ if consumes:
+ if 'data' in kw:
+ raise OverflowError(f'data kwarg already being passed to {func}')
+
+ data = validate_data(consumes, request.json)
+ if isinstance(data, sanic.response.HTTPResponse):
+ # Error in validation
+ return data
+ kw['data'] = data
+
+ result = func(request, *a, **kw)
+ if inspect.iscoroutine(result):
+ result = await result
+ return result
+ except ValidationError as e:
+ error = API_ERRORS[type(e)]
+ body = {
+ 'error': error['message'],
+ 'code': error['code'],
+ }
+ logger.error(e, exc_info=True)
+
+ if e.__cause__:
+ cause = e.__cause__
+ cause = API_ERRORS[type(cause)] if cause else None
+ body['cause'] = {'error': cause['message'], 'code': cause['code']}
+
+ r = response.json(body, error['status'])
+ return r
+ except APIError as e:
+ # The endpoint returned a standardized APIError, convert it to a json response
+ error = API_ERRORS[type(e)]
+ r = response.json({'message': str(e), 'api_error': error['message'], 'code': error['code']},
+ error['status'])
+ return r
+
+ # Apply the docs to the wrapped function so sanic-openapi can find the wrapped function when
+ # building the schema. If these docs are applied to `func`, sanic-openapi won't be able to lookup `wrapped`
+ if summary:
+ wrapped = doc.summary(summary)(wrapped)
+ if consumes:
+ wrapped = doc.consumes(consumes, location='body')(wrapped)
+ if produces:
+ wrapped = doc.produces(produces)(wrapped)
+ for resp in responses:
+ wrapped = doc.response(*resp)(wrapped)
+ if tag:
+ wrapped = doc.tag(tag)(wrapped)
+
+ return wrapped
+
+ return wrapper
+
+
+class SettingsObject:
+ media_directory = doc.String()
+
+
+class SettingsResponse:
+ config = doc.Object(SettingsObject)
+
+
+class SettingsRequest:
+ media_directory = doc.String()
+ wrol_mode = doc.Boolean()
+ timezone = doc.String()
+
+
+class RegexRequest:
+ regex = doc.String()
+
+
+class RegexResponse:
+ regex = doc.String()
+ valid = doc.Boolean()
+
+
+class EchoResponse:
+ form = doc.Dictionary()
+ headers = doc.Dictionary()
+ json = doc.String()
+ method = doc.String()
+
+
+class EventObject:
+ name = doc.String()
+ is_set = doc.Boolean()
+
+
+class EventsResponse:
+ events = doc.List(EventObject)
+
+
+class Trinary(Field):
+ """
+ A field for API docs. Can be True/False/None.
+ """
+
+ def __init__(self, *a, **kw):
+ kw['choices'] = (True, False, None)
+ super().__init__(*a, **kw)
+
+ def serialize(self):
+ return {"type": "trinary", **super().serialize()}
+
+
+class JSONErrorResponse:
+ error = doc.String()
diff --git a/wrolpi/test/__init__.py b/wrolpi/test/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/wrolpi/test/common.py b/wrolpi/test/common.py
new file mode 100644
index 00000000..3a54bf00
--- /dev/null
+++ b/wrolpi/test/common.py
@@ -0,0 +1,277 @@
+import json
+import pathlib
+import tempfile
+import unittest
+from contextlib import contextmanager
+from functools import partialmethod
+from http import HTTPStatus
+from itertools import zip_longest
+from queue import Empty
+from shutil import copyfile
+from typing import List, Tuple, Optional
+from uuid import uuid1
+
+import mock
+import websockets
+import yaml
+from sanic_openapi.api import Response
+from sqlalchemy import create_engine
+from sqlalchemy.engine import Engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from wrolpi.common import EXAMPLE_CONFIG_PATH, get_config, Base, QUEUES, set_test_media_directory
+from wrolpi.db import postgres_engine, get_db_args
+from wrolpi.root_api import BLUEPRINTS, api_app
+from wrolpi.vars import PROJECT_DIR
+
+TEST_CONFIG_PATH = tempfile.NamedTemporaryFile(mode='rt', delete=False)
+
+
+def get_test_db_engine():
+ suffix = str(uuid1()).replace('-', '')
+ db_name = f'wrolpi_testing_{suffix}'
+ conn = postgres_engine.connect()
+ conn.execute(f'DROP DATABASE IF EXISTS {db_name}')
+ conn.execute(f'CREATE DATABASE {db_name}')
+ conn.execute('commit')
+
+ test_args = get_db_args(db_name)
+ test_engine = create_engine('postgresql://{user}:{password}@{host}:{port}/{dbname}'.format(**test_args))
+ return test_engine
+
+
+def test_db() -> Tuple[Engine, Session]:
+ """
+ Create a unique SQLAlchemy engine/session for a test.
+ """
+ test_engine = get_test_db_engine()
+ if test_engine.engine.url.database == 'wrolpi':
+ raise ValueError('Refusing the test on wrolpi database!')
+
+ # Create all tables. No need to check if they exist because this is a test DB.
+ Base.metadata.create_all(test_engine, checkfirst=False)
+ session = sessionmaker(bind=test_engine)()
+ return test_engine, session
+
+
+def wrap_test_db(func):
+ """
+ Wrap a test so that when calling wrolpi.common.get_db, it returns a testing database cloned from the api
+ template.
+ """
+
+ def wrapped(*a, **kw):
+ test_engine, session = test_db()
+
+ def fake_get_db_context():
+ """Get the testing db"""
+ return test_engine, session
+
+ try:
+ with mock.patch('wrolpi.db._get_db_session', fake_get_db_context):
+ # Run the test.
+ result = func(*a, **kw)
+ return result
+ finally:
+ session.rollback()
+ session.close()
+ test_engine.dispose()
+ conn = postgres_engine.connect()
+ conn.execute(f'DROP DATABASE IF EXISTS {test_engine.engine.url.database}')
+
+ return wrapped
+
+
+def get_all_messages_in_queue(q):
+ """Get all messages in a Queue without waiting."""
+ messages = []
+ while True:
+ try:
+ msg = q.get_nowait()
+ messages.append(msg)
+ except Empty:
+ break
+ return messages
+
+
+class PytestCase:
+ """
+ Replicate unittest.TestCase methods to bridge the gap between it and Pytest.
+ """
+
+ @staticmethod
+ def assertGreater(a, b, msg: str = None):
+ assert a > b, msg
+
+ @staticmethod
+ def assertLess(a, b, msg: str = None):
+ assert a < b, msg
+
+ @staticmethod
+ def assertEqual(a, b, msg: str = None):
+ assert a == b, msg or f'{a} != {b}'
+
+ @staticmethod
+ def assertRaises(exception, func, *args, **kwargs):
+ try:
+ func(*args, **kwargs)
+ except exception:
+ pass
+
+ @classmethod
+ def assertDictContains(cls, 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):
+ 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'
+
+ def assertError(self, response, http_status: int, code=None):
+ self.assertEqual(response.status_code, http_status)
+ if code:
+ self.assertEqual(response.json['code'], code)
+
+ @staticmethod
+ def assertTruth(value, expected):
+ """
+ Check that a value is Truthy or Falsy.
+ """
+ if expected is True:
+ assert value, f'Value {value} should have been truthy'
+ else:
+ assert not value, f'Value {value} should have been falsey'
+
+ assertTruthy = partialmethod(assertTruth, expected=True)
+ assertFalsey = partialmethod(assertTruth, expected=False)
+
+ def assertItemsTruthyOrFalsey(self, items_list: List, expected_list: List):
+ for d1, d2 in zip_longest(items_list, expected_list):
+ for d2_key in d2:
+ if d1 is None:
+ raise ValueError('d1 is None')
+ if d2 is None:
+ raise ValueError('d2 is None')
+ self.assertTruth(d1[d2_key], d2[d2_key])
+
+
+class ExtendedTestCase(PytestCase, unittest.TestCase):
+ """
+ Add any specialized test methods to this class.
+ """
+ pass
+
+
+ROUTES_ATTACHED = False
+
+
+class TestAPI(ExtendedTestCase):
+
+ def setUp(self) -> None:
+ self.config_path_patch = mock.patch('wrolpi.vars.CONFIG_PATH', TEST_CONFIG_PATH.name)
+ self.config_path_patch.start()
+ self.tmp_dir = tempfile.TemporaryDirectory()
+ self.tmp_path = pathlib.Path(self.tmp_dir.name)
+ set_test_media_directory(self.tmp_dir.name)
+ # Copy the example config to test against
+ copyfile(str(EXAMPLE_CONFIG_PATH), TEST_CONFIG_PATH.name)
+ # Setup the testing video root directory
+ config = get_config()
+ config['media_directory'] = str(PROJECT_DIR / 'test')
+ with open(TEST_CONFIG_PATH.name, 'wt') as fh:
+ fh.write(yaml.dump(config))
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ global ROUTES_ATTACHED
+ if ROUTES_ATTACHED is False:
+ # Attach any blueprints for the test.
+ for bp in BLUEPRINTS:
+ api_app.blueprint(bp)
+ ROUTES_ATTACHED = True
+
+ def tearDown(self) -> None:
+ self.config_path_patch.stop()
+ set_test_media_directory(None)
+ # Clear out any messages in queues
+ for q in QUEUES:
+ get_all_messages_in_queue(q)
+
+ def assertHTTPStatus(self, response: Response, status: int):
+ self.assertEqual(response.status_code, status)
+
+ assertOK = partialmethod(assertHTTPStatus, status=HTTPStatus.OK)
+ assertCONFLICT = partialmethod(assertHTTPStatus, status=HTTPStatus.CONFLICT)
+ assertNO_CONTENT = partialmethod(assertHTTPStatus, status=HTTPStatus.NO_CONTENT)
+
+
+@contextmanager
+def wrap_media_directory(path: Optional[pathlib.Path] = None):
+ cleanup = False
+ if not path:
+ path = tempfile.TemporaryDirectory()
+ cleanup = True
+
+ set_test_media_directory(path.name)
+ try:
+ yield
+ finally:
+ set_test_media_directory(None)
+ if cleanup:
+ path.cleanup()
+
+
+@contextmanager
+def build_test_directories(paths: List[str]) -> pathlib.Path:
+ """
+ Create directories based on the provided structure.
+
+ Example:
+ >>> build_test_directories([
+ 'channel1/vid1.mp4',
+ 'channel2/vid1.mp4',
+ 'channel2/vid2.mp4',
+ 'channel2/vid2.en.vtt'
+ ])
+
+ Creates directories like so:
+ channel1/vid1.mp4
+ channel2/vid1.mp4
+ channel2/vid2.mp4
+ channel2/vid2.en.vtt
+ """
+ dir_ = get_config().get('media_directory')
+ dir_ = pathlib.Path(dir_).absolute()
+ with tempfile.TemporaryDirectory(dir=dir_) as temp_dir:
+ root = pathlib.Path(temp_dir)
+
+ directories = filter(lambda i: i.endswith('/'), paths)
+ for directory in directories:
+ (root / directory).mkdir(parents=True)
+
+ files = filter(lambda i: not i.endswith('/'), paths)
+ for file in files:
+ file = root / file
+ parents = file.parents
+ parents[0].mkdir(parents=True, exist_ok=True)
+ (root / file).touch()
+
+ yield root.absolute()
+
+
+async def get_all_ws_messages(ws) -> List[dict]:
+ messages = []
+ while True:
+ try:
+ message = await ws.recv()
+ except websockets.exceptions.ConnectionClosedOK:
+ break
+ messages.append(json.loads(message))
+ return messages
diff --git a/wrolpi/test/test_api.py b/wrolpi/test/test_api.py
new file mode 100644
index 00000000..e6c71394
--- /dev/null
+++ b/wrolpi/test/test_api.py
@@ -0,0 +1,74 @@
+import json
+from http import HTTPStatus
+from unittest import mock
+from unittest.mock import MagicMock
+
+import pytest
+from sanic_openapi import swagger_blueprint
+
+from wrolpi.root_api import api_app
+from wrolpi.test.common import TestAPI
+
+
+class TestRootAPI(TestAPI):
+
+ def test_index(self):
+ """
+ Index should have some details in an HTML response
+ """
+ request, response = api_app.test_client.get('/')
+ assert response.status_code == HTTPStatus.OK
+ assert b'html' in response.body
+
+ def test_echo(self):
+ """
+ Echo should send back a JSON object with our request details.
+ """
+ request, response = api_app.test_client.get('/api/echo?hello=world')
+ assert response.status_code == HTTPStatus.OK
+ self.assertDictContains(response.json['form'], {})
+ assert response.json['method'] == 'GET', 'Method was not GET'
+ assert response.json['json'] is None, 'JSON was not empty'
+ assert response.json['args'] == {'hello': ['world']}
+
+ data = {'foo': 'bar'}
+ request, response = api_app.test_client.post('/api/echo', data=json.dumps(data))
+ assert response.status_code == HTTPStatus.OK
+ self.assertDictContains(response.json['form'], {})
+ assert response.json['method'] == 'POST', 'Method was not POST'
+ assert response.json['json'] == data, 'JSON was not data'
+ assert response.json['args'] == {}
+
+ def test_valid_regex(self):
+ """
+ The endpoint should only return valid if the regex is valid.
+ """
+ data = {'regex': 'foo'}
+ request, response = api_app.test_client.post('/api/valid_regex', data=json.dumps(data))
+ assert response.status_code == HTTPStatus.OK
+ assert json.loads(response.body) == {'valid': True, 'regex': 'foo'}
+
+ data = {'regex': '.*(title match).*'}
+ request, response = api_app.test_client.post('/api/valid_regex', data=json.dumps(data))
+ assert response.status_code == HTTPStatus.OK
+ assert json.loads(response.body) == {'valid': True, 'regex': '.*(title match).*'}
+
+ data = {'regex': '.*(missing parenthesis.*'}
+ request, response = api_app.test_client.post('/api/valid_regex', data=json.dumps(data))
+ assert response.status_code == HTTPStatus.BAD_REQUEST
+ assert json.loads(response.body) == {'valid': False, 'regex': '.*(missing parenthesis.*'}
+
+ @pytest.mark.xfail
+ def test_swagger(self):
+ """
+ Swagger can be generated. This test is just to assure that our spec generation hasn't broken.
+ """
+ # An API request is required to set things up (apparently).
+ api_app.test_client.get('/')
+
+ from sanic.response import json as json_
+ json_(swagger_blueprint._spec)
+
+ def test_get_settings(self):
+ request, response = api_app.test_client.get('/api/settings')
+ self.assertOK(response)
diff --git a/wrolpi/test/test_common.py b/wrolpi/test/test_common.py
new file mode 100644
index 00000000..9cea93a0
--- /dev/null
+++ b/wrolpi/test/test_common.py
@@ -0,0 +1,435 @@
+import unittest
+from datetime import date, datetime
+from queue import Queue
+
+import pytest
+from sanic_openapi import doc
+
+from wrolpi.common import combine_dicts, insert_parameter, ProgressReporter, date_range
+from wrolpi.dates import set_timezone, now
+from wrolpi.errors import NoBodyContents, MissingRequiredField, ExcessJSONFields, InvalidTimezone
+from wrolpi.schema import validate_data
+from wrolpi.test.common import build_test_directories, wrap_test_db
+
+
+class Model:
+ name = doc.String(required=True)
+ count = doc.Integer(required=True)
+ fish = doc.Tuple()
+ on = doc.Boolean()
+ obj = doc.Dictionary()
+ many = doc.Float()
+
+
+@pytest.mark.parametrize(
+ 'data,expected',
+ (
+ (
+ dict(name='foo', count='4'),
+ dict(name='foo', count=4),
+ ),
+ (
+ dict(name='foo', count=4, fish=['red', 'blue'], on='false'),
+ dict(name='foo', count=4, fish=('red', 'blue'), on=False),
+ ),
+ (
+ dict(name='foo', count=4, fish=['red', 'blue'], on='true'),
+ dict(name='foo', count=4, fish=('red', 'blue'), on=True),
+ ),
+ (
+ dict(name='foo', count=4, fish=['red', 'blue'], on='t'),
+ dict(name='foo', count=4, fish=('red', 'blue'), on=True),
+ ),
+ (
+ dict(name='foo', count=4, fish=['red', 'blue'], on=1),
+ dict(name='foo', count=4, fish=('red', 'blue'), on=True),
+ ),
+ (
+ dict(name='foo', count=1.5, obj={'foo': 'bar'}),
+ dict(name='foo', count=1, obj={'foo': 'bar'}),
+ ),
+ (
+ dict(name='foo', count=1.5, many='10.3'),
+ dict(name='foo', count=1, many=10.3),
+ ),
+ )
+)
+def test_validate_doc(data, expected):
+ model = Model()
+ result = validate_data(model, data)
+ assert result == expected
+
+
+@pytest.mark.parametrize(
+ 'data,expected',
+ (
+ (dict(), NoBodyContents),
+ (dict(name='foo'), MissingRequiredField),
+ (dict(name='foo', count=10, extra='too many'), ExcessJSONFields),
+ )
+)
+def test_validate_doc_errors(data, expected):
+ model = Model()
+ pytest.raises(expected, validate_data, model, data)
+
+
+@pytest.mark.parametrize(
+ 'data,expected',
+ (
+ (
+ [dict()],
+ dict()
+ ), (
+ [dict(a='b')],
+ dict(a='b')
+ ), (
+ [dict(a='b'), dict(b='c')],
+ dict(a='b', b='c')
+ ), (
+ [dict(b='c'), dict(a='b')],
+ dict(a='b', b='c'),
+ ), (
+ [dict(a=dict(b='c'), d='e'), dict(a=(dict(f='g')))],
+ dict(a=dict(b='c', f='g'), d='e'),
+ ), (
+ [dict(a=dict(b='c'), d='e'), dict(a=(dict(b='g')))],
+ dict(a=dict(b='c'), d='e'),
+ ), (
+ [dict(a=dict(b='c'), d=[1, 2, 3]), dict(a=(dict(b='g')))],
+ dict(a=dict(b='c'), d=[1, 2, 3]),
+ ), (
+ [dict(a='b', c=dict(d='e')), dict(a='c', e='f')],
+ dict(a='b', c=dict(d='e'), e='f')
+ ), (
+ [dict(a='b'), dict(a='c', d='e'), dict(e='f', a='d')],
+ dict(a='b', d='e', e='f')
+ ), (
+ [dict(a='b'), dict(), dict(e='f', a='d')],
+ dict(a='b', e='f')
+ )
+ )
+)
+def test_combine_dicts(data, expected):
+ assert combine_dicts(*data) == expected
+
+
+def test_build_video_directories():
+ structure = [
+ 'channel1/vid1.mp4',
+ ]
+ with build_test_directories(structure) as tempdir:
+ assert (tempdir / 'channel1').is_dir()
+ assert (tempdir / 'channel1/vid1.mp4').is_file()
+
+ structure = [
+ 'channel2/',
+ 'channel2.1/channel2.2/',
+ ]
+ with build_test_directories(structure) as tempdir:
+ assert (tempdir / 'channel2').is_dir()
+ assert (tempdir / 'channel2.1/channel2.2').is_dir()
+
+ structure = [
+ 'channel3/vid1.mp4',
+ 'channel3/vid2.mp4',
+ 'channel4/vid1.mp4',
+ 'channel4/vid1.en.vtt',
+ 'channel5/',
+ ]
+ with build_test_directories(structure) as tempdir:
+ assert (tempdir / 'channel3/vid1.mp4').is_file()
+ assert (tempdir / 'channel3').is_dir()
+ assert (tempdir / 'channel3/vid2.mp4').is_file()
+ assert (tempdir / 'channel4/vid1.mp4').is_file()
+ assert (tempdir / 'channel4/vid1.en.vtt').is_file()
+ assert (tempdir / 'channel5').is_dir()
+
+ structure = [
+ 'channel6/subdirectory/vid1.mp4',
+ ]
+ with build_test_directories(structure) as tempdir:
+ assert (tempdir / 'channel6/subdirectory').is_dir()
+ assert (tempdir / 'channel6/subdirectory/vid1.mp4').is_file()
+
+
+def test_insert_parameter():
+ """
+ A convenience function exists that inserts a parameter or keyword argument into the provided args/kwargs,
+ wherever that may be according to the function's signature.
+ """
+
+ def func(foo, bar):
+ pass
+
+ results = insert_parameter(func, 'bar', 'bar', (1,), {})
+ assert results == ((1, 'bar'), {})
+
+ def func(foo, bar, baz):
+ pass
+
+ results = insert_parameter(func, 'bar', 'bar', (1, 2), {})
+ assert results == ((1, 'bar', 2), {})
+
+ def func(foo, baz, bar=None):
+ pass
+
+ results = insert_parameter(func, 'bar', 'bar', (1, 2), {})
+ assert results == ((1, 2, 'bar'), {})
+
+ def func(foo, baz, bar=None):
+ pass
+
+ results = insert_parameter(func, 'baz', 'baz', (1, 2), {})
+ assert results == ((1, 'baz', 2), {})
+
+ def func(foo, baz, qux=None, bar=None):
+ pass
+
+ results = insert_parameter(func, 'bar', 'bar', (1, 2, 3), {})
+ assert results == ((1, 2, 3, 'bar'), {})
+
+ # bar is not defined as a parameter!
+ def func(foo):
+ pass
+
+ pytest.raises(TypeError, insert_parameter, func, 'bar', 'bar', (1,), {})
+
+
+@pytest.mark.parametrize(
+ 'totals,progresses,messages',
+ [
+ (
+ [25],
+ [],
+ [
+ {'progresses': [dict(message='foo', percent=0, total=25, value=0)]},
+ {'code': 'bar', 'progresses': [dict(message='foo', percent=0, total=25, value=0)]},
+ {'code': 'error', 'progresses': [dict(message='baz', percent=0, total=25, value=0)]},
+ ],
+ ),
+ (
+ [25],
+ [
+ (0, 1),
+ (0, 2),
+ (0, 4),
+ (0, 8),
+ (0, 16),
+ (0, 25),
+ ],
+ [
+ {'progresses': [dict(message='foo', percent=0, total=25, value=0)]},
+ {'code': 'bar', 'progresses': [dict(message='foo', percent=0, total=25, value=0)]},
+ {'progresses': [dict(message='foo', percent=4, total=25, value=1)]},
+ {'progresses': [dict(message='foo', percent=8, total=25, value=2)]},
+ {'progresses': [dict(message='foo', percent=16, total=25, value=4)]},
+ {'progresses': [dict(message='foo', percent=32, total=25, value=8)]},
+ {'progresses': [dict(message='foo', percent=64, total=25, value=16)]},
+ {'progresses': [dict(message='foo', percent=100, total=25, value=25)]},
+ {'code': 'error', 'progresses': [dict(message='baz', percent=100, total=25, value=25)]},
+ ]
+ ),
+ (
+ [25, 10],
+ [
+ (1, 3),
+ (1, 8),
+ (0, 18),
+ (1, 10),
+ (0, 30), # This is higher than the total of 25, percent should be 100.
+ ],
+ [
+ {'progresses': [
+ dict(message='foo', percent=0, total=25, value=0),
+ dict(percent=0, total=10, value=0),
+ ]},
+ {'code': 'bar', 'progresses': [
+ dict(message='foo', percent=0, total=25, value=0),
+ dict(percent=0, total=10, value=0),
+ ]},
+ {'progresses': [
+ dict(message='foo', percent=0, total=25, value=0),
+ dict(percent=30, total=10, value=3),
+ ]},
+ {'progresses': [
+ dict(message='foo', percent=0, total=25, value=0),
+ dict(percent=80, total=10, value=8),
+ ]},
+ {'progresses': [
+ dict(message='foo', percent=72, total=25, value=18),
+ dict(percent=80, total=10, value=8),
+ ]},
+ {'progresses': [
+ dict(message='foo', percent=72, total=25, value=18),
+ dict(percent=100, total=10, value=10),
+ ]},
+ {'progresses': [
+ dict(message='foo', percent=100, total=25, value=30),
+ dict(percent=100, total=10, value=10),
+ ]},
+ {
+ 'code': 'error',
+ 'progresses': [
+ dict(message='baz', percent=100, total=25, value=30),
+ dict(percent=100, total=10, value=10),
+ ]
+ },
+ ]
+ ),
+ ]
+)
+def test_feed_reporter(totals, progresses, messages):
+ q = Queue()
+ reporter = ProgressReporter(q, len(totals))
+
+ for idx, total in enumerate(totals):
+ reporter.set_progress_total(idx, total)
+
+ reporter.message(0, 'foo')
+ reporter.code('bar')
+
+ for progress in progresses:
+ reporter.send_progress(*progress)
+
+ reporter.error(0, 'baz')
+
+ count = 0
+ while not q.empty():
+ received = q.get_nowait()
+ try:
+ message = messages.pop(0)
+ except IndexError:
+ raise AssertionError(f'Queue ({count}) had excess message: {received}')
+
+ assert message == received, f'Message {count} did not match'
+
+ count += 1
+
+
+def test_feed_reporter_finish():
+ q = Queue()
+ reporter = ProgressReporter(q)
+
+ reporter.set_progress_total(0, 50)
+ reporter.send_progress(0, 0)
+ reporter.send_progress(0, 20)
+ reporter.finish(0, 'completed')
+
+ expected = [
+ {'progresses': [{'percent': 0, 'total': 50, 'value': 0}]},
+ {'progresses': [{'percent': 40, 'total': 50, 'value': 20}]},
+ {'progresses': [{'message': 'completed', 'percent': 100, 'value': 50, 'total': 50}]},
+ ]
+
+ count = 0
+
+ while not q.empty():
+ received = q.get_nowait()
+ try:
+ message = expected.pop(0)
+ except IndexError:
+ raise AssertionError(f'Queue ({count}) had excess message: {received}')
+
+ assert message == received, f'Message {count} did not match'
+
+ count += 1
+
+
+class TestCommon(unittest.TestCase):
+
+ def test_date_range(self):
+ # A single step results in the start.
+ result = date_range(date(1970, 1, 1), date(1970, 1, 2), 1)
+ assert result == [
+ date(1970, 1, 1),
+ ]
+
+ # Many steps on a single day results in the same day.
+ result = date_range(date(1970, 1, 1), date(1970, 1, 1), 5)
+ assert result == [
+ date(1970, 1, 1),
+ date(1970, 1, 1),
+ date(1970, 1, 1),
+ date(1970, 1, 1),
+ date(1970, 1, 1),
+ ]
+
+ # Many steps on a single datetime results in a range of times.
+ result = date_range(datetime(1970, 1, 1), datetime(1970, 1, 1, 23, 59, 59), 5)
+ assert result == [
+ datetime(1970, 1, 1, 0, 0),
+ datetime(1970, 1, 1, 4, 47, 59, 800000),
+ datetime(1970, 1, 1, 9, 35, 59, 600000),
+ datetime(1970, 1, 1, 14, 23, 59, 400000),
+ datetime(1970, 1, 1, 19, 11, 59, 200000),
+ ]
+
+ # date_range is not inclusive, like range().
+ result = date_range(date(1970, 1, 1), date(1970, 1, 5), 4)
+ assert result == [
+ date(1970, 1, 1),
+ date(1970, 1, 2),
+ date(1970, 1, 3),
+ date(1970, 1, 4),
+ ]
+
+ # Reversed dates are supported.
+ result = date_range(date(1970, 1, 5), date(1970, 1, 1), 4)
+ assert result == [
+ date(1970, 1, 5),
+ date(1970, 1, 4),
+ date(1970, 1, 3),
+ date(1970, 1, 2),
+ ]
+
+ # Large date spans are supported.
+ result = date_range(date(1970, 1, 1), date(2020, 5, 1), 4)
+ assert result == [
+ date(1970, 1, 1),
+ date(1982, 8, 1),
+ date(1995, 3, 2),
+ date(2007, 10, 1),
+ ]
+
+ result = date_range(datetime(1970, 1, 1, 0, 0, 0), datetime(1970, 1, 1, 10, 0), 8)
+ assert result == [
+ datetime(1970, 1, 1, 0, 0),
+ datetime(1970, 1, 1, 1, 15),
+ datetime(1970, 1, 1, 2, 30),
+ datetime(1970, 1, 1, 3, 45),
+ datetime(1970, 1, 1, 5, 0),
+ datetime(1970, 1, 1, 6, 15),
+ datetime(1970, 1, 1, 7, 30),
+ datetime(1970, 1, 1, 8, 45),
+ ]
+
+ # More steps than days
+ result = date_range(date(1970, 1, 1), date(1970, 1, 7), 10)
+ assert result == [
+ date(1970, 1, 1),
+ date(1970, 1, 1),
+ date(1970, 1, 2),
+ date(1970, 1, 2),
+ date(1970, 1, 3),
+ date(1970, 1, 4),
+ date(1970, 1, 4),
+ date(1970, 1, 5),
+ date(1970, 1, 5),
+ date(1970, 1, 6),
+ ]
+
+ def test_set_timezone(self):
+ """
+ The global timezone can be changed. Invalid timezones are rejected.
+ """
+ original_timezone = now().tzinfo
+
+ try:
+ self.assertRaises(InvalidTimezone, set_timezone, '')
+ self.assertEqual(now().tzinfo, original_timezone)
+
+ set_timezone('US/Pacific')
+ self.assertNotEqual(now().tzinfo, original_timezone)
+ finally:
+ # Restore the timezone before the test.
+ set_timezone(original_timezone)
diff --git a/wrolpi/test/test_dates.py b/wrolpi/test/test_dates.py
new file mode 100644
index 00000000..cb011016
--- /dev/null
+++ b/wrolpi/test/test_dates.py
@@ -0,0 +1,43 @@
+from sqlalchemy import Column, Integer
+
+from wrolpi.common import Base
+from wrolpi.dates import TZDateTime
+from wrolpi.db import get_db_session
+from wrolpi.test.common import TestAPI, wrap_test_db
+
+
+class TestTable(Base):
+ """
+ A table for testing purposes. This should never be in a production database!
+ """
+ __tablename__ = 'test_table'
+ id = Column(Integer, primary_key=True)
+ dt = Column(TZDateTime)
+
+
+class TestDates(TestAPI):
+
+ @wrap_test_db
+ def test_TZDateTime(self):
+ with get_db_session(commit=True) as session:
+ # TZDateTime can be None
+ session.add(TestTable())
+
+ with get_db_session(commit=True) as session:
+ tt = session.query(TestTable).one()
+ self.assertIsNone(tt.dt)
+
+ # Store a timezone naive string, it should be assumed this is a local time.
+ tt.dt = '2021-10-05 16:20:10.346823'
+
+ with get_db_session(commit=True) as session:
+ tt = session.query(TestTable).one()
+ self.assertEqual(str(tt.dt), '2021-10-05 16:20:10.346823-06:00')
+
+ # Store a timezone string.
+ tt.dt = '2021-10-05 00:20:10.346824-08:00'
+
+ with get_db_session() as session:
+ tt = session.query(TestTable).one()
+ # Timezone is preserved.
+ self.assertEqual(str(tt.dt), '2021-10-05 08:20:10.346824-06:00')
diff --git a/wrolpi/test/test_media_path.py b/wrolpi/test/test_media_path.py
new file mode 100644
index 00000000..b698c31a
--- /dev/null
+++ b/wrolpi/test/test_media_path.py
@@ -0,0 +1,39 @@
+import json
+import pathlib
+import tempfile
+import unittest
+from unittest import mock
+
+from wrolpi.media_path import MediaPath
+from wrolpi.root_api import CustomJSONEncoder
+
+
+class TestMediaPath(unittest.TestCase):
+
+ def setUp(self):
+ self.test_dir = tempfile.TemporaryDirectory()
+ path = pathlib.Path(self.test_dir.name).absolute()
+ self.patch = mock.patch('wrolpi.media_path.get_media_directory', lambda: path)
+ self.patch.start()
+
+ def tearDown(self):
+ self.test_dir.cleanup()
+ self.patch.stop()
+
+ def test_errors(self):
+ self.assertRaises(ValueError, MediaPath, '')
+ self.assertRaises(ValueError, MediaPath, '/')
+
+ def test_json(self):
+ p = MediaPath('foo')
+ result = json.dumps(p, cls=CustomJSONEncoder)
+ self.assertEqual(result, '"foo"')
+
+ def test_paths(self):
+ # Paths may be relative to media directory.
+ d = MediaPath('foo')
+ self.assertEqual(d._path, pathlib.Path(f'{self.test_dir.name}/foo'))
+
+ # Absolute paths must be in media directory.
+ d = MediaPath(f'{self.test_dir.name}/foo')
+ self.assertEqual(d._path, pathlib.Path(f'{self.test_dir.name}/foo'))
diff --git a/wrolpi/test/test_timeout.py b/wrolpi/test/test_timeout.py
new file mode 100644
index 00000000..a84d098c
--- /dev/null
+++ b/wrolpi/test/test_timeout.py
@@ -0,0 +1,47 @@
+import time
+import unittest
+from unittest import mock
+
+from wrolpi.timeout import timeout, WorkerException
+
+
+class TestTimeout(unittest.TestCase):
+
+ def test_timeout(self):
+ """
+ The process will be killed if it takes too long.
+ """
+
+ @timeout(2)
+ def func():
+ time.sleep(5)
+ raise Exception('Was not killed!')
+
+ self.assertRaises(TimeoutError, func)
+
+ def test_timeout_exception(self):
+ """
+ If the wrapped function has an error, it will raise a WorkerException.
+ """
+
+ @timeout(10)
+ def func():
+ raise ValueError('Cause a worker error!')
+
+ self.assertRaises(WorkerException, func)
+
+ def test_test_timeout(self):
+ """Tests can overwrite the timeout"""
+
+ @timeout(0.1)
+ def func():
+ time.sleep(1)
+ return True
+
+ # Killed after 0.1 seconds
+ self.assertRaises(TimeoutError, func)
+
+ with mock.patch('wrolpi.timeout.TEST_TIMEOUT', 2):
+ # New timeout allows the function to finish.
+ result = func()
+ self.assertTrue(result)
diff --git a/wrolpi/timeout.py b/wrolpi/timeout.py
new file mode 100644
index 00000000..3322014e
--- /dev/null
+++ b/wrolpi/timeout.py
@@ -0,0 +1,114 @@
+"""
+Wrap a function with `timeout` to kill it after some amount of seconds, the wrapped function is run in it's own
+process. If the timeout is reached, kill the process and raise a TimeoutError.
+
+Log any errors in the global logger. If the worker process raises an Exception, raise an Exception sharing it's
+description, log the traceback.
+
+>>> @timeout(5)
+>>> def long_running_function():
+>>> while True:
+>>> # Run infinitely
+>>> time.sleep(1)
+
+>>> long_running_function()
+TimeoutError()
+"""
+import multiprocessing.connection
+import time
+import traceback
+from datetime import timedelta
+from functools import wraps
+from multiprocessing import Process, Pipe
+
+from wrolpi.common import logger
+from wrolpi.dates import utc_now
+
+logger = logger.getChild(__name__)
+
+
+class WorkerException(Exception):
+ pass
+
+
+TEST_TIMEOUT = None
+KILL_ATTEMPTS = 100
+
+
+def timeout(seconds: float) -> callable:
+ """
+ Create a worker process, let it run until the timeout, then kill it. If the worker isn't killed, return
+ it's result or exception. If it is killed, raise a TimeoutError.
+ """
+
+ def wrapper(func):
+
+ def worker_wrapper(pipe: multiprocessing.connection.Connection, *args, **kwargs):
+ """
+ Wraps a worker function so we can catch it's Exception, or return its results.
+ """
+ to_send = {}
+ try:
+ result = func(*args, **kwargs)
+ to_send['result'] = result
+ except Exception as e:
+ to_send.update({'exception': str(e), 'traceback': traceback.format_exc()})
+ finally:
+ pipe.send(to_send)
+
+ @wraps(func)
+ def wrapped(*args, **kwargs):
+ # unittest may override the timeout
+ _seconds = TEST_TIMEOUT or seconds
+
+ # Start the worker_wrapper, give it a way to communicate back.
+ parent_conn, child_conn = Pipe()
+ args = (child_conn,) + args
+ worker = Process(target=worker_wrapper, args=args, kwargs=kwargs)
+ worker.start()
+
+ # Continually attempt to get a result until the timeout is reached.
+ kill_time = utc_now() + timedelta(seconds=_seconds)
+ while True:
+ if not worker.is_alive():
+ # Worker exited.
+ break
+ if not utc_now() < kill_time:
+ # Timeout was reached.
+ break
+ if parent_conn.poll(0.1): # This prevents a "busy loop"
+ # Result is waiting.
+ break
+
+ # Get the result, if any.
+ result = None
+ if parent_conn.poll(0):
+ result = parent_conn.recv()
+
+ attempts = 0
+ while worker.is_alive():
+ # Worker has run out of time.
+ worker.kill()
+ time.sleep(0.1)
+ attempts += 1
+ if attempts > KILL_ATTEMPTS:
+ logger.warning(f'Failed to kill worker! {func}')
+ break
+
+ worker.join()
+
+ if result:
+ if 'result' in result:
+ return result['result']
+ elif 'exception' in result:
+ exception = result['exception']
+ _traceback = result['traceback']
+ logger.warning(f'Traceback from worker: {_traceback}')
+ raise WorkerException(f'Exception from worker: {exception}')
+
+ if attempts > 0:
+ raise TimeoutError(f'Killed worker after {_seconds} seconds: {func}')
+
+ return wrapped
+
+ return wrapper
diff --git a/wrolpi/vars.py b/wrolpi/vars.py
new file mode 100644
index 00000000..7c0960c0
--- /dev/null
+++ b/wrolpi/vars.py
@@ -0,0 +1,28 @@
+import os
+import pathlib
+import sys
+
+PROJECT_DIR: pathlib.Path = pathlib.Path(__file__).parents[1].absolute()
+CONFIG_DIR: pathlib.Path = PROJECT_DIR / 'config'
+CONFIG_PATH: pathlib.Path = CONFIG_DIR / 'local.yaml'
+EXAMPLE_CONFIG_PATH: pathlib.Path = CONFIG_DIR / 'example.yaml'
+MODULES_DIR: pathlib.Path = PROJECT_DIR / 'modules'
+PUBLIC_HOST = os.environ.get('PUBLIC_HOST')
+PUBLIC_PORT = os.environ.get('PUBLIC_PORT')
+
+DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
+PYTEST = 'pytest' in sys.modules
+
+DEFAULT_TIMEZONE_STR = 'America/Boise'
+LAST_MODIFIED_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT'
+DATE_FORMAT = '%Y-%M-%d'
+DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S'
+DATETIME_FORMAT_MS = '%Y-%m-%d %H:%M:%S.%f'
+
+DEFAULT_FILE_PERMISSIONS = 0o644
+
+DB_HOST = os.environ.get('DB_HOST', '127.0.0.1')
+DB_PORT = int(os.environ.get('DB_PORT', 5432))
+DB_NAME = os.environ.get('DB_NAME', 'wrolpi')
+DB_USER = os.environ.get('DB_USER', 'wrolpi')
+DB_PASSWORD = os.environ.get('DB_PASSWORD', 'wrolpi')