From 83b88fb89e7f3cc04b43d437e2d0e408db6cb2ff Mon Sep 17 00:00:00 2001 From: Felix Stupp Date: Fri, 8 Oct 2021 13:23:18 +0200 Subject: [PATCH] Init flask / ponyorm server --- server/.gitignore | 273 ++++++++++++++++ server/app.py | 237 ++++++++++++++ server/entertainment_decider/__init__.py | 0 server/entertainment_decider/common.py | 13 + server/entertainment_decider/config.py | 7 + .../extractors/collection/__init__.py | 25 ++ .../extractors/collection/base.py | 37 +++ .../extractors/collection/tt_rss.py | 177 ++++++++++ .../extractors/collection/youtube.py | 105 ++++++ .../extractors/generic.py | 148 +++++++++ .../extractors/media/__init__.py | 18 + .../extractors/media/base.py | 75 +++++ .../extractors/media/ytdl.py | 93 ++++++ server/entertainment_decider/models.py | 308 ++++++++++++++++++ server/requirements.txt | 10 + server/templates/collection_element.htm | 63 ++++ server/templates/collection_list.htm | 43 +++ server/templates/media_element.htm | 45 +++ server/templates/media_list.htm | 53 +++ 19 files changed, 1730 insertions(+) create mode 100644 server/.gitignore create mode 100644 server/app.py create mode 100644 server/entertainment_decider/__init__.py create mode 100644 server/entertainment_decider/common.py create mode 100644 server/entertainment_decider/config.py create mode 100644 server/entertainment_decider/extractors/collection/__init__.py create mode 100644 server/entertainment_decider/extractors/collection/base.py create mode 100644 server/entertainment_decider/extractors/collection/tt_rss.py create mode 100644 server/entertainment_decider/extractors/collection/youtube.py create mode 100644 server/entertainment_decider/extractors/generic.py create mode 100644 server/entertainment_decider/extractors/media/__init__.py create mode 100644 server/entertainment_decider/extractors/media/base.py create mode 100644 server/entertainment_decider/extractors/media/ytdl.py create mode 100644 server/entertainment_decider/models.py create mode 100644 server/requirements.txt create mode 100644 server/templates/collection_element.htm create mode 100644 server/templates/collection_list.htm create mode 100644 server/templates/media_element.htm create mode 100644 server/templates/media_list.htm diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..fcd410f --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,273 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,flask,windows,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python,flask,windows,linux + +### Flask ### +instance/* +!instance/.gitignore +.webassets-cache +.env + +### Flask.Python Stack ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Python ### +# Byte-compiled / optimized / DLL files + +# C extensions + +# Distribution / packaging + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. + +# Installer logs + +# Unit test / coverage reports + +# Translations + +# Django stuff: + +# Flask stuff: + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# Jupyter Notebook + +# IPython + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow + +# Celery stuff + +# SageMath parsed files + +# Environments + +# Spyder project settings + +# Rope project settings + +# mkdocs documentation + +# mypy + +# Pyre type checker + +# pytype static type analyzer + +# Cython debug symbols + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python,flask,windows,linux + +/config.yml +*.sqlite diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..1a0b47d --- /dev/null +++ b/server/app.py @@ -0,0 +1,237 @@ +#### +## Imports +#### + +from __future__ import annotations + +from functools import partial +import logging +import os +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, Optional, Union + +from flask import Flask, jsonify, make_response, request +from flask.templating import render_template +from markupsafe import Markup +from pony.flask import Pony +from pony import orm + +from entertainment_decider import common +from entertainment_decider.models import db, MediaCollection, MediaCollectionLink, MediaElement +from entertainment_decider.extractors.collection import collection_extract_uri +from entertainment_decider.extractors.media import media_extract_uri + + +#### +## Logging Config +#### + + +logging.basicConfig(format = "%(asctime)s === %(message)s", level=logging.DEBUG) + + +#### +## Flask Config +#### + + +flask_app = Flask(__name__) +flask_app.config.update(dict( + CELERY = dict( + ), + DEBUG = True, + PONY = dict( + provider = "sqlite", + filename = "./db.sqlite", + create_db = True, + ) +)) + +def environ_bool(value: Union[str, bool]) -> bool: + if type(value) == bool: + return value + return value.strip()[0].lower() in ["1", "t", "y"] + +ConfigKeySetter: Callable[[str, Any], Any] +ConfigSingleTranslator = Callable[[Any], Any] +ConfigTranslatorIterable = Iterable[ConfigSingleTranslator] +ConfigTranslatorCreator = Callable[[str], ConfigTranslatorIterable] + +def config_suffixer(setter: ConfigKeySetter, prefix: str, lower: bool = True) -> ConfigTranslatorCreator: + def creator(key: str): + if not key.startswith(prefix): + raise Exception(f"Environment key {key!r} is missing suffix {prefix!r}") + new_key = key[len(prefix):] + new_key = new_key.lower() if lower else new_key + return ( + partial(setter, new_key) + ) + return creator + +def celery_config_setter(key: str, val: Any): + flask_app.config["CELERY"][key] = val + +celery_config_same = config_suffixer(celery_config_setter, "CELERY_") + +def flask_config_setter(key: str, val: Any): + flask_app.config[key] = val + +flask_config_same = config_suffixer(flask_config_setter, "FLASK_", lower=False) + +def pony_config_setter(key: str, val: Any): + flask_app.config["PONY"][key] = val + +pony_config_same = config_suffixer(pony_config_setter, "PONY_") + +CONFIG_TRANSLATE_TABLE: Dict[str, Union[ConfigTranslatorIterable, ConfigTranslatorCreator]] = { + "CELERY_BROKER_URL": celery_config_same, + "CELERY_RESULT_BACKEND": celery_config_same, + "FLASK_DEBUG": ( + environ_bool, + partial(flask_config_setter, "DEBUG"), + ), + "PONY_PROVIDER": pony_config_same, + "PONY_FILENAME": pony_config_same, + "PONY_CREATE_DB": ( + environ_bool, + partial(pony_config_setter, "create_db"), + ), + "PONY_HOST": pony_config_same, + "PONY_DATABASE": pony_config_same, + "PONY_DB": pony_config_same, + "PONY_USER": pony_config_same, + "PONY_PASSWORD": pony_config_same, + "PONY_PASSWD": pony_config_same, + "PONY_DSN": pony_config_same, +} + +for key, val in os.environ.items(): + trans = CONFIG_TRANSLATE_TABLE.get(key) + if trans is not None: + trans = trans(key) if callable(trans) else trans + res: Any = val + for caller in trans: + new_res = caller(res) + if new_res is None: + res = res + + +#### +## Pony init +#### + + +db.bind(**flask_app.config["PONY"]) +db.generate_mapping(create_tables=True) + +Pony(flask_app) + + +#### +## Return filters +#### + + +@flask_app.template_filter() +def as_link(uri: str): + uri = Markup.escape(uri) + return Markup(f'{uri}') + +@flask_app.template_filter() +def tenary(b: bool, true_str: str, false_str: str) -> str: + return true_str if b else false_str + + +#### +## Routes +#### + + +@flask_app.route("/") +def hello_world(): + return 'Collections & Media' + + +@flask_app.route("/collection") +def list_collection(): + collection_list: Iterable[MediaCollection] = MediaCollection.select().order_by(orm.desc(MediaCollection.release_date), MediaCollection.title, MediaCollection.id) + return render_template("collection_list.htm", collection_list=collection_list) + +@flask_app.route("/collection/") +def show_collection(collection_id): + collection: MediaCollection = MediaCollection.get(id=collection_id) + if collection is None: + return make_response(f"Not found", 404) + return render_template( + "collection_element.htm", + collection=collection, + media_links=MediaCollectionLink.sorted(MediaCollectionLink.select(lambda l: l.collection == collection)), + ) + +@flask_app.route("/collection/", methods = ["POST"]) +def update_collection(collection_id): + collection: MediaCollection = MediaCollection.get(id=collection_id) + if collection is None: + return f"Not found", 404 + data: Optional[Dict] = request.get_json() + if data is None: + return f"JSON data missing", 400 + for key in data.keys(): + if key not in ["watch_in_order"]: + return { + "successful": False, + "error": { + "message": f"Failed to update key {key!r} as this is not allowed to update on a collection", + }, + }, 400 + for key, value in data.items(): + if key == "watch_in_order": + collection.watch_in_order = common.update_bool_value(collection.watch_in_order, value) + collection.watch_in_order_auto = False + return { + "successful": True, + "error": None, + }, 200 + + +@flask_app.route("/media") +def list_media(): + media_list: Iterable[MediaElement] = MediaElement.select().order_by(orm.desc(MediaElement.release_date), MediaElement.id) + return render_template("media_list.htm", media_list=list(media_list)) + +@flask_app.route("/media/length") +def get_media_length(): + c = len(MediaElement.select()) + return f"{c}" + +@flask_app.route("/media/") +def show_media(media_id): + element: MediaElement = MediaElement.get(id=media_id) + if element is None: + return make_response(f"Not found", 404) + return render_template("media_element.htm", element=element) + + +@flask_app.route("/debug/test") +def test(): + first: MediaElement = MediaElement.select().first() + return { + "data": first.to_dict(), + }, 200 + +# TODO add table for failed attempts so these may be resolved afterwards with increasing delays (add to MediaElement with flag "retrieved" and "extractor_cache_date" as date to resolve last try) + + +@flask_app.route("/api/media/list") +def api_media_list(): + media_list: Iterable[MediaElement] = MediaElement.select() + return { + "status": True, + "data": [{ + "id": media.id, + "title": media.title, + "release_date": media.release_date, + "length": media.length, + "progress": media.progress, + } for media in media_list], + }, 200 diff --git a/server/entertainment_decider/__init__.py b/server/entertainment_decider/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/entertainment_decider/common.py b/server/entertainment_decider/common.py new file mode 100644 index 0000000..59874df --- /dev/null +++ b/server/entertainment_decider/common.py @@ -0,0 +1,13 @@ +import subprocess +from typing import Literal, Union + +def call(args, check=True, stdin=None) -> subprocess.CompletedProcess: + proc = subprocess.run(args, capture_output=True, check=check, text=True, stdin=stdin) + return proc + +def update_bool_value(old_value: bool, new_value: Union[bool, Literal["toggle"]]) -> bool: + if new_value == "toggle": + return not old_value + if type(new_value) != bool: + raise Exception(f"Invalid type of new_value: Expected bool or literal \"toggle\", got type={type(new_value)!r}, value={new_value!r}") + return new_value diff --git a/server/entertainment_decider/config.py b/server/entertainment_decider/config.py new file mode 100644 index 0000000..e463db9 --- /dev/null +++ b/server/entertainment_decider/config.py @@ -0,0 +1,7 @@ +from pathlib import Path + +import yaml + + +with Path("./config.yml").open("r") as fh: + app_config = yaml.safe_load(fh) diff --git a/server/entertainment_decider/extractors/collection/__init__.py b/server/entertainment_decider/extractors/collection/__init__.py new file mode 100644 index 0000000..1b28dc9 --- /dev/null +++ b/server/entertainment_decider/extractors/collection/__init__.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Dict + +from ...config import app_config +from ...models import MediaCollection +from .base import CollectionExtractor +from .tt_rss import TtRssCollectionExtractor, TtRssConnectionParameter +from .youtube import YouTubeCollectionExtractor + + +tt_rss_params = TtRssConnectionParameter(**app_config["extractors"]["tt_rss"]) +COLLECTION_EXTRACTORS: Dict[str, CollectionExtractor] = { + "tt-rss": TtRssCollectionExtractor(params=tt_rss_params, label_filter=-1033), + "youtube": YouTubeCollectionExtractor(), +} + +def collection_extract_uri(extractor_name: str, uri: str) -> MediaCollection: + elem: MediaCollection = CollectionExtractor.check_uri(uri) + ex = COLLECTION_EXTRACTORS[extractor_name] + if not elem: + elem = ex.extract_and_store(uri) + else: + ex.update_object(elem, check_cache_expired=False) + return elem diff --git a/server/entertainment_decider/extractors/collection/base.py b/server/entertainment_decider/extractors/collection/base.py new file mode 100644 index 0000000..e72276c --- /dev/null +++ b/server/entertainment_decider/extractors/collection/base.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import logging +from typing import Optional, TypeVar + +from ...models import CollectionUriMapping, MediaCollection +from ..generic import ExtractedData, GeneralExtractor + + +T = TypeVar("T") + + +class CollectionExtractor(GeneralExtractor[MediaCollection, T]): + + @staticmethod + def check_uri(uri: str) -> Optional[MediaCollection]: + mapping: CollectionUriMapping = CollectionUriMapping.get(uri=uri) + if mapping: + return mapping.element + elem: MediaCollection = MediaCollection.get(uri=uri) + if elem: + logging.warning( + f"Add missing URI mapping entry for uri {uri!r}, " + + "this should not happen at this point and is considered a bug" + ) + elem.add_uris((uri,)) + return elem + return None + + def _create_object(self, data: ExtractedData[T]) -> MediaCollection: + collection = data.create_collection() + return collection + + def _load_object(self, data: ExtractedData[T]) -> MediaCollection: + collection = data.load_collection() + collection.keep_updated = True + return collection diff --git a/server/entertainment_decider/extractors/collection/tt_rss.py b/server/entertainment_decider/extractors/collection/tt_rss.py new file mode 100644 index 0000000..8e57a9a --- /dev/null +++ b/server/entertainment_decider/extractors/collection/tt_rss.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import Enum +from functools import partial +import logging +import re +from typing import Dict, List, Optional +import urllib.parse as url + +from pony import orm # TODO remove +from tinytinypy import Connection +from tinytinypy.main import Headline + +from ...models import MediaCollection +from ..generic import ExtractedData, ExtractionError +from .base import CollectionExtractor + + +logger = logging.getLogger(__name__) + + +@dataclass +class TtRssConnectionParameter: + host: str + username: str + password: str + proto: str = "https" + endpoint: str = "/api/" + + +TT_RSS_CONNECTION: Connection = None + +HeadlineList = List[Headline] + +def _build_connection(params: TtRssConnectionParameter) -> Connection: + global TT_RSS_CONNECTION + if TT_RSS_CONNECTION is None: + TT_RSS_CONNECTION = Connection(proto=params.proto, host=params.host, endpoint=params.endpoint) + if not TT_RSS_CONNECTION.isLoggedIn(): + TT_RSS_CONNECTION.login(username=params.username, password=params.password) + return TT_RSS_CONNECTION + +def get_headlines(params: TtRssConnectionParameter, **kwargs) -> HeadlineList: + conn = _build_connection(params) + if "limit" in kwargs: + kwargs["limit"] = int(kwargs["limit"]) + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"Request headlines from tt-rss: {kwargs!r}") + headlines = conn.getHeadlines(**kwargs) + logger.debug(f"Got {len(headlines)} headlines from tt-rss using: {kwargs!r}") + return headlines + + +class TtRssUriKind(Enum): + ALL = ("all", lambda id: get_headlines) + CATEGORY = ("category", lambda id: partial(get_headlines, cat_id=id)) + FEED = ("feed", lambda id: partial(get_headlines, feed_id=id)) + + @property + def path_name(self) -> str: + return self.value[0] + + @property + def request(self): + return self.value[1] + + @classmethod + def from_path_name(cls, name: str) -> "TtRssUriKind": + for e in cls: + if e.path_name.lower() == name.lower(): + return e + raise KeyError() + + +@dataclass +class TtRssUri: + + supported_kinds = '|'.join(re.escape(n.path_name.lower()) for n in TtRssUriKind) + scheme = "tt-rss" + path_re = re.compile(fr"^/((?Pall)|(?P{supported_kinds})/(?P-?\d+))/?$") + + kind: TtRssUriKind + id: Optional[str] + options: Dict[str, str] + + @classmethod + def from_str_uri(cls, uri: str) -> "TtRssUri": + parts = url.urlparse(uri, scheme=cls.scheme) + if parts.scheme != cls.scheme: + raise Exception(f"Invalid scheme for tt-rss uri: {parts.scheme!r}") + if (parts.netloc, parts.params, parts.fragment) != ("", "", ""): + raise Exception(f"tt-rss uris do not accept netloc, params and fragments") + m = cls.path_re.search(parts.path) + if m is None: + raise Exception(f"Could not parse path of tt-rss uri: {parts.path!r}") + return TtRssUri( + kind = TtRssUriKind.ALL if m.group("all") else TtRssUriKind.from_path_name(m.group("kind")), + id = m.group("id"), + options = {single[0]: single[1] for single in (single.split("=") for single in parts.query.split("&"))} if parts.query else {}, + ) + + def request(self, params: TtRssConnectionParameter, **kwargs) -> HeadlineList: + return self.kind.request(self.id)(params, **self.options, **kwargs) + + +class TtRssCollectionExtractor(CollectionExtractor[HeadlineList]): + + __params: TtRssConnectionParameter + __label_filter: Optional[int] + __mark_as_read: bool + + def __init__(self, + params: TtRssConnectionParameter, + mark_as_read: bool = False, + label_filter: Optional[int] = None, + ): + super().__init__("tt-rss") + self.__params = params + self.__label_filter = label_filter + self.__mark_as_read = mark_as_read + + def __decode_uri(self, uri: str) -> TtRssUri: + return TtRssUri.from_str_uri(uri) + + def can_extract_offline(self, uri: str, cache: Dict = None) -> bool: + return True + + def _cache_expired(self, date: datetime) -> bool: + return (datetime.now() - date) < timedelta(hours=4) + + def _extract_offline(self, uri: str, cache: Dict = None) -> ExtractedData[HeadlineList]: + return ExtractedData( + extractor_name=self.name, + object_key=uri, + object_uri=uri, + cache=cache, + ) + + def _extract_online(self, uri: str, cache: Dict = None) -> ExtractedData[HeadlineList]: + rss_uri = self.__decode_uri(uri) + logging.info(f"Extract collection from tt-rss: {uri!r}") + data = rss_uri.request(self.__params, order_by="feed_dates", view_mode="unread") + if self.__label_filter is not None: + print([headline.labels for headline in data]) + data = [ + headline for headline in data + if self.__label_filter in (label_marker[0] for label_marker in headline.labels) + ] + if self.__mark_as_read: + parameters = { + "article_ids": ",".join(str(headline.feedId) for headline in data), + "field": "2", # unread + "mode": "0", # false + } + raise NotImplementedError("Cannot set articles as read with tinytinypy for now") # TODO + return ExtractedData( + extractor_name=self.name, + object_key=uri, + object_uri=uri, + data=data, + ) + + def _update_object_raw(self, object: MediaCollection, data: HeadlineList) -> str: + if not object.title: + object.title = object.uri + logging.debug(f"Got {len(data)} headlines") + for headline in data: + logging.debug(f"Add to collection {headline.url!r}") + try: + object.add_episode(media_extract_uri("ytdl", headline.url)) + orm.commit() + except ExtractionError: + logging.warning(f"Failed while extracting media {headline.url!r}", exc_info=True) + if object.watch_in_order_auto: + object.watch_in_order = False # no order available diff --git a/server/entertainment_decider/extractors/collection/youtube.py b/server/entertainment_decider/extractors/collection/youtube.py new file mode 100644 index 0000000..1fcb5b8 --- /dev/null +++ b/server/entertainment_decider/extractors/collection/youtube.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +import re +from typing import Dict + +from pony import orm # TODO remove +import youtubesearchpython + +from ...models import MediaCollection +from ..generic import ExtractedData, ExtractionError +from .base import CollectionExtractor + + +class YouTubeCollectionExtractor(CollectionExtractor[Dict]): + + __uri_regex = re.compile(r"^https?://(www\.)?youtube\.com/(channel/|playlist\?list=)(?P[^/&?]+)") + + @classmethod + def __get_id(cls, uri: str) -> str: + m = cls.__uri_regex.search(uri) + if not m: + raise Exception(f"Failed to parse Youtube collection uri {uri!r}") + return m.group("id") + + @staticmethod + def __is_channel_id(collection_id: str) -> bool: + return collection_id.startswith("UC") or collection_id.startswith("UU") + + @staticmethod + def __convert_channel_id(channel_id: str) -> str: + if channel_id.startswith("UU"): + return channel_id + if channel_id.startswith("UC"): + return f"UU{channel_id[2:]}" + raise Exception(f"Got not valid channel id: {channel_id!r}") + + @classmethod + def __convert_if_required(cls, collection_id: str) -> str: + if cls.__is_channel_id(collection_id): + return cls.__convert_channel_id(collection_id) + return collection_id + + def __init__(self): + super().__init__("youtube") + + def can_extract_offline(self, uri: str, cache: Dict = None) -> bool: + return True + + def _cache_expired(self, date: datetime) -> bool: + return (datetime.now() - date) < timedelta(hours=4) + + def _extract_offline(self, uri: str, cache: Dict = None) -> ExtractedData[Dict]: + playlist_id = self.__convert_if_required(self.__get_id(uri)) + return ExtractedData( + extractor_name=self.name, + object_key=playlist_id, + object_uri=uri, + cache=cache, + ) + + def _extract_online(self, uri: str, cache: Dict = None) -> ExtractedData[Dict]: + playlist_id = self.__convert_if_required(self.__get_id(uri)) + playlist_link = f"https://www.youtube.com/playlist?list={playlist_id}" + logging.info(f"Request Youtube playlist {playlist_link!r}") + playlist = youtubesearchpython.Playlist(playlist_link) + while playlist.hasMoreVideos: + playlist.getNextVideos() + logging.debug(f"Retrieved {len(playlist.videos)} videos from playlist {playlist_link!r}") + return ExtractedData( + extractor_name=self.name, + object_key=playlist_id, + object_uri=uri, + data={ + "info": playlist.info["info"], + "videos": playlist.videos, + }, + ) + + def _update_object_raw(self, object: MediaCollection, data: Dict): + info = data["info"] + object.title = f"{info['title']} ({info['channel']['name']})" + object.add_uris((info["link"],)) + video_list = data["videos"] + is_channel = self.__is_channel_id(info["id"]) + if object.watch_in_order_auto: + object.watch_in_order = not is_channel + len_video_list = len(video_list) + if is_channel: + video_list = reversed(video_list) + for index, video in enumerate(video_list): + video_url = f"https://www.youtube.com/watch?v={video['id']}" + other_urls = [ + f"https://youtube.com/watch?v={video['id']}", + f"https://youtu.be/{video['id']}", + ] + logging.debug(f"[youtube] Add to collection {object.title!r} video {video_url!r} ({index+1} of {len_video_list})") + try: + element = media_extract_uri("ytdl", video_url) + element.add_uris(other_urls) + object.add_episode(element, episode=index+1) + orm.commit() # so progress is stored + except ExtractionError: + logging.warning(f"Failed while extracting media {video_url!r}", exc_info=True) diff --git a/server/entertainment_decider/extractors/generic.py b/server/entertainment_decider/extractors/generic.py new file mode 100644 index 0000000..c00dbc8 --- /dev/null +++ b/server/entertainment_decider/extractors/generic.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass +from datetime import datetime +import logging +from typing import Dict, Generic, Optional, TypeVar + +from ..models import MediaCollection, MediaElement + + +T = TypeVar("T") + + +class ExtractionError(Exception): + pass + + +@dataclass +class ExtractedDataLight: + object_uri: str + extractor_name: str + object_key: str + + def create_media(self) -> MediaElement: + return MediaElement( + uri = self.object_uri, + extractor_name = self.extractor_name, + extractor_key = self.object_key, + ) + + def create_collection(self) -> MediaCollection: + return MediaCollection( + uri = self.object_uri, + extractor_name = self.extractor_name, + extractor_key = self.object_key + ) + + +@dataclass +class ExtractedData(ExtractedDataLight, Generic[T]): + data: T = dataclasses.field(default=None, repr=False, compare=False) + cache: Dict = dataclasses.field(default=None, repr=False, compare=False) + + @property + def has_data(self) -> bool: + return self.data is not None + + def load_media(self) -> MediaElement: + return MediaElement.get(extractor_name=self.extractor_name, extractor_key=self.object_key) + + def load_collection(self) -> MediaCollection: + return MediaCollection.get(extractor_name=self.extractor_name, extractor_key=self.object_key) + + +@dataclass +class AuthorExtractedData(ExtractedDataLight): + author_name: str + + @property + def is_valid(self): + return len(list(v for _, v in self.__dict__.items() if v is None)) <= 0 + + +E = TypeVar("E", MediaElement, MediaCollection) + +class GeneralExtractor(Generic[E, T]): + + name: str + + def __init__(self, name: str): + self.name = name + + # abstract (for media & collection base classes) + + @staticmethod + def check_uri(uri: str) -> Optional[E]: + raise NotImplementedError() + + def _create_object(self, data: ExtractedData[T]) -> E: + raise NotImplementedError() + + def _load_object(self, data: ExtractedData[T]) -> E: + raise NotImplementedError() + + # abstract (for specific extractor classes) + + #def uri_suitable(self, uri: str) -> bool: + # raise NotImplementedError() + + def can_extract_offline(self, uri: str, cache: Dict = None) -> bool: + return False + + def _cache_expired(self, date: datetime) -> bool: + return False + + def _extract_offline_only(self, uri: str, cache: Dict = None) -> ExtractedData[T]: + raise NotImplementedError() + + def _extract_online(self, uri: str, cache: Dict = None) -> ExtractedData[T]: + raise NotImplementedError() + + def _update_object_raw(self, object: E, data: T): + raise NotImplementedError() + + def _update_hook(self, object: E, data: ExtractedData[T]): + return None + + # defined + + def _extract_offline(self, uri: str, cache: Dict = None) -> ExtractedData[T]: + return self._extract_offline_only(uri, cache) if self.can_extract_offline(uri, cache) else self._extract_online(uri, cache) + + def _extract_required(self, data: ExtractedData[T]) -> ExtractedData[T]: + if data.has_data: + return data + return self._extract_online(data.object_uri, data.cache) + + def _update_object(self, object: E, data: ExtractedData[T]) -> E: + object.extractor_cache = data.cache + object.uri = data.object_uri + object.add_uris((data.object_uri,)) + self._update_object_raw(object, data.data) + self._update_hook(object, data) + return object + + def update_object(self, object: E, check_cache_expired: bool = True) -> E: + if object.extractor_cache_date and check_cache_expired and not self._cache_expired(object.extractor_cache_date): + return object + data = self._extract_online(object.uri, object.extractor_cache) + logging.debug(f"Updating info for media: {data!r}") + return self._update_object(object, data) + + def store_object(self, data: ExtractedData[T]) -> E: + object = self._load_object(data) + if object: + logging.debug(f"Found object already in database: {data!r}") + return object + data = self._extract_required(data) + logging.debug(f"Store info for object: {data!r}") + object = self._create_object(data) + return self._update_object(object, data) + + def extract_and_store(self, uri: str) -> E: + object = self.check_uri(uri) + if object is not None: + return object + return self.store_object(self._extract_offline(uri)) diff --git a/server/entertainment_decider/extractors/media/__init__.py b/server/entertainment_decider/extractors/media/__init__.py new file mode 100644 index 0000000..1b2036d --- /dev/null +++ b/server/entertainment_decider/extractors/media/__init__.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Dict + +from ...models import MediaElement +from .base import MediaExtractor +from .ytdl import YtdlMediaExtractor + + +MEDIA_EXTRACTORS: Dict[str, MediaExtractor] = { + "ytdl": YtdlMediaExtractor(), +} + +def media_extract_uri(extractor_name: str, uri: str) -> MediaElement: + elem: MediaElement = MediaExtractor.check_uri(uri) + if not elem: + elem = MEDIA_EXTRACTORS[extractor_name].extract_and_store(uri) + return elem diff --git a/server/entertainment_decider/extractors/media/base.py b/server/entertainment_decider/extractors/media/base.py new file mode 100644 index 0000000..47302e3 --- /dev/null +++ b/server/entertainment_decider/extractors/media/base.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import logging +from typing import Dict, Optional, TypeVar + +from ...models import MediaCollection, MediaElement, MediaUriMapping +from ..generic import AuthorExtractedData, ExtractedData, GeneralExtractor +from ..collection.base import CollectionExtractor + + +T = TypeVar("T") + + +class MediaExtractor(GeneralExtractor[MediaElement, T]): + + # abstract + + def _get_author_data(self, data: Dict) -> Optional[AuthorExtractedData]: + return None + + # defined + + @staticmethod + def check_uri(uri: str) -> Optional[MediaElement]: + mapping: MediaUriMapping = MediaUriMapping.get(uri=uri) + if mapping: + return mapping.element + elem: MediaElement = MediaElement.get(uri=uri) + if elem: + logging.warning( + f"Add missing URI mapping entry for uri {uri!r}, " + + "this should not happen at this point and is considered a bug" + ) + elem.add_uris((uri,)) + return elem + return None + + def _create_object(self, data: ExtractedData[T]) -> MediaElement: + return data.create_media() + + def _load_object(self, data: ExtractedData[T]) -> MediaElement: + return data.load_media() + + def _create_author_collection(self, author_data: AuthorExtractedData) -> MediaCollection: + collection = author_data.create_collection() + collection.add_uris((author_data.object_uri,)) + collection.keep_updated = False + collection.watch_in_order = False + return collection + + def _lookup_author_collection(self, author_data: AuthorExtractedData) -> Optional[MediaCollection]: + return CollectionExtractor.check_uri( + uri=author_data.object_uri, + ) or MediaCollection.get( + extractor_name=author_data.extractor_name, + extractor_key=author_data.object_key, + ) + + def _get_author_collection(self, author_data: AuthorExtractedData) -> MediaCollection: + collection = self._lookup_author_collection(author_data) + if collection is None: + collection = self._create_author_collection(author_data) + if not collection.title or collection.title.startswith(f"(author:{author_data.extractor_name}) "): + collection.title = f"(author:{author_data.extractor_name}) {author_data.author_name}" + return collection + + def _add_to_author_collection(self, element: MediaElement, data: Dict): + author_data = self._get_author_data(data) + if author_data is None or not author_data.is_valid: + return + collection = self._get_author_collection(author_data) + collection.add_episode(element) + + def _update_hook(self, object: MediaElement, data: ExtractedData[T]): + self._add_to_author_collection(object, data.data) diff --git a/server/entertainment_decider/extractors/media/ytdl.py b/server/entertainment_decider/extractors/media/ytdl.py new file mode 100644 index 0000000..de45e54 --- /dev/null +++ b/server/entertainment_decider/extractors/media/ytdl.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import json +from datetime import datetime +import logging +import subprocess +from typing import Dict, List, Optional + +from jsoncache import ApplicationCache + +from ...common import call +from ...models import MediaElement +from ..generic import AuthorExtractedData, ExtractedData, ExtractionError +from .base import MediaExtractor + + +cache = ApplicationCache(app_name="entertainment-decider-ytdl", create_cache_dir=True, default_max_age=7*86400) +cache.clean_cache() + +YTDL_CALL = [ + "yt-dlp", +] + + +class YtdlErrorException(subprocess.CalledProcessError): + pass + +def ytdl_call(args: List[str]) -> dict: + proc = call(YTDL_CALL + args, check=False) + if proc.returncode != 0: + raise YtdlErrorException( + returncode=proc.returncode, + cmd=args, + output=proc.stdout, + stderr=proc.stderr, + ) + return json.loads(proc.stdout.strip()) + +@cache.cache_json() +def get_video_info(uri: str) -> dict: + return ytdl_call([ + "--no-playlist", + "--dump-json", + uri, + ]) + +@cache.cache_json() +def get_playlist_info(uri: str) -> dict: + return ytdl_call(uri) + + +class YtdlMediaExtractor(MediaExtractor[Dict]): + + def __init__(self): + super().__init__("ytdl") + + def _get_author_data(self, data: Dict) -> Optional[AuthorExtractedData]: + video_extractor_key = data.get("extractor_key") or data["ie_key"] + author_key = data.get("channel_id") or data.get("uploader_id") + author_name = data.get("channel") or data.get("uploader") or data.get("uploader_id") + return AuthorExtractedData( + object_uri = data.get("channel_url") or data.get("uploader_url"), + extractor_name = self.name, + object_key = f"author:{video_extractor_key}:{author_key}" if author_key else None, + author_name = f"{video_extractor_key}: {author_name}" if author_name else None, + ) + + def _extract_online(self, uri: str, cache: Dict) -> ExtractedData[Dict]: + if cache: + logging.debug(f"Use preloaded cache to get infos of video {uri!r}") + vid_data = cache + else: + logging.info(f"Request info using youtube-dl for {uri!r}") + try: + vid_data = get_video_info(uri) + except YtdlErrorException as e: + raise ExtractionError from e + if vid_data.get("is_live", False): + raise ExtractionError("Video is live, so pass extraction") + ytdl_extractor_key = vid_data.get("extractor_key") or vid_data["ie_key"] + ytdl_video_id = vid_data["id"] + return ExtractedData[Dict]( + object_uri=uri, + extractor_name=self.name, + object_key=f"{ytdl_extractor_key}:{ytdl_video_id}", + data=vid_data, + cache=None, + ) + + def _update_object_raw(self, object: MediaElement, data: Dict) -> str: + object.title = f"{data['title']} - {data['uploader']}" if "uploader" in data else data["title"] + object.release_date = datetime.strptime(data["upload_date"], "%Y%m%d") + object.length = int(data["duration"]) diff --git a/server/entertainment_decider/models.py b/server/entertainment_decider/models.py new file mode 100644 index 0000000..9f35734 --- /dev/null +++ b/server/entertainment_decider/models.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from typing import Dict, Iterable, List, Optional, Set + +from pony import orm + +db = orm.Database() + + +#### +## Model Extensions +#### + + +@dataclass +class CollectionStats: + + to_watch_count: int + ignored_count: int # but not watched + watched_count: int + + to_watch_seconds: int + ignored_seconds: int # but not watched + watched_seconds: int + + @property + def full_count(self) -> int: + return self.to_watch_count + self.ignored_count + self.watched_count + + @property + def full_seconds(self) -> int: + return self.to_watch_seconds + self.ignored_seconds + self.watched_seconds + + @classmethod + def from_collection(cls, collection: MediaCollection) -> CollectionStats: + to_watch_count = 0 + ignored_count = 0 + watched_count = 0 + to_watch_seconds = 0 + ignored_seconds = 0 + watched_seconds = 0 + for link in collection.media_links: + media = link.element + if media.watched: + watched_count += 1 + watched_seconds += media.length + else: + watched_seconds += media.progress + if media.ignored: + ignored_count += 1 + ignored_seconds += media.left_length + else: + to_watch_count += 1 + to_watch_seconds += media.left_length + return CollectionStats( + to_watch_count=to_watch_count, + ignored_count=ignored_count, + watched_count=watched_count, + to_watch_seconds=to_watch_seconds, + ignored_seconds=ignored_seconds, + watched_seconds=watched_seconds, + ) + + +#### +## Models +#### + + +class Tag(db.Entity): + + id: int = orm.PrimaryKey(int, auto=True) + + title: str = orm.Required(str) + notes: str = orm.Optional(str) + + use_for_preferences: bool = orm.Required(bool, default=True) + + _collection_list: Iterable[MediaCollection] = orm.Set(lambda: MediaCollection) + _media_list: Iterable[MediaElement] = orm.Set(lambda: MediaElement) + + +class MediaCollectionLink(db.Entity): + + collection: MediaCollection = orm.Required(lambda: MediaCollection) + element: MediaElement = orm.Required(lambda: MediaElement) + orm.PrimaryKey(collection, element) + season: int = orm.Required(int, default=0) + episode: int = orm.Required(int, default=0) + orm.composite_index(season, episode) + + @property + def element_id(self): + return self.element.id + + @property + def element_release_date(self): + return self.element.release_date + + @staticmethod + def sorted(iterable: Iterable[MediaCollectionLink]) -> List[MediaCollectionLink]: + return sorted(iterable, key=lambda m: (m.season, m.episode, m.element_release_date, m.element_id)) + natural_order = (season, episode, element_release_date, element_id) # unusuable due to ponyorm, see https://github.com/ponyorm/pony/issues/612 + + +class MediaElement(db.Entity): + + id: int = orm.PrimaryKey(int, auto=True) + uri: str = orm.Required(str, unique=True) + + title: str = orm.Optional(str) + notes: str = orm.Optional(str) + release_date: datetime = orm.Optional(datetime) + + extractor_name: str = orm.Required(str) + extractor_key: str = orm.Required(str) + orm.composite_key(extractor_name, extractor_key) + _extractor_cache: Dict = orm.Optional(orm.Json, nullable=True) + extractor_cache_date: datetime = orm.Optional(datetime) + + watched: bool = orm.Required(bool, default=False) + ignored: bool = orm.Required(bool, default=False) + progress: int = orm.Required(int, default=0) + length: int = orm.Optional(int) + + tag_list : Iterable[Tag] = orm.Set(lambda: Tag) + _uris: Iterable[MediaUriMapping] = orm.Set(lambda: MediaUriMapping) + collection_links: Iterable[MediaCollectionLink] = orm.Set(lambda: MediaCollectionLink) + + def extractor_cache_valid(self, max_age: timedelta): + return (datetime.now() - self.extractor_cache_date) < max_age + + def __get_cache(self): + return self._extractor_cache + def __set_cache(self, cache: Dict): + self._extractor_cache = cache + self.extractor_cache_date = datetime.now() + extractor_cache = property(__get_cache, __set_cache) + + @property + def left_length(self) -> int: + return self.length - self.progress + + @property + def ignored_recursive(self) -> bool: + links = orm.select(link for link in MediaCollectionLink if link.episode == self and link.collection.ignored == True) + return len(links) > 0 + + @property + def ignored_any(self) -> bool: + return self.ignored or self.ignored_recursive + + @property + def skip_over(self) -> bool: + return self.ignored or self.watched + + @property + def can_considered(self) -> bool: + if self.skip_over: + return False + for link in self.collection_links: + if link.collection.watch_in_order and self != link.collection.next_episode.element: + return False + return True + + @property + def inherited_tags(self) -> Set[Tag]: + result = set() + for link in self.collection_links: + result |= link.collection.all_tags + return result + + @property + def all_tags(self) -> Iterable[Tag]: + return set(self.tag_list) | self.inherited_tags + + def merge_to(self, other: MediaElement): + if self.watched: + other.watched = True + if self.ignored: + other.ignored = True + if self.progress >= 0 and other.progress <= 0: + other.progress = self.progress + for uri_map in self._uris: + uri_map.element = other + for link in self.collection_links: + if not MediaCollectionLink.get(collection=link.collection, element=other): + link.element = other + self.delete() # will also delete still existing uri mappings and collection links + orm.flush() + + def add_uris(self, uri_list: Iterable[str]): + for uri in set(uri_list): + mapping: MediaUriMapping = MediaUriMapping.get(uri=uri) + if not mapping: + logging.debug(f"Add URI mapping {uri!r} to media {self.id!r}") + MediaUriMapping( + uri=uri, + element=self, + ) + continue + if mapping.element != self: + raise Exception(f"URI duplicated for two different media's: {uri}") # TODO may replace with merge call + orm.flush() + + @property + def info_link(self): + return f"/media/{self.id}" + + +class MediaUriMapping(db.Entity): + + id: int = orm.PrimaryKey(int, auto=True) + uri: str = orm.Required(str, unique=True) + element: MediaElement = orm.Required(MediaElement) + + +class MediaCollection(db.Entity): + + id: int = orm.PrimaryKey(int, auto=True) + uri: str = orm.Required(str, unique=True) + + title: str = orm.Optional(str) + notes: str = orm.Optional(str) + release_date: datetime = orm.Optional(datetime) + + extractor_name: str = orm.Required(str) + extractor_key: str = orm.Required(str) + orm.composite_key(extractor_name, extractor_key) + _extractor_cache: Dict = orm.Optional(orm.Json, nullable=True) + extractor_cache_date: datetime = orm.Optional(datetime) + + keep_updated: bool = orm.Required(bool, default=False) + watch_in_order_auto: bool = orm.Required(bool, default=True) + + ignored: bool = orm.Required(bool, default=False) + watch_in_order: bool = orm.Required(bool, default=True) + + tag_list: Iterable[Tag] = orm.Set(lambda: Tag) + _uris: Iterable[CollectionUriMapping] = orm.Set(lambda: CollectionUriMapping) + media_links: Iterable[MediaCollectionLink] = orm.Set(MediaCollectionLink) + + def extractor_cache_valid(self, max_age: timedelta): + return (datetime.now() - self.extractor_cache_date) < max_age + + def __get_cache(self): + return self._extractor_cache + def __set_cache(self, cache: Dict): + self._extractor_cache = cache + self.extractor_cache_date = datetime.now() + extractor_cache = property(__get_cache, __set_cache) + + @property + def next_episode(self) -> Optional[MediaCollectionLink]: + #return orm \ + # .select(link for link in self.media_links if not link.element.watched) \ + # .order_by(*MediaCollectionLink.natural_order) \ + # .first() + episodes = MediaCollectionLink.sorted(orm.select(link for link in self.media_links if not link.element.watched and not link.element.ignored)) + return episodes[0] if len(episodes) > 0 else None + + @property + def completed(self) -> bool: + return self.next_episode is None + + @property + def all_tags(self) -> Iterable[Tag]: + return self.tag_list + + @property + def stats(self) -> CollectionStats: + return CollectionStats.from_collection(self) + + def add_episode(self, media: MediaElement, season: int = 0, episode: int = 0) -> MediaCollectionLink: + link: MediaCollectionLink = MediaCollectionLink.get(collection=self, element=media) + if link is None: + link = MediaCollectionLink(collection=self, element=media) + link.season, link.episode = season, episode + orm.flush() + return link + + def add_uris(self, uri_list: Iterable[str]): + for uri in set(uri_list): + mapping: CollectionUriMapping = CollectionUriMapping.get(uri=uri) + if not mapping: + logging.debug(f"Add URI mapping {uri!r} to collection {self.id!r}") + CollectionUriMapping( + uri=uri, + element=self, + ) + continue + if mapping.element != self: + raise Exception(f"URI duplicated for two different collections's: {uri}") # TODO may replace with merge call + orm.flush() + + @property + def info_link(self): + return f"/collection/{self.id}" + + +class CollectionUriMapping(db.Entity): + + id: int = orm.PrimaryKey(int, auto=True) + uri: str = orm.Required(str, unique=True) + element: MediaCollection = orm.Required(MediaCollection) diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..a2acbaf --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,10 @@ +Flask[async]>=2.0.1 +pony>=0.7.14 +pyyaml>=5.4.1 +rss-parser>=0.2.3 +youtube-dl>=2021.6.6 +youtube-search-python>=1.4.9 + +# custom, local requirements; TODO remove or change to persistent dependency +/home/zocker/Repositories/python-jsoncache +/home/zocker/Repositories/tinytinypy diff --git a/server/templates/collection_element.htm b/server/templates/collection_element.htm new file mode 100644 index 0000000..c8167c2 --- /dev/null +++ b/server/templates/collection_element.htm @@ -0,0 +1,63 @@ + + + {% set title = collection.title %} + + + {{ title }} + + + + <- back to list +

