Init flask / ponyorm server
commit
83b88fb89e
@ -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
|
@ -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'<a href="{uri}">{uri}</a>')
|
||||||
|
|
||||||
|
@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 '<a href=/collection>Collections</a> & <a href=/media>Media</a>'
|
||||||
|
|
||||||
|
|
||||||
|
@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/<int:collection_id>")
|
||||||
|
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/<int:collection_id>", 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/<int:media_id>")
|
||||||
|
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
|
@ -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
|
@ -0,0 +1,7 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
with Path("./config.yml").open("r") as fh:
|
||||||
|
app_config = yaml.safe_load(fh)
|
@ -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
|
@ -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
|
@ -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"^/((?P<all>all)|(?P<kind>{supported_kinds})/(?P<id>-?\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
|
@ -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<id>[^/&?]+)")
|
||||||
|
|
||||||
|
@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)
|
@ -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))
|
@ -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
|
@ -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)
|
@ -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"])
|
@ -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)
|
@ -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
|
@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{% set title = collection.title %}
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<style>
|
||||||
|
table tr th, table tr td {
|
||||||
|
margin: 0;
|
||||||
|
padding: .2em;
|
||||||
|
border: solid black 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a href="/collection"><- back to list</a>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<h2>Properties</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Watch In Order: {{ collection.watch_in_order | tenary("Yes", "no") }} {%- if collection.watch_in_order_auto %} (automatic){% endif %}</li>
|
||||||
|
<li>Keep Updated: {{ collection.keep_updated | tenary("Yes", "no") }}</li>
|
||||||
|
{% if collection.watch_in_order %}
|
||||||
|
<li>
|
||||||
|
Next Episode:
|
||||||
|
{% set link = collection.next_episode %}
|
||||||
|
{% if link %}
|
||||||
|
<a href="{{ link.element.info_link }}">{{ link.element.title }}</a>
|
||||||
|
{%- if link.season != 0 -%}
|
||||||
|
, Season {{ link.season }}
|
||||||
|
{% endif %}
|
||||||
|
{%- if link.episode != 0 -%}
|
||||||
|
, Episode {{ link.episode }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
no next episode
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
<h2>Notes</h2>
|
||||||
|
<pre>{{ collection.notes or "" }}</pre>
|
||||||
|
<h2>Episodes</h2>
|
||||||
|
<ul>
|
||||||
|
{% for link in media_links %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ link.element.info_link }}">{{ link.element.title }}</a>
|
||||||
|
{%- if link.season != 0 -%}
|
||||||
|
, Season {{ link.season }}
|
||||||
|
{% endif %}
|
||||||
|
{%- if link.episode != 0 -%}
|
||||||
|
, Episode {{ link.episode }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<h2>Links</h2>
|
||||||
|
<ul>
|
||||||
|
{% for link in collection._uris %}
|
||||||
|
<li>{{ link.uri | as_link }} {% if collection.uri == link.uri %}*{% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{% set title = collection_list | length | string + " Collections known" %}
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<style>
|
||||||
|
table tr th, table tr td {
|
||||||
|
margin: 0;
|
||||||
|
padding: .2em;
|
||||||
|
border: solid black 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Count</th>
|
||||||
|
<th>Watched</th>
|
||||||
|
<th>To Watch</th>
|
||||||
|
</tr>
|
||||||
|
{% for collection in collection_list %}
|
||||||
|
{% set stats = collection.stats %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ collection.info_link }}">{{ collection.title }}</a></td>
|
||||||
|
<td>
|
||||||
|
{% if collection.release_date %}
|
||||||
|
{{ collection.release_date.strftime("%d.%m.%Y") }}
|
||||||
|
{% else %}
|
||||||
|
unknown
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ stats.full_count }}</td>
|
||||||
|
<td>{{ stats.watched_count }}</td>
|
||||||
|
<td>{{ stats.to_watch_count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{% set title = element.title %}
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<style>
|
||||||
|
table tr th, table tr td {
|
||||||
|
margin: 0;
|
||||||
|
padding: .2em;
|
||||||
|
border: solid black 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a href="/media"><- back to list</a>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<h2>Notes</h2>
|
||||||
|
<pre>{{ element.notes or "" }}</pre>
|
||||||
|
<h2>Properties</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Can be considered: {{ element.can_considered | tenary("Yes", "no") }}</li>
|
||||||
|
</ul>
|
||||||
|
<h2>Part of Collections</h2>
|
||||||
|
<ul>
|
||||||
|
{% for link in element.collection_links %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ link.collection.info_link }}">{{ link.collection.title }}</a>
|
||||||
|
{%- if link.season != 0 -%}
|
||||||
|
, Season {{ link.season }}
|
||||||
|
{% endif %}
|
||||||
|
{%- if link.episode != 0 -%}
|
||||||
|
, Episode {{ link.episode }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<h2>Links</h2>
|
||||||
|
<ul>
|
||||||
|
{% for link in element._uris %}
|
||||||
|
<li>{{ link.uri | as_link }} {% if element.uri == link.uri %}*{% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{% set title = media_list | length | string + " Videos known" %}
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<style>
|
||||||
|
table tr th, table tr td {
|
||||||
|
margin: 0;
|
||||||
|
padding: .2em;
|
||||||
|
border: solid black 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Full length:
|
||||||
|
{{ media_list | map(attribute='length') | sum }}
|
||||||
|
seconds
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Length</th>
|
||||||
|
<th>Consider</th>
|
||||||
|
<th>Link</th>
|
||||||
|
</tr>
|
||||||
|
{% for media in media_list %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ media.info_link }}">{{ media.title }}</a></td>
|
||||||
|
<td>{{ media.release_date.strftime("%d.%m.%Y") }}</td>
|
||||||
|
<td>
|
||||||
|
{% if media.watched %}
|
||||||
|
completed
|
||||||
|
{% elif media.progress <= 0 %}
|
||||||
|
not started
|
||||||
|
{% else %}
|
||||||
|
{{ media.progress }} s
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ media.length }} s</td>
|
||||||
|
<td>{{ media.can_considered | tenary("Yes", "no") }}</td>
|
||||||
|
<td><a href="{{ media.uri }}">link</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue