push latest changes

This commit is contained in:
Daniel S. 2021-12-13 19:11:43 +01:00
parent 7523a19d1f
commit cb2b5c2c2b
63 changed files with 3158 additions and 1552 deletions

View file

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

649
api.py
View file

@ -1,649 +0,0 @@
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}

143
api/__init__.py Normal file
View file

@ -0,0 +1,143 @@
import io
from fabric import Connection
from utils import genpw, handle_config
from .jackett import Jackett
from .jellyfin import Jellyfin
from .portainer import Portainer
from .qbittorrent import QBittorrent
from .radarr import Radarr
from .sonarr import Sonarr
class Client(object):
def __init__(self, cfg=None):
if cfg is None:
cfg = handle_config()
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_user"], cfg["jellyfin_password"]
)
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):
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 = [
line.split(
None,
2) for line 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, key_name in keys:
if key not in seen_keys:
seen_keys.add(key)
new_keys.append([key_type, key, 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 = user.json()
self.jellyfin.post(
"Users/{Id}/Configuration".format(**user), json=user_config)
self.jellyfin.post(
"Users/{Id}/Policy".format(**user), json=user_policy)
return passwd
def queue(self, ids=[]):
ret = []
for item in self.sonarr.queue():
if not ids or item.get("seriesId") in ids:
item["type"] = "sonarr"
ret.append(item)
for item in self.radarr.queue():
item["download"] = self.qbittorent.status(item["downloadId"])
if not ids or item.get("movieId") in ids:
item["type"] = "radarr"
ret.append(item)
return ret
@staticmethod
def test(cls, cfg=None):
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}

45
api/jackett.py Normal file
View file

@ -0,0 +1,45 @@
import time
from urllib.parse import urljoin
import requests as RQ
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, "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

333
api/jellyfin.py Normal file
View file

@ -0,0 +1,333 @@
import time
import base64
from urllib.parse import urljoin
from datetime import timedelta
import requests as RQ
from dateutil.parser import parse as parse_datetime
from utils import timed_cache
class Jellyfin(object):
def __init__(self, url, user, password):
self.url = url
self.session = RQ.Session()
self.device_id = str(
base64.b64encode(
"MediaDash ({})".format(
self.session.headers["User-Agent"]).encode("utf-8")),
"utf8",
)
self.auth_headers = {
"X-Emby-Authorization": 'MediaBrowser Client="MediaDash", Device="Python", DeviceId="{}", Version="{}"'.format(
self.device_id, RQ.__version__)}
self.user = None
if user is not None:
res = self.login_user(user, password)
self.api_key = res["AccessToken"]
else:
self.api_key = password
self.auth_headers = {
"X-Emby-Authorization": 'MediaBrowser Client="MediaDash", Device="Python", DeviceId="{}", Version="{}", Token="{}"'.format(
self.device_id, RQ.__version__, self.api_key)}
# ws_url=self.url.replace("http","ws").rstrip("/")+"/?"+urlencode({"api_key":self.api_key,"deviceId":self.device_id})
# self.ws = websocket.WebSocketApp(ws_url,on_open=print,on_error=print,on_message=print,on_close=print)
# self.ws_thread = Thread(target=self.ws.run_forever,daemon=True)
self.session.headers.update(
{**self.auth_headers, "X-Emby-Token": self.api_key})
self.user = self.get_self()
self.user_id = self.user["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",
]
)
def login_user(self, user, passwd):
res = self.post(
"Users/AuthenticateByName",
json={"Username": user, "Pw": passwd},
headers=self.auth_headers,
)
res.raise_for_status()
res = res.json()
self.session.headers.update(
{**self.auth_headers, "X-Emby-Token": res["AccessToken"]}
)
return res
def logout(self):
self.session.post(urljoin(self.url, "Sessions/Logout"))
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 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.get("Sessions")
res.raise_for_status()
return res.json()
@timed_cache()
def season_episodes(self, item_id, season_id):
res = self.get(
"Shows/{}/Episodes".format(item_id),
params={
"UserId": self.user_id,
"seasonId": season_id,
"fields": "Overview,MediaStreams,MediaSources,ExternalUrls",
},
)
res.raise_for_status()
res = res.json()["Items"]
for episode in res:
episode["Info"] = self.media_info(episode["Id"])
return res
@timed_cache()
def seasons(self, item_id):
res = self.get(
"Shows/{}/Seasons".format(item_id),
params={
"UserId": self.user_id,
"fields": "Overview,MediaStreams,MediaSources,ExternalUrls",
},
)
res.raise_for_status()
res = res.json()["Items"]
for season in res:
season["Episodes"] = self.season_episodes(item_id, season["Id"])
return res
@timed_cache()
def media_info(self, item_id):
res = self.get(
"Users/{}/Items/{}".format(self.user_id, item_id),
)
res.raise_for_status()
res = res.json()
if res["Type"] == "Series":
res["Seasons"] = self.seasons(item_id)
return res
def system_info(self):
res = self.get("System/Info")
res.raise_for_status()
return res.json()
def __get_child_items(self, item_id):
print(item_id)
res = self.get(
"Users/{}/Items".format(self.user_id),
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_counts(self):
res = self.get("Items/Counts").json()
return res
@timed_cache(seconds=10)
def id_map(self):
res = self.get(
"Users/{}/Items".format(self.user_id),
params={
"recursive": True,
"includeItemTypes": "Movie,Series",
"collapseBoxSetItems": False,
"fields": "ProviderIds",
},
)
res.raise_for_status()
res = res.json()["Items"]
id_map = {}
for item in res:
for _, prov_id in item["ProviderIds"].items():
for prov in ["Imdb", "Tmdb", "Tvdb"]:
id_map[(prov.lower(), prov_id)] = item["Id"]
return id_map
@timed_cache()
def get_library(self):
res = self.get(
"Users/{}/Items".format(self.user_id),
params={
"recursive": True,
"includeItemTypes": "Movie,Series",
"collapseBoxSetItems": False,
},
).json()
library = {}
for item in res["Items"]:
library[item["Id"]] = item
for item in res["Items"]:
for key, value in item.copy().items():
if key != "Id" and key.endswith("Id"):
key = key[:-2]
if value in library and key not in item:
item[key] = library[value]
return library
def get_usage(self):
report = self.post(
"user_usage_stats/submit_custom_query",
params={"stamp": int(time.time())},
json={
"CustomQueryString": "SELECT * FROM PlaybackActivity",
"ReplaceUserId": True,
},
).json()
ret = []
for row in report["results"]:
rec = dict(zip(report["colums"], row))
rec["PlayDuration"] = timedelta(seconds=int(rec["PlayDuration"]))
ts = rec.pop("DateCreated")
if ts:
rec["Timestamp"] = parse_datetime(ts)
ret.append(rec)
return ret
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.get("Users/Me")
return res.json()
def get_users(self, user_id=None):
if user_id:
res = self.get("Users/{}".format(user_id))
else:
res = self.get("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 {}

75
api/portainer.py Normal file
View file

@ -0,0 +1,75 @@
import json
from urllib.parse import urljoin
import requests as RQ
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": username, "password": passwd},
).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 {}

155
api/qbittorrent.py Normal file
View file

@ -0,0 +1,155 @@
import time
from urllib.parse import urljoin, urlparse
import requests as RQ
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(url, 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 {}

98
api/radarr.py Normal file
View file

@ -0,0 +1,98 @@
import time
from datetime import datetime, timedelta
from urllib.parse import urljoin
import requests as RQ
from utils import timed_cache
class Radarr(object):
def __init__(self, url, api_key):
self.url = url
self.api_key = api_key
self.root_folder = self.get("api/v3/rootFolder")[0]["path"]
self.quality_profile = self.get("api/v3/qualityprofile")[0]
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 Exception:
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")
@timed_cache()
def history(self, pageSize=500):
return self.get(
"api/v3/history",
page=1,
pageSize=500,
sortDirection="descending",
sortKey="date",
)
@timed_cache()
def calendar(self, days=90):
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(),
)
@timed_cache()
def movies(self, movie_id=None):
if movie_id is None:
return self.get("api/v3/movie")
return self.get("api/v3/movie/{}".format(movie_id))
@timed_cache(seconds=60)
def queue(self, **kwargs):
data = []
page = 1
while True:
res = self.get("api/v3/queue", page=page, pageSize=100, **kwargs)
data += res.get("records", [])
page += 1
if len(data) >= res.get("totalRecords", 0):
break
return data
def add(self, data):
data["qualityProfileId"] = self.quality_profile["id"]
data["minimumAvailability"] = 2 # InCinema
data["rootFolderPath"] = self.root_folder
data["addOptions"] = {"searchForMovie": True}
params = dict(apikey=self.api_key)
res = RQ.post(
urljoin(
self.url,
"api/v3/movie"),
json=data,
params=params)
return res.json()
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 {}

116
api/sonarr.py Normal file
View file

@ -0,0 +1,116 @@
import time
from urllib.parse import urljoin
from datetime import datetime, timedelta
import requests as RQ
from utils import timed_cache
class Sonarr(object):
def __init__(self, url, api_key):
self.url = url
self.api_key = api_key
self.root_folder = self.get("api/v3/rootFolder")[0]["path"]
self.quality_profile = self.get("api/v3/qualityprofile")[0]
self.language_profile = self.get("api/v3/languageprofile")[0]
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 Exception:
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")
@timed_cache()
def history(self, pageSize=500):
return self.get(
"api/v3/history",
page=1,
pageSize=500,
sortDirection="descending",
sortKey="date",
)
@timed_cache()
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(),
)
@timed_cache()
def series(self, series_id=None, keys=None):
if series_id is None:
return self.get("api/v3/series")
ret = {}
ret["series"] = self.get("api/v3/series/{}".format(series_id))
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
@timed_cache(seconds=60)
def queue(self, **kwargs):
data = []
page = 1
while True:
res = self.get("api/v3/queue", page=page, pageSize=100, **kwargs)
data = res.get("records", [])
page += 1
if len(data) >= res.get("totalRecords", 0):
break
return data
@timed_cache()
def details(self, episode_id):
return self.get("api/v3/queue/details", episodeIds=episode_id)
@timed_cache()
def episodes(self, series_id):
return self.get("api/v3/episode", seriesId=series_id)
def add(self, data):
data["qualityProfileId"] = self.quality_profile["id"]
data["languageProfileId"] = self.language_profile["id"]
data["rootFolderPath"] = self.root_folder
data["addOptions"] = {
"ignoreEpisodesWithoutFiles": False,
"ignoreEpisodesWithFiles": True,
"searchForMissingEpisodes": True,
}
data["seasonFolder"] = True
params = dict(apikey=self.api_key)
res = RQ.post(
urljoin(
self.url,
"api/v3/series"),
json=data,
params=params)
return res.json()
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 {}

34
api/user.py Normal file
View file

@ -0,0 +1,34 @@
from flask_login import UserMixin
from api import Jellyfin
from utils import handle_config
class JellyfinUser(UserMixin):
def __init__(self, username, password):
api = Jellyfin(handle_config()["jellyfin_url"], username, password)
self.user = api.user
self.api_key = api.api_key
self.id = self.user["Id"]
api.logout()
def __getitem__(self, key):
return self.user[key]
@property
def is_anonymous(self):
return False
@property
def is_admin(self):
pol = self.user["Policy"]
return pol["IsAdministrator"]
@property
def is_authenticated(self):
return True
@property
def is_active(self):
pol = self.user["Policy"]
return not pol["IsDisabled"]

656
app.py
View file

@ -1,83 +1,107 @@
import sys import sys # isort:skip
from gevent import monkey from gevent import monkey # isort:skip
if not "--debug" in sys.argv[1:]:
if __name__ == "__main__" and "--debug" not in sys.argv[1:]:
monkey.patch_all() monkey.patch_all()
import os import os
import requests as RQ 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 ( from flask import (
Flask, Flask,
abort,
flash,
redirect,
render_template, render_template,
send_from_directory,
request, request,
send_file, send_file,
redirect, send_from_directory,
flash,
url_for,
session, session,
jsonify, url_for,
Markup
) )
from flask_nav import Nav, register_renderer
from flask_nav.elements import Navbar, View, Subgroup
from flask_bootstrap import Bootstrap from flask_bootstrap import Bootstrap
from flask_wtf.csrf import CSRFProtect
from flask_debugtoolbar import DebugToolbarExtension from flask_debugtoolbar import DebugToolbarExtension
from flask_sqlalchemy import SQLAlchemy from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_login import LoginManager, current_user
from flask_login import login_user, logout_user
from flask_nav import Nav, register_renderer
from flask_nav.elements import Navbar, Text, View
from flask_session import Session
from flask_wtf.csrf import CSRFProtect
# ===================
import stats_collect import stats_collect
from forms import ConfigForm, SearchForm, TranscodeProfileForm, AddSSHUser from api.user import JellyfinUser
from api import Client
from models import db, TranscodeJob, Stats from forms import LoginForm
from models import RequestUser, db
from transcode import profiles from transcode import profiles
from utils import ( from utils import (
BootsrapRenderer, BootsrapRenderer,
eval_expr,
make_tree,
make_placeholder_image,
with_application_context,
handle_config, handle_config,
genpw is_safe_url,
login_required,
make_placeholder_image,
setup_template_filters,
with_application_context,
) )
from views import register_blueprints
def left_nav(): def left_nav():
requests_badge = None
if current_user.is_authenticated:
num_notifications = RequestUser.query.filter(
(RequestUser.user_id == current_user.id) & (
RequestUser.updated is True)).count()
if num_notifications > 0:
requests_badge = (num_notifications, "danger")
links = [ links = [
View("Home", "index"), View("Home", "home.index"),
View("Containers", "containers", container_id=None), View("Requests", "requests.index", __badge=requests_badge),
View("qBittorrent", "qbittorrent", infohash=None), View("Containers", "containers.index", container_id=None),
View("Sonarr", "sonarr", id=None), View("qBittorrent", "qbittorrent.index", infohash=None),
View("Radarr", "radarr", id=None), View("Sonarr", "sonarr.index"),
View("Jellyfin", "jellyfin"), View("Radarr", "radarr.index"),
View("Search", "search"), View("Jellyfin", "jellyfin.index"),
View("History", "history"), View("Search", "search.index"),
View("Transcode", "transcode"), View("History", "history.index"),
View("Config", "config"), View("Transcode", "transcode.index"),
View("Remote", "remote"), View("Config", "config.index"),
View("Log", "app_log"), View("Remote", "remote.index"),
View("Log", "log.index"),
] ]
if current_user.is_authenticated:
links.append(View("Logout", "logout"))
links[-1].classes = ["btn", "btn-danger", "my-2", "my-sm-0"]
else:
links.append(View("Login", "login"))
links[-1].classes = ["btn", "btn-success", "my-2", "my-sm-0"]
for n, link in enumerate(links):
adapter = app.url_map.bind("localhost")
name, args = adapter.match(link.get_url(), method="GET")
func = app.view_functions[name]
if getattr(func, "requires_login", False):
if not current_user.is_authenticated:
links[n] = None
if getattr(func, "requires_admin", False):
if not (current_user.is_authenticated and current_user.is_admin):
links[n] = None
links = list(filter(None, links))
return Navbar("PirateDash", *links) return Navbar("PirateDash", *links)
def right_nav():
if current_user.is_authenticated:
return Text(current_user["Name"])
else:
return Text("")
def create_app(): def create_app():
templates = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") templates = os.path.join(
os.path.dirname(
os.path.abspath(__file__)),
"templates")
app = Flask(__name__, template_folder=templates) app = Flask(__name__, template_folder=templates)
app.config.from_pyfile("config.cfg") app.config.from_pyfile("config.cfg")
app.bootstrap = Bootstrap(app) app.bootstrap = Bootstrap(app)
@ -90,102 +114,43 @@ def create_app():
app.jinja_env.lstrip_blocks = True app.jinja_env.lstrip_blocks = True
register_renderer(app, "bootstrap4", BootsrapRenderer) register_renderer(app, "bootstrap4", BootsrapRenderer)
app.nav.register_element("left_nav", left_nav) app.nav.register_element("left_nav", left_nav)
db.init_app(app) app.nav.register_element("right_nav", right_nav)
app.db = db app.db = db
app.db.init_app(app)
app.login_manager = LoginManager(app)
app.login_manager.login_view = "/login"
app.config["SESSION_SQLALCHEMY"] = app.db
app.session = Session(app)
# app.limiter = Limiter(
# app, key_func=get_remote_address, default_limits=["120 per minute"]
# )
# for handler in app.logger.handlers:
# app.limiter.logger.addHandler(handler)
return app return app
app = create_app() app = create_app()
setup_template_filters(app)
register_blueprints(app)
@app.template_filter("hash") @app.errorhandler(500)
def t_hash(s): def internal_error(error):
return hashlib.sha512(bytes(s, "utf-8")).hexdigest() print(error)
return ""
@app.template_filter() @app.errorhandler(404)
def regex_replace(s, find, replace): def internal_error(error):
"""A non-optimal implementation of a regex filter""" print(error)
return re.sub(find, replace, s) return ""
@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 @app.before_request
def before_request(): def before_request():
db.create_all()
app.config["APP_CONFIG"] = handle_config() app.config["APP_CONFIG"] = handle_config()
# if request.cookies.get('magic')!="FOO":
# return ""
@app.route("/static/<path:path>") @app.route("/static/<path:path>")
@ -193,391 +158,74 @@ def send_static(path):
return send_from_directory("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") @app.route("/placeholder")
def placeholder(): def placeholder():
return send_file(make_placeholder_image(**request.args), mimetype="image/png") return send_file(
make_placeholder_image(
**request.args),
mimetype="image/png")
@app.route("/containers", defaults={"container_id": None}) @app.login_manager.user_loader
@app.route("/containers/<container_id>") def load_user(user_id):
def containers(container_id): if "jf_user" in session:
cfg = handle_config() if session["jf_user"].id == user_id:
c = Client(cfg) return session["jf_user"]
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(): @app.route("/logout")
if os.path.isfile("stats.lock"): @login_required
return None def logout():
try: del session["jf_user"]
if os.path.isfile("stats.json"): logout_user()
with open("stats.json") as fh: return redirect("/login")
return json.load(fh)
except:
return None
@app.route("/transcode", methods=["GET", "POST"]) @app.route("/login", methods=["GET", "POST"])
def transcode(): def login():
return render_template("transcode/profiles.html") next_url = request.args.get("next")
if current_user.is_authenticated:
if next_url and not is_safe_url(next_url):
@app.route("/log") next_url = None
def app_log(): return redirect(next_url or url_for("home.index"))
cfg = handle_config() form = LoginForm()
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(): if form.validate_on_submit():
key=load_ssh_public_key(bytes(form.data['ssh_key'],"utf8")) try:
rawKeyData = key.public_bytes( jf = JellyfinUser(form.username.data, form.password.data)
encoding=serialization.Encoding.OpenSSH, except RQ.exceptions.HTTPError as e:
format=serialization.PublicFormat.OpenSSH, if e.response.status_code != 401:
) raise
passwd=c.add_user(form.data['name'],str(rawKeyData,"utf8")) flash("Invalid credentials", "error")
flash(Markup("".join([ return render_template("login.html", form=form)
f"<p>Name: <b>{form.data['name']}</b></p>", login_user(jf, remember=form.remember.data)
f"<p>PW: <b>{passwd}</b></p>", session["jf_user"] = jf
f"<p>FP: <b>{ssh_fingerprint(rawKeyData.split()[1])}</b></p>"
]))) next_url = request.args.get("next")
return render_template("remote/add.html",form=form) if next_url and not is_safe_url(next_url):
return abort(400)
return redirect(next_url or url_for("home.index"))
return render_template("login.html", form=form)
@app.route("/") @app.before_first_request
def index(): def before_first_request():
return render_template("index.html", fluid=True, data=get_stats()) app.db.create_all()
# stats_collect.loop(60)
@with_application_context(app)
def init_app():
app.db.create_all()
if __name__ == "__main__": if __name__ == "__main__":
stats_collector = threading.Thread(
None, stats_collect.loop, "stats_collector", (10,), {}, daemon=True
)
stats_collector.start()
port = 5000 port = 5000
if "--init" in sys.argv:
init_app()
if "--debug" in sys.argv: if "--debug" in sys.argv:
app.run(host="0.0.0.0",port=port, debug=True) os.environ["FLASK_ENV"] = "development"
app.debug = True
app.run(host="0.0.0.0", port=port, debug=True)
else: else:
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer

View file

@ -2,3 +2,6 @@ SECRET_KEY = b"DEADBEEF"
SQLALCHEMY_DATABASE_URI = "sqlite:///Mediadash.db" SQLALCHEMY_DATABASE_URI = "sqlite:///Mediadash.db"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
MAX_CONTENT_LENGTH = 1 * 1024 * 1024 #1MB MAX_CONTENT_LENGTH = 1 * 1024 * 1024 #1MB
SESSION_TYPE="sqlalchemy"
SESSION_USE_SIGNER = True
BOOTSTRAP_SERVE_LOCAL = True

View file

@ -1,42 +1,37 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from flask_wtf import FlaskForm
import json import json
import os
from cryptography.hazmat.primitives.serialization import load_ssh_public_key from cryptography.hazmat.primitives.serialization import load_ssh_public_key
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField
from wtforms import ( from wtforms import (
StringField,
PasswordField,
FieldList,
FloatField,
BooleanField, BooleanField,
PasswordField,
# RadioField,
SelectField, SelectField,
SubmitField,
validators,
Field,
FieldList,
SelectMultipleField, SelectMultipleField,
StringField,
SubmitField,
TextAreaField, 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.fields.html5 import SearchField
from wtforms.widgets.html5 import NumberInput from wtforms.validators import URL, DataRequired, Optional
from wtforms.widgets import TextInput, CheckboxInput, ListWidget, PasswordInput from wtforms.widgets import PasswordInput
from wtforms.validators import (
ValidationError,
DataRequired,
URL,
ValidationError,
Optional,
)
def json_prettify(file): def json_prettify(file):
with open(file, "r") as fh: with open(file, "r") as fh:
return json.dumps(json.load(fh), indent=4) return json.dumps(json.load(fh), indent=4)
class RequestForm(FlaskForm):
query = SearchField("Query", validators=[DataRequired()])
search_type = SelectField(
"Type", choices=[("sonarr", "TV Show"), ("radarr", "Movie")]
)
search = SubmitField("Search")
class SearchForm(FlaskForm): class SearchForm(FlaskForm):
query = SearchField("Query", validators=[DataRequired()]) query = SearchField("Query", validators=[DataRequired()])
tv_shows = BooleanField("TV Shows", default=True) tv_shows = BooleanField("TV Shows", default=True)
@ -46,21 +41,30 @@ class SearchForm(FlaskForm):
group_by_tracker = BooleanField("Group torrents by tracker") group_by_tracker = BooleanField("Group torrents by tracker")
search = SubmitField("Search") search = SubmitField("Search")
class HiddenPassword(PasswordField): class HiddenPassword(PasswordField):
widget = PasswordInput(hide_value=False) widget = PasswordInput(hide_value=False)
class TranscodeProfileForm(FlaskForm): class TranscodeProfileForm(FlaskForm):
test = TextAreaField() test = TextAreaField()
save = SubmitField("Save") save = SubmitField("Save")
class LoginForm(FlaskForm):
username = StringField("Username", validators=[DataRequired()])
password = HiddenPassword("Password", validators=[DataRequired()])
remember = BooleanField("Remember me")
login = SubmitField("Login")
class AddSSHUser(FlaskForm): class AddSSHUser(FlaskForm):
name = StringField("Name", validators=[DataRequired()]) name = StringField("Name", validators=[DataRequired()])
ssh_key = StringField("Public key", validators=[DataRequired()]) ssh_key = StringField("Public key", validators=[DataRequired()])
add = SubmitField("Add") add = SubmitField("Add")
def validate_ssh_key(self,field): def validate_ssh_key(self, field):
key=load_ssh_public_key(bytes(field.data,"utf8")) load_ssh_public_key(bytes(field.data, "utf8"))
class ConfigForm(FlaskForm): class ConfigForm(FlaskForm):

View file

@ -1,4 +1,8 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy # isort:skip
db = SQLAlchemy()
from .stats import Stats db = SQLAlchemy() # isort:skip
from .transcode import TranscodeJob from .transcode import TranscodeJob
from .stats import Stats
from .requests import RequestItem, RequestUser
from flask_sqlalchemy import SQLAlchemy

74
models/requests.py Normal file
View file

@ -0,0 +1,74 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import Float, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from api import Client
from . import db
class RequestItem(db.Model):
id = db.Column(
db.String,
default=lambda: str(
uuid4()),
index=True,
unique=True)
item_id = db.Column(db.String, primary_key=True)
added_date = db.Column(db.DateTime)
request_type = db.Column(db.String)
data = db.Column(db.String)
approved = db.Column(db.Boolean, nullable=True)
arr_id = db.Column(db.String, nullable=True)
jellyfin_id = db.Column(db.String, nullable=True)
users = relationship("RequestUser", back_populates="requests")
@property
def downloads(self):
yield from self._download_state()
@property
def arr_item(self):
c = Client()
if self.request_type == "sonarr":
return c.sonarr.series(self.arr_id)
if self.request_type == "radarr":
return c.radarr.movies(self.arr_id)
def _download_state(self):
c = Client()
if self.request_type == "sonarr":
q = c.sonarr.queue()
for item in q:
if item["seriesId"] == str(self.arr_id):
item["download"] = c.qbittorent.poll(self.download_id)
yield item
c = Client()
if self.request_type == "radarr":
q = c.radarr.queue()
for item in q:
if str(item["movieId"]) == str(self.arr_id):
if item["protocol"] == "torrent":
item["download"] = c.qbittorent.poll(
item["downloadId"])
yield item
class RequestUser(db.Model):
item_id = db.Column(
db.String,
db.ForeignKey(
RequestItem.item_id),
primary_key=True)
user_id = db.Column(db.String, primary_key=True)
hidden = db.Column(db.Boolean, default=False)
updated = db.Column(db.Boolean, default=True)
user_name = db.Column(db.String)
requests = relationship("RequestItem", back_populates="users")
@property
def details(self):
c = Client()
return c.jellyfin.get_users(self.user_id)

View file

@ -1,7 +1,9 @@
from . import db
from sqlalchemy import String, Float, Column, Integer, DateTime
from datetime import datetime from datetime import datetime
from sqlalchemy import Column, DateTime, Float, Integer, String
from . import db
class Stats(db.Model): class Stats(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -9,6 +11,7 @@ class Stats(db.Model):
key = db.Column(db.String) key = db.Column(db.String)
value = db.Column(db.Float) value = db.Column(db.Float)
class Diagrams(db.Model): class Diagrams(db.Model):
name = db.Column(db.String,primary_key=True) name = db.Column(db.String, primary_key=True)
data = db.Column(db.String) data = db.Column(db.String)

View file

@ -1,9 +1,11 @@
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 from datetime import datetime
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy_utils import JSONType
from . import db
class TranscodeJob(db.Model): class TranscodeJob(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

3
setup.cfg Normal file
View file

@ -0,0 +1,3 @@
[flake8]
extend_exclude = .history
ingore = E501

51
static/icon.svg Normal file
View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
style="fill:none"
width="134.00002"
height="134"
id="svg19"
sodipodi:docname="dotgrid-21R11-649862.svg"
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs23" />
<sodipodi:namedview
id="namedview21"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="true"
showgrid="false"
inkscape:zoom="1.744"
inkscape:cx="-31.536697"
inkscape:cy="39.56422"
inkscape:window-width="1347"
inkscape:window-height="1080"
inkscape:window-x="2147"
inkscape:window-y="486"
inkscape:window-maximized="0"
inkscape:current-layer="svg19" />
<path
style="fill:none;stroke:#ff7700;stroke-width:14px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 7,127 V 127 7 L 67,67 127.00001,7 v 120 m 0,-120 v 120 m 0,-120 V 7 M 37,7 V 127 A 60,60 0 0 0 97,67 60,60 0 0 0 37,7"
id="path17" />
<g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="slices"
transform="translate(-122.54128,-107.22018)">
<rect
style="opacity:0.25;fill:none;fill-opacity:0.607843;stroke:none"
id="rect150"
width="138.76147"
height="139.90825"
x="119.83945"
y="103.78441" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -127,3 +127,16 @@ ul.tree {
.active { .active {
display: block; display: block;
} }
.notification-badge {
float: right;
margin-bottom: -10px;
}
.darken {
filter: brightness(0.95)
}
.lighten {
filter: brightness(1.05)
}

1
stats/calendar.json Normal file

File diff suppressed because one or more lines are too long

1
stats/data.json Normal file

File diff suppressed because one or more lines are too long

1
stats/images.json Normal file

File diff suppressed because one or more lines are too long

1
stats/library.json Normal file

File diff suppressed because one or more lines are too long

1
stats/qbt_hist.json Normal file
View file

@ -0,0 +1 @@
{"t":[1631138513.856375],"dl":[0.0],"ul":[258608.0],"dl_size":[28783725830170],"ul_size":[11341122517104],"dl_size_sess":[516489974112],"ul_size_sess":[398693624144],"connections":[62.0],"bw_per_conn":[4171.096774193548],"dht_nodes":[378]}

View file

@ -1,15 +1,19 @@
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 io
import os import os
from urllib.parse import quote import shutil
from datetime import datetime import threading
import time
from base64 import b64encode
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from urllib.parse import quote
import pylab as PL
import ujson as json
from matplotlib.ticker import EngFormatter
from api import Client
from utils import handle_config
mpl_style = "dark_background" mpl_style = "dark_background"
@ -17,14 +21,15 @@ smoothness = 5
def make_svg(data, dtype): def make_svg(data, dtype):
data_uri = "data:{};base64,{}".format(dtype, quote(str(b64encode(data), "ascii"))) data_uri = "data:{};base64,{}".format(
dtype, quote(str(b64encode(data), "ascii")))
return '<embed type="image/svg+xml" src="{}"/>'.format(data_uri) return '<embed type="image/svg+xml" src="{}"/>'.format(data_uri)
def make_smooth(data, window_size): def make_smooth(data, window_size):
ret = [] ret = []
for i, _ in enumerate(data): for i, _ in enumerate(data):
block = data[i : i + window_size] block = data[i: i + window_size]
ret.append(sum(block) / len(block)) ret.append(sum(block) / len(block))
return ret return ret
@ -93,7 +98,8 @@ def histogram(values, bins, title=None, color="#eee", unit=""):
def prc_label(label, idx, values): def prc_label(label, idx, values):
return "{} ({}, {:.2%}%)".format(label, values[idx], values[idx] / sum(values)) return "{} ({}, {:.2%}%)".format(
label, values[idx], values[idx] / sum(values))
def byte_labels(label, idx, values): def byte_labels(label, idx, values):
@ -104,11 +110,11 @@ def byte_labels(label, idx, values):
values[idx] /= 1024 values[idx] /= 1024
i += 1 i += 1
val = "{:.2f} {}iB".format(values[idx], suffix[i]) val = "{:.2f} {}iB".format(values[idx], suffix[i])
return "{} ({}, {:.2%}%)".format(label, val, orig_values[idx] / sum(orig_values)) return "{} ({}, {:.2%}%)".format(
label, val, orig_values[idx] / sum(orig_values))
def byte_rate_labels(label, idx, values): def byte_rate_labels(label, idx, values):
orig_values = list(values)
suffix = ["", "K", "M", "G", "T", "P", "E"] suffix = ["", "K", "M", "G", "T", "P", "E"]
i = 0 i = 0
while values[idx] > 1024 and i < len(suffix): while values[idx] > 1024 and i < len(suffix):
@ -147,7 +153,7 @@ def piechart(items, title=None, labelfunc=prc_label, sort=True):
return make_svg(fig.getvalue(), "image/svg+xml") return make_svg(fig.getvalue(), "image/svg+xml")
hist = { qbt_hist = {
"t": [], "t": [],
"dl": [], "dl": [],
"ul": [], "ul": [],
@ -162,59 +168,87 @@ hist = {
def update_qbt_hist(stats, limit=1024): def update_qbt_hist(stats, limit=1024):
global hist global qbt_hist
data = stats["qbt"]["status"] data = stats["qbt"]["status"]
hist["t"].append(time.time()) qbt_hist["t"].append(time.time())
hist["dl"].append(data["server_state"]["dl_info_speed"]) qbt_hist["dl"].append(data["server_state"]["dl_info_speed"])
hist["ul"].append(data["server_state"]["up_info_speed"]) qbt_hist["ul"].append(data["server_state"]["up_info_speed"])
hist["dl_size"].append(data["server_state"]["alltime_dl"]) qbt_hist["dl_size"].append(data["server_state"]["alltime_dl"])
hist["ul_size"].append(data["server_state"]["alltime_ul"]) qbt_hist["ul_size"].append(data["server_state"]["alltime_ul"])
hist["dl_size_sess"].append(data["server_state"]["dl_info_data"]) qbt_hist["dl_size_sess"].append(data["server_state"]["dl_info_data"])
hist["ul_size_sess"].append(data["server_state"]["up_info_data"]) qbt_hist["ul_size_sess"].append(data["server_state"]["up_info_data"])
hist["connections"].append(data["server_state"]["total_peer_connections"]) qbt_hist["connections"].append(
hist["dht_nodes"].append(data["server_state"]["dht_nodes"]) data["server_state"]["total_peer_connections"])
hist["bw_per_conn"].append( qbt_hist["dht_nodes"].append(data["server_state"]["dht_nodes"])
(data["server_state"]["dl_info_speed"] + data["server_state"]["up_info_speed"]) qbt_hist["bw_per_conn"].append(
/ data["server_state"]["total_peer_connections"] (data["server_state"]["dl_info_speed"] +
) data["server_state"]["up_info_speed"]) /
for k in hist: data["server_state"]["total_peer_connections"])
hist[k] = hist[k][-limit:] for k in qbt_hist:
qbt_hist[k] = qbt_hist[k][-limit:]
last_idx = 0 last_idx = 0
for i, (t1, t2) in enumerate(zip(hist["t"], hist["t"][1:])): for i, (t1, t2) in enumerate(zip(qbt_hist["t"], qbt_hist["t"][1:])):
if abs(t1 - t2) > (60 * 60): # 1h if abs(t1 - t2) > (60 * 60): # 1h
last_idx = i + 1 last_idx = i + 1
for k in hist: for k in qbt_hist:
hist[k] = hist[k][last_idx:] qbt_hist[k] = qbt_hist[k][last_idx:]
return hist return qbt_hist
def collect_stats(): def qbt_stats():
cfg = handle_config()
c = Client(cfg)
return {"status": c.qbittorent.status()}
def get_base_stats(pool):
cfg = handle_config()
client = Client(cfg)
sonarr = {}
radarr = {}
qbt = {}
jellyfin = {}
sonarr["entries"] = pool.submit(client.sonarr.series)
sonarr["status"] = pool.submit(client.sonarr.status)
sonarr["calendar"] = pool.submit(client.sonarr.calendar)
radarr["entries"] = pool.submit(client.radarr.movies)
radarr["status"] = pool.submit(client.radarr.status)
radarr["calendar"] = pool.submit(client.radarr.calendar)
qbt["status"] = pool.submit(client.qbittorent.status)
t_1 = datetime.today()
jellyfin["library"] = pool.submit(client.jellyfin.get_library)
ret = {}
for d in sonarr, radarr, qbt, jellyfin:
for k, v in d.items():
if hasattr(v, "result"):
d[k] = v.result()
print("Jellyfin[{}]:".format(k), datetime.today() - t_1)
sonarr["details"] = {}
return {
"sonarr": sonarr,
"radarr": radarr,
"qbt": qbt,
"jellyfin": jellyfin}
def collect_stats(pool):
from collections import Counter from collections import Counter
PL.clf() PL.clf()
cfg = handle_config() cfg = handle_config()
c = Client(cfg) c = Client(cfg)
series={} series = {}
movies={} movies = {}
data = { data = get_base_stats(pool)
"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"]: for show in data["sonarr"]["entries"]:
series[show["id"]]=show series[show["id"]] = show
for movie in data["radarr"]["entries"]: for movie in data["radarr"]["entries"]:
movies[movie["id"]]=movie movies[movie["id"]] = movie
torrent_states = {} torrent_states = {}
torrent_categories = {} torrent_categories = {}
for torrent in data["qbt"]["status"]["torrents"].values(): for torrent in data["qbt"]["status"]["torrents"].values():
state = c.qbittorent.status_map.get(torrent["state"], (torrent["state"], None))[ state = c.qbittorent.status_map.get(
0 torrent["state"], (torrent["state"], None))[0]
]
category = torrent["category"] or "<None>" category = torrent["category"] or "<None>"
torrent_states.setdefault(state, 0) torrent_states.setdefault(state, 0)
torrent_categories.setdefault(category, 0) torrent_categories.setdefault(category, 0)
@ -234,14 +268,44 @@ def collect_stats():
else: else:
radarr_stats["missing"] += 1 radarr_stats["missing"] += 1
sizes["Movies"] += movie.get("movieFile", {}).get("size", 0) sizes["Movies"] += movie.get("movieFile", {}).get("size", 0)
vbr = movie.get("movieFile", {}).get("mediaInfo", {}).get("videoBitrate", None) vbr = movie.get(
abr = movie.get("movieFile", {}).get("mediaInfo", {}).get("audioBitrate", None) "movieFile",
acodec = movie.get("movieFile", {}).get("mediaInfo", {}).get("audioCodec", None) {}).get(
vcodec = movie.get("movieFile", {}).get("mediaInfo", {}).get("videoCodec", None) "mediaInfo",
fmt = movie.get("movieFile", {}).get("relativePath", "").split(".")[-1].lower() {}).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 = ( qual = (
movie.get("movieFile", {}).get("quality", {}).get("quality", {}).get("name") movie.get(
) "movieFile",
{}).get(
"quality",
{}).get(
"quality",
{}).get("name"))
if qual: if qual:
qualities.append(qual) qualities.append(qual)
if acodec: if acodec:
@ -260,50 +324,68 @@ def collect_stats():
formats.append(fmt) formats.append(fmt)
sonarr_stats = {"missing": 0, "available": 0} sonarr_stats = {"missing": 0, "available": 0}
info_jobs = [] info_jobs = []
with ThreadPoolExecutor(16) as pool: for show in data["sonarr"]["entries"]:
for show in data["sonarr"]["entries"]: info_jobs.append(pool.submit(c.sonarr.series, show["id"]))
info_jobs.append(pool.submit(c.sonarr.series, show["id"])) t_1 = datetime.today()
for job, show in zip( for job, show in zip(
as_completed(info_jobs), as_completed(info_jobs),
data["sonarr"]["entries"], data["sonarr"]["entries"],
): ):
info = job.result() info = job.result()
data["sonarr"]["details"][show["id"]] = info data["sonarr"]["details"][show["id"]] = info
for file in info["episodeFile"]: for file in info["episodeFile"]:
vbr = file.get("mediaInfo", {}).get("videoBitrate", None) vbr = file.get("mediaInfo", {}).get("videoBitrate", None)
abr = file.get("mediaInfo", {}).get("audioBitrate", None) abr = file.get("mediaInfo", {}).get("audioBitrate", None)
acodec = file.get("mediaInfo", {}).get("audioCodec", None) acodec = file.get("mediaInfo", {}).get("audioCodec", None)
vcodec = file.get("mediaInfo", {}).get("videoCodec", None) vcodec = file.get("mediaInfo", {}).get("videoCodec", None)
fmt = file.get("relativePath", "").split(".")[-1].lower() fmt = file.get("relativePath", "").split(".")[-1].lower()
qual = file.get("quality", {}).get("quality", {}).get("name") qual = file.get("quality", {}).get("quality", {}).get("name")
sizes["Shows"] += file.get("size", 0) sizes["Shows"] += file.get("size", 0)
if qual: if qual:
qualities.append(qual) qualities.append(qual)
if acodec: if acodec:
acodecs.append(acodec) acodecs.append(acodec)
if vcodec: if vcodec:
if vcodec.lower() in ["x265", "h265", "hevc"]: if vcodec.lower() in ["x265", "h265", "hevc"]:
vcodec = "H.265" vcodec = "H.265"
if vcodec.lower() in ["x264", "h264"]: if vcodec.lower() in ["x264", "h264"]:
vcodec = "H.264" vcodec = "H.264"
vcodecs.append(vcodec) vcodecs.append(vcodec)
if vbr: if vbr:
vbitrates.append(vbr) vbitrates.append(vbr)
if abr: if abr:
abitrates.append(abr) abitrates.append(abr)
if fmt: if fmt:
formats.append(fmt) formats.append(fmt)
for season in show.get("seasons", []): for season in show.get("seasons", []):
stats = season.get("statistics", {}) stats = season.get("statistics", {})
sonarr_stats["missing"] += ( sonarr_stats["missing"] += (
stats["totalEpisodeCount"] - stats["episodeFileCount"] stats["totalEpisodeCount"] - stats["episodeFileCount"]
) )
sonarr_stats["available"] += stats["episodeFileCount"] sonarr_stats["available"] += stats["episodeFileCount"]
hist = update_qbt_hist(data) print("Sonarr:", datetime.today() - t_1)
qbt_hist = update_qbt_hist(data)
calendar = {"movies": [], "episodes": []}
for movie in data.get("radarr", {}).pop("calendar", []):
calendar["movies"].append(movie)
for episode in data.get("sonarr", {}).pop("calendar", []):
t = episode["airDateUtc"].rstrip("Z").split(".")[0]
t = datetime.strptime(t, "%Y-%m-%dT%H:%M:%S")
episode["hasAired"] = datetime.today() > t
details = c.sonarr.details(episode["id"])
calendar["episodes"].append(
{
"episode": episode,
"details": details,
"series": series[episode["seriesId"]],
}
)
library = data.pop("jellyfin", {}).pop("library", None)
sonarr_stats["available"] = (sonarr_stats["available"], "#5f5") sonarr_stats["available"] = (sonarr_stats["available"], "#5f5")
sonarr_stats["missing"] = (sonarr_stats["missing"], "#f55") sonarr_stats["missing"] = (sonarr_stats["missing"], "#f55")
radarr_stats["available"] = (radarr_stats["available"], "#5f5") radarr_stats["available"] = (radarr_stats["available"], "#5f5")
radarr_stats["missing"] = (radarr_stats["missing"], "#f55") radarr_stats["missing"] = (radarr_stats["missing"], "#f55")
t_1 = datetime.today()
imgs = [ imgs = [
[ [
"Media", "Media",
@ -322,88 +404,105 @@ def collect_stats():
piechart(torrent_states, "Torrents"), piechart(torrent_states, "Torrents"),
piechart(torrent_categories, "Torrent categories"), piechart(torrent_categories, "Torrent categories"),
piechart( piechart(
{"Upload": hist["ul"][-1]+0.0, "Download": hist["dl"][-1]+0.0}, {
"Upload": qbt_hist["ul"][-1] + 0.0,
"Download": qbt_hist["dl"][-1] + 0.0,
},
"Bandwidth utilization", "Bandwidth utilization",
byte_rate_labels, byte_rate_labels,
sort=False, sort=False,
), ),
stackplot( stackplot(
hist, qbt_hist,
{"Download": "dl", "Upload": "ul"}, {"Download": "dl", "Upload": "ul"},
"Transfer speed", "Transfer speed",
unit="b/s", unit="b/s",
smooth=smoothness, smooth=smoothness,
), ),
stackplot( stackplot(
hist, qbt_hist,
{"Download": "dl_size_sess", "Upload": "ul_size_sess"}, {"Download": "dl_size_sess", "Upload": "ul_size_sess"},
"Transfer volume (Session)", "Transfer volume (Session)",
unit="b", unit="b",
), ),
stackplot( stackplot(
hist, qbt_hist,
{"Download": "dl_size", "Upload": "ul_size"}, {"Download": "dl_size", "Upload": "ul_size"},
"Transfer volume (Total)", "Transfer volume (Total)",
unit="b", unit="b",
), ),
lineplot( lineplot(
hist, qbt_hist,
{"Connections": "connections"}, {"Connections": "connections"},
"Peers", "Peers",
unit=None, unit=None,
smooth=smoothness, smooth=smoothness,
), ),
lineplot( lineplot(
hist, qbt_hist,
{"Bandwidth per connection": "bw_per_conn"}, {"Bandwidth per connection": "bw_per_conn"},
"Connections", "Connections",
unit="b/s", unit="b/s",
smooth=smoothness, smooth=smoothness,
), ),
lineplot(hist, {"DHT Nodes": "dht_nodes"}, "DHT", unit=None), lineplot(qbt_hist, {"DHT Nodes": "dht_nodes"}, "DHT", unit=None),
], ],
] ]
calendar = {"movies":[],"episodes":[]} print("Diagrams:", datetime.today() - t_1)
for movie in c.radarr.calendar(): return {
calendar["movies"].append(movie) "data": data,
for episode in c.sonarr.calendar(): "images": imgs,
t = episode['airDateUtc'].rstrip("Z").split(".")[0] "qbt_hist": qbt_hist,
t = datetime.strptime(t, "%Y-%m-%dT%H:%M:%S") "calendar": calendar,
episode['hasAired']=datetime.today()>t "library": library,
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(): def update():
print("Updating...")
try: try:
stats = collect_stats() with ThreadPoolExecutor(16) as pool:
stats = collect_stats(pool)
except Exception as e: except Exception as e:
print("Error collectin statistics:", str(e)) print("Error collectin statistics:", e)
stats = None stats = None
if stats: if stats:
with open("stats_temp.json", "w") as of: for k, v in stats.items():
json.dump(stats, of) with open("stats/{}_temp.json".format(k), "w") as of:
open("stats.lock", "w").close() json.dump(v, of)
if os.path.isfile("stats.json"): shutil.move(
os.unlink("stats.json") "stats/{}_temp.json".format(k),
os.rename("stats_temp.json", "stats.json") "stats/{}.json".format(k))
os.unlink("stats.lock")
print("Done!") print("Done!")
def loop(seconds): def loop(seconds):
while True: t_start = time.time()
update() print("Updating")
time.sleep(seconds) update()
dt = time.time() - t_start
print("Next update in", seconds - dt)
if __name__=="__main__": t = threading.Timer(seconds - dt, loop, (seconds,))
t.start()
class Stats(object):
def __init__(self):
self.override = {}
def __setitem__(self, key, value):
if os.path.isfile("stats/{}.json".format(key)):
self.override[key] = value
def __getitem__(self, key):
try:
with open("stats/{}.json".format(key)) as fh:
if key in self.override:
return self.override[key]
return json.load(fh)
except Exception as e:
print("Error opening stats file:", key, e)
return []
if __name__ == "__main__":
update() update()

View file

@ -10,21 +10,21 @@
{{ bootstrap.load_css() }} {{ bootstrap.load_css() }}
<link rel="stylesheet" href="{{url_for('static', filename='theme.css')}}"> <link rel="stylesheet" href="{{url_for('static', filename='theme.css')}}">
{% endblock %} {% endblock %}
<link rel="shortcut icon" type="image/svg" href="{{url_for('static',filename='icon.svg')}}"/>
<title>MediaDash</title> <title>MediaDash</title>
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
{% block navbar %} {% block navbar %}
<nav class="navbar sticky-top navbar-expand-lg navbar-dark" style="background-color: #222;"> <nav class="navbar sticky-top navbar-expand-lg navbar-dark" style="background-color: #222;">
<a class="navbar-brand" href="/">MediaDash</a> {% if request.path!=url_for("login") %}
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar_main" aria-controls="navbar_main" aria-expanded="false" aria-label="Toggle navigation"> <img src="{{url_for('static',filename='icon.svg')}}" width=40 height=40/>
<span class="navbar-toggler-icon"></span> {% endif %}
</button>
<div class="collapse navbar-collapse" id="navbar_main"> <div class="collapse navbar-collapse" id="navbar_main">
{{nav.left_nav.render(renderer='bootstrap4')}} {{nav.left_nav.render(renderer='bootstrap4')}}
{{nav.right_nav.render(renderer='bootstrap4')}}
</div> </div>
</nav> </nav>
</div>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class={{"container-fluid" if fluid else "container"}}> <div class={{"container-fluid" if fluid else "container"}}>

View file

@ -30,7 +30,7 @@
<h1> <h1>
<a href="{{config.APP_CONFIG.portainer_url}}">Portainer</a> <a href="{{config.APP_CONFIG.portainer_url}}">Portainer</a>
</h1> </h1>
<table class="table table-sm"> <table class="table table-sm table-bordered">
<thead> <thead>
<tr> <tr>
<th scope="col">Name</th> <th scope="col">Name</th>
@ -42,7 +42,7 @@
{% set label = container.Labels["com.docker.compose.service"] %} {% set label = container.Labels["com.docker.compose.service"] %}
<tr> <tr>
<td> <td>
<a href="{{url_for('containers',container_id=container.Id)}}"> <a href="{{url_for('containers.details',container_id=container.Id)}}">
{{container.Labels["com.docker.compose.project"]}}/{{container.Labels["com.docker.compose.service"]}} {{container.Labels["com.docker.compose.project"]}}/{{container.Labels["com.docker.compose.service"]}}
</a> </a>
</td> </td>

8
templates/error.html Normal file
View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% from 'utils.html' import custom_render_form_row,make_tabs %}
{% block app_content %}
<div class="container" style="max-width: 30% !important;">
<h1>Oops</h1>
</div>
{% endblock %}

View file

@ -41,62 +41,89 @@
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<h3>Movies</h3> <h3>Movies</h3>
<table class="table table-sm"> <table class="table table-sm table-bordered">
<tr> <tr>
<th>Title</th> <th>Title</th>
<th>Status</th>
<th>In Cinemas</th> <th>In Cinemas</th>
<th>Digital Release</th> <th>Digital Release</th>
</tr> </tr>
{% for movie in data.calendar.movies %} {% for movie in data.calendar.movies %}
{% if movie.isAvailable and movie.hasFile %} {% if movie.isAvailable and movie.hasFile %}
{% set row_class = "bg-success" %} {% set row_attrs = "bg-success" %}
{% elif movie.isAvailable and not movie.hasFile %} {% elif movie.isAvailable and not movie.hasFile %}
{% set row_class = "bg-danger" %} {% set row_attrs = "bg-danger" %}
{% elif not movie.isAvailable and movie.hasFile %} {% elif not movie.isAvailable and movie.hasFile %}
{% set row_class = "bg-primary" %} {% set row_attrs = "bg-primary" %}
{% elif not movie.isAvailable and not movie.hasFile %} {% elif not movie.isAvailable and not movie.hasFile %}
{% set row_class = "bg-info" %} {% set row_attrs = "bg-info" %}
{% endif %} {% endif %}
<tr class={{row_class}}> <tr class={{row_attrs}}>
<td> <td>
<a href="{{urljoin(config.APP_CONFIG.radarr_url,'movie/'+movie.titleSlug)}}" style="color: #eee; text-decoration: underline;"> <a title="{{movie.overview}}" href="{{urljoin(config.APP_CONFIG.radarr_url,'movie/'+movie.titleSlug)}}" style="color: #eee; text-decoration: underline;">
{{movie.title}} {{movie.title}}
</a> </a>
</td> </td>
<td>{{movie.status}}</td>
<td>{{movie.inCinemas|fromiso|ago_dt_utc_human(rnd=0)}}</td> <td>{{movie.inCinemas|fromiso|ago_dt_utc_human(rnd=0)}}</td>
<td>{{movie.digitalRelease|fromiso|ago_dt_utc_human(rnd=0)}}</td> {% if movie.digitalRelease %}
<td>{{movie.digitalRelease|fromiso|ago_dt_utc_human(rnd=0)}}</td>
{% else %}
<td>Unknown</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
<h3>Episodes</h3> <h3>Episodes</h3>
<table class="table table-sm"> <table class="table table-sm table-bordered">
<tr> <tr>
<th>Season | Episode Number</th> <th>Season | Episode Number</th>
<th>Show</th> <th>Show</th>
<th>Title</th> <th>Title</th>
<th>Status</th>
<th>Air Date</th> <th>Air Date</th>
</tr> </tr>
{% for entry in data.calendar.episodes %} {% for entry in data.calendar.episodes %}
{% if entry.episode.hasAired and entry.episode.hasFile %} {% if entry.details %}
{% set row_class = "bg-success" %} {% set details = entry.details[0] %}
{% elif entry.episode.hasAired and not entry.episode.hasFile %} {% endif %}
{% set row_class = "bg-danger" %} {% if entry.episode.hasAired and entry.episode.hasFile %}
{% elif not entry.episode.hasAired and entry.episode.hasFile %} {% set row_attrs = {"class":"bg-success"} %}
{% set row_class = "bg-primary" %} {% elif entry.episode.hasAired and not entry.episode.hasFile and details %}
{% elif not entry.episode.hasAired and not entry.episode.hasFile %} {% set row_attrs = {"style":"background-color: green !important"} %}
{% set row_class = "bg-info" %} {% elif entry.episode.hasAired and not entry.episode.hasFile %}
{% endif %} {% set row_attrs = {"class":"bg-danger"} %}
<tr class={{row_class}}> {% elif not entry.episode.hasAired and entry.episode.hasFile %}
<td>{{entry.episode.seasonNumber}} | {{entry.episode.episodeNumber}}</td> {% set row_attrs = {"class":"bg-primary"} %}
<td> {% elif not entry.episode.hasAired and not entry.episode.hasFile %}
<a href="{{urljoin(config.APP_CONFIG.sonarr_url,'series/'+entry.series.titleSlug)}}" style="color: #eee; text-decoration: underline;"> {% set row_attrs = {"class":"bg-info"} %}
{{entry.series.title}} {% endif %}
</a> <tr {{row_attrs|xmlattr}}>
</td> <td>{{entry.episode.seasonNumber}} | {{entry.episode.episodeNumber}}</td>
<td>{{entry.episode.title}}</td> <td>
<td>{{entry.episode.airDateUtc|fromiso|ago_dt_utc_human(rnd=0)}}</td> <a href="{{urljoin(config.APP_CONFIG.sonarr_url,'series/'+entry.series.titleSlug)}}" style="color: #eee; text-decoration: underline;">
</tr> {{entry.series.title}}
</a>
</td>
<td title="{{entry.episode.overview}}">{{entry.episode.title}}</td>
<td>
{% if details %}
{% set details = entry.details[0] %}
{% set dl_prc =((details.size-details.sizeleft)/details.size)*100|round(2) %}
{{details.status}} ({{dl_prc|round(2)}} %)
{% elif row_attrs.class=="bg-success" %}
downloaded
{% elif row_attrs.class=="bg-danger" %}
not downloaded
{% elif row_attrs.class=="bg-primary" %}
leaked?
{% elif row_attrs.class=="bg-info" %}
not aired
{% endif %}
</td>
<td>{{entry.episode.airDateUtc|fromiso|ago_dt_utc_human(rnd=0)}}</td>
</tr>
{% endfor %} {% endfor %}
</table> </table>
</div> </div>
@ -109,7 +136,7 @@
<h2>No Data available!</h2> <h2>No Data available!</h2>
{% else %} {% else %}
{% set tabs = [] %} {% set tabs = [] %}
{% do tabs.append(("Upcoming",[upcoming(data)])) %} {% do tabs.append(("Schedule",[upcoming(data)])) %}
{% for row in data.images %} {% for row in data.images %}
{% if row[0] is string %} {% if row[0] is string %}
{% set title=row[0] %} {% set title=row[0] %}

View file

@ -0,0 +1,20 @@
{% 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={{info.LocalAddress}}>Jellyfin</a> v{{info.Version}}</h2>
{% if status.HasUpdateAvailable %}
<h3>Update available</h3>
{% endif %}
{% if status.HasPendingRestart %}
<h3>Restart pending</h3>
{% endif %}
<img src={{cfg.jellyfin_url}}/Items/{{item.Id}}/Images/Art>
<pre>{{item|pformat}}</pre>
{% endblock %}

View file

@ -3,119 +3,80 @@
{% from 'bootstrap/utils.html' import render_icon %} {% from 'bootstrap/utils.html' import render_icon %}
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %} {% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
{% 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 make_table(items) %}
<table class="table table-sm table-bordered">
{% for item in items|sort(attribute="Name")%}
<tr>
<td><a href="{{url_for('jellyfin.details',item_id=item.Id)}}">{{item.Name}}</a> ({{item.ProductionYear}})</td>
</tr>
{% endfor %}
</table>
{% endmacro %}
{% block app_content %} {% block app_content %}
<h1><a href={{info.LocalAddress}}>Jellyfin</a> v{{info.Version}}</h1>
{% if status.HasUpdateAvailable %}
<h3>Update available</h3>
{% endif %}
{% if status.HasPendingRestart %}
<h3>Restart pending</h3>
{% endif %}
<h2><a href={{jellyfin.info.LocalAddress}}>Jellyfin</a> v{{jellyfin.info.Version}}</h2> <h3>Library statistics</h3>
<div class="row"> <table class="table table-sm table-bordered">
<div class="col-lg"> {% for name, value in counts.items() %}
<h4>Active Streams</h4> {% if value != 0 %}
<table class="table table-sm"> <tr>
<tr> <td>{{name}}</td>
<th>Episode</th> <td>{{value}}</td>
<th>Show</th> </tr>
<th>Language</th> {% endif %}
<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 %} {% endfor %}
</table> </table>
</div> {% if library %}
</div> <h3>{{library|count}} Items</h3>
{% endif %}
<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>
{% set tabs = [] %}
{% for title,group in library.values()|groupby("Type") %}
{% do tabs.append((title,[make_table(group)])) %}
{% endfor %}
{{make_tabs(tabs)}}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,56 @@
{% 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><a href={{info.LocalAddress}}>Jellyfin</a> v{{info.Version}}</h1>
{% if status.HasUpdateAvailable %}
<h3>Update available</h3>
{% endif %}
{% if status.HasPendingRestart %}
<h3>Restart pending</h3>
{% endif %}
<div class="row">
<div class="col">
{% for ext in item.ExternalUrls %}
<a href={{ext.Url}}><span class="badge badge-secondary">{{ext.Name}}</span></a>
{% endfor %}
</div>
</div>
<h2 title="{{item.Id}}">
{{item.Name}}
{% if item.IsHD %}
<span class="badge badge-primary">HD</span>
{% endif %}
</h2>
<p>{{item.Overview}}</p>
<hr>
{# <img src={{info.LocalAddress}}/Items/{{item.Id}}/Images/Primary> #}
{% set data = [
("Path", item.Path),
("Genres", item.Genres|join(", ")),
] %}
<div class="row">
<div class="col">
<table class="table table-sm table-bordered">
{% for k,v in data %}
<tr>
<td>{{k}}</td>
<td>{{v}}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<pre>{{item|pformat}}</pre>
{% endblock %}

View file

@ -0,0 +1,70 @@
{% 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><a href={{info.LocalAddress}}>Jellyfin</a> v{{info.Version}}</h1>
{% if status.HasUpdateAvailable %}
<h3>Update available</h3>
{% endif %}
{% if status.HasPendingRestart %}
<h3>Restart pending</h3>
{% endif %}
<h2 title="{{item.Id}}"><a href="{{info.LocalAddress}}/web/index.html#!/details?id={{item.Id}}">{{item.Name}}</a></h2>
<div class="row">
<div class="col">
{% for ext in item.ExternalUrls %}
<a href={{ext.Url}}><span class="badge badge-secondary">{{ext.Name}}</span></a>
{% endfor %}
</div>
</div>
<div class="text-center">
<img class="rounded" src={{info.LocalAddress}}/Items/{{item.Id}}/Images/Primary>
</div>
<p>{{item.Overview}}</p>
<div class="row">
<div class="col">
<table class="table table-sm table-bordered">
<tr>
<td>Path</td>
<td>{{item.Path}}</td>
</tr>
</table>
</div>
</div>
<div>
{% for season in item.Seasons %}
<div class="row">
<div class="col">
<h3>{{season.Name}}</h3>
<table class="table table-sm table-bordered">
{% for episode in season.Episodes %}
<tr>
<td>{{episode.Name}}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endfor %}
</div>
<table class="table table-sm table-bordered">
{% for k,v in item|flatten %}
<tr>
<td>{{k}}</td>
<td>{{v}}</td>
</tr>
{% endfor %}
</table>
{% endblock %}

16
templates/login.html Normal file
View file

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% from 'utils.html' import custom_render_form_row,make_tabs %}
{% block app_content %}
<img src="{{url_for('static',filename='icon.svg')}}" class="mx-auto d-block" width=150 height=150/>
<div class="container" style="max-width: 30% !important;">
<h2>Login</h2>
<form method="post" class="form">
{{form.csrf_token()}}
{{custom_render_form_row([form.username])}}
{{custom_render_form_row([form.password])}}
{{custom_render_form_row([form.remember])}}
{{custom_render_form_row([form.login])}}
</form>
</div>
{% endblock %}

View file

@ -208,9 +208,11 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h2>Trackers</h2> <h2>Trackers</h2>
<a href="{{url_for('qbittorent_add_trackers',infohash=qbt.info.hash)}}"> {% if current_user.is_admin %}
<span class="badge badge-primary">Add default trackers</span> <a href="{{url_for('qbittorrent.add_trackers',infohash=qbt.info.hash)}}">
</a> <span class="badge badge-primary">Add default trackers</span>
</a>
{% endif %}
</div> </div>
</div> </div>

View file

@ -4,7 +4,7 @@
{% set state_label,badge_type = status_map[torrent.state] or (torrent.state,'light') %} {% set state_label,badge_type = status_map[torrent.state] or (torrent.state,'light') %}
<li class="list-group-item"> <li class="list-group-item">
<a href="{{url_for('qbittorrent_details',infohash=torrent.hash)}}">{{torrent.name|truncate(75)}}</a> <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) (DL: {{torrent.dlspeed|filesizeformat(binary=true)}}/s, UL: {{torrent.upspeed|filesizeformat(binary=true)}}/s)
<span class="badge badge-{{badge_type}}">{{state_label}}</span> <span class="badge badge-{{badge_type}}">{{state_label}}</span>
{% if torrent.category %} {% if torrent.category %}
@ -27,12 +27,12 @@
{% block app_content %} {% block app_content %}
<h2> <h1>
<a href="{{config.APP_CONFIG.qbt_url}}">QBittorrent</a> <a href="{{config.APP_CONFIG.qbt_url}}">QBittorrent</a>
{{qbt.version}} {{qbt.version}}
(DL: {{qbt.server_state.dl_info_speed|filesizeformat(binary=True)}}/s, (DL: {{qbt.server_state.dl_info_speed|filesizeformat(binary=True)}}/s,
UL: {{qbt.server_state.up_info_speed|filesizeformat(binary=True)}}/s) UL: {{qbt.server_state.up_info_speed|filesizeformat(binary=True)}}/s)
</h2> </h1>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@ -99,7 +99,7 @@
{% set state_label,badge_type = status_map[state] or (state,'light') %} {% set state_label,badge_type = status_map[state] or (state,'light') %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<a href={{url_for("qbittorrent",state=state)}} >{{state_label}}</a> <a href={{url_for("qbittorrent.index",state=state)}} >{{state_label}}</a>
</div> </div>
<div class="col"> <div class="col">
{{torrents|length}} {{torrents|length}}
@ -110,7 +110,7 @@
{% if state_filter %} {% if state_filter %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<a href={{url_for("qbittorrent")}}>[Clear filter]</a> <a href={{url_for("qbittorrent.index")}}>[Clear filter]</a>
</div> </div>
<div class="col"> <div class="col">
</div> </div>

View file

@ -15,10 +15,10 @@
{% endmacro %} {% endmacro %}
{% block app_content %} {% block app_content %}
<h2> <h1>
<a href="{{config.APP_CONFIG.radarr_url}}">Radarr</a> <a href="{{config.APP_CONFIG.radarr_url}}">Radarr</a>
v{{status.version}} ({{movies|count}} Movies) v{{status.version}} ({{movies|count}} Movies)
</h2> </h1>
<div class="row"> <div class="row">
<div class="col"> <div class="col">

View file

@ -6,13 +6,13 @@
{% block app_content %} {% block app_content %}
<h1> <h1>
Remote access <a href={{url_for("remote_add")}}>{{render_icon("person-plus-fill")}}</a> Remote access <a href={{url_for("remote.add")}}>{{render_icon("person-plus-fill")}}</a>
</h1> </h1>
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<h4>SSH</h4> <h4>SSH</h4>
<table class="table table-sm"> <table class="table table-sm table-bordered">
<tr> <tr>
<th></th> <th></th>
<th>Type</th> <th>Type</th>
@ -23,9 +23,9 @@ Remote access <a href={{url_for("remote_add")}}>{{render_icon("person-plus-fill"
<tr {{ {"class":"text-muted" if key.disabled else none}|xmlattr }}> <tr {{ {"class":"text-muted" if key.disabled else none}|xmlattr }}>
<td> <td>
{% if key.disabled %} {% if key.disabled %}
<a href="{{url_for("remote",enabled=True,key=key.key)}}">{{render_icon("person-x-fill",color='danger')}}</a> <a href="{{url_for("remote.index",enabled=True,key=key.key)}}">{{render_icon("person-x-fill",color='danger')}}</a>
{% else %} {% else %}
<a href="{{url_for("remote",enabled=False,key=key.key)}}">{{render_icon("person-check-fill",color='success')}}</a> <a href="{{url_for("remote.index",enabled=False,key=key.key)}}">{{render_icon("person-check-fill",color='success')}}</a>
{% endif %} {% endif %}
</td> </td>
<td>{{key.type}}</td> <td>{{key.type}}</td>
@ -37,17 +37,99 @@ Remote access <a href={{url_for("remote_add")}}>{{render_icon("person-plus-fill"
</div> </div>
</div> </div>
<div class="row">
<div class="col-lg">
<h4>Active Streams</h4>
<table class="table table-sm table-bordered">
<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('remote.stop',session=session.Id)}}">
{{render_icon("stop-circle")}}
</a>
{% endif %}
<a title="{{ps.MediaSourceId}}" 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) %}
{% if ps.AudioStreamIndex == -1 %}
{% set audio_lang = "-" %}
{% else %}
{% set audio_lang = np.MediaStreams[ps.AudioStreamIndex].Language or "?" %}
{% endif %}
{% if ps.SubtitleStreamIndex == -1 %}
{% set subtitle_lang = "-" %}
{% else %}
{% set subtitle_lang = np.MediaStreams[ps.AudioStreamIndex].Language or "?" %}
{% endif %}
{{audio_lang}}/{{subtitle_lang}}
{% else %}
?/?
{% 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="row">
<div class="col-lg"> <div class="col-lg">
<h4><a href="{{cfg().jellyfin_url}}web/index.html#!/userprofiles.html">Jellyfin</a></h4> <h4><a href="{{cfg().jellyfin_url}}web/index.html#!/userprofiles.html">Jellyfin</a></h4>
<table class="table table-sm"> <table class="table table-sm table-bordered">
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Last Login</th> <th>Last Login</th>
<th>Last Active</th> <th>Last Active</th>
<th>Bandwidth Limit</th> <th>Bandwidth Limit</th>
</tr> </tr>
{% for user in jf|sort(attribute="LastLoginDate",reverse=True) %} {% for user in jellyfin.users|defaultattr("LastLoginDate","")|sort(attribute="LastLoginDate",reverse=True) %}
<tr> <tr>
<td> <td>
<a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{user.Id}}"> <a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{user.Id}}">
@ -55,20 +137,26 @@ Remote access <a href={{url_for("remote_add")}}>{{render_icon("person-plus-fill"
</a> </a>
</td> </td>
<td> <td>
{% if "LastLoginDate" in user %} {% if user.LastLoginDate %}
{{user.LastLoginDate|fromiso|ago_dt_utc(2)}} ago {{user.LastLoginDate|fromiso|ago_dt_utc(2)}} ago
{% else %} {% else %}
Never Never
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if "LastActivityDate" in user %} {% if user.LastActivityDate %}
{{user.LastActivityDate|fromiso|ago_dt_utc(2)}} ago {{user.LastActivityDate|fromiso|ago_dt_utc(2)}} ago
{% else %} {% else %}
Never Never
{% endif %} {% endif %}
</td> </td>
<td>{{user.Policy.RemoteClientBitrateLimit|filesizeformat(binary=False)}}/s</td> <td>
{% if user.Policy.RemoteClientBitrateLimit!=0 %}
{{user.Policy.RemoteClientBitrateLimit|filesizeformat(binary=False)}}/s
{% else %}
None
{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View file

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% from 'bootstrap/utils.html' import render_icon %}
{% block app_content %}
<h1>Request details</h1>
{% set data = request.data|fromjson%}
<div class="row">
<div class="col">
{% set label = {True:"Approved",False:"Declined",None:"Pending"}[request.approved] %}
{% set class = {True:"bg-success",False:"bg-danger", None: ""}[request.approved] %}
{% if request.approved and jellyfin_id %}
{% set label = "Approved and Downloaded" %}
{% endif %}
{% if data.tvdbId %}
{% set link="https://www.thetvdb.com/?tab=series&id=%s"|format(data.tvdbId) %}
{% elif data.imdbId %}
{% set link="https://www.imdb.com/title/%s"|format(data.imdbId) %}
{% endif %}
<p><b>Title</b>: <a href="{{link}}">{{data.title}}</a> ({{data.year}})</p>
<p><b>Type</b>: {{{"sonarr":"TV Show","radarr":"Movie"}.get(request.request_type,"Unknown")}}</p>
<p><b>Added</b>: {{request.added_date|ago_dt(0)}} ago</p>
<p><b>Status</b>: <span class="{{class}}">{{label}}</span></p>
</div>
</div>
<div>
<p>{{data.overview}}</p>
{% if jellyfin_id %}
<a class="btn btn-success" href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{jellyfin_id}}">Open in Jellyfin</a>
{% endif %}
</div>
{% set downloads = (request.downloads|list) %}
{% if downloads %}
<h3>Downloads</h3>
<table class="table table-sm">
<tr>
<th>Name</th>
<th>Quality</th>
<th>Progress</th>
</tr>
{% for download in downloads %}
{% set torrent = download.download.info %}
{% set dl_rate = torrent.downloaded / torrent.time_active %}
{% set eta_act = [0, (torrent.size - torrent.downloaded) / dl_rate]|max %}
<tr>
<td><a href="{{url_for('requests.download_details',request_id=request.id,download_id=torrent.hash)}}">{{download.title}}</a></td>
<td>{{download.quality.quality.name}}</td>
<td>
{{(torrent.progress*100)|round(2)}}&nbsp;% (ETA: {{[torrent.eta,eta_act]|min|round(0)|timedelta(clamp=true)}})
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,116 @@
{% 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 %}
{% macro search_tab() %}
<form method="post" enctype="multipart/form-data">
{{ form.csrf_token() }}
{{ custom_render_form_row([form.query],render_args={'form_type':'horizontal','horizontal_columns':('lg',1,6)}) }}
{{ custom_render_form_row([form.search_type],render_args={'form_type':'horizontal','horizontal_columns':('lg',1,6)}) }}
{{ custom_render_form_row([form.search]) }}
</form>
{% if results %}
<form method="post">
{{ form.csrf_token() }}
{% if search_type %}
<input type="hidden" name="search_type" value="{{search_type}}">
{% endif %}
<table class="table table-sm table-bordered mt-3">
<tr>
<th>Title</th>
<th>In Cinemas</th>
<th>Digital Release</th>
</tr>
{% for result in results %}
{% if result.tvdbId %}
{% set link="https://www.thetvdb.com/?tab=series&id=%s"|format(result.tvdbId) %}
{% elif result.imdbId %}
{% set link="https://www.imdb.com/title/%s"|format(result.imdbId) %}
{% endif %}
{% if result.path %}
{% set style="background-color: darkgreen !important;" %}
{% else %}
{% set style="" %}
{% endif %}
<tr style="{{style}}">
<td>
{% if result.path %}
<input type="checkbox" disabled>
{% else %}
<input type="checkbox" name="selected[]" value="{{result|tojson|base64}}">
{% endif %}
<a href="{{link}}">{{result.title}}</a> ({{result.year}})
</td>
<td>
{% if result.inCinemas %}
{{result.inCinemas|fromiso}}
{% else %}
None
{% endif %}
</td>
<td>
{% if result.digitalRelease %}
{{result.digitalRelease|fromiso}}
{% else %}
None
{% endif %}
</td>
</tr>
{% endfor %}
</table>
<button class="btn btn-success" type="submit">Submit Request</button>
</form>
{% endif %}
{% endmacro %}
{% macro request_queue() %}
<form method="post">
{{ form.csrf_token() }}
<table class="table table-sm table-bordered mt-3">
<tr>
<th>Title</th>
<th>Requested at</th>
{% if current_user.is_admin %}
<th>Requested by</th>
{% endif %}
</tr>
{% for request in requests %}
{% set data = (request.data|fromjson) %}
{% if request.approved==True %}
<tr class="bg-success">
{% elif request.approved==False %}
<tr class="bg-danger">
{% else %}
<tr class="">
{% endif %}
<td>
{% if current_user.is_admin and request.approved!=True %}
<input type="checkbox" name="selected[]" value="{{request.item_id}}">
{% endif %}
<a href="{{url_for('requests.details',request_id=request.id)}}" style="color: #eee; text-decoration: underline;">{{data.title}}</a> ({{data.year}})
</td>
<td>{{request.added_date}}</td>
{% if current_user.is_admin %}
<td>{{request.users|join(", ",attribute="user_name")}}</td>
{% endif %}
</tr>
{% endfor %}
</table>
{% if current_user.is_admin %}
<button name="approve" value="approve" class="btn btn-success" type="submit">Approve</button>
<button name="decline" value="decline" class="btn btn-danger" type="submit">Decline</button>
{% endif %}
</form>
{% endmacro %}
{% block app_content %}
<h1>Requests</h1>
{% set requests_tabs = [
('Queue',request_queue()),
('Search',search_tab()),
] %}
{{ make_tabs(requests_tabs) }}
{% endblock %}

View file

@ -2,7 +2,7 @@
{% macro tv_show_results(results) -%} {% macro tv_show_results(results) -%}
<div class="d-flex flex-wrap"> <div class="d-flex flex-wrap">
{% for result in results %} {% for result in results %}
<form action="search/details" method="POST"> <form action="details" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="type" value="show"/> <input type="hidden" name="type" value="show"/>
<input type="hidden" name="data" value="{{result|tojson|urlencode}}" /> <input type="hidden" name="data" value="{{result|tojson|urlencode}}" />

View file

@ -27,7 +27,7 @@
<div class="alert alert-success alert-dismissible fade show" role="alert"> <div class="alert alert-success alert-dismissible fade show" role="alert">
{% for torrent in session.pop('new_torrents',{}).values() %} {% for torrent in session.pop('new_torrents',{}).values() %}
<p> <p>
Added <a class="alert-link" href="{{url_for('qbittorrent',infohash=torrent.hash)}}">{{torrent.name}}</a> Added <a class="alert-link" href="{{url_for('qbittorrent.details',infohash=torrent.hash)}}">{{torrent.name}}</a>
</p> </p>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -15,10 +15,10 @@
{% endmacro %} {% endmacro %}
{% block app_content %} {% block app_content %}
<h2> <h1>
<a href="{{config.APP_CONFIG.sonarr_url}}">Sonarr</a> <a href="{{config.APP_CONFIG.sonarr_url}}">Sonarr</a>
v{{status.version}} ({{series|count}} Shows) v{{status.version}} ({{series|count}} Shows)
</h2> </h1>
<div class="row"> <div class="row">
<div class="col"> <div class="col">

View file

@ -22,13 +22,15 @@
<div class="col-lg"> <div class="col-lg">
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist"> <ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
{% for label,tab in tabs if tab %} {% for label,tab in tabs if tab %}
{% set id_name = [loop.index,tabs_id ]|join("-") %} {% if tab %}
{% if not (loop.first and loop.last) %} {% set id_name = [loop.index,tabs_id ]|join("-") %}
<li class="nav-item"> {% if not (loop.first and loop.last) %}
<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}}"> <li class="nav-item">
{{label}} <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}}">
</a> {{label}}
</li> </a>
</li>
{% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -38,10 +40,12 @@
<div class="col-lg"> <div class="col-lg">
<div class="tab-content" id="searchResults"> <div class="tab-content" id="searchResults">
{% for label,tab in tabs if tab %} {% for label,tab in tabs if tab %}
{% set id_name = [loop.index,tabs_id ]|join("-") %} {% if tab %}
<div class="tab-pane fade {{'show active' if loop.first}}" id="pills-{{id_name}}" role="tabpanel" aria-labelledby="nav-{{id_name}}-tab"> {% set id_name = [loop.index,tabs_id ]|join("-") %}
{{ tab|safe }} <div class="tab-pane fade {{'show active' if loop.first}}" id="pills-{{id_name}}" role="tabpanel" aria-labelledby="nav-{{id_name}}-tab">
</div> {{ tab|safe }}
</div>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View file

@ -1,12 +1,14 @@
import subprocess as SP
import json
import shlex
import time
import os
import io import io
import json
import os
import shlex
import subprocess as SP
import sys import sys
import time
import uuid import uuid
from tqdm import tqdm from tqdm import tqdm
from utils import handle_config from utils import handle_config
profiles = handle_config().get("transcode_profiles", {}) profiles = handle_config().get("transcode_profiles", {})
@ -32,7 +34,7 @@ def ffprobe(file):
out = SP.check_output(cmd) out = SP.check_output(cmd)
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
except: except BaseException:
return file, None return file, None
return file, json.loads(out) return file, json.loads(out)
@ -110,7 +112,13 @@ def transcode(file, outfile, profile, job_id=None, **kwargs):
info = ffprobe(file) info = ffprobe(file)
frames = count_frames(file) frames = count_frames(file)
progbar = tqdm(desc="Processing {}".format(outfile), total=frames, unit=" frames", disable=False,leave=False) 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): for state in run_transcode(file, outfile, profile, job_id, **kwargs):
if "frame" in state: if "frame" in state:
progbar.n = int(state["frame"]) progbar.n = int(state["frame"])
@ -132,10 +140,16 @@ if __name__ == "__main__":
for profile in ["H.265 transcode", "H.264 transcode"]: for profile in ["H.265 transcode", "H.264 transcode"]:
for preset in ["ultrafast", "fast", "medium", "slow", "veryslow"]: for preset in ["ultrafast", "fast", "medium", "slow", "veryslow"]:
for crf in list(range(10, 54, 4))[::-1]: for crf in list(range(10, 54, 4))[::-1]:
outfile = os.path.join("E:\\","transcode",profile,"{}_{}.mkv".format(crf, preset)) outfile = os.path.join(
"E:\\",
"transcode",
profile,
"{}_{}.mkv".format(
crf,
preset))
os.makedirs(os.path.dirname(outfile), exist_ok=True) os.makedirs(os.path.dirname(outfile), exist_ok=True)
if os.path.isfile(outfile): if os.path.isfile(outfile):
print("Skipping",outfile) print("Skipping", outfile)
continue continue
for _ in transcode( for _ in transcode(
file, outfile, profile, "transcode", preset=preset, crf=crf file, outfile, profile, "transcode", preset=preset, crf=crf

268
utils.py
View file

@ -1,45 +1,100 @@
from flask_nav.renderers import Renderer, SimpleRenderer import base64
from dominate import tags import functools
import asteval import hashlib
import operator as op import inspect
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 json
import math
import operator as op
import os import os
import random
import shlex
import string
import subprocess as SP
import sys
import textwrap
import time
from datetime import datetime, timedelta
from functools import wraps
from io import BytesIO
from pprint import pformat
from urllib.parse import quote, unquote_plus, urljoin, urlparse
from urllib.request import urlopen
from PIL import Image import asteval
from PIL import ImageFont import cachetools
from PIL import ImageDraw from cachetools import TTLCache
from dominate import tags
from flask import current_app, flash, json, redirect, request
from flask_login import current_user
from flask_login import login_required as _login_required
from flask_nav.renderers import Renderer, SimpleRenderer
from PIL import Image, ImageDraw, ImageFont
from slugify import slugify
def is_safe_url(target):
ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target))
return test_url.scheme in (
"http", "https") and ref_url.netloc == test_url.netloc
def admin_required(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if current_user.is_authenticated and current_user.is_admin:
return func(*args, **kwargs)
if current_user.is_authenticated:
flash("Insufficient permissions!", "error")
return redirect("/")
return current_app.login_manager.unauthorized()
wrapper.requires_admin = True
return wrapper
def login_required(func):
func = _login_required(func)
func.requires_login = True
return func
def timed_cache(**timedelta_kwargs):
kwargs = timedelta_kwargs or {"minutes": 10}
ttl = timedelta(**kwargs).total_seconds()
cache = TTLCache(sys.maxsize, ttl)
def make_key(*args, **kwargs):
args = list(args)
args[0] = type(args[0])
return cachetools.keys.hashkey(*args, **kwargs)
def _wrapper(func):
return cachetools.cached(cache, key=make_key)(func)
return _wrapper
def handle_config(cfg=None): def handle_config(cfg=None):
if cfg is None: if cfg is None:
if os.path.isfile("config.json"): if os.path.isfile("config.json"):
with open("config.json") as fh: with open("config.json") as fh:
return json.load(fh) cfg=json.load(fh)
with open("config.json", "w") as fh: with open("config.json", "w") as fh:
cfg = json.dump(cfg, fh, indent=4) json.dump(cfg, fh, indent=4)
return return cfg
def with_application_context(app): def with_application_context(app):
def inner(func): def wrapper(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapped(*args, **kwargs):
with app.app_context(): with app.app_context():
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapped
return inner return wrapper
def getsize(text, font_size): def getsize(text, font_size):
@ -83,7 +138,7 @@ def make_placeholder_image(text, width, height, poster=None, wrap=0):
try: try:
with urlopen(poster) as fh: with urlopen(poster) as fh:
poster = Image.open(fh) poster = Image.open(fh)
except Exception as e: except Exception:
poster = None poster = None
else: else:
poster_size = poster.size poster_size = poster.size
@ -95,7 +150,8 @@ def make_placeholder_image(text, width, height, poster=None, wrap=0):
poster = poster.resize(new_size) poster = poster.resize(new_size)
mid = -int((poster.size[1] - height) / 2) mid = -int((poster.size[1] - height) / 2)
im.paste(poster, (0, mid)) im.paste(poster, (0, mid))
draw.text(((width - w) / 2, (height - h) / 2), text, fill="#eee", font=font) draw.text(((width - w) / 2, (height - h) / 2),
text, fill="#eee", font=font)
im.save(io, "PNG") im.save(io, "PNG")
io.seek(0) io.seek(0)
return io return io
@ -123,12 +179,26 @@ class BootsrapRenderer(Renderer):
ret = tags.ul(sub, cls="navbar-nav mr-auto") ret = tags.ul(sub, cls="navbar-nav mr-auto")
return ret return ret
def visit_Text(self, node):
return tags.span(node.text, cls="navbar-text")
def visit_View(self, node): def visit_View(self, node):
badge = node.url_for_kwargs.pop("__badge", None)
classes = ["nav-link"] classes = ["nav-link"]
if hasattr(node, "classes"):
classes = node.classes
if node.active: if node.active:
classes.append("active") classes.append("active")
ret = [tags.a(node.text, href=node.get_url(), cls=" ".join(classes))]
if badge:
ret.insert(
0,
tags.span(
badge[0], cls="badge badge-{} notification-badge".format(badge[1])
),
)
return tags.li( return tags.li(
tags.a(node.text, href=node.get_url(), cls=" ".join(classes)), ret,
cls="nav-item", cls="nav-item",
) )
@ -192,5 +262,147 @@ def eval_expr(expr, ctx=None):
def sort_by(values, expr): def sort_by(values, expr):
return sorted(value, key=lambda v: eval_expr(expr, v)) return sorted(value, key=lambda v: eval_expr(expr, v))
def genpw(num=20): def genpw(num=20):
return "".join(random.choice(string.ascii_lowercase+string.ascii_uppercase+string.digits) for _ in range(num)) return "".join(
random.choice(string.ascii_lowercase + string.ascii_uppercase + string.digits)
for _ in range(num)
)
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__
)
)
def setup_template_filters(app):
@app.template_filter("flatten")
def flatten(obj, path=None):
path = path or ""
if isinstance(obj, dict):
for k, v in sorted(obj.items()):
yield from flatten(v, "{}.{}".format(path, k))
elif isinstance(obj, list):
for k, v in enumerate(obj):
yield from flatten(v, "{}[{}]".format(path, k))
else:
yield path.lstrip("."), obj
@app.template_filter("defaultattr")
def defaultattr(lst, attr, val):
assert isinstance(lst, list)
for item in lst:
assert isinstance(item, dict)
if attr not in item:
item[attr] = val
return lst
@app.template_filter("pformat")
def t_pformat(o):
return pformat(o)
@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("base64")
def jinja_b64(s):
return str(base64.b64encode(bytes(s, "utf8")), "utf8")
@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 BaseException:
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("fromjson")
def fromjson(s):
return json.loads(s)
app.template_filter()(make_tree)
app.add_template_global(handle_config, "cfg")

24
views/__init__.py Normal file
View file

@ -0,0 +1,24 @@
import sys
from flask import Blueprint
from .api import api # noqa
from .config import config_page # noqa
from .containers import containers_page # noqa
from .history import history_page # noqa
from .home import home_page # noqa
from .jellyfin import jellyfin_page # noqa
from .logs import logs_page # noqa
from .qbittorrent import qbittorrent_page # noqa
from .radarr import radarr_page # noqa
from .remote import remote_page # noqa
from .requests import requests_page # noqa
from .search import search_page # noqa
from .sonarr import sonarr_page # noqa
from .transcode import transcode_page # noqa
def register_blueprints(app):
for k, v in vars(sys.modules[__name__]).items():
if isinstance(v, Blueprint):
app.register_blueprint(v)

32
views/api/__init__.py Normal file
View file

@ -0,0 +1,32 @@
from flask import Blueprint, request, flash, session, redirect, url_for
import time
from api import Client
from utils import admin_required
api = Blueprint("api", __name__, url_prefix="/api")
@api.route("/add_torrent", methods=["POST"])
@admin_required
def add_torrent():
category = request.form.get("category")
c = Client()
hashes_1 = set(c.qbittorent.status().get("torrents", {}))
links = ""
count = 0
for link in request.form.getlist("torrent[]"):
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"))

45
views/config/__init__.py Normal file
View file

@ -0,0 +1,45 @@
from flask import Blueprint, json, render_template, request
from api import Client
from forms import ConfigForm
from utils import admin_required, handle_config, populate_form, validate_transcoding_profiles
config_page = Blueprint("config", __name__, url_prefix="/config")
@config_page.route("/", methods=["GET", "POST"])
@admin_required
def index():
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 = handle_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)

View file

@ -0,0 +1,22 @@
from flask import Blueprint, render_template
from api import Client
from utils import admin_required
containers_page = Blueprint("containers", __name__, url_prefix="/containers")
@containers_page.route("/")
@admin_required
def index():
c = Client()
containers = c.portainer.containers()
return render_template("containers/index.html", containers=containers)
@containers_page.route("/<container_id>")
@admin_required
def details(container_id):
c = Client()
container = c.portainer.containers(container_id)
return render_template("containers/details.html", container=container)

15
views/history/__init__.py Normal file
View file

@ -0,0 +1,15 @@
from flask import Blueprint, render_template
from api import Client
from utils import admin_required
history_page = Blueprint("history", __name__, url_prefix="/history")
@history_page.route("/")
@admin_required
def index():
c = Client()
sonarr = c.sonarr.history()
radarr = c.radarr.history()
return render_template("history.html", sonarr=sonarr, radarr=radarr)

16
views/home/__init__.py Normal file
View file

@ -0,0 +1,16 @@
from flask import Blueprint, render_template
from flask_login import current_user
import stats_collect
home_page = Blueprint("home", __name__)
@home_page.route("/")
def index():
stats = stats_collect.Stats()
if not (current_user.is_authenticated and current_user.is_admin):
stats["images"] = [
img for img in stats["images"] if img[0] != "Torrents"]
return render_template("index.html", fluid=True, data=stats)

View file

@ -0,0 +1,43 @@
from flask import Blueprint, render_template
from flask_login import current_user
import stats_collect
from api import Client
from utils import login_required
jellyfin_page = Blueprint("jellyfin", __name__, url_prefix="/jellyfin")
@jellyfin_page.route("/")
@login_required
def index():
c = Client()
stats = stats_collect.Stats()
jellyfin = {
"info": c.jellyfin.system_info(),
"status": c.jellyfin.status(),
"counts": c.jellyfin.get_counts(),
"library": stats["library"],
}
if not (current_user.is_authenticated and current_user.is_admin):
jellyfin["library"] = {}
jellyfin["status"]["HasUpdateAvailable"] = False
jellyfin["status"]["HasPendingRestart"] = False
return render_template("jellyfin/index.html", **jellyfin)
@jellyfin_page.route("/<item_id>")
@login_required
def details(item_id):
c = Client()
jellyfin = {
"info": c.jellyfin.system_info(),
"status": c.jellyfin.status(),
"item": c.jellyfin.media_info(item_id),
}
if jellyfin["item"].get("Type") == "Movie":
return render_template("jellyfin/movie.html", **jellyfin)
if jellyfin["item"].get("Type") == "Series":
return render_template("jellyfin/series.html", **jellyfin)
return render_template("jellyfin/details.html", **jellyfin)

19
views/logs/__init__.py Normal file
View file

@ -0,0 +1,19 @@
from flask import Blueprint, render_template
from api import Client
from utils import admin_required
logs_page = Blueprint("log", __name__, url_prefix="/log")
@logs_page.route("/")
@admin_required
def index():
c = Client()
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)

View file

@ -0,0 +1,48 @@
from flask import Blueprint, render_template, request, redirect
from api import Client
from utils import admin_required
qbittorrent_page = Blueprint(
"qbittorrent",
__name__,
url_prefix="/qbittorrent")
@qbittorrent_page.route("/")
@admin_required
def index():
c = Client()
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,
)
@qbittorrent_page.route("/add_trackers/<infohash>")
@admin_required
def add_trackers(infohash):
c = Client()
c.qbittorent.add_trackers(infohash)
return redirect(url_for("qbittorrent_details", infohash=infohash))
@qbittorrent_page.route("/<infohash>")
@admin_required
def details(infohash):
c = Client()
qbt = c.qbittorent.status(infohash)
return render_template(
"qbittorrent/details.html", qbt=qbt, status_map=c.qbittorent.status_map
)

24
views/radarr/__init__.py Normal file
View file

@ -0,0 +1,24 @@
from flask import Blueprint, render_template
from api import Client
from utils import admin_required
radarr_page = Blueprint("radarr", __name__, url_prefix="/radarr")
@radarr_page.route("/")
@admin_required
def index():
c = Client()
movies = c.radarr.movies()
status = c.radarr.status()
history = c.radarr.history()
return render_template(
"radarr/index.html", movies=movies, status=status, history=history
)
@radarr_page.route("/<movie_id>")
@admin_required
def details(movie_id):
return render_template("radarr/details.html")

98
views/remote/__init__.py Normal file
View file

@ -0,0 +1,98 @@
import base64
import hashlib
import io
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization import load_ssh_public_key
from flask import Blueprint, redirect, render_template, request, url_for, Markup, flash
from forms import AddSSHUser
from api import Client
from utils import admin_required
remote_page = Blueprint("remote", __name__, url_prefix="/remote")
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]))
@remote_page.route("/stop")
@admin_required
def stop():
c = Client()
session_id = request.args.get("session")
c.jellyfin.stop_session(session_id)
return redirect(url_for("remote.index"))
@remote_page.route("/")
@admin_required
def index():
c = Client()
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
load_ssh_public_key(bytes(key, "utf8"))
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(),
"sessions": c.jellyfin.sessions(),
"info": c.jellyfin.system_info(),
}
return render_template(
"remote/index.html",
ssh=ssh_keys,
jellyfin=jellyfin)
@remote_page.route("/add", methods=["GET", "POST"])
@admin_required
def add():
form = AddSSHUser()
c = Client()
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)

217
views/requests/__init__.py Normal file
View file

@ -0,0 +1,217 @@
import base64
import json
from datetime import datetime
from flask import (
Blueprint,
current_app,
flash,
redirect,
render_template,
request,
session,
url_for,
)
from flask_login import current_user
from collections import defaultdict
from api import Client
from forms import RequestForm
from models import RequestItem, RequestUser
from utils import login_required, handle_config
requests_page = Blueprint("requests", __name__, url_prefix="/requests")
@requests_page.route("/<request_id>/<download_id>", methods=["GET"])
@login_required
def download_details(request_id, download_id):
c = Client()
request = RequestItem.query.filter(RequestItem.id == request_id).one_or_none()
if request is None:
flash("Unknown request ID", "danger")
return redirect(url_for("requests.details", request_id=request_id))
try:
qbt = c.qbittorent.poll(download_id)
except Exception:
flash("Unknown download ID", "danger")
return redirect(url_for("requests.details", request_id=request_id))
return render_template("qbittorrent/details.html", qbt=qbt)
@requests_page.route("/<request_id>", methods=["GET"])
@login_required
def details(request_id):
c = Client()
if current_user.is_admin:
requests = RequestItem.query
else:
user_id = current_user.get_id()
requests = RequestItem.query.filter(
RequestItem.users.any(RequestUser.user_id == user_id)
)
request = requests.filter(RequestItem.id == request_id).one_or_none()
if request is None:
flash("Unknown request ID", "danger")
return redirect(url_for("requests.index"))
RequestUser.query.filter(
(RequestUser.user_id == current_user.id)
& (RequestUser.item_id == request.item_id)
).update({RequestUser.updated: False})
current_app.db.session.commit()
jf_item = None
arr = request.arr_item
id_map = c.jellyfin.id_map()
for key_id in ["tmdbId", "imdbId", "tvdbId"]:
if key_id in arr:
key = (key_id.lower()[:-2], str(arr[key_id]))
jf_item = id_map.get(key, None)
if jf_item:
break
return render_template(
"requests/details.html", request=request, jellyfin_id=jf_item
)
@requests_page.route("/", methods=["GET", "POST"])
@login_required
def index():
c = Client()
form = RequestForm()
user_id = current_user.get_id()
cfg = handle_config()
used_requests = defaultdict(int)
if current_user.is_admin:
requests = RequestItem.query
else:
requests = RequestItem.query.filter(
RequestItem.users.any(RequestUser.user_id == user_id)
)
for item in requests:
if item.approved is None:
used_requests[item.request_type] += 1
remaining = {}
remaining["movies"] = (
cfg["num_requests_per_user"]["movies"] - used_requests["radarr"]
)
remaining["shows"] = cfg["num_requests_per_user"]["shows"] - used_requests["sonarr"]
print("RQs:", used_requests, remaining)
if (
request.method == "POST"
and ("approve" in request.form)
or ("decline" in request.form)
):
approved = "approve" in request.form
declined = "decline" in request.form
if approved and declined:
flash("What the fuck?")
approved = False
declined = False
if approved or declined:
new_state = approved
print("Approved:", approved)
for item_id in request.form.getlist("selected[]"):
item = RequestItem.query.get(item_id)
if item.approved != new_state:
RequestUser.query.filter(RequestUser.item_id == item_id).update(
{RequestUser.updated: True}
)
item.approved = new_state
if new_state is True:
search_type = item.request_type
if hasattr(c, search_type):
api = getattr(c, search_type)
if hasattr(api, "add"):
result = api.add(json.loads(item.data))
print(result)
if item.request_type == "sonarr":
item.arr_id = result["seriesId"]
if item.request_type == "radarr":
item.arr_id = result["id"]
else:
flash("Invalid search type: {}".format(search_type), "danger")
current_app.db.session.merge(item)
current_app.db.session.commit()
return render_template(
"requests/index.html",
results=[],
form=form,
search_type=None,
requests=requests,
)
return redirect(url_for("requests.index"))
if request.method == "POST" and form.search.data is False:
request_type = request.form["search_type"]
for item in request.form.getlist("selected[]"):
data = str(base64.b64decode(item), "utf8")
item = json.loads(data)
item_id = "{type}/{titleSlug}/{year}".format(type=request_type, **item)
user_id = session["jf_user"].get_id()
request_entry = RequestItem.query.get(item_id)
if request_entry is None:
request_entry = RequestItem(
added_date=datetime.now(),
item_id=item_id,
users=[],
data=data,
request_type=request_type,
)
current_app.db.session.add(request_entry)
request_entry.users.append(
RequestUser(
user_id=user_id, item_id=item_id, user_name=current_user["Name"]
)
)
current_app.db.session.merge(request_entry)
current_app.db.session.commit()
return render_template(
"requests/index.html", results=[], form=form, requests=requests
)
if form and form.validate_on_submit():
c = Client()
query = form.query.data
search_type = form.search_type.data
if hasattr(c, search_type):
api = getattr(c, search_type)
if hasattr(api, "search"):
results = api.search(query)
return render_template(
"requests/index.html",
results=results,
form=form,
search_type=search_type,
)
flash("Invalid search type: {}".format(search_type), "danger")
return render_template(
"requests/index.html",
results=[],
form=form,
search_type=None,
requests=requests,
)
"""
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,
)
"""

62
views/search/__init__.py Normal file
View file

@ -0,0 +1,62 @@
from urllib.parse import unquote_plus
from flask import Blueprint, json, render_template, request
from api import Client
from forms import SearchForm
from utils import admin_required
search_page = Blueprint("search", __name__, url_prefix="/search")
@search_page.route("/details", methods=["GET", "POST"])
@admin_required
def details():
data = {
"info": json.loads(unquote_plus(request.form["data"])),
"type": request.form["type"],
}
return render_template("search/details.html", **data)
@search_page.route("/", methods=["GET", "POST"])
@admin_required
def index():
c = Client()
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",
)

24
views/sonarr/__init__.py Normal file
View file

@ -0,0 +1,24 @@
from flask import Blueprint, render_template
from api import Client
from utils import admin_required
sonarr_page = Blueprint("sonarr", __name__, url_prefix="/sonarr")
@sonarr_page.route("/")
@admin_required
def index():
c = Client()
series = c.sonarr.series()
status = c.sonarr.status()
history = c.sonarr.history()
return render_template(
"sonarr/index.html", series=series, status=status, history=history
)
@sonarr_page.route("/<show_id>")
@admin_required
def details(show_id):
return render_template("sonarr/details.html")

View file

@ -0,0 +1,10 @@
from flask import Blueprint, render_template
from utils import admin_required
transcode_page = Blueprint("transcode", __name__, url_prefix="/transcode")
@transcode_page.route("/", methods=["GET", "POST"])
@admin_required
def index():
return render_template("transcode/profiles.html")