Initial commit
This commit is contained in:
commit
7523a19d1f
40 changed files with 3984 additions and 0 deletions
149
.gitignore
vendored
Normal file
149
.gitignore
vendored
Normal 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
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Media Server Dashboard
|
||||||
|
|
||||||
|
WIP
|
8
TODO.md
Normal file
8
TODO.md
Normal 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
649
api.py
Normal 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
586
app.py
Normal 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
4
config.cfg
Normal 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
125
config.example.json
Normal 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
96
forms.py
Normal 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
4
models/__init__.py
Normal 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
14
models/stats.py
Normal 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
13
models/transcode.py
Normal 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
0
models/users.py
Normal file
129
static/theme.css
Normal file
129
static/theme.css
Normal 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
409
stats_collect.py
Normal 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
40
templates/base.html
Normal 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
70
templates/config.html
Normal 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 %}
|
14
templates/containers/details.html
Normal file
14
templates/containers/details.html
Normal 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 %}
|
58
templates/containers/index.html
Normal file
58
templates/containers/index.html
Normal 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
65
templates/history.html
Normal 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
122
templates/index.html
Normal 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 %}
|
121
templates/jellyfin/index.html
Normal file
121
templates/jellyfin/index.html
Normal 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
39
templates/logs.html
Normal 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 %}
|
236
templates/qbittorrent/details.html
Normal file
236
templates/qbittorrent/details.html
Normal 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)}} %
|
||||||
|
</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)}} %
|
||||||
|
{% 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 %}
|
138
templates/qbittorrent/index.html
Normal file
138
templates/qbittorrent/index.html
Normal 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)}} % (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 %}
|
0
templates/radarr/details.html
Normal file
0
templates/radarr/details.html
Normal file
28
templates/radarr/index.html
Normal file
28
templates/radarr/index.html
Normal 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
23
templates/remote/add.html
Normal 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 %}
|
78
templates/remote/index.html
Normal file
78
templates/remote/index.html
Normal 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 %}
|
10
templates/search/details.html
Normal file
10
templates/search/details.html
Normal 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 %}
|
18
templates/search/include/movie.html
Normal file
18
templates/search/include/movie.html
Normal 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 %}
|
123
templates/search/include/torrent.html
Normal file
123
templates/search/include/torrent.html
Normal 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 %}
|
23
templates/search/include/tv_show.html
Normal file
23
templates/search/include/tv_show.html
Normal 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 %}
|
64
templates/search/index.html
Normal file
64
templates/search/index.html
Normal 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 %}
|
0
templates/sonarr/details.html
Normal file
0
templates/sonarr/details.html
Normal file
28
templates/sonarr/index.html
Normal file
28
templates/sonarr/index.html
Normal 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
43
templates/test.html
Normal 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 %}
|
30
templates/transcode/profiles.html
Normal file
30
templates/transcode/profiles.html
Normal 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 <infile> {{cfg.command}} <outfile></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
85
templates/utils.html
Normal 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)}} % ({{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
143
transcode.py
Normal 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
196
utils.py
Normal 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))
|
Loading…
Reference in a new issue