Initial commit

This commit is contained in:
Daniel S. 2021-08-29 15:03:28 +02:00
commit 7523a19d1f
40 changed files with 3984 additions and 0 deletions

149
.gitignore vendored Normal file
View File

@ -0,0 +1,149 @@
# Ignore dynaconf secret files
.secrets.*
# ---> Python
# 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/
.webassets-cache
# 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
.env
.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/
stats.json
stats_temp.json
config.json
Mediadash.db
.history
.vscode

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# Media Server Dashboard
WIP

8
TODO.md Normal file
View File

@ -0,0 +1,8 @@
- Jellyfin integration (?)
- Webhooks for transcode queue
- Webhook event log
- Database models
- Container details
- Transcode Job queue
- Transcode profile editor
- DB Models

649
api.py Normal file
View File

@ -0,0 +1,649 @@
import requests as RQ
from requests.auth import HTTPBasicAuth
from urllib.parse import urljoin, urlparse
from fabric import Connection
import time
import json
import base64
import io
from datetime import datetime,timedelta
from sshpubkeys import AuthorizedKeysFile
from utils import genpw,handle_config
from pprint import pprint
"""NOTES
http://192.168.2.25:8080/sonarr/api/v3/release?seriesId=158&seasonNumber=8
http://192.168.2.25:8080/sonarr/api/v3/release?episodeId=12272
http://192.168.2.25:8080/radarr/api/v3/release?movieId=567
http://192.168.2.25:9000/api/endpoints/1/docker/containers/json?all=1&filters=%7B%22label%22:%5B%22com.docker.compose.project%3Dtvstack%22%5D%7D
"""
class Api(object):
def __init__(self, url, **kwargs):
self.url = url
self.session= RQ.Session()
for k, v in kwargs.items():
setattr(self, k, v)
if hasattr(self, "login"):
self.login()
def get(self, endpoint, **kwargs):
ret = self.session.get(urljoin(self.url, endpoint), **kwargs)
ret.raise_for_status()
return ret
def post(self, endpoint, **kwargs):
return self.session.post(urljoin(self.url, endpoint), **kwargs)
class Portainer(object):
def __init__(self, url, username, passwd):
self.url = url
self.session= RQ.Session()
jwt = self.session.post(
urljoin(self.url, "api/auth"),
json={"username": passwd, "password": username},
).json()
self.session.headers.update({"Authorization": "Bearer {0[jwt]}".format(jwt)})
def containers(self, container_id=None):
if container_id is None:
res = self.session.get(
urljoin(self.url, "api/endpoints/1/docker/containers/json"),
params={
"all": 1,
"filters": json.dumps(
{"label": ["com.docker.compose.project=tvstack"]}
),
},
)
else:
res = self.session.get(
urljoin(
self.url,
"api/endpoints/1/docker/containers/{}/json".format(container_id),
)
)
res.raise_for_status()
res = res.json()
if container_id is None:
for container in res:
pass
# print("Gettings stats for",container['Id'])
# container['stats']=self.stats(container['Id'])
# container['top']=self.top(container['Id'])
else:
res["stats"] = self.stats(container_id)
res["top"] = self.top(container_id)
return res
def top(self, container_id):
res = self.session.get(
urljoin(
self.url,
"api/endpoints/1/docker/containers/{}/top".format(container_id),
)
)
res.raise_for_status()
res = res.json()
cols = res["Titles"]
ret = []
return res
def stats(self, container_id):
res = self.session.get(
urljoin(
self.url,
"api/endpoints/1/docker/containers/{}/stats".format(container_id),
),
params={"stream": False},
)
res.raise_for_status()
return res.json()
def test(self):
self.containers()
return {}
class Jellyfin(object):
def __init__(self, url, api_key):
self.url = url
self.session = RQ.Session()
self.session.headers.update({"X-Emby-Token": api_key})
self.api_key = api_key
self.user_id = self.get_self()['Id']
self.playstate_commands = sorted([
"Stop", "Pause", "Unpause", "NextTrack", "PreviousTrack", "Seek", "Rewind", "FastForward", "PlayPause"
])
self.session_commands = sorted([
"MoveUp", "MoveDown", "MoveLeft", "MoveRight", "PageUp", "PageDown", "PreviousLetter", "NextLetter", "ToggleOsd", "ToggleContextMenu", "Select", "Back", "TakeScreenshot", "SendKey", "SendString", "GoHome", "GoToSettings", "VolumeUp", "VolumeDown", "Mute", "Unmute", "ToggleMute", "SetVolume", "SetAudioStreamIndex", "SetSubtitleStreamIndex", "ToggleFullscreen", "DisplayContent", "GoToSearch", "DisplayMessage", "SetRepeatMode", "ChannelUp", "ChannelDown", "Guide", "ToggleStats", "PlayMediaSource", "PlayTrailers", "SetShuffleQueue", "PlayState", "PlayNext", "ToggleOsdMenu", "Play"
])
# auth = 'MediaBrowser Client="MediaDash", Device="Python", DeviceId="{}", Version="{}", Token="{}"'.format(
# self.device_id, RQ.__version__, self.api_key
# )
# self.session.headers.update({"X-Emby-Authorization": auth})
def status(self):
res = self.session.get(urljoin(self.url, "System/Info"))
res.raise_for_status()
return res.json()
def chapter_image_url(self,item_id,chapter_num,tag):
return chapter_image_url(urljoin(self.url, "Items",item_id,"Images","Chapter",chapter_num))
def rq(self,method,url,*args,**kwargs):
res=self.session.request(method,urljoin(self.url, url), *args, **kwargs)
res.raise_for_status()
return res
def get(self, url, *args, **kwargs):
res=self.session.get(urljoin(self.url, url), *args, **kwargs)
res.raise_for_status()
return res
def post(self, url, *args, **kwargs):
res=self.session.post(urljoin(self.url, url), *args, **kwargs)
res.raise_for_status()
return res
def sessions(self):
res = self.session.get(urljoin(self.url, "Sessions"))
res.raise_for_status()
return res.json()
def media_info(self,item_id):
res = self.session.get(urljoin(self.url, "Users",self.user_id,"Items",item_id))
res.raise_for_status()
return res.json()
def system_info(self):
res = self.session.get(urljoin(self.url, "System/Info"))
res.raise_for_status()
return res.json()
def __get_child_items(self, item_id):
print(item_id)
res = self.session.get(
urljoin(self.url, "Users",self.user_id,"Items"),
params={"ParentId": item_id},
)
res.raise_for_status()
return res.json()
def get_recursive(self, item_id):
for item in self.__get_child_items(item_id).get("Items", []):
yield item
yield from self.get_recursive(item["Id"])
def get_library(self):
res = self.session.get(urljoin(self.url, "Library/MediaFolders"))
res.raise_for_status()
for folder in res.json().get("Items", []):
for item in self.get_recursive(folder["Id"]):
pass
def __db_fetch(self, endpoint):
ret = []
res = self.session.get(
urljoin(self.url, endpoint),
params={"StartIndex": 0, "IncludeItemTypes": "*", "ReportColumns": ""},
)
res.raise_for_status()
res = res.json()
headers = [h["Name"].lower() for h in res["Headers"]]
for row in res["Rows"]:
fields = [c["Name"] for c in row["Columns"]]
ret.append(dict(zip(headers, fields)))
ret[-1]["row_type"] = row["RowType"]
return ret
def get_self(self):
res=self.session.get(urljoin(self.url, "users","me"))
res.raise_for_status()
return res.json()[0]
def get_users(self):
res=self.session.get(urljoin(self.url, "users"))
res.raise_for_status()
return res.json()
def activity(self):
return self.__db_fetch("Reports/Activities")
def report(self):
return self.__db_fetch("Reports/Items")
def stop_session(self,session_id):
sessions=self.get("Sessions").json()
for session in sessions:
if session['Id']==session_id and "NowPlayingItem" in session:
s_id=session["Id"]
u_id=session["UserId"]
i_id=session['NowPlayingItem']['Id']
d_id=session['DeviceId']
self.rq("delete","Videos/ActiveEncodings",params={"deviceId":d_id,"playSessionId":s_id})
self.rq("delete",f"Users/{u_id}/PlayingItems/{i_id}")
self.rq("post",f"Sessions/{s_id}/Playing/Stop")
def test(self):
self.status()
return {}
class QBittorrent(object):
status_map = {
"downloading": ("Downloading", "primary"),
"uploading": ("Seeding", "success"),
"forcedDL": ("Downloading [Forced]", "primary"),
"forcedUP": ("Seeding [Forced]", "success"),
"pausedDL": ("Downloading [Paused]", "secondary"),
"pausedUP": ("Seeding [Paused]", "secondary"),
"stalledDL": ("Downloading [Stalled]", "warning"),
"stalledUP": ("Seeding [Stalled]", "warning"),
"metaDL": ("Downloading metadata", "primary"),
"error": ("Error", "danger"),
"missingFiles": ("Missing Files", "danger"),
"queuedUP": ("Seeding [Queued]", "info"),
"queuedDL": ("Downloading [Queued]", "info"),
}
tracker_status = {
0: ("Disabled", "secondary"),
1: ("Not contacted", "info"),
2: ("Working", "success"),
3: ("Updating", "warning"),
4: ("Not working", "danger"),
}
def __init__(self, url, username, passwd):
self.url = url
self.username = username
self.passwd = passwd
self.rid = int(time.time())
self.session= RQ.Session()
url = urljoin(self.url, "/api/v2/auth/login")
self.session.post(
url, data={"username": self.username, "password": self.passwd}
).raise_for_status()
def get(self, url, **kwargs):
kwargs["rid"] = self.rid
url = urljoin(self.url, url)
res = self.session.get(url, params=kwargs)
res.raise_for_status()
try:
return res.json()
except ValueError:
return res.text
def add(self, **kwargs):
self.rid += 1
url = urljoin(self.url, "/api/v2/torrents/add")
ret = self.session.post(url, data=kwargs)
return ret.text, ret.status_code
def add_trackers(self, infohash, trackers=None):
if trackers is None:
trackers = []
for tracker_list in [
"https://newtrackon.com/api/live",
"https://ngosang.github.io/trackerslist/trackers_best.txt",
]:
try:
trackers_res = RQ.get(tracker_list)
trackers_res.raise_for_status()
except Exception as e:
print("Error getting tracker list:", e)
continue
trackers += trackers_res.text.split()
url = urljoin(self.url, "/api/v2/torrents/addTrackers")
data = {"hash": infohash, "urls": "\n\n".join(trackers)}
ret = self.session.post(url, data=data)
ret.raise_for_status()
return ret.text
def poll(self, infohash=None):
if infohash:
ret = {}
res = self.get("/api/v2/torrents/info", hashes=infohash)
ret["info"] = res
for endpoint in ["properties", "trackers", "webseeds", "files"]:
url = "/api/v2/torrents/{}".format(endpoint)
res = self.get("/api/v2/torrents/{}".format(endpoint), hash=infohash)
if endpoint == "trackers":
for v in res:
if v["tier"] == "":
v["tier"] = -1
v["status"] = self.tracker_status.get(
v["status"], ("Unknown", "light")
)
v["total_peers"] = (
v["num_seeds"] + v["num_leeches"] + v["num_peers"]
)
for k in [
"num_seeds",
"num_leeches",
"total_peers",
"num_downloaded",
"num_peers",
]:
if v[k] < 0:
v[k] = (-1, "?")
else:
v[k] = (v[k], v[k])
ret[endpoint] = res
ret["info"] = ret["info"][0]
ret["info"]["state"] = self.status_map.get(
ret["info"]["state"], (ret["info"]["state"], "light")
)
for tracker in ret["trackers"]:
tracker["name"] = urlparse(tracker["url"]).netloc or tracker["url"]
tracker["has_url"] = bool(urlparse(tracker["url"]).netloc)
return ret
res = self.get("/api/v2/sync/maindata")
if "torrents" in res:
for k, v in res["torrents"].items():
v["hash"] = k
v["speed"] = v["upspeed"] + v["dlspeed"]
dl_rate = v["downloaded"] / max(0, time.time() - v["added_on"])
if dl_rate > 0:
v["eta"] = max(0, (v["size"] - v["downloaded"]) / dl_rate)
else:
v["eta"] = 0
if v["time_active"]==0:
dl_rate=0
else:
dl_rate = v["downloaded"] / v["time_active"]
if dl_rate > 0:
v["eta_act"] = max(0, (v["size"] - v["downloaded"]) / dl_rate)
else:
v["eta_act"] = 0
res["torrents"][k] = v
res["version"] = self.get("/api/v2/app/version")
self.rid = res["rid"]
return res
def status(self, infohash=None):
self.rid += 1
return self.poll(infohash)
def peer_log(self, limit=0):
return self.get("/api/v2/log/peers")[-limit:]
def log(self, limit=0):
return self.get("/api/v2/log/main")[-limit:]
def test(self):
self.poll()
return {}
class Radarr(object):
def __init__(self, url, api_key):
self.url = url
self.api_key = api_key
def get(self, url, **kwargs):
kwargs["apikey"] = self.api_key
kwargs["_"] = str(int(time.time()))
res = RQ.get(urljoin(self.url, url), params=kwargs)
res.raise_for_status()
try:
return res.json()
except:
return res.text
def search(self, query):
return self.get("api/v3/movie/lookup", term=query)
def status(self):
return self.get("api/v3/system/status")
def history(self, pageSize=500):
return self.get(
"api/v3/history",
page=1,
pageSize=500,
sortDirection="descending",
sortKey="date",
)
def calendar(self,days=30):
today=datetime.today()
start=today-timedelta(days=days)
end=today+timedelta(days=days)
return self.get("api/v3/calendar",unmonitored=False,start=start.isoformat(),end=end.isoformat())
def movies(self):
return self.get("api/v3/movie")
def queue(self, series_id):
return self.get("api/v3/queue")
def log(self, limit=0):
return self.get(
"api/v3/log",
page=1,
pageSize=(limit or 1024),
sortDirection="descending",
sortKey="time",
)
def test(self):
self.status()
return {}
class Sonarr(object):
def __init__(self, url, api_key):
self.url = url
self.api_key = api_key
def get(self, url, **kwargs):
kwargs["apikey"] = self.api_key
kwargs["_"] = str(int(time.time()))
res = RQ.get(urljoin(self.url, url), params=kwargs)
res.raise_for_status()
try:
return res.json()
except:
return res.text
def search(self, query):
return self.get("api/v3/series/lookup", term=query)
def status(self):
return self.get("api/v3/system/status")
def history(self, pageSize=500):
return self.get(
"api/v3/history",
page=1,
pageSize=500,
sortDirection="descending",
sortKey="date",
)
def calendar(self,days=30):
today=datetime.today()
start=today-timedelta(days=days)
end=today+timedelta(days=days)
return self.get("api/v3/calendar",unmonitored=False,start=start.isoformat(),end=end.isoformat())
def series(self, series_id=None):
if series_id is None:
return self.get("api/v3/series")
ret = {}
ret["episodes"] = self.get("api/v3/episode", seriesId=series_id)
ret["episodeFile"] = self.get("api/v3/episodeFile", seriesId=series_id)
ret["queue"] = self.get("api/v3/queue/details", seriesId=series_id)
return ret
def queue(self, series_id):
return self.get("api/v3/queue")
def episodes(self, series_id):
return self.get("api/v3/episode", seriesId=series_id)
def log(self, limit=0):
return self.get(
"api/v3/log",
page=1,
pageSize=(limit or 1024),
sortDirection="descending",
sortKey="time",
)
def test(self):
self.status()
return {}
class Jackett(object):
def __init__(self, url, api_key):
self.url = url
self.api_key = api_key
self.session= RQ.Session()
self.session.post("http://192.168.2.25:9117/jackett/UI/Dashboard")
def search(self, query, indexers=None):
params = {"apikey": self.api_key, "Query": query, "_": str(int(time.time()))}
if indexers:
params["Tracker[]"] = indexers
res = self.session.get(
urljoin(self.url, f"api/v2.0/indexers/all/results"), params=params
)
res.raise_for_status()
res = res.json()
for val in res["Results"]:
for prop in ["Gain", "Seeders", "Peers", "Grabs", "Files"]:
val[prop] = val.get(prop) or 0
return res
def indexers(self):
return [
(t["id"], t["name"])
for t in self.session.get(urljoin(self.url, "api/v2.0/indexers")).json()
if t.get("configured")
]
def test(self):
errors = {}
for idx, name in self.indexers():
print("Testing indexer", name)
result = self.session.post(
urljoin(self.url, "api/v2.0/indexers/{}/test".format(idx))
)
if result.text:
errors[name] = result.json()["error"]
return errors
class Client(object):
def __init__(self, cfg):
self.cfg = cfg
self.jackett = Jackett(cfg["jackett_url"], cfg["jackett_api_key"])
self.sonarr = Sonarr(cfg["sonarr_url"], cfg["sonarr_api_key"])
self.radarr = Radarr(cfg["radarr_url"], cfg["radarr_api_key"])
self.jellyfin = Jellyfin(
cfg["jellyfin_url"], cfg["jellyfin_api_key"]
)
self.qbittorent = QBittorrent(
cfg["qbt_url"], cfg["qbt_username"], cfg["qbt_passwd"]
)
self.portainer = Portainer(
cfg["portainer_url"], cfg["portainer_username"], cfg["portainer_passwd"]
)
self.ssh = Connection('root@server')
def _get_ssh_keys(self):
cfg = handle_config()
res=self.ssh.get("/data/.ssh/authorized_keys",io.BytesIO())
res.local.seek(0)
ret=[]
for line in str(res.local.read(),"utf8").splitlines():
if line.startswith("#"):
continue
else:
key_type,key,comment=line.split(None,2)
ret.append((key_type,key,comment))
return ret
def add_user(self,name,ssh_key):
cfg = handle_config()
user_config = cfg['jellyfin_user_config']
user_policy = cfg['jellyfin_user_policy']
passwd = genpw()
res=self.ssh.get("/data/.ssh/authorized_keys",io.BytesIO())
res.local.seek(0)
keys=[l.split(None,2) for l in str(res.local.read(),"utf8").splitlines()]
key_type,key,*_=ssh_key.split()
keys.append([key_type,key,name])
new_keys=[]
seen_keys=set()
for key_type,key,name in keys:
if key not in seen_keys:
seen_keys.add(key)
new_keys.append([key_type,key,name])
new_keys_file="\n".join(" ".join(key) for key in new_keys)
self.ssh.put(io.BytesIO(bytes(new_keys_file,"utf8")),"/data/.ssh/authorized_keys",preserve_mode=False)
user = self.jellyfin.post("Users/New", json={"Name": name, "Password": passwd})
user.raise_for_status()
user = user.json()
self.jellyfin.post("Users/{Id}/Configuration".format(**user), json=user_config).raise_for_status()
self.jellyfin.post("Users/{Id}/Policy".format(**user), json=user_policy).raise_for_status()
return passwd
@staticmethod
def test(cfg=None):
cfg = cfg or self.cfg
modules = [
(
"Jackett",
lambda cfg: Jackett(cfg["jackett_url"], cfg["jackett_api_key"]),
),
("Sonarr", lambda cfg: Sonarr(cfg["sonarr_url"], cfg["sonarr_api_key"])),
("Radarr", lambda cfg: Radarr(cfg["radarr_url"], cfg["radarr_api_key"])),
(
"QBittorrent",
lambda cfg: QBittorrent(
cfg["qbt_url"], cfg["qbt_username"], cfg["qbt_passwd"]
),
),
(
"Jellyfin",
lambda cfg: Jellyfin(
cfg["jellyfin_url"],
cfg["jellyfin_username"],
cfg["jellyfin_passwd"],
),
),
(
"Portainer",
lambda cfg: Portainer(
cfg["portainer_url"],
cfg["portainer_username"],
cfg["portainer_passwd"],
),
),
]
errors = {}
success = True
for mod, Module in modules:
try:
print("Testing", mod)
errors[mod] = Module(cfg).test()
if errors[mod]:
success = False
except Exception as e:
print(dir(e))
errors[mod] = str(e)
success = False
print(errors)
return {"success": success, "errors": errors}

