#### ## Imports #### from __future__ import annotations import datetime from functools import partial import itertools import logging import os import random from urllib.parse import urlencode, quote_plus from typing import Any, Callable, Dict, Iterable, List, Optional, Union from flask import Flask, jsonify, make_response, request, redirect 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 PreferenceScore, Tag, db, MediaCollection, MediaCollectionLink, MediaElement, generate_preference_list from entertainment_decider.extractors.collection import collection_extract_uri, collection_update 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 encode_options(opts: dict[str, Any]): return urlencode({k: str(v) for k, v in opts.items()}, quote_via=quote_plus) @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 TIMEDELTA_FORMAT = ( datetime.timedelta(hours=1), datetime.timedelta(minutes=1), ) @flask_app.template_filter() def timedelta(seconds: int) -> str: delta = datetime.timedelta(seconds=seconds) ret = "" for unit in TIMEDELTA_FORMAT: if ret or unit <= delta: unit_size = delta // unit delta -= unit * unit_size if ret: ret += f"{unit_size:02}:" else: ret += f"{unit_size}:" ret += f"{delta.seconds:02}" return ret #### ## 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/extract") def extract_collection(): return render_template("collection_extract.htm") @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("/media") def list_media(): media_list: Iterable[MediaElement] = orm.select(m for m in MediaElement if not (m.ignored or m.watched)).order_by(orm.desc(MediaElement.release_date), MediaElement.id) def get_considerable(): for element in media_list: if element.can_considered: yield element return render_template( "media_list.htm", media_list=list(itertools.islice(get_considerable(), 100)) ) @flask_app.route("/media/extract") def extract_media(): return render_template("media_extract.htm") @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("/recommendations/simple/binge") @flask_app.route("/recommendations/simple/binge/") def recommend_binge(random_val: int = None): if random_val is None: random_val = (datetime.datetime.now() - datetime.timedelta(hours=4)).toordinal() def gen_list(): l = [m for m in orm.select(m for m in MediaElement if not (m.watched or m.ignored)) if m.can_considered] r = random.Random(random_val) r.shuffle(l) return l return render_template( "recommendations_simple.htm", mode_name="Binge Watch", random_val=random_val, media_list=generate_preference_list( base=PreferenceScore(), object_gen=gen_list, score_adapt=-1, limit=5, ) ) @flask_app.route("/recommendations/simple/variety") def recommend_variety(): def gen_list(): l = [m for m in orm.select(m for m in MediaElement if not (m.watched or m.ignored)).order_by(MediaElement.release_date) if m.can_considered] return l return render_template( "recommendations_simple.htm", mode_name="Variety", media_list=generate_preference_list( base=PreferenceScore(), object_gen=gen_list, score_adapt=1, limit=5, ) ) @flask_app.route("/api/refresh/collections", methods=["POST"]) def refresh_collections(): collections: List[MediaCollection] = orm.select(c for c in MediaCollection if c.keep_updated) for coll in collections: collection_update(coll) return redirect_back_or_okay() @flask_app.route("/api/refresh/collection/", methods=["POST"]) def force_refresh_collection(collection_id: int): coll: MediaCollection = MediaCollection.get(id=collection_id) if coll is None: return "404 Not Found", 404 collection_update(coll) return redirect_back_or_okay() @flask_app.route("/stats") def show_stats(): elements: List[MediaElement] = MediaElement.select() return render_template( "stats.htm", stats={ "media": { "known": orm.count(elements), "known_seconds": orm.sum(m.length for m in elements), "watched": orm.count(m for m in elements if m.watched), "watched_seconds": orm.sum((m.length if m.watched else m.progress) for m in elements if m.watched), "ignored": orm.count(m for m in elements if m.ignored), "ignored_seconds": orm.sum(m.length - m.progress for m in elements if m.ignored), "to_watch": orm.count(m for m in elements if not m.ignored and not m.watched), "to_watch_seconds": orm.sum(m.length - m.progress for m in elements if not m.ignored and not m.watched) } } ) @flask_app.route("/tag") def show_tag(): tag_list: List[Tag] = Tag.select() return render_template( "tag_list.htm", tag_list=tag_list, ) @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 "last_updated" as date to resolve last try) def redirect_back_or_okay(): if "redirect" not in request.form: return { "status": True, }, 200 uri = request.form.get("redirect", type=str) if not uri.startswith("/"): return "400 Bad Request : Invalid Redirect Specified", 400 return redirect(uri) @flask_app.route("/api/collection/list") def api_collection_list(): collection_list: Iterable[MediaCollection] = MediaCollection.select() return { "status": True, "data": [{ "id": collection.id, "title": collection.title, "release_date": collection.release_date, "length": collection.length, "progress": collection.progress, } for collection in collection_list], }, 200 @flask_app.route("/api/collection/extract", methods=["POST"]) def api_collection_extract(): data = request.form.to_dict() if "uri" not in data: return { "status": False, "error": f"Missing uri value to extract", } m = collection_extract_uri(data["uri"]) orm.flush() if m and environ_bool(data.get("redirect_to_object", False)): return redirect(m.info_link) return redirect_back_or_okay() @flask_app.route("/api/collection/", methods=["GET", "POST"]) def api_collection_element(collection_id: int): collection: MediaCollection = MediaCollection.get(id=collection_id) if collection is None: return { "status": False, "error": f"Object not found", }, 404 if request.method == "GET": return { "status": True, "data": { "id": collection.id, "title": collection.title, "notes": collection.notes, "release_date": collection.release_date, "ignored": collection.ignored, "media_links": [{ "media": { "id": link.element.id, "title": link.element.title, }, "season": link.season, "episode": link.episode, } for link in collection.media_links] } }, 200 elif request.method == "POST": data = request.form.to_dict() if "redirect" in data: del data["redirect"] KEY_CONVERTER = { "title": str, "notes": str, "pinned": environ_bool, "ignored": environ_bool, "keep_updated": environ_bool, "watch_in_order": environ_bool, } for key in data: if key not in KEY_CONVERTER: return { "status": False, "error": f"Cannot set key {key!r} on MediaCollection", }, 400 if "watch_in_order" in data: # TODO move to property collection.watch_in_order_auto = False collection.set(**{key: KEY_CONVERTER[key](val) for key, val in data.items()}) return redirect_back_or_okay() else: return { "status": False, "error": "405 Method Not Allowed", }, 405 @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 @flask_app.route("/api/media/extract", methods=["POST"]) def api_media_extract(): data = request.form.to_dict() if "uri" not in data: return { "status": False, "error": f"Missing uri value to extract", } m = media_extract_uri(data["uri"]) orm.flush() if m and environ_bool(data.get("redirect_to_object", False)): return redirect(m.info_link) return redirect_back_or_okay() @flask_app.route("/api/media/", methods=["GET", "POST"]) def api_media_element(media_id: int): element: MediaElement = MediaElement.get(id=media_id) if element is None: return { "status": False, "error": f"Object not found", }, 404 if request.method == "GET": return { "status": True, "data": { "id": element.id, "title": element.title, "notes": element.notes, "release_date": element.release_date, "length": element.length, "progress": element.progress, "ignored": element.ignored, "watched": element.watched, "can_considered": element.can_considered, "collection_links": [{ "collection": { "id": link.collection.id, "title": link.collection.title, }, "season": link.season, "episode": link.episode, } for link in element.collection_links] } }, 200 elif request.method == "POST": data = request.form.to_dict() if "redirect" in data: del data["redirect"] KEY_CONVERTER = { "title": str, "notes": str, "progress": int, "ignored": environ_bool, "watched": environ_bool, } for key in data: if key not in KEY_CONVERTER: return { "status": False, "error": f"Cannot set key {key!r} on MediaElement", }, 400 element.set(**{key: KEY_CONVERTER[key](val) for key, val in data.items()}) return redirect_back_or_okay() else: return { "status": False, "error": "405 Method Not Allowed", }, 405