{{ title }}

+

Properties

+
    +
  • Watch In Order: {{ collection.watch_in_order | tenary("Yes", "no") }} {%- if collection.watch_in_order_auto %} (automatic){% endif %}
  • +
  • Keep Updated: {{ collection.keep_updated | tenary("Yes", "no") }}
  • + {% if collection.watch_in_order %} +
  • + Next Episode: + {% set link = collection.next_episode %} + {% if link %} + {{ link.element.title }} + {%- if link.season != 0 -%} + , Season {{ link.season }} + {% endif %} + {%- if link.episode != 0 -%} + , Episode {{ link.episode }} + {% endif %} + {% else %} + no next episode + {% endif %} +
  • + {% endif %} +
+

Notes

+
{{ collection.notes or "" }}
+

Episodes

+
    + {% for link in media_links %} +
  • + {{ link.element.title }} + {%- if link.season != 0 -%} + , Season {{ link.season }} + {% endif %} + {%- if link.episode != 0 -%} + , Episode {{ link.episode }} + {% endif %} +
  • + {% endfor %} +
+

Links

+
    + {% for link in collection._uris %} +
  • {{ link.uri | as_link }} {% if collection.uri == link.uri %}*{% endif %}
  • + {% endfor %} +
+ + diff --git a/server/templates/collection_list.htm b/server/templates/collection_list.htm new file mode 100644 index 0000000..41c5b4d --- /dev/null +++ b/server/templates/collection_list.htm @@ -0,0 +1,43 @@ + + + {% set title = collection_list | length | string + " Collections known" %} + + + {{ title }} + + + +