586
app.py Normal file
View File

@ -0,0 +1,586 @@
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()

4
config.cfg Normal file
View File

@ -0,0 +1,4 @@
SECRET_KEY = b"DEADBEEF"
SQLALCHEMY_DATABASE_URI = "sqlite:///Mediadash.db"
SQLALCHEMY_TRACK_MODIFICATIONS = False
MAX_CONTENT_LENGTH = 1 * 1024 * 1024 #1MB

125
config.example.json Normal file
View File

@ -0,0 +1,125 @@
{
"jellyfin_url": "http://127.0.0.1:8096/",
"jellyfin_api_key": "<Jellyfin Access Token>",
"qbt_url": "http://127.0.0.1:8081/",
"qbt_username": "<qBittorrent Username>",
"qbt_passwd": "<qBittorrent Password>",
"sonarr_url": "http://127.0.0.1:8080/sonarr/",
"sonarr_api_key": "<Sonarr API Key>",
"radarr_url": "http://127.0.0.1:8080/radarr/",
"radarr_api_key": "<Radarr API Key>",
"jackett_url": "http://127.0.0.1:9117/jackett/",
"jackett_api_key": "<Jakcett API Key>",
"portainer_url": "http://127.0.0.1:9000/",
"portainer_username": "<Portainer Username>",
"portainer_passwd": "<Portainer Username>",
"transcode_default_profile": "MKV Remux",
"transcode_profiles": {
"MKV Remux": {
"command": "-vcodec copy -acodec copy -scodec copy -map 0 -map_metadata 0 -f {format}",
"doc": "Remux",
"vars": {
"format": "Conainter format"
},
"defaults": {
"format": "matroska"
}
},
"H.264 transcode": {
"command": "-vcodec h264 -crf {crf} -preset {preset} -acodec copy -scodec copy -map 0 -map_metadata 0",
"doc": "Transcode video to H.264",
"vars": {
"crf": "Constant Rate Factor (Quality, lower is better)",
"preset": "H.264 preset"
},
"defaults": {
"crf": 18,
"preset": "medium"
},
"choices": {
"tune": ["animation","film","grain"],
"preset": ["ultrafast","fast","medium","slow","veryslow"],
"crf": {"range":[10,31]}
}
},
"H.265 transcode": {
"command": "-vcodec hevc -crf {crf} -preset {preset} -tune {tune} -acodec copy -scodec copy -map 0 -map_metadata 0",
"doc": "Transcode video to H.265",
"vars": {
"crf": "Constant Rate Factor (Quality, lower is better)",
"preset": "H.265 preset",
"tune": "H.265 tune preset"
},
"defaults": {
"crf": 24,
"preset": "medium",
"tune": "animation"
},
"choices": {
"tune": ["animation","film","grain"],
"preset": ["ultrafast","fast","medium","slow","veryslow"],
"crf": {"range":[10,31]}
}
},
"AAC transcode": {
"command": "-vcodec copy -acodec aac -scodec copy -map 0 -map_metadata 0",
"doc": "Transcode audio to AAC"
}
},
"jellyfin_user_config": {
"DisplayCollectionsView": false,
"DisplayMissingEpisodes": false,
"EnableLocalPassword": false,
"EnableNextEpisodeAutoPlay": true,
"GroupedFolders": [],
"HidePlayedInLatest": true,
"LatestItemsExcludes": [],
"MyMediaExcludes": [],
"OrderedViews": [],
"PlayDefaultAudioTrack": true,
"RememberAudioSelections": true,
"RememberSubtitleSelections": true,
"SubtitleLanguagePreference": "",
"SubtitleMode": "Default"
},
"jellyfin_user_policy": {
"AccessSchedules": [],
"AuthenticationProviderId": "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider",
"BlockUnratedItems": [],
"BlockedChannels": [],
"BlockedMediaFolders": [],
"BlockedTags": [],
"EnableAllChannels": false,
"EnableAllDevices": true,
"EnableAllFolders": false,
"EnableAudioPlaybackTranscoding": true,
"EnableContentDeletion": false,
"EnableContentDeletionFromFolders": [],
"EnableContentDownloading": true,
"EnableLiveTvAccess": true,
"EnableLiveTvManagement": true,
"EnableMediaConversion": true,
"EnableMediaPlayback": true,
"EnablePlaybackRemuxing": true,
"EnablePublicSharing": true,
"EnableRemoteAccess": true,
"EnableRemoteControlOfOtherUsers": false,
"EnableSharedDeviceControl": true,
"EnableSyncTranscoding": true,
"EnableUserPreferenceAccess": true,
"EnableVideoPlaybackTranscoding": true,
"EnabledChannels": [],
"EnabledDevices": [],
"EnabledFolders": [],
"ForceRemoteSourceTranscoding": false,
"InvalidLoginAttemptCount": 0,
"IsAdministrator": false,
"IsDisabled": false,
"IsHidden": true,
"LoginAttemptsBeforeLockout": -1,
"MaxActiveSessions": 1,
"PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider",
"RemoteClientBitrateLimit": 1000000,
"SyncPlayAccess": "CreateAndJoinGroups"
}
}

