587 lines
17 KiB
Python
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()
|