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