96
forms.py Normal file
View File

@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
from flask_wtf import FlaskForm
import json
import os
from cryptography.hazmat.primitives.serialization import load_ssh_public_key
from wtforms import (
StringField,
PasswordField,
FieldList,
FloatField,
BooleanField,
SelectField,
SubmitField,
validators,
Field,
FieldList,
SelectMultipleField,
TextAreaField,
FieldList,
FormField,
)
from flask_wtf.file import FileField, FileAllowed, FileRequired
from wtforms.ext.sqlalchemy.orm import model_form
from wtforms.fields.html5 import SearchField
from wtforms.widgets.html5 import NumberInput
from wtforms.widgets import TextInput, CheckboxInput, ListWidget, PasswordInput
from wtforms.validators import (
ValidationError,
DataRequired,
URL,
ValidationError,
Optional,
)
def json_prettify(file):
with open(file, "r") as fh:
return json.dumps(json.load(fh), indent=4)
class SearchForm(FlaskForm):
query = SearchField("Query", validators=[DataRequired()])
tv_shows = BooleanField("TV Shows", default=True)
movies = BooleanField("Movies", default=True)
torrents = BooleanField("Torrents", default=True)
indexer = SelectMultipleField(choices=[])
group_by_tracker = BooleanField("Group torrents by tracker")
search = SubmitField("Search")
class HiddenPassword(PasswordField):
widget = PasswordInput(hide_value=False)
class TranscodeProfileForm(FlaskForm):
test = TextAreaField()
save = SubmitField("Save")
class AddSSHUser(FlaskForm):
name = StringField("Name", validators=[DataRequired()])
ssh_key = StringField("Public key", validators=[DataRequired()])
add = SubmitField("Add")
def validate_ssh_key(self,field):
key=load_ssh_public_key(bytes(field.data,"utf8"))
class ConfigForm(FlaskForm):
jellyfin_url = StringField("URL", validators=[URL()])
jellyfin_api_key = StringField("API Key")
qbt_url = StringField("URL", validators=[URL()])
qbt_username = StringField("Username")
qbt_passwd = HiddenPassword("Password")
sonarr_url = StringField("URL", validators=[URL()])
sonarr_api_key = HiddenPassword("API key")
radarr_url = StringField("URL", validators=[URL()])
radarr_api_key = HiddenPassword("API key")
jackett_url = StringField("URL", validators=[URL()])
jackett_api_key = HiddenPassword("API key")
portainer_url = StringField("URL", validators=[URL()])
portainer_username = StringField("Username")
portainer_passwd = HiddenPassword("Password")
transcode_default_profile = SelectField(
"Default profile", choices=[], validators=[]
)
transcode_profiles = FileField(
"Transcode profiles JSON",
validators=[Optional(), FileAllowed(["json"], "JSON files only!")],
)
test = SubmitField("Test")
save = SubmitField("Save")

4
models/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
from .stats import Stats
from .transcode import TranscodeJob

14
models/stats.py Normal file
View File

@ -0,0 +1,14 @@
from . import db
from sqlalchemy import String, Float, Column, Integer, DateTime
from datetime import datetime
class Stats(db.Model):
id = db.Column(db.Integer, primary_key=True)
timestamp = db.Column(db.DateTime, default=datetime.today)
key = db.Column(db.String)
value = db.Column(db.Float)
class Diagrams(db.Model):
name = db.Column(db.String,primary_key=True)
data = db.Column(db.String)

13
models/transcode.py Normal file
View File

@ -0,0 +1,13 @@
from . import db
from sqlalchemy import String, Float, Column, Integer, DateTime, ForeignKey
from sqlalchemy_utils import JSONType
from sqlalchemy.orm import relationship
from datetime import datetime
class TranscodeJob(db.Model):
id = db.Column(db.Integer, primary_key=True)
created = db.Column(db.DateTime, default=datetime.today)
status = db.Column(JSONType, default={})
completed = db.Column(db.DateTime, default=None)
profile = db.Column(db.String, default=None)

0
models/users.py Normal file
View File

129
static/theme.css Normal file
View File

@ -0,0 +1,129 @@
body,
input,
select,
pre,
textarea,
tr {
background-color: #222 !important;
color: #eee;
}
pre.inline {
display: inline;
margin: 0;
}
th {
border-bottom: 1px;
}
thead, table {
line-height: 1;
color: #eee;
}
hr {
color: #eee;
border-color: #eee;
margin: 10px 0;
}
p {
padding: 0;
margin: 0;
}
.list-group-item {
background-color: #181818;
border-color: #eee;
}
.dropdown-menu {
background-color: #444;
}
.progress {
background-color: #444;
}
.progress-bar {
background-color: #f70;
}
.form-control {
color: #eee !important;
}
.form-group {
margin-bottom: 0;
}
.btn {
margin-top: 10px;
}
.form-control-label {
margin-top: 10px;
}
.torrent_results {
width: 100%;
}
.nav-pills {
margin-top: 10px;
}
h1,
h2,
h3 {
margin-top: 10px;
}
/* Remove default bullets */
ul.file_tree,
ul.tree,
ul.file {
list-style-type: none;
margin: 0;
padding: 0;
}
ul.tree {
padding-left: 10px;
}
.monospace {
font-family: monospace;
overflow: scroll;
max-height: 500px;
min-height: 500px;
max-width: 100%;
min-width: 100%;
}
/* Style the caret/arrow */
.custom_caret {
cursor: pointer;
user-select: none;
/* Prevent text selection */
}
/* Create the caret/arrow with a unicode, and style it */
.custom_caret::before {
content: "[+]";
display: inline-block;
}
/* Rotate the caret/arrow icon when clicked on (using JavaScript) */
.custom_caret-down::before {
content: "[-]";
}
/* Hide the nested list */
.nested {
display: none;
}
/* Show the nested list when the user clicks on the caret/arrow (with JavaScript) */
.active {
display: block;
}

409
stats_collect.py Normal file
View File

