import sys from gevent import monkey if not "--debug" in sys.argv[1:]: monkey.patch_all() import os import requests as RQ import json import re import io import hashlib import base64 import time import threading from webargs import fields from webargs.flaskparser import use_args from datetime import timedelta, datetime from pprint import pprint from urllib.parse import quote, urljoin, unquote_plus import pylab as PL from matplotlib.ticker import EngFormatter from base64 import b64encode from slugify import slugify from cryptography.hazmat.primitives.serialization import load_ssh_public_key from flask import ( Flask, render_template, send_from_directory, request, send_file, redirect, flash, url_for, session, jsonify, Markup ) from flask_nav import Nav, register_renderer from flask_nav.elements import Navbar, View, Subgroup from flask_bootstrap import Bootstrap from flask_wtf.csrf import CSRFProtect from flask_debugtoolbar import DebugToolbarExtension from flask_sqlalchemy import SQLAlchemy # =================== import stats_collect from forms import ConfigForm, SearchForm, TranscodeProfileForm, AddSSHUser from api import Client from models import db, TranscodeJob, Stats from transcode import profiles from utils import ( BootsrapRenderer, eval_expr, make_tree, make_placeholder_image, with_application_context, handle_config, genpw ) def left_nav(): links = [ View("Home", "index"), View("Containers", "containers", container_id=None), View("qBittorrent", "qbittorrent", infohash=None), View("Sonarr", "sonarr", id=None), View("Radarr", "radarr", id=None), View("Jellyfin", "jellyfin"), View("Search", "search"), View("History", "history"), View("Transcode", "transcode"), View("Config", "config"), View("Remote", "remote"), View("Log", "app_log"), ] return Navbar("PirateDash", *links) def create_app(): templates = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") app = Flask(__name__, template_folder=templates) app.config.from_pyfile("config.cfg") app.bootstrap = Bootstrap(app) app.csrf = CSRFProtect(app) app.nav = Nav(app) app.toolbar = DebugToolbarExtension(app) app.jinja_env.add_extension("jinja2.ext.debug") app.jinja_env.add_extension("jinja2.ext.do") app.jinja_env.trim_blocks = True app.jinja_env.lstrip_blocks = True register_renderer(app, "bootstrap4", BootsrapRenderer) app.nav.register_element("left_nav", left_nav) db.init_app(app) app.db = db return app app = create_app() @app.template_filter("hash") def t_hash(s): return hashlib.sha512(bytes(s, "utf-8")).hexdigest() @app.template_filter() def regex_replace(s, find, replace): """A non-optimal implementation of a regex filter""" return re.sub(find, replace, s) @app.template_filter("ctime") def timectime(s): return time.ctime(s) @app.template_filter("ago") def timeago(s, clamp=False): seconds = round(time.time() - s, 0) if clamp: seconds = max(0, seconds) return timedelta(seconds=seconds) @app.template_filter("ago_dt") def ago_dt(s,rnd=None): dt=datetime.today() - s if rnd is not None: secs = round(dt.total_seconds(),rnd) dt=timedelta(seconds=secs) return str(dt).rstrip("0") @app.template_filter("ago_dt_utc") def ago_dt_utc(s,rnd=None): dt=datetime.utcnow() - s if rnd is not None: secs = round(dt.total_seconds(),rnd) dt=timedelta(seconds=secs) return str(dt).rstrip("0") @app.template_filter("ago_dt_utc_human") def ago_dt_utc_human(s,swap=False,rnd=None): if not swap: dt=datetime.utcnow() - s else: dt=s - datetime.utcnow() if rnd is not None: secs = round(dt.total_seconds(),rnd) dt=timedelta(seconds=secs) if dt.total_seconds()<0: return "In "+str(-dt).rstrip("0") else: return str(dt).rstrip("0")+" ago" @app.template_filter("timedelta") def time_timedelta(s, digits=None, clamp=False): if clamp: s = max(s, 0) if digits is not None: s = round(s,digits) return timedelta(seconds=s) @app.template_filter("fromiso") def time_fromiso(s): t = s.rstrip("Z").split(".")[0] t = datetime.strptime(t, "%Y-%m-%dT%H:%M:%S") try: t.microsecond = int(s.rstrip("Z").split(".")[1]) except: pass return t app.add_template_global(urljoin, "urljoin") @app.template_filter("slugify") def make_slug(s): return slugify(s, only_ascii=True) app.template_filter()(make_tree) app.add_template_global(handle_config, "cfg") @app.before_request def before_request(): db.create_all() app.config["APP_CONFIG"] = handle_config() @app.route("/static/") def send_static(path): return send_from_directory("static", path) def populate_form(form, cfg=None): if cfg is None: cfg = handle_config() for name, field in form._fields.items(): field.default = cfg.get(name) form.transcode_default_profile.choices = [(None, "")] form.transcode_default_profile.choices += [ (k, k) for k in (cfg.get("transcode_profiles", {}) or {}).keys() ] def validate_transcoding_profiles(profiles): for name, data in profiles.items(): for req, req_type in [("command", str), ("doc", str)]: if req not in data: raise ValueError( "Profile '{}' is missing required key '{}'".format(name, req) ) if not isinstance(data[req], req_type): raise ValueError( "Key '{}' of profile '{}' should be of type '{}'".format( req, name, req_type.__name__ ) ) @app.route("/config", methods=["GET", "POST"]) def config(): form = ConfigForm() cfg = {} populate_form(form) if form.validate_on_submit(): skip = ["save", "test", "csrf_token"] transcode_profiles = request.files.get("transcode_profiles") if transcode_profiles: try: form.transcode_profiles.data = json.load(transcode_profiles) validate_transcoding_profiles(form.transcode_profiles.data) except ValueError as e: form.transcode_profiles.data = None form.transcode_profiles.errors = [ "Invalid json data in file {}: {}".format( transcode_profiles.filename, e ) ] else: form.transcode_profiles.data = app.config["APP_CONFIG"].get( "transcode_profiles", {} ) if form.errors: return render_template("config.html", form=form) for name, field in form._fields.items(): if name in skip: continue cfg[name] = field.data if form.test.data: test_res = Client.test(cfg) populate_form(form, cfg) return render_template("config.html", form=form, test=test_res) handle_config(cfg) populate_form(form) return render_template("config.html", form=form) form.process() return render_template("config.html", form=form) @app.route("/search/details", methods=["GET", "POST"]) def details(): data = { "info": json.loads(unquote_plus(request.form["data"])), "type": request.form["type"], } return render_template("search/details.html", **data) @app.route("/search", methods=["GET", "POST"]) def search(): cfg = handle_config() c = Client(cfg) results = {} params = request.args form = SearchForm() form.indexer.choices = c.jackett.indexers() if form.validate_on_submit(): query = form.query.data if not (form.torrents.data or form.movies.data or form.tv_shows.data): form.torrents.data = True form.movies.data = True form.tv_shows.data = True if form.torrents.data: results["torrents"] = c.jackett.search( query, form.indexer.data or form.indexer.choices ) if form.movies.data: results["movies"] = c.radarr.search(query) if form.tv_shows.data: results["tv_shows"] = c.sonarr.search(query) return render_template( "search/index.html", # form=form, search_term=query, results=results, client=c, group_by_tracker=form.group_by_tracker.data, ) for name, field in form._fields.items(): field.default = params.get(name) form.process() return render_template( "search/index.html", form=form, results={}, group_by_tracker=False, sort_by="Gain", ) @app.route("/api/add_torrent", methods=["POST"]) def add_torrent(): category=request.form.get("category") cfg = handle_config() c = Client(cfg) hashes_1 = set(c.qbittorent.status().get("torrents", {})) links = "" count = 0 for link in request.form.getlist("torrent[]"): print(link) links += link + "\n" count += 1 c.qbittorent.add(urls=links,category=category) for _ in range(10): status=c.qbittorent.status().get("torrents", {}) hashes_2 = set(status) if len(hashes_2 - hashes_1) == count: break time.sleep(0.5) else: flash("Some torrents failed to get added to QBittorrent", "waring") new_torrents = sorted(hashes_2 - hashes_1) session["new_torrents"] = {h: status[h] for h in new_torrents} return redirect(url_for("search")) @app.route("/history") def history(): cfg = handle_config() c = Client(cfg) sonarr = c.sonarr.history() radarr = c.radarr.history() return render_template("history.html", sonarr=sonarr, radarr=radarr) @app.route("/sonarr", defaults={"show_id": None}) @app.route("/sonarr/") def sonarr(show_id): cfg = handle_config() c = Client(cfg) if not show_id: series = c.sonarr.series() status = c.sonarr.status() return render_template( "sonarr/index.html", series=series, status=status, history=history ) return render_template("sonarr/details.html") @app.route("/radarr", defaults={"movie_id": None}) @app.route("/radarr/") def radarr(movie_id): cfg = handle_config() c = Client(cfg) if movie_id is None: movies = c.radarr.movies() status = c.radarr.status() history = c.radarr.history() return render_template( "radarr/index.html", movies=movies, status=status, history=history ) return render_template("radarr/details.html") @app.route("/qbittorrent") def qbittorrent(): cfg = handle_config() c = Client(cfg) qbt = c.qbittorent.status() sort_by_choices = { "speed": "Transfer Speed", "eta": "Time remaining", "state": "State", "category": "Category", } return render_template( "qbittorrent/index.html", qbt=qbt, status_map=c.qbittorent.status_map, state_filter=request.args.get("state"), sort_by=request.args.get("sort","speed"), sort_by_choices=sort_by_choices, ) @app.route("/qbittorrent/add_trackers/") def qbittorent_add_trackers(infohash): cfg = handle_config() c = Client(cfg) c.qbittorent.add_trackers(infohash) return redirect(url_for("qbittorrent_details",infohash=infohash)) @app.route("/qbittorrent/") def qbittorrent_details(infohash): cfg = handle_config() c = Client(cfg) qbt = c.qbittorent.status(infohash) return render_template( "qbittorrent/details.html", qbt=qbt, status_map=c.qbittorent.status_map ) from wtforms_alchemy import model_form_factory, ModelFieldList from flask_wtf import FlaskForm from wtforms.fields import FormField BaseModelForm = model_form_factory(FlaskForm) class ModelForm(BaseModelForm): @classmethod def get_session(self): return app.db.session @app.route("/test", methods=["GET", "POST"]) def test(): form = TranscodeProfileForm() if form.validate_on_submit(): print(form.data) return render_template("test.html", form=form) @app.route("/placeholder") def placeholder(): return send_file(make_placeholder_image(**request.args), mimetype="image/png") @app.route("/containers", defaults={"container_id": None}) @app.route("/containers/") def containers(container_id): cfg = handle_config() c = Client(cfg) if container_id: container = c.portainer.containers(container_id) return render_template("containers/details.html", container=container) containers = c.portainer.containers() return render_template("containers/index.html", containers=containers) def get_stats(): if os.path.isfile("stats.lock"): return None try: if os.path.isfile("stats.json"): with open("stats.json") as fh: return json.load(fh) except: return None @app.route("/transcode", methods=["GET", "POST"]) def transcode(): return render_template("transcode/profiles.html") @app.route("/log") def app_log(): cfg = handle_config() c = Client(cfg) logs = { "radarr": c.radarr.log(), "sonarr": c.sonarr.log(), "qbt": c.qbittorent.log(), "peers": c.qbittorent.peer_log(), } return render_template("logs.html", logs=logs) def ssh_fingerprint(key): fp=hashlib.md5(base64.b64decode(key)).hexdigest() return ':'.join(a+b for a,b in zip(fp[::2], fp[1::2])) @app.route("/remote") def remote(): cfg = handle_config() c = Client(cfg) res = c.ssh.get("/data/.ssh/authorized_keys",io.BytesIO()) res.local.seek(0) ssh_keys=[] for key in str(res.local.read(),"utf8").splitlines(): disabled=False if key.startswith("#"): key=key.lstrip("#").lstrip() disabled=True try: load_ssh_public_key(bytes(key,"utf8")) except: continue key_type,key,name=key.split(None,2) ssh_keys.append({ 'disabled': disabled, 'type':key_type, 'key':key, 'fingerprint': ssh_fingerprint(key), 'name': name }) key=request.args.get("key") enabled=request.args.get("enabled") if not (key is None or enabled is None): key_file=[] for ssh_key in ssh_keys: if ssh_key['key']==key: ssh_key['disabled']=enabled=="False" if ssh_key['disabled']: key_file.append("#{type} {key} {name}".format(**ssh_key)) else: key_file.append("{type} {key} {name}".format(**ssh_key)) buf=io.BytesIO(bytes("\n".join(key_file),"utf8")) c.ssh.put(buf,"/data/.ssh/authorized_keys",preserve_mode=False) return redirect(url_for("remote")) jellyfin_users = c.jellyfin.get_users() return render_template("remote/index.html",ssh=ssh_keys,jf=jellyfin_users) @app.route("/jellyfin/stop") def stop_stream(): cfg = handle_config() c = Client(cfg) session_id=request.args.get("session") c.jellyfin.stop_session(session_id) return redirect(url_for("jellyfin")) @app.route("/jellyfin") def jellyfin(): cfg = handle_config() c = Client(cfg) jellyfin={ "users":c.jellyfin.get_users(), "sessions": c.jellyfin.sessions(), "info" : c.jellyfin.system_info() } return render_template("jellyfin/index.html",jellyfin=jellyfin) @app.route("/remote/add",methods=["GET","POST"]) def remote_add(): from cryptography.hazmat.primitives import serialization form = AddSSHUser() cfg = handle_config() c = Client(cfg) if form.validate_on_submit(): key=load_ssh_public_key(bytes(form.data['ssh_key'],"utf8")) rawKeyData = key.public_bytes( encoding=serialization.Encoding.OpenSSH, format=serialization.PublicFormat.OpenSSH, ) passwd=c.add_user(form.data['name'],str(rawKeyData,"utf8")) flash(Markup("".join([ f"

Name: {form.data['name']}

", f"

PW: {passwd}

", f"

FP: {ssh_fingerprint(rawKeyData.split()[1])}

" ]))) return render_template("remote/add.html",form=form) @app.route("/") def index(): return render_template("index.html", fluid=True, data=get_stats()) if __name__ == "__main__": stats_collector = threading.Thread( None, stats_collect.loop, "stats_collector", (10,), {}, daemon=True ) stats_collector.start() port = 5000 if "--debug" in sys.argv: app.run(host="0.0.0.0",port=port, debug=True) else: from gevent.pywsgi import WSGIServer server = WSGIServer(("0.0.0.0", port), app) print("Running on {0}:{1}".format(*server.address)) server.serve_forever()