MediaDash/app.py

587 lines
17 KiB
Python

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/<path:path>")
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/<show_id>")
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/<movie_id>")
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/<infohash>")
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/<infohash>")
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/<container_id>")
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"<p>Name: <b>{form.data['name']}</b></p>",
f"<p>PW: <b>{passwd}</b></p>",
f"<p>FP: <b>{ssh_fingerprint(rawKeyData.split()[1])}</b></p>"
])))
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()