@ -0,0 +1,409 @@
import pylab as PL
from matplotlib.ticker import EngFormatter
from base64 import b64encode
from api import Client
from utils import handle_config
import time
import json
import io
import os
from urllib.parse import quote
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
mpl_style = "dark_background"
smoothness = 5
def make_svg(data, dtype):
data_uri = "data:{};base64,{}".format(dtype, quote(str(b64encode(data), "ascii")))
return '<embed type="image/svg+xml" src="{}"/>'.format(data_uri)
def make_smooth(data, window_size):
ret = []
for i, _ in enumerate(data):
block = data[i : i + window_size]
ret.append(sum(block) / len(block))
return ret
def stackplot(data, names, title=None, color="#eee", unit=None, smooth=0):
fig = io.BytesIO()
with PL.style.context(mpl_style):
labels = []
values = []
for k, v in names.items():
t = list(map(datetime.fromtimestamp, data["t"]))
if smooth:
data[v] = make_smooth(data[v], smooth)
values.append(data[v])
labels.append(k)
PL.stackplot(t, values, labels=labels)
PL.legend()
PL.grid(True, ls="--")
PL.gcf().autofmt_xdate()
PL.gca().margins(x=0)
if title:
PL.title(title)
if unit:
PL.gca().yaxis.set_major_formatter(EngFormatter(unit=unit))
PL.tight_layout()
PL.savefig(fig, format="svg", transparent=True)
PL.clf()
return make_svg(fig.getvalue(), "image/svg+xml")
def lineplot(data, names, title=None, color="#eee", unit=None, smooth=0):
fig = io.BytesIO()
with PL.style.context(mpl_style):
for k, v in names.items():
t = list(map(datetime.fromtimestamp, data["t"]))
if smooth:
data[v] = make_smooth(data[v], smooth)
PL.plot(t, data[v], label=k)
PL.legend()
PL.grid(True, ls="--")
PL.gcf().autofmt_xdate()
PL.gca().margins(x=0)
if title:
PL.title(title)
if unit:
PL.gca().yaxis.set_major_formatter(EngFormatter(unit=unit))
PL.tight_layout()
PL.savefig(fig, format="svg", transparent=True)
PL.clf()
return make_svg(fig.getvalue(), "image/svg+xml")
def histogram(values, bins, title=None, color="#eee", unit=""):
fig = io.BytesIO()
with PL.style.context(mpl_style):
PL.hist(values, bins=bins, log=True)
if title:
PL.title(title)
PL.grid(True, ls="--")
PL.gca().xaxis.set_major_formatter(EngFormatter(unit=unit))
PL.gca().margins(x=0)
PL.tight_layout()
PL.savefig(fig, format="svg", transparent=True)
PL.clf()
return make_svg(fig.getvalue(), "image/svg+xml")
def prc_label(label, idx, values):
return "{} ({}, {:.2%}%)".format(label, values[idx], values[idx] / sum(values))
def byte_labels(label, idx, values):
orig_values = list(values)
suffix = ["", "K", "M", "G", "T", "P", "E"]
i = 0
while values[idx] > 1024 and i < len(suffix):
values[idx] /= 1024
i += 1
val = "{:.2f} {}iB".format(values[idx], suffix[i])
return "{} ({}, {:.2%}%)".format(label, val, orig_values[idx] / sum(orig_values))
def byte_rate_labels(label, idx, values):
orig_values = list(values)
suffix = ["", "K", "M", "G", "T", "P", "E"]
i = 0
while values[idx] > 1024 and i < len(suffix):
values[idx] /= 1024
i += 1
val = "{:.2f} {}iB/s".format(values[idx], suffix[i])
return "{} ({})".format(label, val)
def piechart(items, title=None, labelfunc=prc_label, sort=True):
fig = io.BytesIO()
labels = []
values = []
colors = []
if sort:
items = sorted(items.items(), key=lambda v: v[1])
else:
items = sorted(items.items())
for k, v in items:
labels.append(k)
if isinstance(v, tuple) and len(v) == 2:
v, c = v
colors.append(c)
values.append(v)
colors = colors or None
for i, label in enumerate(labels):
labels[i] = labelfunc(label, i, values[:])
with PL.style.context(mpl_style):
PL.pie(values, labels=labels, colors=colors, labeldistance=None)
PL.legend()
if title:
PL.title(title)
PL.tight_layout()
PL.savefig(fig, format="svg", transparent=True)
PL.clf()
return make_svg(fig.getvalue(), "image/svg+xml")
hist = {
"t": [],
"dl": [],
"ul": [],
"dl_size": [],
"ul_size": [],
"dl_size_sess": [],
"ul_size_sess": [],
"connections": [],
"bw_per_conn": [],
"dht_nodes": [],
}
def update_qbt_hist(stats, limit=1024):
global hist
data = stats["qbt"]["status"]
hist["t"].append(time.time())
hist["dl"].append(data["server_state"]["dl_info_speed"])
hist["ul"].append(data["server_state"]["up_info_speed"])
hist["dl_size"].append(data["server_state"]["alltime_dl"])
hist["ul_size"].append(data["server_state"]["alltime_ul"])
hist["dl_size_sess"].append(data["server_state"]["dl_info_data"])
hist["ul_size_sess"].append(data["server_state"]["up_info_data"])
hist["connections"].append(data["server_state"]["total_peer_connections"])
hist["dht_nodes"].append(data["server_state"]["dht_nodes"])
hist["bw_per_conn"].append(
(data["server_state"]["dl_info_speed"] + data["server_state"]["up_info_speed"])
/ data["server_state"]["total_peer_connections"]
)
for k in hist:
hist[k] = hist[k][-limit:]
last_idx = 0
for i, (t1, t2) in enumerate(zip(hist["t"], hist["t"][1:])):
if abs(t1 - t2) > (60 * 60): # 1h
last_idx = i + 1
for k in hist:
hist[k] = hist[k][last_idx:]
return hist
def collect_stats():
from collections import Counter
PL.clf()
cfg = handle_config()
c = Client(cfg)
series={}
movies={}
data = {
"radarr": {"entries": c.radarr.movies(), "status": c.radarr.status()},
"sonarr": {
"entries": c.sonarr.series(),
"status": c.sonarr.status(),
"details": {},
},
"qbt": {"status": c.qbittorent.status()},
}
for show in data["sonarr"]["entries"]:
series[show["id"]]=show
for movie in data["radarr"]["entries"]:
movies[movie["id"]]=movie
torrent_states = {}
torrent_categories = {}
for torrent in data["qbt"]["status"]["torrents"].values():
state = c.qbittorent.status_map.get(torrent["state"], (torrent["state"], None))[
0
]
category = torrent["category"] or "<None>"
torrent_states.setdefault(state, 0)
torrent_categories.setdefault(category, 0)
torrent_states[state] += 1
torrent_categories[category] += 1
vbitrates = []
abitrates = []
acodecs = []
vcodecs = []
qualities = []
formats = []
sizes = {"Shows": 0, "Movies": 0}
radarr_stats = {"missing": 0, "available": 0}
for movie in data["radarr"]["entries"]:
if movie["hasFile"]:
radarr_stats["available"] += 1
else:
radarr_stats["missing"] += 1
sizes["Movies"] += movie.get("movieFile", {}).get("size", 0)
vbr = movie.get("movieFile", {}).get("mediaInfo", {}).get("videoBitrate", None)
abr = movie.get("movieFile", {}).get("mediaInfo", {}).get("audioBitrate", None)
acodec = movie.get("movieFile", {}).get("mediaInfo", {}).get("audioCodec", None)
vcodec = movie.get("movieFile", {}).get("mediaInfo", {}).get("videoCodec", None)
fmt = movie.get("movieFile", {}).get("relativePath", "").split(".")[-1].lower()
qual = (
movie.get("movieFile", {}).get("quality", {}).get("quality", {}).get("name")
)
if qual:
qualities.append(qual)
if acodec:
acodecs.append(acodec)
if vcodec:
if vcodec.lower() in ["x265", "h265", "hevc"]:
vcodec = "H.265"
if vcodec.lower() in ["x264", "h264"]:
vcodec = "H.264"
vcodecs.append(vcodec)
if vbr:
vbitrates.append(vbr)
if abr:
abitrates.append(abr)
if fmt:
formats.append(fmt)
sonarr_stats = {"missing": 0, "available": 0}
info_jobs = []
with ThreadPoolExecutor(16) as pool:
for show in data["sonarr"]["entries"]:
info_jobs.append(pool.submit(c.sonarr.series, show["id"]))
for job, show in zip(
as_completed(info_jobs),
data["sonarr"]["entries"],
):
info = job.result()
data["sonarr"]["details"][show["id"]] = info
for file in info["episodeFile"]:
vbr = file.get("mediaInfo", {}).get("videoBitrate", None)
abr = file.get("mediaInfo", {}).get("audioBitrate", None)
acodec = file.get("mediaInfo", {}).get("audioCodec", None)
vcodec = file.get("mediaInfo", {}).get("videoCodec", None)
fmt = file.get("relativePath", "").split(".")[-1].lower()
qual = file.get("quality", {}).get("quality", {}).get("name")
sizes["Shows"] += file.get("size", 0)
if qual:
qualities.append(qual)
if acodec:
acodecs.append(acodec)
if vcodec:
if vcodec.lower() in ["x265", "h265", "hevc"]:
vcodec = "H.265"
if vcodec.lower() in ["x264", "h264"]:
vcodec = "H.264"
vcodecs.append(vcodec)
if vbr:
vbitrates.append(vbr)
if abr:
abitrates.append(abr)
if fmt:
formats.append(fmt)
for season in show.get("seasons", []):
stats = season.get("statistics", {})
sonarr_stats["missing"] += (
stats["totalEpisodeCount"] - stats["episodeFileCount"]
)
sonarr_stats["available"] += stats["episodeFileCount"]
hist = update_qbt_hist(data)
sonarr_stats["available"] = (sonarr_stats["available"], "#5f5")
sonarr_stats["missing"] = (sonarr_stats["missing"], "#f55")
radarr_stats["available"] = (radarr_stats["available"], "#5f5")
radarr_stats["missing"] = (radarr_stats["missing"], "#f55")
imgs = [
[
"Media",
histogram([vbitrates], "auto", "Video Bitrate", unit="b/s"),
histogram([abitrates], "auto", "Audio Bitrate", unit="b/s"),
piechart(dict(Counter(vcodecs)), "Video codecs"),
piechart(dict(Counter(acodecs)), "Audio codecs"),
piechart(dict(Counter(formats)), "Container formats"),
piechart(dict(Counter(qualities)), "Quality"),
piechart(sizes, "Disk usage", byte_labels),
piechart(sonarr_stats, "Episodes"),
piechart(radarr_stats, "Movies"),
],
[
"Torrents",
piechart(torrent_states, "Torrents"),
piechart(torrent_categories, "Torrent categories"),
piechart(
{"Upload": hist["ul"][-1]+0.0, "Download": hist["dl"][-1]+0.0},
"Bandwidth utilization",
byte_rate_labels,
sort=False,
),
stackplot(
hist,
{"Download": "dl", "Upload": "ul"},
"Transfer speed",
unit="b/s",
smooth=smoothness,
),
stackplot(
hist,
{"Download": "dl_size_sess", "Upload": "ul_size_sess"},
"Transfer volume (Session)",
unit="b",
),
stackplot(
hist,
{"Download": "dl_size", "Upload": "ul_size"},
"Transfer volume (Total)",
unit="b",
),
lineplot(
hist,
{"Connections": "connections"},
"Peers",
unit=None,
smooth=smoothness,
),
lineplot(
hist,
{"Bandwidth per connection": "bw_per_conn"},
"Connections",
unit="b/s",
smooth=smoothness,
),
lineplot(hist, {"DHT Nodes": "dht_nodes"}, "DHT", unit=None),
],
]
calendar = {"movies":[],"episodes":[]}
for movie in c.radarr.calendar():
calendar["movies"].append(movie)
for episode in c.sonarr.calendar():
t = episode['airDateUtc'].rstrip("Z").split(".")[0]
t = datetime.strptime(t, "%Y-%m-%dT%H:%M:%S")
episode['hasAired']=datetime.today()>t
calendar["episodes"].append({"episode":episode,"series":series[episode["seriesId"]]})
return {"data": data, "images": imgs, "hist": hist,"calendar": calendar}
if os.path.isfile("stats.json"):
with open("stats.json", "r") as of:
try:
hist = json.load(of)["hist"]
except Exception as e:
print("Error loading history:", str(e))
def update():
print("Updating...")
try:
stats = collect_stats()
except Exception as e:
print("Error collectin statistics:", str(e))
stats = None
if stats:
with open("stats_temp.json", "w") as of:
json.dump(stats, of)
open("stats.lock", "w").close()
if os.path.isfile("stats.json"):
os.unlink("stats.json")
os.rename("stats_temp.json", "stats.json")
os.unlink("stats.lock")
print("Done!")
def loop(seconds):
while True:
update()
time.sleep(seconds)
if __name__=="__main__":
update()

40
templates/base.html Normal file
View File

@ -0,0 +1,40 @@
{% from 'bootstrap/utils.html' import render_messages %}
<!doctype html>
<html lang="en">
<head>
{% block head %}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{% block styles %}
{{ bootstrap.load_css() }}
<link rel="stylesheet" href="{{url_for('static', filename='theme.css')}}">
{% endblock %}
<title>MediaDash</title>
{% endblock %}
</head>
<body>
{% block navbar %}
<nav class="navbar sticky-top navbar-expand-lg navbar-dark" style="background-color: #222;">
<a class="navbar-brand" href="/">MediaDash</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar_main" aria-controls="navbar_main" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar_main">
{{nav.left_nav.render(renderer='bootstrap4')}}
</div>
</nav>
</div>
{% endblock %}
{% block content %}
<div class={{"container-fluid" if fluid else "container"}}>
{{render_messages()}}
{% block app_content %}{% endblock %}
</div>
{% endblock %}
{% block scripts %}
{{ bootstrap.load_js(with_popper=False) }}
{% endblock %}
</body>
</html>

70
templates/config.html Normal file
View File

