push latest changes
This commit is contained in:
parent
7523a19d1f
commit
cb2b5c2c2b
63 changed files with 3158 additions and 1552 deletions
6
TODO.md
6
TODO.md
|
@ -1,8 +1,8 @@
|
|||
- Jellyfin integration (?)
|
||||
- Jellyfin integration
|
||||
- Details page
|
||||
- Webhooks for transcode queue
|
||||
- Webhook event log
|
||||
- Database models
|
||||
- Container details
|
||||
- Transcode Job queue
|
||||
- Transcode profile editor
|
||||
- DB Models
|
||||
- Transcode profile editor
|
649
api.py
649
api.py
|
@ -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
143
api/__init__.py
Normal 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
45
api/jackett.py
Normal 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
333
api/jellyfin.py
Normal 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
75
api/portainer.py
Normal 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
155
api/qbittorrent.py
Normal 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
98
api/radarr.py
Normal 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
116
api/sonarr.py
Normal 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
34
api/user.py
Normal 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
656
app.py
|
@ -1,83 +1,107 @@
|
|||
import sys
|
||||
from gevent import monkey
|
||||
if not "--debug" in sys.argv[1:]:
|
||||
import sys # isort:skip
|
||||
from gevent import monkey # isort:skip
|
||||
|
||||
if __name__ == "__main__" and "--debug" not in sys.argv[1:]:
|
||||
monkey.patch_all()
|
||||
|
||||
import os
|
||||
|
||||
import requests as RQ
|
||||
import json
|
||||
import re
|
||||
import io
|
||||
import hashlib
|
||||
import base64
|
||||
import time
|
||||
import threading
|
||||
from webargs import fields
|
||||
from webargs.flaskparser import use_args
|
||||
from datetime import timedelta, datetime
|
||||
from pprint import pprint
|
||||
from urllib.parse import quote, urljoin, unquote_plus
|
||||
import pylab as PL
|
||||
from matplotlib.ticker import EngFormatter
|
||||
from base64 import b64encode
|
||||
from slugify import slugify
|
||||
from cryptography.hazmat.primitives.serialization import load_ssh_public_key
|
||||
from flask import (
|
||||
Flask,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
send_from_directory,
|
||||
request,
|
||||
send_file,
|
||||
redirect,
|
||||
flash,
|
||||
url_for,
|
||||
send_from_directory,
|
||||
session,
|
||||
jsonify,
|
||||
Markup
|
||||
url_for,
|
||||
)
|
||||
from flask_nav import Nav, register_renderer
|
||||
from flask_nav.elements import Navbar, View, Subgroup
|
||||
from flask_bootstrap import Bootstrap
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from flask_debugtoolbar import DebugToolbarExtension
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
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
|
||||
from forms import ConfigForm, SearchForm, TranscodeProfileForm, AddSSHUser
|
||||
from api import Client
|
||||
from models import db, TranscodeJob, Stats
|
||||
from api.user import JellyfinUser
|
||||
|
||||
from forms import LoginForm
|
||||
from models import RequestUser, db
|
||||
from transcode import profiles
|
||||
from utils import (
|
||||
BootsrapRenderer,
|
||||
eval_expr,
|
||||
make_tree,
|
||||
make_placeholder_image,
|
||||
with_application_context,
|
||||
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():
|
||||
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 = [
|
||||
View("Home", "index"),
|
||||
View("Containers", "containers", container_id=None),
|
||||
View("qBittorrent", "qbittorrent", infohash=None),
|
||||
View("Sonarr", "sonarr", id=None),
|
||||
View("Radarr", "radarr", id=None),
|
||||
View("Jellyfin", "jellyfin"),
|
||||
View("Search", "search"),
|
||||
View("History", "history"),
|
||||
View("Transcode", "transcode"),
|
||||
View("Config", "config"),
|
||||
View("Remote", "remote"),
|
||||
View("Log", "app_log"),
|
||||
View("Home", "home.index"),
|
||||
View("Requests", "requests.index", __badge=requests_badge),
|
||||
View("Containers", "containers.index", container_id=None),
|
||||
View("qBittorrent", "qbittorrent.index", infohash=None),
|
||||
View("Sonarr", "sonarr.index"),
|
||||
View("Radarr", "radarr.index"),
|
||||
View("Jellyfin", "jellyfin.index"),
|
||||
View("Search", "search.index"),
|
||||
View("History", "history.index"),
|
||||
View("Transcode", "transcode.index"),
|
||||
View("Config", "config.index"),
|
||||
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)
|
||||
|
||||
|
||||
def right_nav():
|
||||
if current_user.is_authenticated:
|
||||
return Text(current_user["Name"])
|
||||
else:
|
||||
return Text("")
|
||||
|
||||
|
||||
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.config.from_pyfile("config.cfg")
|
||||
app.bootstrap = Bootstrap(app)
|
||||
|
@ -90,102 +114,43 @@ def create_app():
|
|||
app.jinja_env.lstrip_blocks = True
|
||||
register_renderer(app, "bootstrap4", BootsrapRenderer)
|
||||
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.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
|
||||
|
||||
|
||||
app = create_app()
|
||||
setup_template_filters(app)
|
||||
register_blueprints(app)
|
||||
|
||||
|
||||
@app.template_filter("hash")
|
||||
def t_hash(s):
|
||||
return hashlib.sha512(bytes(s, "utf-8")).hexdigest()
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
print(error)
|
||||
return ""
|
||||
|
||||
|
||||
@app.template_filter()
|
||||
def regex_replace(s, find, replace):
|
||||
"""A non-optimal implementation of a regex filter"""
|
||||
return re.sub(find, replace, s)
|
||||
@app.errorhandler(404)
|
||||
def internal_error(error):
|
||||
print(error)
|
||||
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
|
||||
def before_request():
|
||||
db.create_all()
|
||||
app.config["APP_CONFIG"] = handle_config()
|
||||
# if request.cookies.get('magic')!="FOO":
|
||||
# return ""
|
||||
|
||||
|
||||
@app.route("/static/<path:path>")
|
||||
|
@ -193,391 +158,74 @@ def send_static(path):
|
|||
return send_from_directory("static", path)
|
||||
|
||||
|
||||
def populate_form(form, cfg=None):
|
||||
if cfg is None:
|
||||
cfg = handle_config()
|
||||
for name, field in form._fields.items():
|
||||
field.default = cfg.get(name)
|
||||
form.transcode_default_profile.choices = [(None, "")]
|
||||
form.transcode_default_profile.choices += [
|
||||
(k, k) for k in (cfg.get("transcode_profiles", {}) or {}).keys()
|
||||
]
|
||||
|
||||
|
||||
def validate_transcoding_profiles(profiles):
|
||||
for name, data in profiles.items():
|
||||
for req, req_type in [("command", str), ("doc", str)]:
|
||||
if req not in data:
|
||||
raise ValueError(
|
||||
"Profile '{}' is missing required key '{}'".format(name, req)
|
||||
)
|
||||
if not isinstance(data[req], req_type):
|
||||
raise ValueError(
|
||||
"Key '{}' of profile '{}' should be of type '{}'".format(
|
||||
req, name, req_type.__name__
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.route("/config", methods=["GET", "POST"])
|
||||
def config():
|
||||
form = ConfigForm()
|
||||
cfg = {}
|
||||
populate_form(form)
|
||||
if form.validate_on_submit():
|
||||
skip = ["save", "test", "csrf_token"]
|
||||
transcode_profiles = request.files.get("transcode_profiles")
|
||||
if transcode_profiles:
|
||||
try:
|
||||
form.transcode_profiles.data = json.load(transcode_profiles)
|
||||
validate_transcoding_profiles(form.transcode_profiles.data)
|
||||
except ValueError as e:
|
||||
form.transcode_profiles.data = None
|
||||
form.transcode_profiles.errors = [
|
||||
"Invalid json data in file {}: {}".format(
|
||||
transcode_profiles.filename, e
|
||||
)
|
||||
]
|
||||
else:
|
||||
form.transcode_profiles.data = app.config["APP_CONFIG"].get(
|
||||
"transcode_profiles", {}
|
||||
)
|
||||
if form.errors:
|
||||
return render_template("config.html", form=form)
|
||||
for name, field in form._fields.items():
|
||||
if name in skip:
|
||||
continue
|
||||
cfg[name] = field.data
|
||||
if form.test.data:
|
||||
test_res = Client.test(cfg)
|
||||
populate_form(form, cfg)
|
||||
return render_template("config.html", form=form, test=test_res)
|
||||
handle_config(cfg)
|
||||
populate_form(form)
|
||||
return render_template("config.html", form=form)
|
||||
form.process()
|
||||
return render_template("config.html", form=form)
|
||||
|
||||
|
||||
@app.route("/search/details", methods=["GET", "POST"])
|
||||
def details():
|
||||
data = {
|
||||
"info": json.loads(unquote_plus(request.form["data"])),
|
||||
"type": request.form["type"],
|
||||
}
|
||||
return render_template("search/details.html", **data)
|
||||
|
||||
|
||||
@app.route("/search", methods=["GET", "POST"])
|
||||
def search():
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
results = {}
|
||||
params = request.args
|
||||
form = SearchForm()
|
||||
form.indexer.choices = c.jackett.indexers()
|
||||
if form.validate_on_submit():
|
||||
query = form.query.data
|
||||
if not (form.torrents.data or form.movies.data or form.tv_shows.data):
|
||||
form.torrents.data = True
|
||||
form.movies.data = True
|
||||
form.tv_shows.data = True
|
||||
|
||||
if form.torrents.data:
|
||||
results["torrents"] = c.jackett.search(
|
||||
query, form.indexer.data or form.indexer.choices
|
||||
)
|
||||
if form.movies.data:
|
||||
results["movies"] = c.radarr.search(query)
|
||||
if form.tv_shows.data:
|
||||
results["tv_shows"] = c.sonarr.search(query)
|
||||
return render_template(
|
||||
"search/index.html",
|
||||
# form=form,
|
||||
search_term=query,
|
||||
results=results,
|
||||
client=c,
|
||||
group_by_tracker=form.group_by_tracker.data,
|
||||
)
|
||||
for name, field in form._fields.items():
|
||||
field.default = params.get(name)
|
||||
form.process()
|
||||
return render_template(
|
||||
"search/index.html",
|
||||
form=form,
|
||||
results={},
|
||||
group_by_tracker=False,
|
||||
sort_by="Gain",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/add_torrent", methods=["POST"])
|
||||
def add_torrent():
|
||||
category=request.form.get("category")
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
hashes_1 = set(c.qbittorent.status().get("torrents", {}))
|
||||
links = ""
|
||||
count = 0
|
||||
for link in request.form.getlist("torrent[]"):
|
||||
print(link)
|
||||
links += link + "\n"
|
||||
count += 1
|
||||
c.qbittorent.add(urls=links,category=category)
|
||||
for _ in range(10):
|
||||
status=c.qbittorent.status().get("torrents", {})
|
||||
hashes_2 = set(status)
|
||||
if len(hashes_2 - hashes_1) == count:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
flash("Some torrents failed to get added to QBittorrent", "waring")
|
||||
new_torrents = sorted(hashes_2 - hashes_1)
|
||||
session["new_torrents"] = {h: status[h] for h in new_torrents}
|
||||
return redirect(url_for("search"))
|
||||
|
||||
|
||||
@app.route("/history")
|
||||
def history():
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
sonarr = c.sonarr.history()
|
||||
radarr = c.radarr.history()
|
||||
return render_template("history.html", sonarr=sonarr, radarr=radarr)
|
||||
|
||||
|
||||
@app.route("/sonarr", defaults={"show_id": None})
|
||||
@app.route("/sonarr/<show_id>")
|
||||
def sonarr(show_id):
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
if not show_id:
|
||||
series = c.sonarr.series()
|
||||
status = c.sonarr.status()
|
||||
return render_template(
|
||||
"sonarr/index.html", series=series, status=status, history=history
|
||||
)
|
||||
return render_template("sonarr/details.html")
|
||||
|
||||
|
||||
@app.route("/radarr", defaults={"movie_id": None})
|
||||
@app.route("/radarr/<movie_id>")
|
||||
def radarr(movie_id):
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
if movie_id is None:
|
||||
movies = c.radarr.movies()
|
||||
status = c.radarr.status()
|
||||
history = c.radarr.history()
|
||||
return render_template(
|
||||
"radarr/index.html", movies=movies, status=status, history=history
|
||||
)
|
||||
return render_template("radarr/details.html")
|
||||
|
||||
|
||||
@app.route("/qbittorrent")
|
||||
def qbittorrent():
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
qbt = c.qbittorent.status()
|
||||
sort_by_choices = {
|
||||
"speed": "Transfer Speed",
|
||||
"eta": "Time remaining",
|
||||
"state": "State",
|
||||
"category": "Category",
|
||||
}
|
||||
return render_template(
|
||||
"qbittorrent/index.html",
|
||||
qbt=qbt,
|
||||
status_map=c.qbittorent.status_map,
|
||||
state_filter=request.args.get("state"),
|
||||
sort_by=request.args.get("sort","speed"),
|
||||
sort_by_choices=sort_by_choices,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/qbittorrent/add_trackers/<infohash>")
|
||||
def qbittorent_add_trackers(infohash):
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
c.qbittorent.add_trackers(infohash)
|
||||
return redirect(url_for("qbittorrent_details",infohash=infohash))
|
||||
|
||||
@app.route("/qbittorrent/<infohash>")
|
||||
def qbittorrent_details(infohash):
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
qbt = c.qbittorent.status(infohash)
|
||||
return render_template(
|
||||
"qbittorrent/details.html", qbt=qbt, status_map=c.qbittorent.status_map
|
||||
)
|
||||
|
||||
|
||||
from wtforms_alchemy import model_form_factory, ModelFieldList
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms.fields import FormField
|
||||
|
||||
BaseModelForm = model_form_factory(FlaskForm)
|
||||
|
||||
|
||||
class ModelForm(BaseModelForm):
|
||||
@classmethod
|
||||
def get_session(self):
|
||||
return app.db.session
|
||||
|
||||
|
||||
@app.route("/test", methods=["GET", "POST"])
|
||||
def test():
|
||||
form = TranscodeProfileForm()
|
||||
if form.validate_on_submit():
|
||||
print(form.data)
|
||||
return render_template("test.html", form=form)
|
||||
|
||||
|
||||
@app.route("/placeholder")
|
||||
def placeholder():
|
||||
return send_file(make_placeholder_image(**request.args), mimetype="image/png")
|
||||
return send_file(
|
||||
make_placeholder_image(
|
||||
**request.args),
|
||||
mimetype="image/png")
|
||||
|
||||
|
||||
@app.route("/containers", defaults={"container_id": None})
|
||||
@app.route("/containers/<container_id>")
|
||||
def containers(container_id):
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
if container_id:
|
||||
container = c.portainer.containers(container_id)
|
||||
return render_template("containers/details.html", container=container)
|
||||
containers = c.portainer.containers()
|
||||
return render_template("containers/index.html", containers=containers)
|
||||
@app.login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
if "jf_user" in session:
|
||||
if session["jf_user"].id == user_id:
|
||||
return session["jf_user"]
|
||||
|
||||
|
||||
def get_stats():
|
||||
if os.path.isfile("stats.lock"):
|
||||
return None
|
||||
try:
|
||||
if os.path.isfile("stats.json"):
|
||||
with open("stats.json") as fh:
|
||||
return json.load(fh)
|
||||
except:
|
||||
return None
|
||||
@app.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
del session["jf_user"]
|
||||
logout_user()
|
||||
return redirect("/login")
|
||||
|
||||
|
||||
@app.route("/transcode", methods=["GET", "POST"])
|
||||
def transcode():
|
||||
return render_template("transcode/profiles.html")
|
||||
|
||||
|
||||
@app.route("/log")
|
||||
def app_log():
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
logs = {
|
||||
"radarr": c.radarr.log(),
|
||||
"sonarr": c.sonarr.log(),
|
||||
"qbt": c.qbittorent.log(),
|
||||
"peers": c.qbittorent.peer_log(),
|
||||
}
|
||||
return render_template("logs.html", logs=logs)
|
||||
|
||||
|
||||
def ssh_fingerprint(key):
|
||||
fp=hashlib.md5(base64.b64decode(key)).hexdigest()
|
||||
return ':'.join(a+b for a,b in zip(fp[::2], fp[1::2]))
|
||||
|
||||
@app.route("/remote")
|
||||
def remote():
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
res = c.ssh.get("/data/.ssh/authorized_keys",io.BytesIO())
|
||||
res.local.seek(0)
|
||||
ssh_keys=[]
|
||||
for key in str(res.local.read(),"utf8").splitlines():
|
||||
disabled=False
|
||||
if key.startswith("#"):
|
||||
key=key.lstrip("#").lstrip()
|
||||
disabled=True
|
||||
try:
|
||||
load_ssh_public_key(bytes(key,"utf8"))
|
||||
except:
|
||||
continue
|
||||
key_type,key,name=key.split(None,2)
|
||||
ssh_keys.append({
|
||||
'disabled': disabled,
|
||||
'type':key_type,
|
||||
'key':key,
|
||||
'fingerprint': ssh_fingerprint(key),
|
||||
'name': name
|
||||
})
|
||||
key=request.args.get("key")
|
||||
enabled=request.args.get("enabled")
|
||||
if not (key is None or enabled is None):
|
||||
key_file=[]
|
||||
for ssh_key in ssh_keys:
|
||||
if ssh_key['key']==key:
|
||||
ssh_key['disabled']=enabled=="False"
|
||||
if ssh_key['disabled']:
|
||||
key_file.append("#{type} {key} {name}".format(**ssh_key))
|
||||
else:
|
||||
key_file.append("{type} {key} {name}".format(**ssh_key))
|
||||
buf=io.BytesIO(bytes("\n".join(key_file),"utf8"))
|
||||
c.ssh.put(buf,"/data/.ssh/authorized_keys",preserve_mode=False)
|
||||
return redirect(url_for("remote"))
|
||||
jellyfin_users = c.jellyfin.get_users()
|
||||
return render_template("remote/index.html",ssh=ssh_keys,jf=jellyfin_users)
|
||||
|
||||
@app.route("/jellyfin/stop")
|
||||
def stop_stream():
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
session_id=request.args.get("session")
|
||||
c.jellyfin.stop_session(session_id)
|
||||
return redirect(url_for("jellyfin"))
|
||||
|
||||
@app.route("/jellyfin")
|
||||
def jellyfin():
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
jellyfin={
|
||||
"users":c.jellyfin.get_users(),
|
||||
"sessions": c.jellyfin.sessions(),
|
||||
"info" : c.jellyfin.system_info()
|
||||
}
|
||||
return render_template("jellyfin/index.html",jellyfin=jellyfin)
|
||||
|
||||
@app.route("/remote/add",methods=["GET","POST"])
|
||||
def remote_add():
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
form = AddSSHUser()
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
next_url = request.args.get("next")
|
||||
if current_user.is_authenticated:
|
||||
if next_url and not is_safe_url(next_url):
|
||||
next_url = None
|
||||
return redirect(next_url or url_for("home.index"))
|
||||
form = LoginForm()
|
||||
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)
|
||||
try:
|
||||
jf = JellyfinUser(form.username.data, form.password.data)
|
||||
except RQ.exceptions.HTTPError as e:
|
||||
if e.response.status_code != 401:
|
||||
raise
|
||||
flash("Invalid credentials", "error")
|
||||
return render_template("login.html", form=form)
|
||||
login_user(jf, remember=form.remember.data)
|
||||
session["jf_user"] = jf
|
||||
|
||||
next_url = request.args.get("next")
|
||||
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("/")
|
||||
def index():
|
||||
return render_template("index.html", fluid=True, data=get_stats())
|
||||
@app.before_first_request
|
||||
def before_first_request():
|
||||
app.db.create_all()
|
||||
# stats_collect.loop(60)
|
||||
|
||||
|
||||
@with_application_context(app)
|
||||
def init_app():
|
||||
app.db.create_all()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
stats_collector = threading.Thread(
|
||||
None, stats_collect.loop, "stats_collector", (10,), {}, daemon=True
|
||||
)
|
||||
stats_collector.start()
|
||||
port = 5000
|
||||
if "--init" in sys.argv:
|
||||
init_app()
|
||||
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:
|
||||
from gevent.pywsgi import WSGIServer
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
SECRET_KEY = b"DEADBEEF"
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite:///Mediadash.db"
|
||||
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
|
|
@ -1,42 +1,37 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from flask_wtf import FlaskForm
|
||||
import json
|
||||
import os
|
||||
|
||||
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 (
|
||||
StringField,
|
||||
PasswordField,
|
||||
FieldList,
|
||||
FloatField,
|
||||
BooleanField,
|
||||
PasswordField,
|
||||
# RadioField,
|
||||
SelectField,
|
||||
SubmitField,
|
||||
validators,
|
||||
Field,
|
||||
FieldList,
|
||||
SelectMultipleField,
|
||||
StringField,
|
||||
SubmitField,
|
||||
TextAreaField,
|
||||
FieldList,
|
||||
FormField,
|
||||
)
|
||||
from flask_wtf.file import FileField, FileAllowed, FileRequired
|
||||
from wtforms.ext.sqlalchemy.orm import model_form
|
||||
from wtforms.fields.html5 import SearchField
|
||||
from wtforms.widgets.html5 import NumberInput
|
||||
from wtforms.widgets import TextInput, CheckboxInput, ListWidget, PasswordInput
|
||||
from wtforms.validators import (
|
||||
ValidationError,
|
||||
DataRequired,
|
||||
URL,
|
||||
ValidationError,
|
||||
Optional,
|
||||
)
|
||||
from wtforms.validators import URL, DataRequired, Optional
|
||||
from wtforms.widgets import PasswordInput
|
||||
|
||||
|
||||
def json_prettify(file):
|
||||
with open(file, "r") as fh:
|
||||
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):
|
||||
query = SearchField("Query", validators=[DataRequired()])
|
||||
tv_shows = BooleanField("TV Shows", default=True)
|
||||
|
@ -46,21 +41,30 @@ class SearchForm(FlaskForm):
|
|||
group_by_tracker = BooleanField("Group torrents by tracker")
|
||||
search = SubmitField("Search")
|
||||
|
||||
|
||||
class HiddenPassword(PasswordField):
|
||||
widget = PasswordInput(hide_value=False)
|
||||
|
||||
|
||||
class TranscodeProfileForm(FlaskForm):
|
||||
test = TextAreaField()
|
||||
save = SubmitField("Save")
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField("Username", validators=[DataRequired()])
|
||||
password = HiddenPassword("Password", validators=[DataRequired()])
|
||||
remember = BooleanField("Remember me")
|
||||
login = SubmitField("Login")
|
||||
|
||||
|
||||
class AddSSHUser(FlaskForm):
|
||||
name = StringField("Name", validators=[DataRequired()])
|
||||
ssh_key = StringField("Public key", validators=[DataRequired()])
|
||||
add = SubmitField("Add")
|
||||
|
||||
def validate_ssh_key(self,field):
|
||||
key=load_ssh_public_key(bytes(field.data,"utf8"))
|
||||
|
||||
def validate_ssh_key(self, field):
|
||||
load_ssh_public_key(bytes(field.data, "utf8"))
|
||||
|
||||
|
||||
class ConfigForm(FlaskForm):
|
|
@ -1,4 +1,8 @@
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
db = SQLAlchemy()
|
||||
from flask_sqlalchemy import SQLAlchemy # isort:skip
|
||||
|
||||
db = SQLAlchemy() # isort:skip
|
||||
|
||||
from .transcode import TranscodeJob
|
||||
from .stats import Stats
|
||||
from .transcode import TranscodeJob
|
||||
from .requests import RequestItem, RequestUser
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
|
74
models/requests.py
Normal file
74
models/requests.py
Normal 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)
|
|
@ -1,7 +1,9 @@
|
|||
from . import db
|
||||
from sqlalchemy import String, Float, Column, Integer, DateTime
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, Float, Integer, String
|
||||
|
||||
from . import db
|
||||
|
||||
|
||||
class Stats(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
@ -9,6 +11,7 @@ class Stats(db.Model):
|
|||
key = db.Column(db.String)
|
||||
value = db.Column(db.Float)
|
||||
|
||||
|
||||
class Diagrams(db.Model):
|
||||
name = db.Column(db.String,primary_key=True)
|
||||
data = db.Column(db.String)
|
||||
name = db.Column(db.String, primary_key=True)
|
||||
data = db.Column(db.String)
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
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 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):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
created = db.Column(db.DateTime, default=datetime.today)
|
||||
status = db.Column(JSONType, default={})
|
||||
completed = db.Column(db.DateTime, default=None)
|
||||
profile = db.Column(db.String, default=None)
|
||||
profile = db.Column(db.String, default=None)
|
||||
|
|
3
setup.cfg
Normal file
3
setup.cfg
Normal file
|
@ -0,0 +1,3 @@
|
|||
[flake8]
|
||||
extend_exclude = .history
|
||||
ingore = E501
|
51
static/icon.svg
Normal file
51
static/icon.svg
Normal 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 |
|
@ -127,3 +127,16 @@ ul.tree {
|
|||
.active {
|
||||
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
1
stats/calendar.json
Normal file
File diff suppressed because one or more lines are too long
1
stats/data.json
Normal file
1
stats/data.json
Normal file
File diff suppressed because one or more lines are too long
1
stats/images.json
Normal file
1
stats/images.json
Normal file
File diff suppressed because one or more lines are too long
1
stats/library.json
Normal file
1
stats/library.json
Normal file
File diff suppressed because one or more lines are too long
1
stats/qbt_hist.json
Normal file
1
stats/qbt_hist.json
Normal 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]}
|
375
stats_collect.py
375
stats_collect.py
|
@ -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 os
|
||||
from urllib.parse import quote
|
||||
from datetime import datetime
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from base64 import b64encode
|
||||
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"
|
||||
|
||||
|
@ -17,14 +21,15 @@ smoothness = 5
|
|||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def make_smooth(data, window_size):
|
||||
ret = []
|
||||
for i, _ in enumerate(data):
|
||||
block = data[i : i + window_size]
|
||||
block = data[i: i + window_size]
|
||||
ret.append(sum(block) / len(block))
|
||||
return ret
|
||||
|
||||
|
@ -93,7 +98,8 @@ def histogram(values, bins, title=None, color="#eee", unit=""):
|
|||
|
||||
|
||||
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):
|
||||
|
@ -104,11 +110,11 @@ def byte_labels(label, idx, values):
|
|||
values[idx] /= 1024
|
||||
i += 1
|
||||
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):
|
||||
orig_values = list(values)
|
||||
suffix = ["", "K", "M", "G", "T", "P", "E"]
|
||||
i = 0
|
||||
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")
|
||||
|
||||
|
||||
hist = {
|
||||
qbt_hist = {
|
||||
"t": [],
|
||||
"dl": [],
|
||||
"ul": [],
|
||||
|
@ -162,59 +168,87 @@ hist = {
|
|||
|
||||
|
||||
def update_qbt_hist(stats, limit=1024):
|
||||
global hist
|
||||
global qbt_hist
|
||||
data = stats["qbt"]["status"]
|
||||
hist["t"].append(time.time())
|
||||
hist["dl"].append(data["server_state"]["dl_info_speed"])
|
||||
hist["ul"].append(data["server_state"]["up_info_speed"])
|
||||
hist["dl_size"].append(data["server_state"]["alltime_dl"])
|
||||
hist["ul_size"].append(data["server_state"]["alltime_ul"])
|
||||
hist["dl_size_sess"].append(data["server_state"]["dl_info_data"])
|
||||
hist["ul_size_sess"].append(data["server_state"]["up_info_data"])
|
||||
hist["connections"].append(data["server_state"]["total_peer_connections"])
|
||||
hist["dht_nodes"].append(data["server_state"]["dht_nodes"])
|
||||
hist["bw_per_conn"].append(
|
||||
(data["server_state"]["dl_info_speed"] + data["server_state"]["up_info_speed"])
|
||||
/ data["server_state"]["total_peer_connections"]
|
||||
)
|
||||
for k in hist:
|
||||
hist[k] = hist[k][-limit:]
|
||||
qbt_hist["t"].append(time.time())
|
||||
qbt_hist["dl"].append(data["server_state"]["dl_info_speed"])
|
||||
qbt_hist["ul"].append(data["server_state"]["up_info_speed"])
|
||||
qbt_hist["dl_size"].append(data["server_state"]["alltime_dl"])
|
||||
qbt_hist["ul_size"].append(data["server_state"]["alltime_ul"])
|
||||
qbt_hist["dl_size_sess"].append(data["server_state"]["dl_info_data"])
|
||||
qbt_hist["ul_size_sess"].append(data["server_state"]["up_info_data"])
|
||||
qbt_hist["connections"].append(
|
||||
data["server_state"]["total_peer_connections"])
|
||||
qbt_hist["dht_nodes"].append(data["server_state"]["dht_nodes"])
|
||||
qbt_hist["bw_per_conn"].append(
|
||||
(data["server_state"]["dl_info_speed"] +
|
||||
data["server_state"]["up_info_speed"]) /
|
||||
data["server_state"]["total_peer_connections"])
|
||||
for k in qbt_hist:
|
||||
qbt_hist[k] = qbt_hist[k][-limit:]
|
||||
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
|
||||
last_idx = i + 1
|
||||
for k in hist:
|
||||
hist[k] = hist[k][last_idx:]
|
||||
return hist
|
||||
for k in qbt_hist:
|
||||
qbt_hist[k] = qbt_hist[k][last_idx:]
|
||||
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
|
||||
|
||||
PL.clf()
|
||||
cfg = handle_config()
|
||||
c = Client(cfg)
|
||||
series={}
|
||||
movies={}
|
||||
data = {
|
||||
"radarr": {"entries": c.radarr.movies(), "status": c.radarr.status()},
|
||||
"sonarr": {
|
||||
"entries": c.sonarr.series(),
|
||||
"status": c.sonarr.status(),
|
||||
"details": {},
|
||||
},
|
||||
"qbt": {"status": c.qbittorent.status()},
|
||||
}
|
||||
series = {}
|
||||
movies = {}
|
||||
data = get_base_stats(pool)
|
||||
for show in data["sonarr"]["entries"]:
|
||||
series[show["id"]]=show
|
||||
series[show["id"]] = show
|
||||
for movie in data["radarr"]["entries"]:
|
||||
movies[movie["id"]]=movie
|
||||
movies[movie["id"]] = movie
|
||||
torrent_states = {}
|
||||
torrent_categories = {}
|
||||
for torrent in data["qbt"]["status"]["torrents"].values():
|
||||
state = c.qbittorent.status_map.get(torrent["state"], (torrent["state"], None))[
|
||||
0
|
||||
]
|
||||
state = c.qbittorent.status_map.get(
|
||||
torrent["state"], (torrent["state"], None))[0]
|
||||
category = torrent["category"] or "<None>"
|
||||
torrent_states.setdefault(state, 0)
|
||||
torrent_categories.setdefault(category, 0)
|
||||
|
@ -234,14 +268,44 @@ def collect_stats():
|
|||
else:
|
||||
radarr_stats["missing"] += 1
|
||||
sizes["Movies"] += movie.get("movieFile", {}).get("size", 0)
|
||||
vbr = movie.get("movieFile", {}).get("mediaInfo", {}).get("videoBitrate", None)
|
||||
abr = movie.get("movieFile", {}).get("mediaInfo", {}).get("audioBitrate", None)
|
||||
acodec = movie.get("movieFile", {}).get("mediaInfo", {}).get("audioCodec", None)
|
||||
vcodec = movie.get("movieFile", {}).get("mediaInfo", {}).get("videoCodec", None)
|
||||
fmt = movie.get("movieFile", {}).get("relativePath", "").split(".")[-1].lower()
|
||||
vbr = movie.get(
|
||||
"movieFile",
|
||||
{}).get(
|
||||
"mediaInfo",
|
||||
{}).get(
|
||||
"videoBitrate",
|
||||
None)
|
||||
abr = movie.get(
|
||||
"movieFile",
|
||||
{}).get(
|
||||
"mediaInfo",
|
||||
{}).get(
|
||||
"audioBitrate",
|
||||
None)
|
||||
acodec = movie.get(
|
||||
"movieFile",
|
||||
{}).get(
|
||||
"mediaInfo",
|
||||
{}).get(
|
||||
"audioCodec",
|
||||
None)
|
||||
vcodec = movie.get(
|
||||
"movieFile",
|
||||
{}).get(
|
||||
"mediaInfo",
|
||||
{}).get(
|
||||
"videoCodec",
|
||||
None)
|
||||
fmt = movie.get("movieFile", {}).get(
|
||||
"relativePath", "").split(".")[-1].lower()
|
||||
qual = (
|
||||
movie.get("movieFile", {}).get("quality", {}).get("quality", {}).get("name")
|
||||
)
|
||||
movie.get(
|
||||
"movieFile",
|
||||
{}).get(
|
||||
"quality",
|
||||
{}).get(
|
||||
"quality",
|
||||
{}).get("name"))
|
||||
if qual:
|
||||
qualities.append(qual)
|
||||
if acodec:
|
||||
|
@ -260,50 +324,68 @@ def collect_stats():
|
|||
formats.append(fmt)
|
||||
sonarr_stats = {"missing": 0, "available": 0}
|
||||
info_jobs = []
|
||||
with ThreadPoolExecutor(16) as pool:
|
||||
for show in data["sonarr"]["entries"]:
|
||||
info_jobs.append(pool.submit(c.sonarr.series, show["id"]))
|
||||
for job, show in zip(
|
||||
as_completed(info_jobs),
|
||||
data["sonarr"]["entries"],
|
||||
):
|
||||
info = job.result()
|
||||
data["sonarr"]["details"][show["id"]] = info
|
||||
for file in info["episodeFile"]:
|
||||
vbr = file.get("mediaInfo", {}).get("videoBitrate", None)
|
||||
abr = file.get("mediaInfo", {}).get("audioBitrate", None)
|
||||
acodec = file.get("mediaInfo", {}).get("audioCodec", None)
|
||||
vcodec = file.get("mediaInfo", {}).get("videoCodec", None)
|
||||
fmt = file.get("relativePath", "").split(".")[-1].lower()
|
||||
qual = file.get("quality", {}).get("quality", {}).get("name")
|
||||
sizes["Shows"] += file.get("size", 0)
|
||||
if qual:
|
||||
qualities.append(qual)
|
||||
if acodec:
|
||||
acodecs.append(acodec)
|
||||
if vcodec:
|
||||
if vcodec.lower() in ["x265", "h265", "hevc"]:
|
||||
vcodec = "H.265"
|
||||
if vcodec.lower() in ["x264", "h264"]:
|
||||
vcodec = "H.264"
|
||||
vcodecs.append(vcodec)
|
||||
if vbr:
|
||||
vbitrates.append(vbr)
|
||||
if abr:
|
||||
abitrates.append(abr)
|
||||
if fmt:
|
||||
formats.append(fmt)
|
||||
for season in show.get("seasons", []):
|
||||
stats = season.get("statistics", {})
|
||||
sonarr_stats["missing"] += (
|
||||
stats["totalEpisodeCount"] - stats["episodeFileCount"]
|
||||
)
|
||||
sonarr_stats["available"] += stats["episodeFileCount"]
|
||||
hist = update_qbt_hist(data)
|
||||
for show in data["sonarr"]["entries"]:
|
||||
info_jobs.append(pool.submit(c.sonarr.series, show["id"]))
|
||||
t_1 = datetime.today()
|
||||
for job, show in zip(
|
||||
as_completed(info_jobs),
|
||||
data["sonarr"]["entries"],
|
||||
):
|
||||
info = job.result()
|
||||
data["sonarr"]["details"][show["id"]] = info
|
||||
for file in info["episodeFile"]:
|
||||
vbr = file.get("mediaInfo", {}).get("videoBitrate", None)
|
||||
abr = file.get("mediaInfo", {}).get("audioBitrate", None)
|
||||
acodec = file.get("mediaInfo", {}).get("audioCodec", None)
|
||||
vcodec = file.get("mediaInfo", {}).get("videoCodec", None)
|
||||
fmt = file.get("relativePath", "").split(".")[-1].lower()
|
||||
qual = file.get("quality", {}).get("quality", {}).get("name")
|
||||
sizes["Shows"] += file.get("size", 0)
|
||||
if qual:
|
||||
qualities.append(qual)
|
||||
if acodec:
|
||||
acodecs.append(acodec)
|
||||
if vcodec:
|
||||
if vcodec.lower() in ["x265", "h265", "hevc"]:
|
||||
vcodec = "H.265"
|
||||
if vcodec.lower() in ["x264", "h264"]:
|
||||
vcodec = "H.264"
|
||||
vcodecs.append(vcodec)
|
||||
if vbr:
|
||||
vbitrates.append(vbr)
|
||||
if abr:
|
||||
abitrates.append(abr)
|
||||
if fmt:
|
||||
formats.append(fmt)
|
||||
for season in show.get("seasons", []):
|
||||
stats = season.get("statistics", {})
|
||||
sonarr_stats["missing"] += (
|
||||
stats["totalEpisodeCount"] - stats["episodeFileCount"]
|
||||
)
|
||||
sonarr_stats["available"] += stats["episodeFileCount"]
|
||||
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["missing"] = (sonarr_stats["missing"], "#f55")
|
||||
radarr_stats["available"] = (radarr_stats["available"], "#5f5")
|
||||
radarr_stats["missing"] = (radarr_stats["missing"], "#f55")
|
||||
t_1 = datetime.today()
|
||||
imgs = [
|
||||
[
|
||||
"Media",
|
||||
|
@ -322,88 +404,105 @@ def collect_stats():
|
|||
piechart(torrent_states, "Torrents"),
|
||||
piechart(torrent_categories, "Torrent categories"),
|
||||
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",
|
||||
byte_rate_labels,
|
||||
sort=False,
|
||||
),
|
||||
stackplot(
|
||||
hist,
|
||||
qbt_hist,
|
||||
{"Download": "dl", "Upload": "ul"},
|
||||
"Transfer speed",
|
||||
unit="b/s",
|
||||
smooth=smoothness,
|
||||
),
|
||||
stackplot(
|
||||
hist,
|
||||
qbt_hist,
|
||||
{"Download": "dl_size_sess", "Upload": "ul_size_sess"},
|
||||
"Transfer volume (Session)",
|
||||
unit="b",
|
||||
),
|
||||
stackplot(
|
||||
hist,
|
||||
qbt_hist,
|
||||
{"Download": "dl_size", "Upload": "ul_size"},
|
||||
"Transfer volume (Total)",
|
||||
unit="b",
|
||||
),
|
||||
lineplot(
|
||||
hist,
|
||||
qbt_hist,
|
||||
{"Connections": "connections"},
|
||||
"Peers",
|
||||
unit=None,
|
||||
smooth=smoothness,
|
||||
),
|
||||
lineplot(
|
||||
hist,
|
||||
qbt_hist,
|
||||
{"Bandwidth per connection": "bw_per_conn"},
|
||||
"Connections",
|
||||
unit="b/s",
|
||||
smooth=smoothness,
|
||||
),
|
||||
lineplot(hist, {"DHT Nodes": "dht_nodes"}, "DHT", unit=None),
|
||||
lineplot(qbt_hist, {"DHT Nodes": "dht_nodes"}, "DHT", unit=None),
|
||||
],
|
||||
]
|
||||
calendar = {"movies":[],"episodes":[]}
|
||||
for movie in c.radarr.calendar():
|
||||
calendar["movies"].append(movie)
|
||||
for episode in c.sonarr.calendar():
|
||||
t = episode['airDateUtc'].rstrip("Z").split(".")[0]
|
||||
t = datetime.strptime(t, "%Y-%m-%dT%H:%M:%S")
|
||||
episode['hasAired']=datetime.today()>t
|
||||
calendar["episodes"].append({"episode":episode,"series":series[episode["seriesId"]]})
|
||||
return {"data": data, "images": imgs, "hist": hist,"calendar": calendar}
|
||||
|
||||
|
||||
if os.path.isfile("stats.json"):
|
||||
with open("stats.json", "r") as of:
|
||||
try:
|
||||
hist = json.load(of)["hist"]
|
||||
except Exception as e:
|
||||
print("Error loading history:", str(e))
|
||||
print("Diagrams:", datetime.today() - t_1)
|
||||
return {
|
||||
"data": data,
|
||||
"images": imgs,
|
||||
"qbt_hist": qbt_hist,
|
||||
"calendar": calendar,
|
||||
"library": library,
|
||||
}
|
||||
|
||||
|
||||
def update():
|
||||
print("Updating...")
|
||||
try:
|
||||
stats = collect_stats()
|
||||
with ThreadPoolExecutor(16) as pool:
|
||||
stats = collect_stats(pool)
|
||||
except Exception as e:
|
||||
print("Error collectin statistics:", str(e))
|
||||
print("Error collectin statistics:", e)
|
||||
stats = None
|
||||
if stats:
|
||||
with open("stats_temp.json", "w") as of:
|
||||
json.dump(stats, of)
|
||||
open("stats.lock", "w").close()
|
||||
if os.path.isfile("stats.json"):
|
||||
os.unlink("stats.json")
|
||||
os.rename("stats_temp.json", "stats.json")
|
||||
os.unlink("stats.lock")
|
||||
for k, v in stats.items():
|
||||
with open("stats/{}_temp.json".format(k), "w") as of:
|
||||
json.dump(v, of)
|
||||
shutil.move(
|
||||
"stats/{}_temp.json".format(k),
|
||||
"stats/{}.json".format(k))
|
||||
print("Done!")
|
||||
|
||||
|
||||
def loop(seconds):
|
||||
while True:
|
||||
update()
|
||||
time.sleep(seconds)
|
||||
t_start = time.time()
|
||||
print("Updating")
|
||||
update()
|
||||
dt = time.time() - t_start
|
||||
print("Next update in", seconds - dt)
|
||||
t = threading.Timer(seconds - dt, loop, (seconds,))
|
||||
t.start()
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
update()
|
||||
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()
|
||||
|
|
|
@ -10,21 +10,21 @@
|
|||
{{ bootstrap.load_css() }}
|
||||
<link rel="stylesheet" href="{{url_for('static', filename='theme.css')}}">
|
||||
{% endblock %}
|
||||
<link rel="shortcut icon" type="image/svg" href="{{url_for('static',filename='icon.svg')}}"/>
|
||||
<title>MediaDash</title>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block navbar %}
|
||||
<nav class="navbar sticky-top navbar-expand-lg navbar-dark" style="background-color: #222;">
|
||||
<a class="navbar-brand" href="/">MediaDash</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar_main" aria-controls="navbar_main" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
{% if request.path!=url_for("login") %}
|
||||
<img src="{{url_for('static',filename='icon.svg')}}" width=40 height=40/>
|
||||
{% endif %}
|
||||
<div class="collapse navbar-collapse" id="navbar_main">
|
||||
{{nav.left_nav.render(renderer='bootstrap4')}}
|
||||
{{nav.right_nav.render(renderer='bootstrap4')}}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class={{"container-fluid" if fluid else "container"}}>
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
<h1>
|
||||
<a href="{{config.APP_CONFIG.portainer_url}}">Portainer</a>
|
||||
</h1>
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
|
@ -42,7 +42,7 @@
|
|||
{% set label = container.Labels["com.docker.compose.service"] %}
|
||||
<tr>
|
||||
<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"]}}
|
||||
</a>
|
||||
</td>
|
||||
|
|
8
templates/error.html
Normal file
8
templates/error.html
Normal 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 %}
|
|
@ -41,62 +41,89 @@
|
|||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<h3>Movies</h3>
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>In Cinemas</th>
|
||||
<th>Digital Release</th>
|
||||
</tr>
|
||||
{% for movie in data.calendar.movies %}
|
||||
{% if movie.isAvailable and movie.hasFile %}
|
||||
{% set row_class = "bg-success" %}
|
||||
{% set row_attrs = "bg-success" %}
|
||||
{% elif movie.isAvailable and not movie.hasFile %}
|
||||
{% set row_class = "bg-danger" %}
|
||||
{% set row_attrs = "bg-danger" %}
|
||||
{% 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 %}
|
||||
{% set row_class = "bg-info" %}
|
||||
{% set row_attrs = "bg-info" %}
|
||||
{% endif %}
|
||||
<tr class={{row_class}}>
|
||||
<tr class={{row_attrs}}>
|
||||
<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}}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{movie.status}}</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>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<h3>Episodes</h3>
|
||||
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr>
|
||||
<th>Season | Episode Number</th>
|
||||
<th>Show</th>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Air Date</th>
|
||||
</tr>
|
||||
{% for entry in data.calendar.episodes %}
|
||||
{% if entry.episode.hasAired and entry.episode.hasFile %}
|
||||
{% set row_class = "bg-success" %}
|
||||
{% elif entry.episode.hasAired and not entry.episode.hasFile %}
|
||||
{% set row_class = "bg-danger" %}
|
||||
{% elif not entry.episode.hasAired and entry.episode.hasFile %}
|
||||
{% set row_class = "bg-primary" %}
|
||||
{% elif not entry.episode.hasAired and not entry.episode.hasFile %}
|
||||
{% set row_class = "bg-info" %}
|
||||
{% endif %}
|
||||
<tr class={{row_class}}>
|
||||
<td>{{entry.episode.seasonNumber}} | {{entry.episode.episodeNumber}}</td>
|
||||
<td>
|
||||
<a href="{{urljoin(config.APP_CONFIG.sonarr_url,'series/'+entry.series.titleSlug)}}" style="color: #eee; text-decoration: underline;">
|
||||
{{entry.series.title}}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{entry.episode.title}}</td>
|
||||
<td>{{entry.episode.airDateUtc|fromiso|ago_dt_utc_human(rnd=0)}}</td>
|
||||
</tr>
|
||||
{% if entry.details %}
|
||||
{% set details = entry.details[0] %}
|
||||
{% endif %}
|
||||
{% if entry.episode.hasAired and entry.episode.hasFile %}
|
||||
{% set row_attrs = {"class":"bg-success"} %}
|
||||
{% elif entry.episode.hasAired and not entry.episode.hasFile and details %}
|
||||
{% set row_attrs = {"style":"background-color: green !important"} %}
|
||||
{% elif entry.episode.hasAired and not entry.episode.hasFile %}
|
||||
{% set row_attrs = {"class":"bg-danger"} %}
|
||||
{% elif not entry.episode.hasAired and entry.episode.hasFile %}
|
||||
{% set row_attrs = {"class":"bg-primary"} %}
|
||||
{% elif not entry.episode.hasAired and not entry.episode.hasFile %}
|
||||
{% set row_attrs = {"class":"bg-info"} %}
|
||||
{% endif %}
|
||||
<tr {{row_attrs|xmlattr}}>
|
||||
<td>{{entry.episode.seasonNumber}} | {{entry.episode.episodeNumber}}</td>
|
||||
<td>
|
||||
<a href="{{urljoin(config.APP_CONFIG.sonarr_url,'series/'+entry.series.titleSlug)}}" style="color: #eee; text-decoration: underline;">
|
||||
{{entry.series.title}}
|
||||
</a>
|
||||
</td>
|
||||
<td 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 %}
|
||||
</table>
|
||||
</div>
|
||||
|
@ -109,7 +136,7 @@
|
|||
<h2>No Data available!</h2>
|
||||
{% else %}
|
||||
{% set tabs = [] %}
|
||||
{% do tabs.append(("Upcoming",[upcoming(data)])) %}
|
||||
{% do tabs.append(("Schedule",[upcoming(data)])) %}
|
||||
{% for row in data.images %}
|
||||
{% if row[0] is string %}
|
||||
{% set title=row[0] %}
|
||||
|
|
20
templates/jellyfin/details.html
Normal file
20
templates/jellyfin/details.html
Normal 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 %}
|
|
@ -3,119 +3,80 @@
|
|||
{% from 'bootstrap/utils.html' import render_icon %}
|
||||
{% 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 %}
|
||||
<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">
|
||||
<div class="col-lg">
|
||||
<h4>Active Streams</h4>
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<th>Episode</th>
|
||||
<th>Show</th>
|
||||
<th>Language</th>
|
||||
<th>User</th>
|
||||
<th>Device</th>
|
||||
<th>Mode</th>
|
||||
</tr>
|
||||
{% for session in jellyfin.sessions %}
|
||||
{% if "NowPlayingItem" in session %}
|
||||
{% with np=session.NowPlayingItem, ps=session.PlayState%}
|
||||
<tr>
|
||||
<td>
|
||||
{% if session.SupportsMediaControl %}
|
||||
<a href="{{url_for('stop_stream',session=session.Id)}}">
|
||||
{{render_icon("stop-circle")}}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.Id}}">
|
||||
{{np.Name}}
|
||||
</a>
|
||||
({{(ps.PositionTicks/10_000_000)|timedelta(digits=0)}}/{{(np.RunTimeTicks/10_000_000)|timedelta(digits=0)}})
|
||||
{% if ps.IsPaused %}
|
||||
(Paused)
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.SeriesId}}">
|
||||
{{np.SeriesName}}
|
||||
</a>
|
||||
<a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.SeasonId}}">
|
||||
({{np.SeasonName}})
|
||||
</a>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
{% if ("AudioStreamIndex" in ps) and ("SubtitleStreamIndex" in ps) %}
|
||||
{{np.MediaStreams[ps.AudioStreamIndex].Language or "None"}}/{{np.MediaStreams[ps.SubtitleStreamIndex].Language or "None"}}
|
||||
{% else %}
|
||||
Unk/Unk
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{session.UserId}}">
|
||||
{{session.UserName}}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{session.DeviceName}}
|
||||
</td>
|
||||
<td>
|
||||
{% if ps.PlayMethod =="Transcode" %}
|
||||
<p title="{{session.TranscodingInfo.Bitrate|filesizeformat(binary=False)}}/s | {{session.TranscodingInfo.CompletionPercentage|round(2)}}%">
|
||||
{{ps.PlayMethod}}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
{{ps.PlayMethod}}
|
||||
</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
<table class="table table-sm table-bordered">
|
||||
{% for name, value in counts.items() %}
|
||||
{% if value != 0 %}
|
||||
<tr>
|
||||
<td>{{name}}</td>
|
||||
<td>{{value}}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</table>
|
||||
{% if library %}
|
||||
<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>
|
||||
|
||||
{% endblock %}
|
||||
{% set tabs = [] %}
|
||||
{% for title,group in library.values()|groupby("Type") %}
|
||||
{% do tabs.append((title,[make_table(group)])) %}
|
||||
{% endfor %}
|
||||
{{make_tabs(tabs)}}
|
||||
{% endblock %}
|
||||
|
|
56
templates/jellyfin/movie.html
Normal file
56
templates/jellyfin/movie.html
Normal 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 %}
|
70
templates/jellyfin/series.html
Normal file
70
templates/jellyfin/series.html
Normal 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
16
templates/login.html
Normal 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 %}
|
|
@ -208,9 +208,11 @@
|
|||
<div class="row">
|
||||
<div class="col">
|
||||
<h2>Trackers</h2>
|
||||
<a href="{{url_for('qbittorent_add_trackers',infohash=qbt.info.hash)}}">
|
||||
<span class="badge badge-primary">Add default trackers</span>
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{url_for('qbittorrent.add_trackers',infohash=qbt.info.hash)}}">
|
||||
<span class="badge badge-primary">Add default trackers</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
{% set state_label,badge_type = status_map[torrent.state] or (torrent.state,'light') %}
|
||||
|
||||
<li class="list-group-item">
|
||||
<a href="{{url_for('qbittorrent_details',infohash=torrent.hash)}}">{{torrent.name|truncate(75)}}</a>
|
||||
<a href="{{url_for('qbittorrent.details',infohash=torrent.hash)}}">{{torrent.name|truncate(75)}}</a>
|
||||
(DL: {{torrent.dlspeed|filesizeformat(binary=true)}}/s, UL: {{torrent.upspeed|filesizeformat(binary=true)}}/s)
|
||||
<span class="badge badge-{{badge_type}}">{{state_label}}</span>
|
||||
{% if torrent.category %}
|
||||
|
@ -27,12 +27,12 @@
|
|||
|
||||
{% block app_content %}
|
||||
|
||||
<h2>
|
||||
<h1>
|
||||
<a href="{{config.APP_CONFIG.qbt_url}}">QBittorrent</a>
|
||||
{{qbt.version}}
|
||||
(DL: {{qbt.server_state.dl_info_speed|filesizeformat(binary=True)}}/s,
|
||||
UL: {{qbt.server_state.up_info_speed|filesizeformat(binary=True)}}/s)
|
||||
</h2>
|
||||
</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
|
@ -99,7 +99,7 @@
|
|||
{% set state_label,badge_type = status_map[state] or (state,'light') %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href={{url_for("qbittorrent",state=state)}} >{{state_label}}</a>
|
||||
<a href={{url_for("qbittorrent.index",state=state)}} >{{state_label}}</a>
|
||||
</div>
|
||||
<div class="col">
|
||||
{{torrents|length}}
|
||||
|
@ -110,7 +110,7 @@
|
|||
{% if state_filter %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href={{url_for("qbittorrent")}}>[Clear filter]</a>
|
||||
<a href={{url_for("qbittorrent.index")}}>[Clear filter]</a>
|
||||
</div>
|
||||
<div class="col">
|
||||
</div>
|
||||
|
|
|
@ -15,10 +15,10 @@
|
|||
{% endmacro %}
|
||||
|
||||
{% block app_content %}
|
||||
<h2>
|
||||
<h1>
|
||||
<a href="{{config.APP_CONFIG.radarr_url}}">Radarr</a>
|
||||
v{{status.version}} ({{movies|count}} Movies)
|
||||
</h2>
|
||||
</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
|
|
|
@ -6,13 +6,13 @@
|
|||
{% block app_content %}
|
||||
|
||||
<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>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<h4>SSH</h4>
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm table-bordered">
|
||||
<tr>
|
||||
<th></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 }}>
|
||||
<td>
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
</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 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="col-lg">
|
||||
<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>
|
||||
<th>Name</th>
|
||||
<th>Last Login</th>
|
||||
<th>Last Active</th>
|
||||
<th>Bandwidth Limit</th>
|
||||
</tr>
|
||||
{% for user in jf|sort(attribute="LastLoginDate",reverse=True) %}
|
||||
{% for user in jellyfin.users|defaultattr("LastLoginDate","")|sort(attribute="LastLoginDate",reverse=True) %}
|
||||
<tr>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
{% if "LastLoginDate" in user %}
|
||||
{% if user.LastLoginDate %}
|
||||
{{user.LastLoginDate|fromiso|ago_dt_utc(2)}} ago
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if "LastActivityDate" in user %}
|
||||
{% if user.LastActivityDate %}
|
||||
{{user.LastActivityDate|fromiso|ago_dt_utc(2)}} ago
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</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>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
|
54
templates/requests/details.html
Normal file
54
templates/requests/details.html
Normal 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)}} % (ETA: {{[torrent.eta,eta_act]|min|round(0)|timedelta(clamp=true)}})
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
116
templates/requests/index.html
Normal file
116
templates/requests/index.html
Normal 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 %}
|
|
@ -2,7 +2,7 @@
|
|||
{% macro tv_show_results(results) -%}
|
||||
<div class="d-flex flex-wrap">
|
||||
{% 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="type" value="show"/>
|
||||
<input type="hidden" name="data" value="{{result|tojson|urlencode}}" />
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{% for torrent in session.pop('new_torrents',{}).values() %}
|
||||
<p>
|
||||
Added <a class="alert-link" href="{{url_for('qbittorrent',infohash=torrent.hash)}}">{{torrent.name}}</a>
|
||||
Added <a class="alert-link" href="{{url_for('qbittorrent.details',infohash=torrent.hash)}}">{{torrent.name}}</a>
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
|
@ -15,10 +15,10 @@
|
|||
{% endmacro %}
|
||||
|
||||
{% block app_content %}
|
||||
<h2>
|
||||
<h1>
|
||||
<a href="{{config.APP_CONFIG.sonarr_url}}">Sonarr</a>
|
||||
v{{status.version}} ({{series|count}} Shows)
|
||||
</h2>
|
||||
</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
|
|
|
@ -22,13 +22,15 @@
|
|||
<div class="col-lg">
|
||||
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
|
||||
{% for label,tab in tabs if tab %}
|
||||
{% set id_name = [loop.index,tabs_id ]|join("-") %}
|
||||
{% if not (loop.first and loop.last) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{'active' if loop.first}}" id="nav-{{id_name}}-tab" data-toggle="pill" href="#pills-{{id_name}}" role="tab" aria-controls="pills-{{id_name}}" aria-selected="{{loop.first}}">
|
||||
{{label}}
|
||||
</a>
|
||||
</li>
|
||||
{% if tab %}
|
||||
{% set id_name = [loop.index,tabs_id ]|join("-") %}
|
||||
{% if not (loop.first and loop.last) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{'active' if loop.first}}" id="nav-{{id_name}}-tab" data-toggle="pill" href="#pills-{{id_name}}" role="tab" aria-controls="pills-{{id_name}}" aria-selected="{{loop.first}}">
|
||||
{{label}}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -38,10 +40,12 @@
|
|||
<div class="col-lg">
|
||||
<div class="tab-content" id="searchResults">
|
||||
{% for label,tab in tabs if tab %}
|
||||
{% set id_name = [loop.index,tabs_id ]|join("-") %}
|
||||
<div class="tab-pane fade {{'show active' if loop.first}}" id="pills-{{id_name}}" role="tabpanel" aria-labelledby="nav-{{id_name}}-tab">
|
||||
{{ tab|safe }}
|
||||
</div>
|
||||
{% if tab %}
|
||||
{% set id_name = [loop.index,tabs_id ]|join("-") %}
|
||||
<div class="tab-pane fade {{'show active' if loop.first}}" id="pills-{{id_name}}" role="tabpanel" aria-labelledby="nav-{{id_name}}-tab">
|
||||
{{ tab|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
32
transcode.py
32
transcode.py
|
@ -1,12 +1,14 @@
|
|||
import subprocess as SP
|
||||
import json
|
||||
import shlex
|
||||
import time
|
||||
import os
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess as SP
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from utils import handle_config
|
||||
|
||||
profiles = handle_config().get("transcode_profiles", {})
|
||||
|
@ -32,7 +34,7 @@ def ffprobe(file):
|
|||
out = SP.check_output(cmd)
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except:
|
||||
except BaseException:
|
||||
return file, None
|
||||
return file, json.loads(out)
|
||||
|
||||
|
@ -110,7 +112,13 @@ def transcode(file, outfile, profile, job_id=None, **kwargs):
|
|||
|
||||
info = ffprobe(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):
|
||||
if "frame" in state:
|
||||
progbar.n = int(state["frame"])
|
||||
|
@ -132,10 +140,16 @@ if __name__ == "__main__":
|
|||
for profile in ["H.265 transcode", "H.264 transcode"]:
|
||||
for preset in ["ultrafast", "fast", "medium", "slow", "veryslow"]:
|
||||
for crf in list(range(10, 54, 4))[::-1]:
|
||||
outfile = os.path.join("E:\\","transcode",profile,"{}_{}.mkv".format(crf, preset))
|
||||
outfile = os.path.join(
|
||||
"E:\\",
|
||||
"transcode",
|
||||
profile,
|
||||
"{}_{}.mkv".format(
|
||||
crf,
|
||||
preset))
|
||||
os.makedirs(os.path.dirname(outfile), exist_ok=True)
|
||||
if os.path.isfile(outfile):
|
||||
print("Skipping",outfile)
|
||||
print("Skipping", outfile)
|
||||
continue
|
||||
for _ in transcode(
|
||||
file, outfile, profile, "transcode", preset=preset, crf=crf
|
||||
|
|
268
utils.py
268
utils.py
|
@ -1,45 +1,100 @@
|
|||
from flask_nav.renderers import Renderer, SimpleRenderer
|
||||
from dominate import tags
|
||||
import asteval
|
||||
import operator as op
|
||||
import textwrap
|
||||
import math
|
||||
import sys
|
||||
import random
|
||||
import string
|
||||
from functools import wraps
|
||||
from urllib.request import urlopen
|
||||
from io import BytesIO
|
||||
import subprocess as SP
|
||||
import shlex
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
import operator as op
|
||||
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
|
||||
from PIL import ImageFont
|
||||
from PIL import ImageDraw
|
||||
import asteval
|
||||
import cachetools
|
||||
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):
|
||||
if cfg is None:
|
||||
if os.path.isfile("config.json"):
|
||||
with open("config.json") as fh:
|
||||
return json.load(fh)
|
||||
cfg=json.load(fh)
|
||||
with open("config.json", "w") as fh:
|
||||
cfg = json.dump(cfg, fh, indent=4)
|
||||
return
|
||||
json.dump(cfg, fh, indent=4)
|
||||
return cfg
|
||||
|
||||
|
||||
def with_application_context(app):
|
||||
def inner(func):
|
||||
def wrapper(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
def wrapped(*args, **kwargs):
|
||||
with app.app_context():
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return wrapped
|
||||
|
||||
return inner
|
||||
return wrapper
|
||||
|
||||
|
||||
def getsize(text, font_size):
|
||||
|
@ -83,7 +138,7 @@ def make_placeholder_image(text, width, height, poster=None, wrap=0):
|
|||
try:
|
||||
with urlopen(poster) as fh:
|
||||
poster = Image.open(fh)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
poster = None
|
||||
else:
|
||||
poster_size = poster.size
|
||||
|
@ -95,7 +150,8 @@ def make_placeholder_image(text, width, height, poster=None, wrap=0):
|
|||
poster = poster.resize(new_size)
|
||||
mid = -int((poster.size[1] - height) / 2)
|
||||
im.paste(poster, (0, mid))
|
||||
draw.text(((width - w) / 2, (height - h) / 2), text, fill="#eee", font=font)
|
||||
draw.text(((width - w) / 2, (height - h) / 2),
|
||||
text, fill="#eee", font=font)
|
||||
im.save(io, "PNG")
|
||||
io.seek(0)
|
||||
return io
|
||||
|
@ -123,12 +179,26 @@ class BootsrapRenderer(Renderer):
|
|||
ret = tags.ul(sub, cls="navbar-nav mr-auto")
|
||||
return ret
|
||||
|
||||
def visit_Text(self, node):
|
||||
return tags.span(node.text, cls="navbar-text")
|
||||
|
||||
def visit_View(self, node):
|
||||
badge = node.url_for_kwargs.pop("__badge", None)
|
||||
classes = ["nav-link"]
|
||||
if hasattr(node, "classes"):
|
||||
classes = node.classes
|
||||
if node.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(
|
||||
tags.a(node.text, href=node.get_url(), cls=" ".join(classes)),
|
||||
ret,
|
||||
cls="nav-item",
|
||||
)
|
||||
|
||||
|
@ -192,5 +262,147 @@ def eval_expr(expr, ctx=None):
|
|||
def sort_by(values, expr):
|
||||
return sorted(value, key=lambda v: eval_expr(expr, v))
|
||||
|
||||
|
||||
def genpw(num=20):
|
||||
return "".join(random.choice(string.ascii_lowercase+string.ascii_uppercase+string.digits) for _ in range(num))
|
||||
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
24
views/__init__.py
Normal 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
32
views/api/__init__.py
Normal 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
45
views/config/__init__.py
Normal 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)
|
22
views/containers/__init__.py
Normal file
22
views/containers/__init__.py
Normal 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
15
views/history/__init__.py
Normal 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
16
views/home/__init__.py
Normal 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)
|
43
views/jellyfin/__init__.py
Normal file
43
views/jellyfin/__init__.py
Normal 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
19
views/logs/__init__.py
Normal 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)
|
48
views/qbittorrent/__init__.py
Normal file
48
views/qbittorrent/__init__.py
Normal 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
24
views/radarr/__init__.py
Normal 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
98
views/remote/__init__.py
Normal 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
217
views/requests/__init__.py
Normal 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
62
views/search/__init__.py
Normal 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
24
views/sonarr/__init__.py
Normal 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")
|
10
views/transcode/__init__.py
Normal file
10
views/transcode/__init__.py
Normal 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")
|
Loading…
Reference in a new issue