{{ title }}

+ + + + + + + + + {% for collection in collection_list %} + {% set stats = collection.stats %} + + + + + + + + {% endfor %} +
TitleDateCountWatchedTo Watch
{{ collection.title }} + {% if collection.release_date %} + {{ collection.release_date.strftime("%d.%m.%Y") }} + {% else %} + unknown + {% endif %} + {{ stats.full_count }}{{ stats.watched_count }}{{ stats.to_watch_count }}
+ + diff --git a/server/templates/media_element.htm b/server/templates/media_element.htm new file mode 100644 index 0000000..bb2182b --- /dev/null +++ b/server/templates/media_element.htm @@ -0,0 +1,45 @@ + + + {% set title = element.title %} + + + {{ title }} + + + + <- back to list +

{{ title }}

+

Notes

+
{{ element.notes or "" }}
+

Properties

+
    +
  • Can be considered: {{ element.can_considered | tenary("Yes", "no") }}
  • +
+

Part of Collections

+
    + {% for link in element.collection_links %} +
  • + {{ link.collection.title }} + {%- if link.season != 0 -%} + , Season {{ link.season }} + {% endif %} + {%- if link.episode != 0 -%} + , Episode {{ link.episode }} + {% endif %} +
  • + {% endfor %} +
+

Links

+
    + {% for link in element._uris %} +
  • {{ link.uri | as_link }} {% if element.uri == link.uri %}*{% endif %}
  • + {% endfor %} +
+ + diff --git a/server/templates/media_list.htm b/server/templates/media_list.htm new file mode 100644 index 0000000..5ed4e59 --- /dev/null +++ b/server/templates/media_list.htm @@ -0,0 +1,53 @@ + + + {% set title = media_list | length | string + " Videos known" %} + + + {{ title }} + + + +

{{ title }}

+
    +
  • + Full length: + {{ media_list | map(attribute='length') | sum }} + seconds +
  • +
+ + + + + + + + + + {% for media in media_list %} + + + + + + + + + {% endfor %} +
TitleDateProgressLengthConsiderLink
{{ media.title }}{{ media.release_date.strftime("%d.%m.%Y") }} + {% if media.watched %} + completed + {% elif media.progress <= 0 %} + not started + {% else %} + {{ media.progress }} s + {% endif %} + {{ media.length }} s{{ media.can_considered | tenary("Yes", "no") }}link
+ +