@ -0,0 +1,70 @@
{% extends "base.html" %}
{% from 'utils.html' import custom_render_form_row,make_tabs %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% set col_size = ('lg',2,6) %}
{% set col_size_seq = ('lg',10,1) %}
{% macro render_fields(fields) %}
{% for field in fields %}
{% if field is sequence %}
{{ custom_render_form_row(field|list,col_map={'transcode_edit':('lg',1),'transcode_new':('lg',1)},render_args={'form_type':'horizontal'}) }}
{% else %}
{{ custom_render_form_row([field],render_args={'form_type':'horizontal','horizontal_columns':col_size}) }}
{% endif %}
{% endfor %}
{% endmacro %}
{% set config_tabs = [] %}
{% for name, fields in [
('Jellyfin',[form.jellyfin_url,form.jellyfin_username,form.jellyfin_passwd]),
('QBittorrent',[form.qbt_url,form.qbt_username,form.qbt_passwd]),
('Sonarr',[form.sonarr_url,form.sonarr_api_key]),
('Radarr',[form.radarr_url,form.radarr_api_key]),
('Portainer',[form.portainer_url,form.portainer_username,form.portainer_passwd]),
('Jackett',[form.jackett_url,form.jackett_api_key]),
('Transcode',[form.transcode_default_profile,form.transcode_profiles]),
] %}
{% do config_tabs.append((name,render_fields(fields))) %}
{% endfor %}
{% block app_content %}
<h1>{{title}}</h1>
{% if test %}
{% if test.success %}
<div class="alert alert-success" role="danger">
<h4>Sucess</h4>
</div>
{% else %}
<div class="alert alert-danger" role="danger">
{% for module,error in test.errors.items() %}
{% if error %}
<h4>{{module}}</h4>
{% if error is mapping %}
{% for key,value in error.items() %}
<p><b>{{key}}</b>: {{value}}</p>
{% endfor %}
{% else %}
<b>{{error}}</b>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endif %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="danger">{{error}}</div>
{% endfor %}
{% endfor %}
<div class="row">
<div class="col">
<form method="post" class="form" enctype="multipart/form-data">
{{ form.csrf_token() }}
{{ make_tabs(config_tabs) }}
{{ custom_render_form_row([form.test, form.save],button_map={'test':'primary','save':'success'},col_map={'test':0,'primary':0},render_args={'form_type':'horizontal'})}}
</form>
{# render_form(form, form_type ="horizontal", button_map={'test':'primary','save':'success'}) #}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block app_content %}
<h1>
<a href="{{config.APP_CONFIG.portainer_url}}#/containers/{{container.Id}}">
{{container.Config.Labels["com.docker.compose.project"]}}/{{container.Config.Labels["com.docker.compose.service"]}}
</a>
</h1>
<h4>Env</h4>
<pre>{{container.Config.Env|join("\n")}}</pre>
<pre>{{container|tojson(indent=4)}}</pre>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% from "utils.html" import make_tabs %}
{% macro container_row(info) %}
<div class="row">
<div class="col">
Image
</div>
<div class="col">
<a href="{{urljoin('https://hub.docker.com/r/',info.Image)}}">{{info.Image}}</a>
</div>
<div class="col">
Status
</div>
<div class="col">
{{info.Status}}
</div>
</div>
<div class="row">
<div class="col">
Id: <a href="{{'{}#/containers/{}'.format(config.APP_CONFIG.portainer_url,info.Id)}}">{{info.Id}}</a>
</div>
</div>
<div class="row">
<pre>{{info|tojson(indent=4)}}</pre>
</div>
{% endmacro %}
{% block app_content %}
<h1>
<a href="{{config.APP_CONFIG.portainer_url}}">Portainer</a>
</h1>
<table class="table table-sm">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Image</th>
<th scope="col">Status</th>
</tr>
</thead>
{% for container in containers %}
{% set label = container.Labels["com.docker.compose.service"] %}
<tr>
<td>
<a href="{{url_for('containers',container_id=container.Id)}}">
{{container.Labels["com.docker.compose.project"]}}/{{container.Labels["com.docker.compose.service"]}}
</a>
</td>
<td>
<a href="{{urljoin('https://hub.docker.com/r/',container.Image)}}">{{container.Image}}</a>
</td>
<td>
{{container.Status}}
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

65
templates/history.html Normal file
View File

@ -0,0 +1,65 @@
{%- extends "base.html" -%}
{%- from 'utils.html' import make_tabs -%}
{%- macro default(event,source) -%}
<h5>Unknown ({{source}})</h5>
<pre>{{event|tojson(indent=4)}}</pre>
{%- endmacro -%}
{%- macro downloadFolderImported(event,source) -%}
[{{event.seriesId}}/{{event.episodeId}}] Imported {{event.data.droppedPath}} from {{event.data.downloadClientName}} to {{event.data.importedPath}}
{%- endmacro -%}
{%- macro grabbed(event,source) -%}
[{{event.seriesId}}/{{event.episodeId}}] Grabbed <a href="{{event.data.guid}}">{{event.sourceTitle}}</a>
{%- endmacro -%}
{%- macro episodeFileDeleted(event,source) -%}
[{{event.seriesId}}/{{event.episodeId}}] Deleted {{event.sourceTitle}} because {{event.data.reason}}
{%- endmacro -%}
{%- macro episodeFileRenamed(event,source) -%}
[{{event.seriesId}}/{{event.episodeId}}] Renamed {{event.data.sourcePath}} to {{event.data.path}}
{%- endmacro -%}
{%- macro movieFileDeleted(event,source) -%}
Renamed {{event.data.sourcePath}} to {{event.data.path}}
{%- endmacro -%}
{%- macro movieFileRenamed(event,source) -%}
<h5>renamed</h5>
<pre>{{event|tojson(indent=4)}}</pre>
{%- endmacro -%}
{%- macro downloadFailed(event,source) -%}
<h5>downloadFailed</h5>
<pre>{{event|tojson(indent=4)}}</pre>
{%- endmacro -%}
{%- set handlers = {
'downloadFolderImported': downloadFolderImported,
'grabbed': grabbed,
'episodeFileDeleted': episodeFileDeleted,
'episodeFileRenamed': episodeFileRenamed,
'movieFileDeleted': movieFileDeleted,
'movieFileRenamed': movieFileRenamed,
'downloadFailed': downloadFailed,
None: default
} -%}
{%- macro history_page(history,source) -%}
<pre>
{%- for entry in history.records -%}
{{handlers.get(entry.eventType,handlers[None])(entry,source)}}{{'\n'}}
{%- endfor -%}
</pre>
{%- endmacro -%}
{%- block app_content -%}
<h2>History</h2>
<div class="row">
<div class="col">
{{make_tabs([('Sonarr',history_page(sonarr,'sonarr')),('Radarr',history_page(radarr,'radarr'))])}}
</div>
</div>
{%- endblock -%}

122
templates/index.html Normal file
View File

@ -0,0 +1,122 @@
{% extends "base.html" %}
{% macro make_row(title,items) %}
<div class="d-flex flex-wrap">
{% for item in items %}
{{item|safe}}
{% endfor %}
</div>
{% endmacro %}
{% macro make_tabs(tabs) %}
<div class="row">
<div class="col">
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
{% for (label,_) in tabs %}
{% set slug = (label|slugify) %}
{% if not (loop.first and loop.last) %}
<li class="nav-item">
<a class="nav-link {{'active' if loop.first}}" id="nav-{{slug}}-tab" data-toggle="pill" href="#pills-{{slug}}" role="tab" aria-controls="pills-{{slug}}" aria-selected="{{loop.first}}">
{{label}}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
<div class="tab-content" id="searchResults">
{% for (label,items) in tabs %}
{% set slug = (label|slugify) %}
<div class="tab-pane fade {{'show active' if loop.first}}" id="pills-{{slug}}" role="tabpanel" aria-labelledby="nav-{{slug}}-tab">
{{make_row(label,items)}}
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro upcoming(data) %}
<div class="container">
<div class="row">
<div class="col-lg">
<h3>Movies</h3>
<table class="table table-sm">
<tr>
<th>Title</th>
<th>In Cinemas</th>
<th>Digital Release</th>
</tr>
{% for movie in data.calendar.movies %}
{% if movie.isAvailable and movie.hasFile %}
{% set row_class = "bg-success" %}
{% elif movie.isAvailable and not movie.hasFile %}
{% set row_class = "bg-danger" %}
{% elif not movie.isAvailable and movie.hasFile %}
{% set row_class = "bg-primary" %}
{% elif not movie.isAvailable and not movie.hasFile %}
{% set row_class = "bg-info" %}
{% endif %}
<tr class={{row_class}}>
<td>
<a href="{{urljoin(config.APP_CONFIG.radarr_url,'movie/'+movie.titleSlug)}}" style="color: #eee; text-decoration: underline;">
{{movie.title}}
</a>
</td>
<td>{{movie.inCinemas|fromiso|ago_dt_utc_human(rnd=0)}}</td>
<td>{{movie.digitalRelease|fromiso|ago_dt_utc_human(rnd=0)}}</td>
</tr>
{% endfor %}
</table>
<h3>Episodes</h3>
<table class="table table-sm">
<tr>
<th>Season | Episode Number</th>
<th>Show</th>
<th>Title</th>
<th>Air Date</th>
</tr>
{% for entry in data.calendar.episodes %}
{% if entry.episode.hasAired and entry.episode.hasFile %}
{% set row_class = "bg-success" %}
{% elif entry.episode.hasAired and not entry.episode.hasFile %}
{% set row_class = "bg-danger" %}
{% elif not entry.episode.hasAired and entry.episode.hasFile %}
{% set row_class = "bg-primary" %}
{% elif not entry.episode.hasAired and not entry.episode.hasFile %}
{% set row_class = "bg-info" %}
{% endif %}
<tr class={{row_class}}>
<td>{{entry.episode.seasonNumber}} | {{entry.episode.episodeNumber}}</td>
<td>
<a href="{{urljoin(config.APP_CONFIG.sonarr_url,'series/'+entry.series.titleSlug)}}" style="color: #eee; text-decoration: underline;">
{{entry.series.title}}
</a>
</td>
<td>{{entry.episode.title}}</td>
<td>{{entry.episode.airDateUtc|fromiso|ago_dt_utc_human(rnd=0)}}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
{% endmacro %}
{% block app_content %}
{% if data is none %}
<h2>No Data available!</h2>
{% else %}
{% set tabs = [] %}
{% do tabs.append(("Upcoming",[upcoming(data)])) %}
{% for row in data.images %}
{% if row[0] is string %}
{% set title=row[0] %}
{% set row=row[1:] %}
{% do tabs.append((title,row)) %}
{% endif %}
{% endfor %}
{{make_tabs(tabs)}}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,121 @@
{% extends "base.html" %}
{% from 'utils.html' import custom_render_form_row,make_tabs %}
{% from 'bootstrap/utils.html' import render_icon %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% block app_content %}
<h2><a href={{jellyfin.info.LocalAddress}}>Jellyfin</a> v{{jellyfin.info.Version}}</h2>
<div class="row">
<div class="col-lg">
<h4>Active Streams</h4>
<table class="table table-sm">
<tr>
<th>Episode</th>
<th>Show</th>
<th>Language</th>
<th>User</th>
<th>Device</th>
<th>Mode</th>
</tr>
{% for session in jellyfin.sessions %}
{% if "NowPlayingItem" in session %}
{% with np=session.NowPlayingItem, ps=session.PlayState%}
<tr>
<td>
{% if session.SupportsMediaControl %}
<a href="{{url_for('stop_stream',session=session.Id)}}">
{{render_icon("stop-circle")}}
</a>
{% endif %}
<a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.Id}}">
{{np.Name}}
</a>
({{(ps.PositionTicks/10_000_000)|timedelta(digits=0)}}/{{(np.RunTimeTicks/10_000_000)|timedelta(digits=0)}})
{% if ps.IsPaused %}
(Paused)
{% endif %}
</td>
<td>
<a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.SeriesId}}">
{{np.SeriesName}}
</a>
<a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.SeasonId}}">
({{np.SeasonName}})
</a>
</td>
<td>
{% if ("AudioStreamIndex" in ps) and ("SubtitleStreamIndex" in ps) %}
{{np.MediaStreams[ps.AudioStreamIndex].Language or "None"}}/{{np.MediaStreams[ps.SubtitleStreamIndex].Language or "None"}}
{% else %}
Unk/Unk
{% endif %}
</td>
<td>
<a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{session.UserId}}">
{{session.UserName}}
</a>
</td>
<td>
{{session.DeviceName}}
</td>
<td>
{% if ps.PlayMethod =="Transcode" %}
<p title="{{session.TranscodingInfo.Bitrate|filesizeformat(binary=False)}}/s | {{session.TranscodingInfo.CompletionPercentage|round(2)}}%">
{{ps.PlayMethod}}
</p>
{% else %}
<p>
{{ps.PlayMethod}}
</p>
{% endif %}
</td>
</tr>
{% endwith %}
{% endif %}
{% endfor %}
</table>
</div>
</div>
<div class="row">
<div class="col-lg">
<h4>Users</h4>
<table class="table table-sm">
<tr>
<th>Name</th>
<th>Last Login</th>
<th>Last Active</th>
<th>Bandwidth Limit</th>
</tr>
{% for user in jellyfin.users|sort(attribute="LastLoginDate",reverse=True) %}
<tr>
<td>
<a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{user.Id}}">
{{user.Name}}
</a>
</td>
<td>
{% if "LastLoginDate" in user %}
{{user.LastLoginDate|fromiso|ago_dt_utc(2)}} ago
{% else %}
Never
{% endif %}
</td>
<td>
{% if "LastActivityDate" in user %}
{{user.LastActivityDate|fromiso|ago_dt_utc(2)}} ago
{% else %}
Never
{% endif %}
</td>
<td>{{user.Policy.RemoteClientBitrateLimit|filesizeformat(binary=False)}}/s</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endblock %}

39
templates/logs.html Normal file
View File

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block app_content %}
<div class="row">
<h2>QBittorrent</h2>
<div class="monospace">
{% set t_first = logs.qbt[0].timestamp %}
{% for message in logs.qbt if "WebAPI login success" not in message.message %}
{%set type={1: 'status' , 2: 'info', 4: 'warning', 8:'danger'}.get(message.type,none) %}
{%set type_name={1: 'NORMAL' , 2: 'INFO', 4: 'WARNING', 8:'CRITICAL'}.get(message.type,none) %}
<p class="text-{{type}}">
[{{((message.timestamp-t_first)/1000) | timedelta}}|{{type_name}}] {{message.message.strip()}}
</p>
{% endfor %}
</div>
<h2>Sonarr</h2>
<div class="monospace">
{% set t_first = (logs.sonarr.records[0].time)|fromiso %}
{% for message in logs.sonarr.records %}
{%set type={'warn': 'warning', 'error':'danger'}.get(message.level,message.level) %}
<p class="text-{{type}}">
[{{message.time | fromiso | ago_dt}}|{{message.logger}}|{{message.level|upper}}] {{message.message.strip()}}
</p>
{% endfor %}
</div>
<h2>Radarr</h2>
<div class="monospace">
{% set t_first = (logs.radarr.records[0].time)|fromiso %}
{% for message in logs.radarr.records %}
{%set type={'warn': 'warning', 8:'danger'}.get(message.level,message.level) %}
<p class="text-{{type}}">
[{{message.time | fromiso | ago_dt}}|{{message.logger}}|{{message.level|upper}}] {{message.message.strip()}}
</p>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,236 @@
{% extends "base.html" %}
{% from "utils.html" import render_tree %}
{% block scripts %}
{{super()}}
<script lang="text/javascript">
var toggler = document.getElementsByClassName("custom_caret");
var i;
for (i = 0; i < toggler.length; i++) {
toggler[i].addEventListener("click", function () {
this.parentElement.querySelector(".nested").classList.toggle("active");
this.classList.toggle("custom_caret-down");
});
}
</script>
{% endblock %}
{% block app_content %}
<div class="row">
<div class="col">
<h1>
<a href="{{qbt.info.magnet_uri}}" title="{{qbt.info.hash}}">{{qbt.info.name}}</a>
</h1>
</div>
</div>
<div class="row">
<div class="col">
<div class="progress" style="width: 100%;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
style="width: {{(qbt.info.progress*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{(qbt.info.progress*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
{{(qbt.info.progress*100)|round(2)}}&nbsp;%
</div>
<div class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
style="width: {{((([qbt.info.availability,1]|min)-qbt.info.progress)*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{((([qbt.info.availability,1]|min)-qbt.info.progress)*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<span class="badge badge-{{qbt.info.state[1]}}">
{{qbt.info.state[0]}}
</span>
{% if qbt.info.category %}
<span class="badge badge-light">{{qbt.info.category}}</span>
{% endif %}
</div>
</div>
<h2>Info</h2>
<div class="row">
<div class="col">
Total Size
</div>
<div class="col">
{{qbt.info.size|filesizeformat(binary=True)}} ({{[0,qbt.info.size-qbt.info.downloaded]|max|filesizeformat(binary=True)}} left)
</div>
<div class="col">
Files
</div>
<div class="col">
{{qbt.files|count}}
</div>
</div>
<div class="row">
<div class="col">
Downloaded
</div>
<div class="col">
{{qbt.info.downloaded|filesizeformat(binary=True)}} ({{qbt.info.dlspeed|filesizeformat(binary=True)}}/s)
</div>
<div class="col">
Uploaded
</div>
<div class="col">
{{qbt.info.uploaded|filesizeformat(binary=True)}} ({{qbt.info.upspeed|filesizeformat(binary=True)}}/s)
</div>
</div>
<h2>Health</h2>
<div class="row">
<div class="col">
Last Active
</div>
<div class="col">
{{qbt.info.last_activity|ago(clamp=True)}} Ago
</div>
<div class="col">
Age
</div>
<div class="col">
{{qbt.info.added_on|ago}}
</div>
</div>
<div class="row">
<div class="col">
Avg. DL rate
</div>
<div class="col">
{{(qbt.info.downloaded/((qbt.info.added_on|ago).total_seconds()))|filesizeformat(binary=True)}}/s
(A: {{(qbt.info.downloaded/qbt.info.time_active)|filesizeformat(binary=True)}}/s)
</div>
<div class="col">
Avg. UL rate
</div>
<div class="col">
{{(qbt.info.uploaded/((qbt.info.added_on|ago).total_seconds()))|filesizeformat(binary=True)}}/s
(A: {{(qbt.info.uploaded/qbt.info.time_active)|filesizeformat(binary=True)}}/s)
</div>
</div>
<div class="row">
<div class="col">
ETC (DL rate while active)
</div>
<div class="col">
{% set dl_rate_act = (qbt.info.downloaded/qbt.info.time_active) %}
{% if dl_rate_act>0 %}
{{((qbt.info.size-qbt.info.downloaded)/dl_rate_act)|round(0)|timedelta(clamp=true)}}
{% else %}
N/A
{% endif %}
</div>
<div class="col">
ETC (avg. DL rate)
</div>
<div class="col">
{% set dl_rate = (qbt.info.downloaded/((qbt.info.added_on|ago(clamp=True)).total_seconds())) %}
{% if dl_rate>0 %}
{{((qbt.info.size-qbt.info.downloaded)/dl_rate)|round(0)|timedelta(clamp=true)}}
{% else %}
N/A
{% endif %}
</div>
</div>
<div class="row">
<div class="col">
Total active time
</div>
<div class="col">
{{qbt.info.time_active|timedelta}}
</div>
<div class="col">
Availability
</div>
<div class="col">
{% if qbt.info.availability==-1 %}
N/A
{% else %}
{{(qbt.info.availability*100)|round(2)}}&nbsp;%
{% endif %}
</div>
</div>
<h2>Swarm</h2>
<div class="row">
<div class="col">
Seeds
</div>
<div class="col">
{{qbt.info.num_seeds}}
</div>
<div class="col">
Leechers
</div>
<div class="col">
{{qbt.info.num_leechs}}
</div>
</div>
<div class="row">
<div class="col">
Last seen completed
</div>
<div class="col">
{{qbt.info.seen_complete|ago}} Ago
</div>
<div class="col"></div>
<div class="col"></div>
</div>
<h2>Files</h2>
{{render_tree(qbt.files|sort(attribute='name')|list|make_tree)}}
<div class="row">
<div class="col">
<h2>Trackers</h2>
<a href="{{url_for('qbittorent_add_trackers',infohash=qbt.info.hash)}}">
<span class="badge badge-primary">Add default trackers</span>
</a>
</div>
</div>
{% for tracker in qbt.trackers|sort(attribute='total_peers', reverse=true) %}
<div class="row">
<div class="col">
{% if tracker.has_url %}
<a href="{{tracker.url}}">{{tracker.name}}</a>
{% else %}
{{tracker.name}}
{% endif %}
{% if tracker.message %}
<code>{{tracker.message}}</code>
{% endif %}
</div>
<div class="col">
<span class="badge badge-{{tracker.status[1]}}">{{tracker.status[0]}}</span>
(S: {{tracker.num_seeds[1]}}, L: {{tracker.num_leeches[1]}}, P: {{tracker.num_peers[1]}}, D: {{tracker.num_downloaded[1]}})
</div>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,138 @@
{% extends "base.html" %}
{% macro torrent_entry(torrent) %}
{% set state_label,badge_type = status_map[torrent.state] or (torrent.state,'light') %}
<li class="list-group-item">
<a href="{{url_for('qbittorrent_details',infohash=torrent.hash)}}">{{torrent.name|truncate(75)}}</a>
(DL: {{torrent.dlspeed|filesizeformat(binary=true)}}/s, UL: {{torrent.upspeed|filesizeformat(binary=true)}}/s)
<span class="badge badge-{{badge_type}}">{{state_label}}</span>
{% if torrent.category %}
<span class="badge badge-light">{{torrent.category}}</span>
{% endif %}
<div style="margin-top: 5px"></div>
<div class="progress" style="width: 100%;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
style="width: {{(torrent.progress*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{(torrent.progress*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
</div>
<div class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
style="width: {{((([torrent.availability,1]|min)-torrent.progress)*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{((([torrent.availability,1]|min)-torrent.progress)*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
</div>
<small class="justify-content-center d-flex position-absolute w-100">{{(torrent.progress*100)|round(2)}}&nbsp;% (ETA: {{[torrent.eta,torrent.eta_act]|min|round(0)|timedelta(clamp=true)}})</small>
</div>
</li>
{% endmacro %}
{% block app_content %}
<h2>
<a href="{{config.APP_CONFIG.qbt_url}}">QBittorrent</a>
{{qbt.version}}
(DL: {{qbt.server_state.dl_info_speed|filesizeformat(binary=True)}}/s,
UL: {{qbt.server_state.up_info_speed|filesizeformat(binary=True)}}/s)
</h2>
<div class="row">
<div class="col">
Total Uploaded
</div>
<div class="col">
{{qbt.server_state.alltime_ul|filesizeformat(binary=True)}}
</div>
<div class="col">
Total Downloaded
</div>
<div class="col">
{{qbt.server_state.alltime_dl|filesizeformat(binary=True)}}
</div>
</div>
<div class="row">
<div class="col">
Session Uploaded
</div>
<div class="col">
{{qbt.server_state.up_info_data|filesizeformat(binary=True)}}
</div>
<div class="col">
Session Downloaded
</div>
<div class="col">
{{qbt.server_state.dl_info_data|filesizeformat(binary=True)}}
</div>
</div>
<div class="row">
<div class="col">
Torrents
</div>
<div class="col">
{{qbt.torrents|length}}
</div>
<div class="col">
Total Queue Size
</div>
<div class="col">
{{qbt.torrents.values()|map(attribute='size')|sum|filesizeformat(binary=true)}}
</div>
</div>
<hr />
<div class="row">
<div class="col">
<form method="GET">
<select class="form-control" name="sort" onchange="this.parentElement.submit()">
<option value="">Sort by</option>
{% for key,value in sort_by_choices.items() %}
<option value="{{key}}">{{value}}</option>
{% endfor %}
</select>
</form>
</div>
</div>
{% for state,torrents in qbt.torrents.values()|sort(attribute='state')|groupby('state') %}
{% set state_label,badge_type = status_map[state] or (state,'light') %}
<div class="row">
<div class="col">
<a href={{url_for("qbittorrent",state=state)}} >{{state_label}}</a>
</div>
<div class="col">
{{torrents|length}}
</div>
</div>
{% endfor %}
{% if state_filter %}
<div class="row">
<div class="col">
<a href={{url_for("qbittorrent")}}>[Clear filter]</a>
</div>
<div class="col">
</div>
</div>
{% endif %}
<hr />
<div class="row">
<div class="col">
<ul style="padding-bottom: 10px;" class="list-group">
{% for torrent in qbt.torrents.values()|sort(attribute=sort_by,reverse=True) %}
{% set state_label,badge_type = status_map[torrent.state] or (torrent.state,'light') %}
{% if state_filter %}
{% if torrent.state==state_filter %}
{{torrent_entry(torrent)}}
{% endif %}
{% else %}
{{torrent_entry(torrent)}}
{% endif %}
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

View File

View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% from 'utils.html' import make_tabs %}
{% macro movie_list() %}
{% for movie in movies|sort(attribute='sortTitle') %}
<h6>
<a href="{{urljoin(config.APP_CONFIG.radarr_url,'movie/'+movie.titleSlug)}}">{{movie.title}}</a>
({{movie.year}})
{% for genre in movie.genres %}
<span class="badge badge-secondary">{{genre}}</span>
{% endfor %}
<span class="badge badge-info">{{movie.status|title}}</span>
</h6>
{% endfor %}
{% endmacro %}
{% block app_content %}
<h2>
<a href="{{config.APP_CONFIG.radarr_url}}">Radarr</a>
v{{status.version}} ({{movies|count}} Movies)
</h2>
<div class="row">
<div class="col">
{{movie_list()}}
</div>
</div>
{% endblock %}

23
templates/remote/add.html Normal file
View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% from 'utils.html' import custom_render_form_row,make_tabs %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% block app_content %}
{% if form %}
<h1>Grant remote access</h1>
{% endif %}
<div class="row">
<div class="col-lg">
<form method="post" class="form">
{{form.csrf_token()}}
{{custom_render_form_row([form.name])}}
{{custom_render_form_row([form.ssh_key])}}
{{custom_render_form_row([form.add])}}
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% from 'utils.html' import custom_render_form_row,make_tabs %}
{% from 'bootstrap/utils.html' import render_icon %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% block app_content %}
<h1>
Remote access <a href={{url_for("remote_add")}}>{{render_icon("person-plus-fill")}}</a>
</h1>
<div class="row">
<div class="col-lg">
<h4>SSH</h4>
<table class="table table-sm">
<tr>
<th></th>
<th>Type</th>
<th>Key fingerprint</th>
<th>Name</th>
</tr>
{% for key in ssh %}
<tr {{ {"class":"text-muted" if key.disabled else none}|xmlattr }}>
<td>
{% if key.disabled %}
<a href="{{url_for("remote",enabled=True,key=key.key)}}">{{render_icon("person-x-fill",color='danger')}}</a>
{% else %}
<a href="{{url_for("remote",enabled=False,key=key.key)}}">{{render_icon("person-check-fill",color='success')}}</a>
{% endif %}
</td>
<td>{{key.type}}</td>
<td title="{{key.key}}">{{key.fingerprint}}</td>
<td>{{key.name}}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="row">
<div class="col-lg">
<h4><a href="{{cfg().jellyfin_url}}web/index.html#!/userprofiles.html">Jellyfin</a></h4>
<table class="table table-sm">
<tr>
<th>Name</th>
<th>Last Login</th>
<th>Last Active</th>
<th>Bandwidth Limit</th>
</tr>
{% for user in jf|sort(attribute="LastLoginDate",reverse=True) %}
<tr>
<td>
<a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{user.Id}}">
{{user.Name}}
</a>
</td>
<td>
{% if "LastLoginDate" in user %}
{{user.LastLoginDate|fromiso|ago_dt_utc(2)}} ago
{% else %}
Never
{% endif %}
</td>
<td>
{% if "LastActivityDate" in user %}
{{user.LastActivityDate|fromiso|ago_dt_utc(2)}} ago
{% else %}
Never
{% endif %}
</td>
<td>{{user.Policy.RemoteClientBitrateLimit|filesizeformat(binary=False)}}/s</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block app_content %}
<h1>{{info.title}} ({{info.year}})</h1>
<h2>{{info.hasFile}}</h2>
<p>{{info.id}}</p>
<pre>
{{info|tojson(indent=4)}}
</pre>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% macro movie_results(results) -%}
<div class="d-flex flex-wrap">
{%for result in results %}
<form action="search/details" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="type" value="movie"/>
<input type="hidden" name="data" value="{{result|tojson|urlencode}}"/>
<a style="cursor: pointer" onclick="this.parentElement.submit()">
{% for poster in result.images|selectattr('coverType','eq','poster') %}
<img class="img-fluid poster" src="{{poster.url}}" title="{{result.title}}">
{% else %}
<img class="img-fluid poster" src="{{url_for('placeholder',width=333, height=500, text=result.title, wrap = 15)}}" title="{{result.title}}" />
{% endfor %}
</a>
</form>
{% endfor %}
</div>
{% endmacro %}

View File

@ -0,0 +1,123 @@
{% macro torrent_result_row(result,with_tracker=false) -%}
<tr>
<td colspan="{{4 if with_tracker else 3}}">
<hr/>
</td>
</tr>
<tr>
<td colspan="{{4 if with_tracker else 3}}">
<input type="checkbox" id="torrent_selected" name="torrent[]" value="{{result.Link or result.MagnetUri}}">
<a href="{{result.Link or result.MagnetUri}}">
{{result.Title}}
</a>
{% if result.DownloadVolumeFactor==0.0 %}
<span class="badge badge-success">Freeleech</span>
{% endif %}
{% if result.UploadVolumeFactor > 1.0 %}
<span class="badge badge-success">UL x{{result.UploadVolumeFactor}}</span>
{% endif %}
</td>
</tr>
<tr>
<td>
{{result.CategoryDesc}}
</td>
<td>
{{result.Size|filesizeformat}}
</td>
{% if with_tracker %}
<td>
<a href="{{result.Guid}}">
{{result.Tracker}}
</a>
</td>
{% endif %}
<td>
({{result.Seeders}}/{{result.Peers}}/{{ "?" if result.Grabs is none else result.Grabs}})
</td>
</tr>
{% endmacro %}
{% macro torrent_result_grouped(results) %}
{% if results %}
<table class="torrent_results">
{% for tracker,results in results.Results|groupby(attribute="Tracker") %}
<thead>
<tr>
<th>
<h2>{{tracker}} ({{results|length}})</h2>
</th>
</tr>
<tr>
<th colspan="{{4 if with_tracker else 3}}">
Name
</th>
</tr>
<tr>
<th>
Category
</th>
<th>
Size
</th>
<th>
Seeds/Peers/Grabs
</th>
</tr>
</thead>
{%for result in results|sort(attribute='Gain',reverse=true) %}
{{ torrent_result_row(result,with_tracker=false) }}
{% endfor %}
{% endfor %}
<tr>
<td colspan="{{4 if with_tracker else 3}}">
<hr/>
</td>
</tr>
</table>
{% endif %}
{% endmacro %}
{% macro torrent_results(results,group_by_tracker=false) %}
<form action="/api/add_torrent" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="text" class="form-control" id="category" name="category" placeholder="Category"/>
<span class="badge badge-primary" style="cursor: pointer" onclick="this.parentElement.submit()">
Add selected to QBittorrent
</span>
{% if group_by_tracker %}
{{ torrent_result_grouped(results) }}
{% else %}
{% if results %}
<table class="torrent_results">
<thead>
<tr>
<th colspan="{{4 if with_tracker else 3}}">
Name
</th>
</tr>
<tr>
<th>
Category
</th>
<th>
Size
</th>
<th>
Tracker
</th>
<th>
Seeds/Peers/Grabs
</th>
</tr>
</thead>
{% for result in results.Results|sort(attribute='Gain',reverse=true) %}
{{ torrent_result_row(result,with_tracker=true) }}
{% endfor %}
</table>
{% endif %}
{% endif %}
</form>
{% endmacro %}

View File

@ -0,0 +1,23 @@
{% macro tv_show_results(results) -%}
<div class="d-flex flex-wrap">
{% for result in results %}
<form action="search/details" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="type" value="show"/>
<input type="hidden" name="data" value="{{result|tojson|urlencode}}" />
<a style="cursor: pointer" onclick="this.parentElement.submit()">
{% for banner in result.images|selectattr('coverType','eq','banner') %}
<img class="img-fluid banner" src="{{client.sonarr.url.rsplit("/",2)[0]+banner.url}}" title="{{result.title}}" />
{% else %}
{% set poster=(result.images|selectattr('coverType','eq','poster')|first) %}
{% if poster %}
{% set poster=(client.sonarr.url.rsplit("/",2)[0]+poster.url) %}
{% endif %}
<img class="img-fluid banner" src="{{url_for('placeholder',width=758, height=140, poster=poster, text=result.title)}}" title="{{result.title}}" />
{% endfor %}
</a>
</form>
{% endfor %}
</div>
{% endmacro %}

View File

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% from "utils.html" import make_tabs, custom_render_form_row %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% from 'bootstrap/table.html' import render_table %}
{% from 'search/include/tv_show.html' import tv_show_results with context %}
{% from 'search/include/movie.html' import movie_results with context %}
{% from 'search/include/torrent.html' import torrent_results with context %}
{% block styles %}
{{super()}}
<style>
.poster {
height: 500px;
object-fit: cover;
}
</style>
{% endblock %}
{% block app_content %}
{% if form %}
<h1>Search</h1>
{% endif %}
<div class="row">
<div class="col-lg">
{% if session.new_torrents %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{% for torrent in session.pop('new_torrents',{}).values() %}
<p>
Added <a class="alert-link" href="{{url_for('qbittorrent',infohash=torrent.hash)}}">{{torrent.name}}</a>
</p>
{% endfor %}
</div>
{% endif %}
{% if form %}
<form method="post" class="form">
{{form.csrf_token()}}
{{custom_render_form_row([form.query],render_args={'form_type':'horizontal','horizontal_columns':('lg',1,11)})}}
{{custom_render_form_row([form.tv_shows,form.movies,form.torrents])}}
{{custom_render_form_row([form.group_by_tracker])}}
{{custom_render_form_row([form.indexer])}}
{{custom_render_form_row([form.search])}}
</form>
{% else %}
<h1>Search results for '{{search_term}}'</h1>
{% endif %}
</div>
</div>
{% set search_results = [
(results.tv_shows,"tv","TV Shows",tv_show_results,{}),
(results.movies,"movie","Movies",movie_results,{}),
(results.torrents,"torrent","Torrents",torrent_results,{"group_by_tracker":group_by_tracker}),
] %}
{% if results %}
{% set tabs = [] %}
{% for results,id_name,label,func,kwargs in search_results if results %}
{% do tabs.append((label,func(results,**kwargs))) %}
{% endfor %}
{{make_tabs(tabs)}}
{% endif %}
{% endblock %}

View File

View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% from 'utils.html' import make_tabs %}
{% macro series_list() %}
{% for show in series|sort(attribute='sortTitle') %}
<h6>
<a href="{{urljoin(config.APP_CONFIG.sonarr_url,'series/'+show.titleSlug)}}">{{show.title}}</a>
({{show.year}})
{% for genre in show.genres %}
<span class="badge badge-secondary">{{genre}}</span>
{% endfor %}
<span class="badge badge-info">{{show.status|title}}</span>
</h6>
{% endfor %}
{% endmacro %}
{% block app_content %}
<h2>
<a href="{{config.APP_CONFIG.sonarr_url}}">Sonarr</a>
v{{status.version}} ({{series|count}} Shows)
</h2>
<div class="row">
<div class="col">
{{series_list()}}
</div>
</div>
{% endblock %}

43
templates/test.html Normal file
View File

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block scripts %}
{{super()}}
<script lang="text/javascript">
let prog=0;
function setPrograb(selector,value) {
let rv=Math.round(prog*100,2)/100;
$(selector).attr('style','width: '+rv+"%;");
$(selector).attr('aria-valuenow',rv);
$(selector).text(rv+' %');
}
setInterval(() => {
for (var i=0;i<100;++i) {
prog=Math.random()*100;
setPrograb("#prog_test_bar_"+i,prog);
}
},1000)
</script>
{% endblock %}
{% block app_content__ %}
<div class="row">
{{render_form(form)}}
</div>
{% endblock %}
{% block app_content %}
{% for i in range(100) %}
<div class="row">
<div id="prog_test_{{i}}" class="progress" style="width: 100%;">
<div id="prog_test_bar_{{i}}" class="progress-bar progress-bar-striped progress-bar-animated"
style="width: 0%;" role="progressbar"
aria-valuenow="" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% from 'utils.html' import make_tabs %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% macro profile_list() %}
{% for name, cfg in config.APP_CONFIG.transcode_profiles.items() %}
<h3>{{name}}</h3>
<h5>{{cfg.doc}}</h5>
<pre>ffmpeg -i &lt;infile&gt; {{cfg.command}} &lt;outfile&gt;</pre>
{% if cfg.vars %}
{% for var,doc in cfg.vars.items() %}
<p>
<pre class="inline">{{var}}</pre>
({{doc}}{% if cfg.defaults[var] %}, Default: <pre class="inline">{{cfg.defaults[var]}}</pre>{% endif %})</p>
{% endfor %}
{% endif %}
<hr>
{% endfor %}
{% endmacro %}
{% block app_content %}
<div class="row">
<div class="col">
<h1>Transcode profiles</h1>
{{profile_list()}}
</div>
</div>
{% endblock %}

85
templates/utils.html Normal file
View File

@ -0,0 +1,85 @@
{% from 'bootstrap/form.html' import render_field %}
{% macro custom_render_form_row(fields, row_class='form-row', col_class_default='col', col_map={}, button_map={}, button_style='', button_size='', render_args={}) %}
<div class="{{ row_class }}">
{% for field in fields %}
{% if field.name in col_map %}
{% set col_class = col_map[field.name] %}
{% else %}
{% set col_class = col_class_default %}
{% endif %}
<div class="{{ col_class }}">
{{ render_field(field, button_map=button_map, button_style=button_style, button_size=button_size, **render_args) }}
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro make_tabs(tabs)%}
{% set tabs_id = tabs|tojson|hash %}
<div class="row">
<div class="col-lg">
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
{% for label,tab in tabs if tab %}
{% set id_name = [loop.index,tabs_id ]|join("-") %}
{% if not (loop.first and loop.last) %}
<li class="nav-item">
<a class="nav-link {{'active' if loop.first}}" id="nav-{{id_name}}-tab" data-toggle="pill" href="#pills-{{id_name}}" role="tab" aria-controls="pills-{{id_name}}" aria-selected="{{loop.first}}">
{{label}}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
<div class="row">
<div class="col-lg">
<div class="tab-content" id="searchResults">
{% for label,tab in tabs if tab %}
{% set id_name = [loop.index,tabs_id ]|join("-") %}
<div class="tab-pane fade {{'show active' if loop.first}}" id="pills-{{id_name}}" role="tabpanel" aria-labelledby="nav-{{id_name}}-tab">
{{ tab|safe }}
</div>
{% endfor %}
</div>
</div>
</div>
{% endmacro %}
{% macro render_tree(tree) -%}
<ul class="file_tree">
{% for node,children in tree.items() recursive %}
{% if node=="__info__" or not children is mapping -%}
{% set file = children %}
<li>
<div class="row" style="margin-left: 10px;">
<div class="progress" style="width: 100%;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
style="width: {{(file.progress*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{(file.progress*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
{{(file.progress*100)|round(2)}}&nbsp;% ({{file.size|filesizeformat(binary=True)}})
</div>
<div class="progress-bar progress-bar-striped progress-bar-animated bg-primary"
style="width: {{(((file.availability or 1) - file.progress)*100)|round(2)}}%;" role="progressbar"
aria-valuenow="{{(((file.availability or 1) - file.progress)*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
</div>
</li>
{% else -%}
<li class="tree">
<span class="{{'custom_caret' if children.items() else '' }}">
{{node}}
</span>
{% if children.items() -%}
<ul class="tree nested">
{{loop(children.items())}}
</ul>
{% endif %}
</li>
{% endif %}
{% endfor %}
</ul>
{% endmacro %}

143
transcode.py Normal file
View File

@ -0,0 +1,143 @@
import subprocess as SP
import json
import shlex
import time
import os
import io
import sys
import uuid
from tqdm import tqdm
from utils import handle_config
profiles = handle_config().get("transcode_profiles", {})
profiles[None] = {
"command": "-vcodec copy -acodec copy -scodec copy -f null",
"doc": "null output for counting frames",
}
def ffprobe(file):
cmd = [
"ffprobe",
"-v",
"error",
"-print_format",
"json",
"-show_format",
"-show_streams",
file,
]
try:
out = SP.check_output(cmd)
except KeyboardInterrupt:
raise
except:
return file, None
return file, json.loads(out)
def make_ffmpeg_command_line(infile, outfile, profile=None, **kwargs):
default_opts = ["-v", "error", "-y", "-nostdin"]
ffmpeg = (
"C:\\Users\\Earthnuker\\scoop\\apps\\ffmpeg-nightly\\current\\bin\\ffmpeg.exe"
)
cmdline = profile["command"]
opts = profile.get("defaults", {}).copy()
opts.update(kwargs)
if isinstance(cmdline, str):
cmdline = shlex.split(cmdline)
cmdline = list(cmdline or [])
cmdline += ["-progress", "-", "-nostats"]
ret = [ffmpeg, *default_opts, "-i", infile, *cmdline, outfile]
ret = [v.format(**opts) for v in ret]
return ret
def count_frames(file, **kwargs):
total_frames = None
for state in run_transcode(file, os.devnull, None):
if state.get("progress") == "end":
total_frames = int(state.get("frame", -1))
if total_frames is None:
return total_frames
if total_frames <= 0:
total_frames = None
return total_frames
def run_transcode(file, outfile, profile, job_id=None, **kwargs):
job_id = job_id or str(uuid.uuid4())
stderr_fh = None
if outfile != os.devnull:
stderr_fh = open("{}.log".format(job_id), "w")
proc = SP.Popen(
make_ffmpeg_command_line(file, outfile, profiles[profile], **kwargs),
stdout=SP.PIPE,
stderr=stderr_fh,
encoding="utf8",
)
state = {}
poll = None
while poll is None:
poll = proc.poll()
state["ret"] = poll
if outfile != os.devnull:
with open("{}.log".format(job_id), "r") as tl:
state["stderr"] = tl.read()
line = proc.stdout.readline().strip()
if not line:
continue
try:
key, val = line.split("=", 1)
except ValueError:
print(line)
continue
key = key.strip()
val = val.strip()
state[key] = val
if key == "progress":
yield state
if stderr_fh:
stderr_fh.close()
os.unlink(stderr_fh.name)
yield state
def transcode(file, outfile, profile, job_id=None, **kwargs):
from pprint import pprint
info = ffprobe(file)
frames = count_frames(file)
progbar = tqdm(desc="Processing {}".format(outfile), total=frames, unit=" frames", disable=False,leave=False)
for state in run_transcode(file, outfile, profile, job_id, **kwargs):
if "frame" in state:
progbar.n = int(state["frame"])
progbar.update(0)
state["total_frames"] = frames
state["file"] = file
state["outfile"] = outfile
# progbar.write(state["stderr"])
yield state
progbar.close()
def preview_command(file, outfile, profile, **kwargs):
return make_ffmpeg_command_line(file, outfile, profiles[profile], **kwargs)
if __name__ == "__main__":
file = sys.argv[1]
for profile in ["H.265 transcode", "H.264 transcode"]:
for preset in ["ultrafast", "fast", "medium", "slow", "veryslow"]:
for crf in list(range(10, 54, 4))[::-1]:
outfile = os.path.join("E:\\","transcode",profile,"{}_{}.mkv".format(crf, preset))
os.makedirs(os.path.dirname(outfile), exist_ok=True)
if os.path.isfile(outfile):
print("Skipping",outfile)
continue
for _ in transcode(
file, outfile, profile, "transcode", preset=preset, crf=crf
):
pass

196
utils.py Normal file
View File

@ -0,0 +1,196 @@
from flask_nav.renderers import Renderer, SimpleRenderer
from dominate import tags
import asteval
import operator as op
import textwrap
import math
import sys
import random
import string
from functools import wraps
from urllib.request import urlopen
from io import BytesIO
import subprocess as SP
import shlex
import json
import os
from PIL import Image
from PIL import ImageFont
from PIL import ImageDraw
def handle_config(cfg=None):
if cfg is None:
if os.path.isfile("config.json"):
with open("config.json") as fh:
return json.load(fh)
with open("config.json", "w") as fh:
cfg = json.dump(cfg, fh, indent=4)
return
def with_application_context(app):
def inner(func):
@wraps(func)
def wrapper(*args, **kwargs):
with app.app_context():
return func(*args, **kwargs)
return wrapper
return inner
def getsize(text, font_size):
font = ImageFont.truetype("arial.ttf", font_size)
return font.getsize_multiline(text)
def does_text_fit(text, width, height, font_size):
w, h = getsize(text, font_size)
return w < width and h < height
def make_placeholder_image(text, width, height, poster=None, wrap=0):
width = int(width)
height = int(height)
wrap = int(wrap)
font_size = 1
bounds = (0, 1)
if wrap:
text = textwrap.fill(text, wrap)
while True:
if not does_text_fit(text, width, height, bounds[1]):
break
bounds = (bounds[1], bounds[1] * 2)
prev_bounds = None
while True:
if does_text_fit(text, width, height, bounds[1]):
bounds = (int(round(sum(bounds) / 2, 0)), bounds[1])
else:
bounds = (bounds[0], int(round(sum(bounds) / 2, 0)))
if prev_bounds == bounds:
break
prev_bounds = bounds
font_size = bounds[0]
io = BytesIO()
im = Image.new("RGBA", (width, height), "#222")
draw = ImageDraw.Draw(im)
font = ImageFont.truetype("arial.ttf", font_size)
w, h = getsize(text, font_size)
if poster:
try:
with urlopen(poster) as fh:
poster = Image.open(fh)
except Exception as e:
poster = None
else:
poster_size = poster.size
factor = width / poster_size[0]
new_size = (
math.ceil(poster_size[0] * factor),
math.ceil(poster_size[1] * factor),
)
poster = poster.resize(new_size)
mid = -int((poster.size[1] - height) / 2)
im.paste(poster, (0, mid))
draw.text(((width - w) / 2, (height - h) / 2), text, fill="#eee", font=font)
im.save(io, "PNG")
io.seek(0)
return io
def make_tree(files, child_key="children"):
tree = {}
for file in files:
root = tree
parts = file["name"].split("/")
for item in parts:
if item not in root:
root[item] = {}
prev_root = root
root = root[item]
prev_root[item] = {"__info__": file}
return tree
class BootsrapRenderer(Renderer):
def visit_Navbar(self, node):
sub = []
for item in node.items:
sub.append(self.visit(item))
ret = tags.ul(sub, cls="navbar-nav mr-auto")
return ret
def visit_View(self, node):
classes = ["nav-link"]
if node.active:
classes.append("active")
return tags.li(
tags.a(node.text, href=node.get_url(), cls=" ".join(classes)),
cls="nav-item",
)
def visit_Subgroup(self, node):
url = "#"
classes = []
child_active = False
if node.title == "":
active = False
for item in node.items:
if item.active:
classes.append("active")
break
node, *children = node.items
for c in children:
if c.active:
child_active = True
break
node.items = children
node.title = node.text
url = node.get_url()
dropdown = tags.ul(
[
tags.li(
tags.a(
item.text,
href=item.get_url(),
cls="nav-link active" if item.active else "nav-link",
style="",
),
cls="nav-item",
)
for item in node.items
],
cls="dropdown-menu ",
)
link = tags.a(
node.title,
href=url,
cls="nav-link active" if node.active else "nav-link",
style="",
)
toggle = tags.a(
[],
cls="dropdown-toggle nav-link active"
if child_active
else "dropdown-toggle nav-link",
data_toggle="dropdown",
href="#",
style="padding-left: 0px; padding-top: 10px",
)
# almost the same as visit_Navbar, but written a bit more concise
return [link, tags.li([toggle, dropdown], cls="dropdown nav-item")]
def eval_expr(expr, ctx=None):
aeval = asteval.Interpreter(minimal=True, use_numpy=False, symtable=ctx)
return aeval(expr)
def sort_by(values, expr):
return sorted(value, key=lambda v: eval_expr(expr, v))
def genpw(num=20):
return "".join(random.choice(string.ascii_lowercase+string.ascii_uppercase+string.digits) for _ in range(num))