push latest changes
This commit is contained in:
parent
7523a19d1f
commit
cb2b5c2c2b
63 changed files with 3158 additions and 1552 deletions
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}
|
|
143
api/__init__.py
Normal file
143
api/__init__.py
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
import io
|
||||||
|
from fabric import Connection
|
||||||
|
|
||||||
|
from utils import genpw, handle_config
|
||||||
|
|
||||||
|
from .jackett import Jackett
|
||||||
|
from .jellyfin import Jellyfin
|
||||||
|
from .portainer import Portainer
|
||||||
|
from .qbittorrent import QBittorrent
|
||||||
|
from .radarr import Radarr
|
||||||
|
from .sonarr import Sonarr
|
||||||
|
|
||||||
|
|
||||||
|
class Client(object):
|
||||||
|
def __init__(self, cfg=None):
|
||||||
|
if cfg is None:
|
||||||
|
cfg = handle_config()
|
||||||
|
self.cfg = cfg
|
||||||
|
self.jackett = Jackett(cfg["jackett_url"], cfg["jackett_api_key"])
|
||||||
|
self.sonarr = Sonarr(cfg["sonarr_url"], cfg["sonarr_api_key"])
|
||||||
|
self.radarr = Radarr(cfg["radarr_url"], cfg["radarr_api_key"])
|
||||||
|
self.jellyfin = Jellyfin(
|
||||||
|
cfg["jellyfin_url"], cfg["jellyfin_user"], cfg["jellyfin_password"]
|
||||||
|
)
|
||||||
|
self.qbittorent = QBittorrent(
|
||||||
|
cfg["qbt_url"], cfg["qbt_username"], cfg["qbt_passwd"]
|
||||||
|
)
|
||||||
|
self.portainer = Portainer(
|
||||||
|
cfg["portainer_url"],
|
||||||
|
cfg["portainer_username"],
|
||||||
|
cfg["portainer_passwd"])
|
||||||
|
self.ssh = Connection("root@server")
|
||||||
|
|
||||||
|
def _get_ssh_keys(self):
|
||||||
|
res = self.ssh.get("/data/.ssh/authorized_keys", io.BytesIO())
|
||||||
|
res.local.seek(0)
|
||||||
|
ret = []
|
||||||
|
for line in str(res.local.read(), "utf8").splitlines():
|
||||||
|
if line.startswith("#"):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
key_type, key, comment = line.split(None, 2)
|
||||||
|
ret.append((key_type, key, comment))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def add_user(self, name, ssh_key):
|
||||||
|
cfg = handle_config()
|
||||||
|
user_config = cfg["jellyfin_user_config"]
|
||||||
|
user_policy = cfg["jellyfin_user_policy"]
|
||||||
|
passwd = genpw()
|
||||||
|
res = self.ssh.get("/data/.ssh/authorized_keys", io.BytesIO())
|
||||||
|
res.local.seek(0)
|
||||||
|
keys = [
|
||||||
|
line.split(
|
||||||
|
None,
|
||||||
|
2) for line in str(
|
||||||
|
res.local.read(),
|
||||||
|
"utf8").splitlines()]
|
||||||
|
key_type, key, *_ = ssh_key.split()
|
||||||
|
keys.append([key_type, key, name])
|
||||||
|
new_keys = []
|
||||||
|
seen_keys = set()
|
||||||
|
for key_type, key, key_name in keys:
|
||||||
|
if key not in seen_keys:
|
||||||
|
seen_keys.add(key)
|
||||||
|
new_keys.append([key_type, key, key_name])
|
||||||
|
new_keys_file = "\n".join(" ".join(key) for key in new_keys)
|
||||||
|
self.ssh.put(
|
||||||
|
io.BytesIO(bytes(new_keys_file, "utf8")),
|
||||||
|
"/data/.ssh/authorized_keys",
|
||||||
|
preserve_mode=False,
|
||||||
|
)
|
||||||
|
user = self.jellyfin.post(
|
||||||
|
"Users/New",
|
||||||
|
json={
|
||||||
|
"Name": name,
|
||||||
|
"Password": passwd})
|
||||||
|
user = user.json()
|
||||||
|
self.jellyfin.post(
|
||||||
|
"Users/{Id}/Configuration".format(**user), json=user_config)
|
||||||
|
self.jellyfin.post(
|
||||||
|
"Users/{Id}/Policy".format(**user), json=user_policy)
|
||||||
|
return passwd
|
||||||
|
|
||||||
|
def queue(self, ids=[]):
|
||||||
|
ret = []
|
||||||
|
for item in self.sonarr.queue():
|
||||||
|
if not ids or item.get("seriesId") in ids:
|
||||||
|
item["type"] = "sonarr"
|
||||||
|
ret.append(item)
|
||||||
|
for item in self.radarr.queue():
|
||||||
|
item["download"] = self.qbittorent.status(item["downloadId"])
|
||||||
|
if not ids or item.get("movieId") in ids:
|
||||||
|
item["type"] = "radarr"
|
||||||
|
ret.append(item)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def test(cls, cfg=None):
|
||||||
|
modules = [
|
||||||
|
(
|
||||||
|
"Jackett",
|
||||||
|
lambda cfg: Jackett(cfg["jackett_url"], cfg["jackett_api_key"]),
|
||||||
|
),
|
||||||
|
("Sonarr", lambda cfg: Sonarr(cfg["sonarr_url"], cfg["sonarr_api_key"])),
|
||||||
|
("Radarr", lambda cfg: Radarr(cfg["radarr_url"], cfg["radarr_api_key"])),
|
||||||
|
(
|
||||||
|
"QBittorrent",
|
||||||
|
lambda cfg: QBittorrent(
|
||||||
|
cfg["qbt_url"], cfg["qbt_username"], cfg["qbt_passwd"]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Jellyfin",
|
||||||
|
lambda cfg: Jellyfin(
|
||||||
|
cfg["jellyfin_url"],
|
||||||
|
cfg["jellyfin_username"],
|
||||||
|
cfg["jellyfin_passwd"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Portainer",
|
||||||
|
lambda cfg: Portainer(
|
||||||
|
cfg["portainer_url"],
|
||||||
|
cfg["portainer_username"],
|
||||||
|
cfg["portainer_passwd"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
errors = {}
|
||||||
|
success = True
|
||||||
|
for mod, Module in modules:
|
||||||
|
try:
|
||||||
|
print("Testing", mod)
|
||||||
|
errors[mod] = Module(cfg).test()
|
||||||
|
if errors[mod]:
|
||||||
|
success = False
|
||||||
|
except Exception as e:
|
||||||
|
print(dir(e))
|
||||||
|
errors[mod] = str(e)
|
||||||
|
success = False
|
||||||
|
print(errors)
|
||||||
|
return {"success": success, "errors": errors}
|
45
api/jackett.py
Normal file
45
api/jackett.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import time
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests as RQ
|
||||||
|
|
||||||
|
|
||||||
|
class Jackett(object):
|
||||||
|
def __init__(self, url, api_key):
|
||||||
|
self.url = url
|
||||||
|
self.api_key = api_key
|
||||||
|
self.session = RQ.Session()
|
||||||
|
self.session.post("http://192.168.2.25:9117/jackett/UI/Dashboard")
|
||||||
|
|
||||||
|
def search(self, query, indexers=None):
|
||||||
|
params = {"apikey": self.api_key,
|
||||||
|
"Query": query, "_": str(int(time.time()))}
|
||||||
|
if indexers:
|
||||||
|
params["Tracker[]"] = indexers
|
||||||
|
res = self.session.get(
|
||||||
|
urljoin(self.url, "api/v2.0/indexers/all/results"), params=params
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
res = res.json()
|
||||||
|
for val in res["Results"]:
|
||||||
|
for prop in ["Gain", "Seeders", "Peers", "Grabs", "Files"]:
|
||||||
|
val[prop] = val.get(prop) or 0
|
||||||
|
return res
|
||||||
|
|
||||||
|
def indexers(self):
|
||||||
|
return [
|
||||||
|
(t["id"], t["name"])
|
||||||
|
for t in self.session.get(urljoin(self.url, "api/v2.0/indexers")).json()
|
||||||
|
if t.get("configured")
|
||||||
|
]
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
errors = {}
|
||||||
|
for idx, name in self.indexers():
|
||||||
|
print("Testing indexer", name)
|
||||||
|
result = self.session.post(
|
||||||
|
urljoin(self.url, "api/v2.0/indexers/{}/test".format(idx))
|
||||||
|
)
|
||||||
|
if result.text:
|
||||||
|
errors[name] = result.json()["error"]
|
||||||
|
return errors
|
333
api/jellyfin.py
Normal file
333
api/jellyfin.py
Normal file
|
@ -0,0 +1,333 @@
|
||||||
|
import time
|
||||||
|
import base64
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import requests as RQ
|
||||||
|
from dateutil.parser import parse as parse_datetime
|
||||||
|
|
||||||
|
from utils import timed_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Jellyfin(object):
|
||||||
|
def __init__(self, url, user, password):
|
||||||
|
self.url = url
|
||||||
|
self.session = RQ.Session()
|
||||||
|
self.device_id = str(
|
||||||
|
base64.b64encode(
|
||||||
|
"MediaDash ({})".format(
|
||||||
|
self.session.headers["User-Agent"]).encode("utf-8")),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
self.auth_headers = {
|
||||||
|
"X-Emby-Authorization": 'MediaBrowser Client="MediaDash", Device="Python", DeviceId="{}", Version="{}"'.format(
|
||||||
|
self.device_id, RQ.__version__)}
|
||||||
|
self.user = None
|
||||||
|
if user is not None:
|
||||||
|
res = self.login_user(user, password)
|
||||||
|
self.api_key = res["AccessToken"]
|
||||||
|
else:
|
||||||
|
self.api_key = password
|
||||||
|
self.auth_headers = {
|
||||||
|
"X-Emby-Authorization": 'MediaBrowser Client="MediaDash", Device="Python", DeviceId="{}", Version="{}", Token="{}"'.format(
|
||||||
|
self.device_id, RQ.__version__, self.api_key)}
|
||||||
|
# ws_url=self.url.replace("http","ws").rstrip("/")+"/?"+urlencode({"api_key":self.api_key,"deviceId":self.device_id})
|
||||||
|
# self.ws = websocket.WebSocketApp(ws_url,on_open=print,on_error=print,on_message=print,on_close=print)
|
||||||
|
# self.ws_thread = Thread(target=self.ws.run_forever,daemon=True)
|
||||||
|
self.session.headers.update(
|
||||||
|
{**self.auth_headers, "X-Emby-Token": self.api_key})
|
||||||
|
self.user = self.get_self()
|
||||||
|
self.user_id = self.user["Id"]
|
||||||
|
self.playstate_commands = sorted(
|
||||||
|
[
|
||||||
|
"Stop",
|
||||||
|
"Pause",
|
||||||
|
"Unpause",
|
||||||
|
"NextTrack",
|
||||||
|
"PreviousTrack",
|
||||||
|
"Seek",
|
||||||
|
"Rewind",
|
||||||
|
"FastForward",
|
||||||
|
"PlayPause",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session_commands = sorted(
|
||||||
|
[
|
||||||
|
"MoveUp",
|
||||||
|
"MoveDown",
|
||||||
|
"MoveLeft",
|
||||||
|
"MoveRight",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
"PreviousLetter",
|
||||||
|
"NextLetter",
|
||||||
|
"ToggleOsd",
|
||||||
|
"ToggleContextMenu",
|
||||||
|
"Select",
|
||||||
|
"Back",
|
||||||
|
"TakeScreenshot",
|
||||||
|
"SendKey",
|
||||||
|
"SendString",
|
||||||
|
"GoHome",
|
||||||
|
"GoToSettings",
|
||||||
|
"VolumeUp",
|
||||||
|
"VolumeDown",
|
||||||
|
"Mute",
|
||||||
|
"Unmute",
|
||||||
|
"ToggleMute",
|
||||||
|
"SetVolume",
|
||||||
|
"SetAudioStreamIndex",
|
||||||
|
"SetSubtitleStreamIndex",
|
||||||
|
"ToggleFullscreen",
|
||||||
|
"DisplayContent",
|
||||||
|
"GoToSearch",
|
||||||
|
"DisplayMessage",
|
||||||
|
"SetRepeatMode",
|
||||||
|
"ChannelUp",
|
||||||
|
"ChannelDown",
|
||||||
|
"Guide",
|
||||||
|
"ToggleStats",
|
||||||
|
"PlayMediaSource",
|
||||||
|
"PlayTrailers",
|
||||||
|
"SetShuffleQueue",
|
||||||
|
"PlayState",
|
||||||
|
"PlayNext",
|
||||||
|
"ToggleOsdMenu",
|
||||||
|
"Play",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def login_user(self, user, passwd):
|
||||||
|
res = self.post(
|
||||||
|
"Users/AuthenticateByName",
|
||||||
|
json={"Username": user, "Pw": passwd},
|
||||||
|
headers=self.auth_headers,
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
res = res.json()
|
||||||
|
self.session.headers.update(
|
||||||
|
{**self.auth_headers, "X-Emby-Token": res["AccessToken"]}
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
self.session.post(urljoin(self.url, "Sessions/Logout"))
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
res = self.session.get(urljoin(self.url, "System/Info"))
|
||||||
|
res.raise_for_status()
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
def chapter_image_url(self, item_id, chapter_num, tag):
|
||||||
|
return urljoin(
|
||||||
|
self.url,
|
||||||
|
"Items",
|
||||||
|
item_id,
|
||||||
|
"Images",
|
||||||
|
"Chapter",
|
||||||
|
chapter_num)
|
||||||
|
|
||||||
|
def rq(self, method, url, *args, **kwargs):
|
||||||
|
res = self.session.request(
|
||||||
|
method, urljoin(
|
||||||
|
self.url, url), *args, **kwargs)
|
||||||
|
res.raise_for_status()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get(self, url, *args, **kwargs):
|
||||||
|
res = self.session.get(urljoin(self.url, url), *args, **kwargs)
|
||||||
|
res.raise_for_status()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def post(self, url, *args, **kwargs):
|
||||||
|
res = self.session.post(urljoin(self.url, url), *args, **kwargs)
|
||||||
|
res.raise_for_status()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def sessions(self):
|
||||||
|
res = self.get("Sessions")
|
||||||
|
res.raise_for_status()
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def season_episodes(self, item_id, season_id):
|
||||||
|
res = self.get(
|
||||||
|
"Shows/{}/Episodes".format(item_id),
|
||||||
|
params={
|
||||||
|
"UserId": self.user_id,
|
||||||
|
"seasonId": season_id,
|
||||||
|
"fields": "Overview,MediaStreams,MediaSources,ExternalUrls",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
res = res.json()["Items"]
|
||||||
|
for episode in res:
|
||||||
|
episode["Info"] = self.media_info(episode["Id"])
|
||||||
|
return res
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def seasons(self, item_id):
|
||||||
|
res = self.get(
|
||||||
|
"Shows/{}/Seasons".format(item_id),
|
||||||
|
params={
|
||||||
|
"UserId": self.user_id,
|
||||||
|
"fields": "Overview,MediaStreams,MediaSources,ExternalUrls",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
res = res.json()["Items"]
|
||||||
|
for season in res:
|
||||||
|
season["Episodes"] = self.season_episodes(item_id, season["Id"])
|
||||||
|
return res
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def media_info(self, item_id):
|
||||||
|
res = self.get(
|
||||||
|
"Users/{}/Items/{}".format(self.user_id, item_id),
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
res = res.json()
|
||||||
|
if res["Type"] == "Series":
|
||||||
|
res["Seasons"] = self.seasons(item_id)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def system_info(self):
|
||||||
|
res = self.get("System/Info")
|
||||||
|
res.raise_for_status()
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
def __get_child_items(self, item_id):
|
||||||
|
print(item_id)
|
||||||
|
res = self.get(
|
||||||
|
"Users/{}/Items".format(self.user_id),
|
||||||
|
params={"ParentId": item_id},
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
def get_recursive(self, item_id):
|
||||||
|
for item in self.__get_child_items(item_id).get("Items", []):
|
||||||
|
yield item
|
||||||
|
yield from self.get_recursive(item["Id"])
|
||||||
|
|
||||||
|
def get_counts(self):
|
||||||
|
res = self.get("Items/Counts").json()
|
||||||
|
return res
|
||||||
|
|
||||||
|
@timed_cache(seconds=10)
|
||||||
|
def id_map(self):
|
||||||
|
res = self.get(
|
||||||
|
"Users/{}/Items".format(self.user_id),
|
||||||
|
params={
|
||||||
|
"recursive": True,
|
||||||
|
"includeItemTypes": "Movie,Series",
|
||||||
|
"collapseBoxSetItems": False,
|
||||||
|
"fields": "ProviderIds",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
res = res.json()["Items"]
|
||||||
|
id_map = {}
|
||||||
|
for item in res:
|
||||||
|
for _, prov_id in item["ProviderIds"].items():
|
||||||
|
for prov in ["Imdb", "Tmdb", "Tvdb"]:
|
||||||
|
id_map[(prov.lower(), prov_id)] = item["Id"]
|
||||||
|
return id_map
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def get_library(self):
|
||||||
|
res = self.get(
|
||||||
|
"Users/{}/Items".format(self.user_id),
|
||||||
|
params={
|
||||||
|
"recursive": True,
|
||||||
|
"includeItemTypes": "Movie,Series",
|
||||||
|
"collapseBoxSetItems": False,
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
library = {}
|
||||||
|
for item in res["Items"]:
|
||||||
|
library[item["Id"]] = item
|
||||||
|
for item in res["Items"]:
|
||||||
|
for key, value in item.copy().items():
|
||||||
|
if key != "Id" and key.endswith("Id"):
|
||||||
|
key = key[:-2]
|
||||||
|
if value in library and key not in item:
|
||||||
|
item[key] = library[value]
|
||||||
|
return library
|
||||||
|
|
||||||
|
def get_usage(self):
|
||||||
|
report = self.post(
|
||||||
|
"user_usage_stats/submit_custom_query",
|
||||||
|
params={"stamp": int(time.time())},
|
||||||
|
json={
|
||||||
|
"CustomQueryString": "SELECT * FROM PlaybackActivity",
|
||||||
|
"ReplaceUserId": True,
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
ret = []
|
||||||
|
for row in report["results"]:
|
||||||
|
rec = dict(zip(report["colums"], row))
|
||||||
|
rec["PlayDuration"] = timedelta(seconds=int(rec["PlayDuration"]))
|
||||||
|
ts = rec.pop("DateCreated")
|
||||||
|
if ts:
|
||||||
|
rec["Timestamp"] = parse_datetime(ts)
|
||||||
|
ret.append(rec)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def __db_fetch(self, endpoint):
|
||||||
|
ret = []
|
||||||
|
res = self.session.get(
|
||||||
|
urljoin(
|
||||||
|
self.url,
|
||||||
|
endpoint),
|
||||||
|
params={
|
||||||
|
"StartIndex": 0,
|
||||||
|
"IncludeItemTypes": "*",
|
||||||
|
"ReportColumns": ""},
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
res = res.json()
|
||||||
|
headers = [h["Name"].lower() for h in res["Headers"]]
|
||||||
|
for row in res["Rows"]:
|
||||||
|
fields = [c["Name"] for c in row["Columns"]]
|
||||||
|
ret.append(dict(zip(headers, fields)))
|
||||||
|
ret[-1]["row_type"] = row["RowType"]
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def get_self(self):
|
||||||
|
res = self.get("Users/Me")
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
def get_users(self, user_id=None):
|
||||||
|
if user_id:
|
||||||
|
res = self.get("Users/{}".format(user_id))
|
||||||
|
else:
|
||||||
|
res = self.get("Users")
|
||||||
|
res.raise_for_status()
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
def activity(self):
|
||||||
|
return self.__db_fetch("Reports/Activities")
|
||||||
|
|
||||||
|
def report(self):
|
||||||
|
return self.__db_fetch("Reports/Items")
|
||||||
|
|
||||||
|
def stop_session(self, session_id):
|
||||||
|
sessions = self.get("Sessions").json()
|
||||||
|
for session in sessions:
|
||||||
|
if session["Id"] == session_id and "NowPlayingItem" in session:
|
||||||
|
s_id = session["Id"]
|
||||||
|
u_id = session["UserId"]
|
||||||
|
i_id = session["NowPlayingItem"]["Id"]
|
||||||
|
d_id = session["DeviceId"]
|
||||||
|
self.rq(
|
||||||
|
"delete",
|
||||||
|
"Videos/ActiveEncodings",
|
||||||
|
params={"deviceId": d_id, "playSessionId": s_id},
|
||||||
|
)
|
||||||
|
self.rq("delete", f"Users/{u_id}/PlayingItems/{i_id}")
|
||||||
|
self.rq("post", f"Sessions/{s_id}/Playing/Stop")
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
self.status()
|
||||||
|
return {}
|
75
api/portainer.py
Normal file
75
api/portainer.py
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import json
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests as RQ
|
||||||
|
|
||||||
|
|
||||||
|
class Portainer(object):
|
||||||
|
def __init__(self, url, username, passwd):
|
||||||
|
self.url = url
|
||||||
|
self.session = RQ.Session()
|
||||||
|
jwt = self.session.post(
|
||||||
|
urljoin(self.url, "api/auth"),
|
||||||
|
json={"username": username, "password": passwd},
|
||||||
|
).json()
|
||||||
|
self.session.headers.update(
|
||||||
|
{"Authorization": "Bearer {0[jwt]}".format(jwt)})
|
||||||
|
|
||||||
|
def containers(self, container_id=None):
|
||||||
|
if container_id is None:
|
||||||
|
res = self.session.get(
|
||||||
|
urljoin(self.url, "api/endpoints/1/docker/containers/json"),
|
||||||
|
params={
|
||||||
|
"all": 1,
|
||||||
|
"filters": json.dumps(
|
||||||
|
{"label": ["com.docker.compose.project=tvstack"]}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
res = self.session.get(
|
||||||
|
urljoin(
|
||||||
|
self.url,
|
||||||
|
"api/endpoints/1/docker/containers/{}/json".format(container_id),
|
||||||
|
))
|
||||||
|
res.raise_for_status()
|
||||||
|
res = res.json()
|
||||||
|
if container_id is None:
|
||||||
|
for container in res:
|
||||||
|
pass
|
||||||
|
# print("Gettings stats for",container['Id'])
|
||||||
|
# container['stats']=self.stats(container['Id'])
|
||||||
|
# container['top']=self.top(container['Id'])
|
||||||
|
else:
|
||||||
|
res["stats"] = self.stats(container_id)
|
||||||
|
res["top"] = self.top(container_id)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def top(self, container_id):
|
||||||
|
res = self.session.get(
|
||||||
|
urljoin(
|
||||||
|
self.url,
|
||||||
|
"api/endpoints/1/docker/containers/{}/top".format(container_id),
|
||||||
|
))
|
||||||
|
res.raise_for_status()
|
||||||
|
res = res.json()
|
||||||
|
cols = res["Titles"]
|
||||||
|
ret = []
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
def stats(self, container_id):
|
||||||
|
res = self.session.get(
|
||||||
|
urljoin(
|
||||||
|
self.url,
|
||||||
|
"api/endpoints/1/docker/containers/{}/stats".format(container_id),
|
||||||
|
),
|
||||||
|
params={
|
||||||
|
"stream": False},
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
self.containers()
|
||||||
|
return {}
|
155
api/qbittorrent.py
Normal file
155
api/qbittorrent.py
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
import time
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import requests as RQ
|
||||||
|
|
||||||
|
|
||||||
|
class QBittorrent(object):
|
||||||
|
|
||||||
|
status_map = {
|
||||||
|
"downloading": ("Downloading", "primary"),
|
||||||
|
"uploading": ("Seeding", "success"),
|
||||||
|
"forcedDL": ("Downloading [Forced]", "primary"),
|
||||||
|
"forcedUP": ("Seeding [Forced]", "success"),
|
||||||
|
"pausedDL": ("Downloading [Paused]", "secondary"),
|
||||||
|
"pausedUP": ("Seeding [Paused]", "secondary"),
|
||||||
|
"stalledDL": ("Downloading [Stalled]", "warning"),
|
||||||
|
"stalledUP": ("Seeding [Stalled]", "warning"),
|
||||||
|
"metaDL": ("Downloading metadata", "primary"),
|
||||||
|
"error": ("Error", "danger"),
|
||||||
|
"missingFiles": ("Missing Files", "danger"),
|
||||||
|
"queuedUP": ("Seeding [Queued]", "info"),
|
||||||
|
"queuedDL": ("Downloading [Queued]", "info"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker_status = {
|
||||||
|
0: ("Disabled", "secondary"),
|
||||||
|
1: ("Not contacted", "info"),
|
||||||
|
2: ("Working", "success"),
|
||||||
|
3: ("Updating", "warning"),
|
||||||
|
4: ("Not working", "danger"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, url, username, passwd):
|
||||||
|
self.url = url
|
||||||
|
self.username = username
|
||||||
|
self.passwd = passwd
|
||||||
|
self.rid = int(time.time())
|
||||||
|
self.session = RQ.Session()
|
||||||
|
url = urljoin(self.url, "/api/v2/auth/login")
|
||||||
|
self.session.post(
|
||||||
|
url, data={"username": self.username, "password": self.passwd}
|
||||||
|
).raise_for_status()
|
||||||
|
|
||||||
|
def get(self, url, **kwargs):
|
||||||
|
kwargs["rid"] = self.rid
|
||||||
|
url = urljoin(self.url, url)
|
||||||
|
res = self.session.get(url, params=kwargs)
|
||||||
|
res.raise_for_status()
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def add(self, **kwargs):
|
||||||
|
self.rid += 1
|
||||||
|
url = urljoin(self.url, "/api/v2/torrents/add")
|
||||||
|
ret = self.session.post(url, data=kwargs)
|
||||||
|
return ret.text, ret.status_code
|
||||||
|
|
||||||
|
def add_trackers(self, infohash, trackers=None):
|
||||||
|
if trackers is None:
|
||||||
|
trackers = []
|
||||||
|
for tracker_list in [
|
||||||
|
"https://newtrackon.com/api/live",
|
||||||
|
"https://ngosang.github.io/trackerslist/trackers_best.txt",
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
trackers_res = RQ.get(tracker_list)
|
||||||
|
trackers_res.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
print("Error getting tracker list:", e)
|
||||||
|
continue
|
||||||
|
trackers += trackers_res.text.split()
|
||||||
|
url = urljoin(self.url, "/api/v2/torrents/addTrackers")
|
||||||
|
data = {"hash": infohash, "urls": "\n\n".join(trackers)}
|
||||||
|
ret = self.session.post(url, data=data)
|
||||||
|
ret.raise_for_status()
|
||||||
|
return ret.text
|
||||||
|
|
||||||
|
def poll(self, infohash=None):
|
||||||
|
if infohash:
|
||||||
|
ret = {}
|
||||||
|
res = self.get("/api/v2/torrents/info", hashes=infohash)
|
||||||
|
ret["info"] = res
|
||||||
|
for endpoint in ["properties", "trackers", "webseeds", "files"]:
|
||||||
|
url = "/api/v2/torrents/{}".format(endpoint)
|
||||||
|
res = self.get(url, hash=infohash)
|
||||||
|
if endpoint == "trackers":
|
||||||
|
for v in res:
|
||||||
|
if v["tier"] == "":
|
||||||
|
v["tier"] = -1
|
||||||
|
v["status"] = self.tracker_status.get(
|
||||||
|
v["status"], ("Unknown", "light")
|
||||||
|
)
|
||||||
|
v["total_peers"] = (
|
||||||
|
v["num_seeds"] + v["num_leeches"] + v["num_peers"]
|
||||||
|
)
|
||||||
|
for k in [
|
||||||
|
"num_seeds",
|
||||||
|
"num_leeches",
|
||||||
|
"total_peers",
|
||||||
|
"num_downloaded",
|
||||||
|
"num_peers",
|
||||||
|
]:
|
||||||
|
if v[k] < 0:
|
||||||
|
v[k] = (-1, "?")
|
||||||
|
else:
|
||||||
|
v[k] = (v[k], v[k])
|
||||||
|
ret[endpoint] = res
|
||||||
|
ret["info"] = ret["info"][0]
|
||||||
|
ret["info"]["state"] = self.status_map.get(
|
||||||
|
ret["info"]["state"], (ret["info"]["state"], "light")
|
||||||
|
)
|
||||||
|
for tracker in ret["trackers"]:
|
||||||
|
tracker["name"] = urlparse(
|
||||||
|
tracker["url"]).netloc or tracker["url"]
|
||||||
|
tracker["has_url"] = bool(urlparse(tracker["url"]).netloc)
|
||||||
|
return ret
|
||||||
|
res = self.get("/api/v2/sync/maindata")
|
||||||
|
if "torrents" in res:
|
||||||
|
for k, v in res["torrents"].items():
|
||||||
|
v["hash"] = k
|
||||||
|
v["speed"] = v["upspeed"] + v["dlspeed"]
|
||||||
|
dl_rate = v["downloaded"] / max(0, time.time() - v["added_on"])
|
||||||
|
if dl_rate > 0:
|
||||||
|
v["eta"] = max(0, (v["size"] - v["downloaded"]) / dl_rate)
|
||||||
|
else:
|
||||||
|
v["eta"] = 0
|
||||||
|
if v["time_active"] == 0:
|
||||||
|
dl_rate = 0
|
||||||
|
else:
|
||||||
|
dl_rate = v["downloaded"] / v["time_active"]
|
||||||
|
if dl_rate > 0:
|
||||||
|
v["eta_act"] = max(
|
||||||
|
0, (v["size"] - v["downloaded"]) / dl_rate)
|
||||||
|
else:
|
||||||
|
v["eta_act"] = 0
|
||||||
|
res["torrents"][k] = v
|
||||||
|
res["version"] = self.get("/api/v2/app/version")
|
||||||
|
self.rid = res["rid"]
|
||||||
|
return res
|
||||||
|
|
||||||
|
def status(self, infohash=None):
|
||||||
|
self.rid += 1
|
||||||
|
return self.poll(infohash)
|
||||||
|
|
||||||
|
def peer_log(self, limit=0):
|
||||||
|
return self.get("/api/v2/log/peers")[-limit:]
|
||||||
|
|
||||||
|
def log(self, limit=0):
|
||||||
|
return self.get("/api/v2/log/main")[-limit:]
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
self.poll()
|
||||||
|
return {}
|
98
api/radarr.py
Normal file
98
api/radarr.py
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests as RQ
|
||||||
|
|
||||||
|
from utils import timed_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Radarr(object):
|
||||||
|
def __init__(self, url, api_key):
|
||||||
|
self.url = url
|
||||||
|
self.api_key = api_key
|
||||||
|
self.root_folder = self.get("api/v3/rootFolder")[0]["path"]
|
||||||
|
self.quality_profile = self.get("api/v3/qualityprofile")[0]
|
||||||
|
|
||||||
|
def get(self, url, **kwargs):
|
||||||
|
kwargs["apikey"] = self.api_key
|
||||||
|
kwargs["_"] = str(int(time.time()))
|
||||||
|
res = RQ.get(urljoin(self.url, url), params=kwargs)
|
||||||
|
res.raise_for_status()
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except Exception:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
return self.get("api/v3/movie/lookup", term=query)
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return self.get("api/v3/system/status")
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def history(self, pageSize=500):
|
||||||
|
return self.get(
|
||||||
|
"api/v3/history",
|
||||||
|
page=1,
|
||||||
|
pageSize=500,
|
||||||
|
sortDirection="descending",
|
||||||
|
sortKey="date",
|
||||||
|
)
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def calendar(self, days=90):
|
||||||
|
today = datetime.today()
|
||||||
|
start = today - timedelta(days=days)
|
||||||
|
end = today + timedelta(days=days)
|
||||||
|
return self.get(
|
||||||
|
"api/v3/calendar",
|
||||||
|
unmonitored=False,
|
||||||
|
start=start.isoformat(),
|
||||||
|
end=end.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def movies(self, movie_id=None):
|
||||||
|
if movie_id is None:
|
||||||
|
return self.get("api/v3/movie")
|
||||||
|
return self.get("api/v3/movie/{}".format(movie_id))
|
||||||
|
|
||||||
|
@timed_cache(seconds=60)
|
||||||
|
def queue(self, **kwargs):
|
||||||
|
data = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
res = self.get("api/v3/queue", page=page, pageSize=100, **kwargs)
|
||||||
|
data += res.get("records", [])
|
||||||
|
page += 1
|
||||||
|
if len(data) >= res.get("totalRecords", 0):
|
||||||
|
break
|
||||||
|
return data
|
||||||
|
|
||||||
|
def add(self, data):
|
||||||
|
data["qualityProfileId"] = self.quality_profile["id"]
|
||||||
|
data["minimumAvailability"] = 2 # InCinema
|
||||||
|
data["rootFolderPath"] = self.root_folder
|
||||||
|
data["addOptions"] = {"searchForMovie": True}
|
||||||
|
params = dict(apikey=self.api_key)
|
||||||
|
res = RQ.post(
|
||||||
|
urljoin(
|
||||||
|
self.url,
|
||||||
|
"api/v3/movie"),
|
||||||
|
json=data,
|
||||||
|
params=params)
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
def log(self, limit=0):
|
||||||
|
return self.get(
|
||||||
|
"api/v3/log",
|
||||||
|
page=1,
|
||||||
|
pageSize=(limit or 1024),
|
||||||
|
sortDirection="descending",
|
||||||
|
sortKey="time",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
self.status()
|
||||||
|
return {}
|
116
api/sonarr.py
Normal file
116
api/sonarr.py
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
import time
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import requests as RQ
|
||||||
|
|
||||||
|
from utils import timed_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Sonarr(object):
|
||||||
|
def __init__(self, url, api_key):
|
||||||
|
self.url = url
|
||||||
|
self.api_key = api_key
|
||||||
|
self.root_folder = self.get("api/v3/rootFolder")[0]["path"]
|
||||||
|
self.quality_profile = self.get("api/v3/qualityprofile")[0]
|
||||||
|
self.language_profile = self.get("api/v3/languageprofile")[0]
|
||||||
|
|
||||||
|
def get(self, url, **kwargs):
|
||||||
|
kwargs["apikey"] = self.api_key
|
||||||
|
kwargs["_"] = str(int(time.time()))
|
||||||
|
res = RQ.get(urljoin(self.url, url), params=kwargs)
|
||||||
|
res.raise_for_status()
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except Exception:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
return self.get("api/v3/series/lookup", term=query)
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return self.get("api/v3/system/status")
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def history(self, pageSize=500):
|
||||||
|
return self.get(
|
||||||
|
"api/v3/history",
|
||||||
|
page=1,
|
||||||
|
pageSize=500,
|
||||||
|
sortDirection="descending",
|
||||||
|
sortKey="date",
|
||||||
|
)
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def calendar(self, days=30):
|
||||||
|
today = datetime.today()
|
||||||
|
start = today - timedelta(days=days)
|
||||||
|
end = today + timedelta(days=days)
|
||||||
|
return self.get(
|
||||||
|
"api/v3/calendar",
|
||||||
|
unmonitored=False,
|
||||||
|
start=start.isoformat(),
|
||||||
|
end=end.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def series(self, series_id=None, keys=None):
|
||||||
|
if series_id is None:
|
||||||
|
return self.get("api/v3/series")
|
||||||
|
ret = {}
|
||||||
|
ret["series"] = self.get("api/v3/series/{}".format(series_id))
|
||||||
|
ret["episodes"] = self.get("api/v3/episode", seriesId=series_id)
|
||||||
|
ret["episodeFile"] = self.get("api/v3/episodeFile", seriesId=series_id)
|
||||||
|
ret["queue"] = self.get("api/v3/queue/details", seriesId=series_id)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
@timed_cache(seconds=60)
|
||||||
|
def queue(self, **kwargs):
|
||||||
|
data = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
res = self.get("api/v3/queue", page=page, pageSize=100, **kwargs)
|
||||||
|
data = res.get("records", [])
|
||||||
|
page += 1
|
||||||
|
if len(data) >= res.get("totalRecords", 0):
|
||||||
|
break
|
||||||
|
return data
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def details(self, episode_id):
|
||||||
|
return self.get("api/v3/queue/details", episodeIds=episode_id)
|
||||||
|
|
||||||
|
@timed_cache()
|
||||||
|
def episodes(self, series_id):
|
||||||
|
return self.get("api/v3/episode", seriesId=series_id)
|
||||||
|
|
||||||
|
def add(self, data):
|
||||||
|
data["qualityProfileId"] = self.quality_profile["id"]
|
||||||
|
data["languageProfileId"] = self.language_profile["id"]
|
||||||
|
data["rootFolderPath"] = self.root_folder
|
||||||
|
data["addOptions"] = {
|
||||||
|
"ignoreEpisodesWithoutFiles": False,
|
||||||
|
"ignoreEpisodesWithFiles": True,
|
||||||
|
"searchForMissingEpisodes": True,
|
||||||
|
}
|
||||||
|
data["seasonFolder"] = True
|
||||||
|
params = dict(apikey=self.api_key)
|
||||||
|
res = RQ.post(
|
||||||
|
urljoin(
|
||||||
|
self.url,
|
||||||
|
"api/v3/series"),
|
||||||
|
json=data,
|
||||||
|
params=params)
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
def log(self, limit=0):
|
||||||
|
return self.get(
|
||||||
|
"api/v3/log",
|
||||||
|
page=1,
|
||||||
|
pageSize=(limit or 1024),
|
||||||
|
sortDirection="descending",
|
||||||
|
sortKey="time",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test(self):
|
||||||
|
self.status()
|
||||||
|
return {}
|
34
api/user.py
Normal file
34
api/user.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
from flask_login import UserMixin
|
||||||
|
|
||||||
|
from api import Jellyfin
|
||||||
|
from utils import handle_config
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinUser(UserMixin):
|
||||||
|
def __init__(self, username, password):
|
||||||
|
api = Jellyfin(handle_config()["jellyfin_url"], username, password)
|
||||||
|
self.user = api.user
|
||||||
|
self.api_key = api.api_key
|
||||||
|
self.id = self.user["Id"]
|
||||||
|
api.logout()
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self.user[key]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self):
|
||||||
|
pol = self.user["Policy"]
|
||||||
|
return pol["IsAdministrator"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
pol = self.user["Policy"]
|
||||||
|
return not pol["IsDisabled"]
|
656
app.py
656
app.py
|
@ -1,83 +1,107 @@
|
||||||
import sys
|
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
|
||||||
|
|
74
models/requests.py
Normal file
74
models/requests.py
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import Float, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from api import Client
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
class RequestItem(db.Model):
|
||||||
|
id = db.Column(
|
||||||
|
db.String,
|
||||||
|
default=lambda: str(
|
||||||
|
uuid4()),
|
||||||
|
index=True,
|
||||||
|
unique=True)
|
||||||
|
item_id = db.Column(db.String, primary_key=True)
|
||||||
|
added_date = db.Column(db.DateTime)
|
||||||
|
request_type = db.Column(db.String)
|
||||||
|
data = db.Column(db.String)
|
||||||
|
approved = db.Column(db.Boolean, nullable=True)
|
||||||
|
arr_id = db.Column(db.String, nullable=True)
|
||||||
|
jellyfin_id = db.Column(db.String, nullable=True)
|
||||||
|
users = relationship("RequestUser", back_populates="requests")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def downloads(self):
|
||||||
|
yield from self._download_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def arr_item(self):
|
||||||
|
c = Client()
|
||||||
|
if self.request_type == "sonarr":
|
||||||
|
return c.sonarr.series(self.arr_id)
|
||||||
|
if self.request_type == "radarr":
|
||||||
|
return c.radarr.movies(self.arr_id)
|
||||||
|
|
||||||
|
def _download_state(self):
|
||||||
|
c = Client()
|
||||||
|
if self.request_type == "sonarr":
|
||||||
|
q = c.sonarr.queue()
|
||||||
|
for item in q:
|
||||||
|
if item["seriesId"] == str(self.arr_id):
|
||||||
|
item["download"] = c.qbittorent.poll(self.download_id)
|
||||||
|
yield item
|
||||||
|
c = Client()
|
||||||
|
if self.request_type == "radarr":
|
||||||
|
q = c.radarr.queue()
|
||||||
|
for item in q:
|
||||||
|
if str(item["movieId"]) == str(self.arr_id):
|
||||||
|
if item["protocol"] == "torrent":
|
||||||
|
item["download"] = c.qbittorent.poll(
|
||||||
|
item["downloadId"])
|
||||||
|
yield item
|
||||||
|
|
||||||
|
|
||||||
|
class RequestUser(db.Model):
|
||||||
|
item_id = db.Column(
|
||||||
|
db.String,
|
||||||
|
db.ForeignKey(
|
||||||
|
RequestItem.item_id),
|
||||||
|
primary_key=True)
|
||||||
|
user_id = db.Column(db.String, primary_key=True)
|
||||||
|
hidden = db.Column(db.Boolean, default=False)
|
||||||
|
updated = db.Column(db.Boolean, default=True)
|
||||||
|
user_name = db.Column(db.String)
|
||||||
|
requests = relationship("RequestItem", back_populates="users")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def details(self):
|
||||||
|
c = Client()
|
||||||
|
return c.jellyfin.get_users(self.user_id)
|
|
@ -1,7 +1,9 @@
|
||||||
from . import db
|
|
||||||
from sqlalchemy import String, Float, Column, Integer, DateTime
|
|
||||||
from datetime import datetime
|
from 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)
|
||||||
|
|
3
setup.cfg
Normal file
3
setup.cfg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[flake8]
|
||||||
|
extend_exclude = .history
|
||||||
|
ingore = E501
|
51
static/icon.svg
Normal file
51
static/icon.svg
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
style="fill:none"
|
||||||
|
width="134.00002"
|
||||||
|
height="134"
|
||||||
|
id="svg19"
|
||||||
|
sodipodi:docname="dotgrid-21R11-649862.svg"
|
||||||
|
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs23" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview21"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#eeeeee"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:pageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="true"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.744"
|
||||||
|
inkscape:cx="-31.536697"
|
||||||
|
inkscape:cy="39.56422"
|
||||||
|
inkscape:window-width="1347"
|
||||||
|
inkscape:window-height="1080"
|
||||||
|
inkscape:window-x="2147"
|
||||||
|
inkscape:window-y="486"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="svg19" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#ff7700;stroke-width:14px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
|
||||||
|
d="M 7,127 V 127 7 L 67,67 127.00001,7 v 120 m 0,-120 v 120 m 0,-120 V 7 M 37,7 V 127 A 60,60 0 0 0 97,67 60,60 0 0 0 37,7"
|
||||||
|
id="path17" />
|
||||||
|
<g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
inkscape:label="slices"
|
||||||
|
transform="translate(-122.54128,-107.22018)">
|
||||||
|
<rect
|
||||||
|
style="opacity:0.25;fill:none;fill-opacity:0.607843;stroke:none"
|
||||||
|
id="rect150"
|
||||||
|
width="138.76147"
|
||||||
|
height="139.90825"
|
||||||
|
x="119.83945"
|
||||||
|
y="103.78441" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.6 KiB |
|
@ -127,3 +127,16 @@ ul.tree {
|
||||||
.active {
|
.active {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-badge {
|
||||||
|
float: right;
|
||||||
|
margin-bottom: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.darken {
|
||||||
|
filter: brightness(0.95)
|
||||||
|
}
|
||||||
|
|
||||||
|
.lighten {
|
||||||
|
filter: brightness(1.05)
|
||||||
|
}
|
1
stats/calendar.json
Normal file
1
stats/calendar.json
Normal file
File diff suppressed because one or more lines are too long
1
stats/data.json
Normal file
1
stats/data.json
Normal file
File diff suppressed because one or more lines are too long
1
stats/images.json
Normal file
1
stats/images.json
Normal file
File diff suppressed because one or more lines are too long
1
stats/library.json
Normal file
1
stats/library.json
Normal file
File diff suppressed because one or more lines are too long
1
stats/qbt_hist.json
Normal file
1
stats/qbt_hist.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{"t":[1631138513.856375],"dl":[0.0],"ul":[258608.0],"dl_size":[28783725830170],"ul_size":[11341122517104],"dl_size_sess":[516489974112],"ul_size_sess":[398693624144],"connections":[62.0],"bw_per_conn":[4171.096774193548],"dht_nodes":[378]}
|
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>
|
||||||
|
|
8
templates/error.html
Normal file
8
templates/error.html
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from 'utils.html' import custom_render_form_row,make_tabs %}
|
||||||
|
|
||||||
|
{% block app_content %}
|
||||||
|
<div class="container" style="max-width: 30% !important;">
|
||||||
|
<h1>Oops</h1>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -41,62 +41,89 @@
|
||||||
<div class="row">
|
<div class="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] %}
|
||||||
|
|
20
templates/jellyfin/details.html
Normal file
20
templates/jellyfin/details.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from 'utils.html' import custom_render_form_row,make_tabs %}
|
||||||
|
{% from 'bootstrap/utils.html' import render_icon %}
|
||||||
|
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
|
||||||
|
|
||||||
|
{% block app_content %}
|
||||||
|
|
||||||
|
<h2><a href={{info.LocalAddress}}>Jellyfin</a> v{{info.Version}}</h2>
|
||||||
|
{% if status.HasUpdateAvailable %}
|
||||||
|
<h3>Update available</h3>
|
||||||
|
{% endif %}
|
||||||
|
{% if status.HasPendingRestart %}
|
||||||
|
<h3>Restart pending</h3>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<img src={{cfg.jellyfin_url}}/Items/{{item.Id}}/Images/Art>
|
||||||
|
|
||||||
|
<pre>{{item|pformat}}</pre>
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -3,119 +3,80 @@
|
||||||
{% from 'bootstrap/utils.html' import render_icon %}
|
{% from 'bootstrap/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 %}
|
56
templates/jellyfin/movie.html
Normal file
56
templates/jellyfin/movie.html
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from 'utils.html' import custom_render_form_row,make_tabs %}
|
||||||
|
{% from 'bootstrap/utils.html' import render_icon %}
|
||||||
|
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
|
||||||
|
|
||||||
|
{% block app_content %}
|
||||||
|
|
||||||
|
<h1><a href={{info.LocalAddress}}>Jellyfin</a> v{{info.Version}}</h1>
|
||||||
|
{% if status.HasUpdateAvailable %}
|
||||||
|
<h3>Update available</h3>
|
||||||
|
{% endif %}
|
||||||
|
{% if status.HasPendingRestart %}
|
||||||
|
<h3>Restart pending</h3>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
{% for ext in item.ExternalUrls %}
|
||||||
|
<a href={{ext.Url}}><span class="badge badge-secondary">{{ext.Name}}</span></a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 title="{{item.Id}}">
|
||||||
|
{{item.Name}}
|
||||||
|
{% if item.IsHD %}
|
||||||
|
<span class="badge badge-primary">HD</span>
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
<p>{{item.Overview}}</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
{# <img src={{info.LocalAddress}}/Items/{{item.Id}}/Images/Primary> #}
|
||||||
|
|
||||||
|
{% set data = [
|
||||||
|
("Path", item.Path),
|
||||||
|
("Genres", item.Genres|join(", ")),
|
||||||
|
] %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
{% for k,v in data %}
|
||||||
|
<tr>
|
||||||
|
<td>{{k}}</td>
|
||||||
|
<td>{{v}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre>{{item|pformat}}</pre>
|
||||||
|
|
||||||
|
{% endblock %}
|
70
templates/jellyfin/series.html
Normal file
70
templates/jellyfin/series.html
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from 'utils.html' import custom_render_form_row,make_tabs %}
|
||||||
|
{% from 'bootstrap/utils.html' import render_icon %}
|
||||||
|
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
|
||||||
|
|
||||||
|
{% block app_content %}
|
||||||
|
|
||||||
|
<h1><a href={{info.LocalAddress}}>Jellyfin</a> v{{info.Version}}</h1>
|
||||||
|
{% if status.HasUpdateAvailable %}
|
||||||
|
<h3>Update available</h3>
|
||||||
|
{% endif %}
|
||||||
|
{% if status.HasPendingRestart %}
|
||||||
|
<h3>Restart pending</h3>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2 title="{{item.Id}}"><a href="{{info.LocalAddress}}/web/index.html#!/details?id={{item.Id}}">{{item.Name}}</a></h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
{% for ext in item.ExternalUrls %}
|
||||||
|
<a href={{ext.Url}}><span class="badge badge-secondary">{{ext.Name}}</span></a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<img class="rounded" src={{info.LocalAddress}}/Items/{{item.Id}}/Images/Primary>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>{{item.Overview}}</p>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<tr>
|
||||||
|
<td>Path</td>
|
||||||
|
<td>{{item.Path}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% for season in item.Seasons %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h3>{{season.Name}}</h3>
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
{% for episode in season.Episodes %}
|
||||||
|
<tr>
|
||||||
|
<td>{{episode.Name}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
{% for k,v in item|flatten %}
|
||||||
|
<tr>
|
||||||
|
<td>{{k}}</td>
|
||||||
|
<td>{{v}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock %}
|
16
templates/login.html
Normal file
16
templates/login.html
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from 'utils.html' import custom_render_form_row,make_tabs %}
|
||||||
|
|
||||||
|
{% block app_content %}
|
||||||
|
<img src="{{url_for('static',filename='icon.svg')}}" class="mx-auto d-block" width=150 height=150/>
|
||||||
|
<div class="container" style="max-width: 30% !important;">
|
||||||
|
<h2>Login</h2>
|
||||||
|
<form method="post" class="form">
|
||||||
|
{{form.csrf_token()}}
|
||||||
|
{{custom_render_form_row([form.username])}}
|
||||||
|
{{custom_render_form_row([form.password])}}
|
||||||
|
{{custom_render_form_row([form.remember])}}
|
||||||
|
{{custom_render_form_row([form.login])}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -208,9 +208,11 @@
|
||||||
<div class="row">
|
<div class="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>
|
||||||
|
|
54
templates/requests/details.html
Normal file
54
templates/requests/details.html
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from 'bootstrap/utils.html' import render_icon %}
|
||||||
|
|
||||||
|
{% block app_content %}
|
||||||
|
<h1>Request details</h1>
|
||||||
|
{% set data = request.data|fromjson%}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
{% set label = {True:"Approved",False:"Declined",None:"Pending"}[request.approved] %}
|
||||||
|
{% set class = {True:"bg-success",False:"bg-danger", None: ""}[request.approved] %}
|
||||||
|
{% if request.approved and jellyfin_id %}
|
||||||
|
{% set label = "Approved and Downloaded" %}
|
||||||
|
{% endif %}
|
||||||
|
{% if data.tvdbId %}
|
||||||
|
{% set link="https://www.thetvdb.com/?tab=series&id=%s"|format(data.tvdbId) %}
|
||||||
|
{% elif data.imdbId %}
|
||||||
|
{% set link="https://www.imdb.com/title/%s"|format(data.imdbId) %}
|
||||||
|
{% endif %}
|
||||||
|
<p><b>Title</b>: <a href="{{link}}">{{data.title}}</a> ({{data.year}})</p>
|
||||||
|
<p><b>Type</b>: {{{"sonarr":"TV Show","radarr":"Movie"}.get(request.request_type,"Unknown")}}</p>
|
||||||
|
<p><b>Added</b>: {{request.added_date|ago_dt(0)}} ago</p>
|
||||||
|
<p><b>Status</b>: <span class="{{class}}">{{label}}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>{{data.overview}}</p>
|
||||||
|
{% if jellyfin_id %}
|
||||||
|
<a class="btn btn-success" href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{jellyfin_id}}">Open in Jellyfin</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% set downloads = (request.downloads|list) %}
|
||||||
|
{% if downloads %}
|
||||||
|
<h3>Downloads</h3>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Quality</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
</tr>
|
||||||
|
{% for download in downloads %}
|
||||||
|
{% set torrent = download.download.info %}
|
||||||
|
{% set dl_rate = torrent.downloaded / torrent.time_active %}
|
||||||
|
{% set eta_act = [0, (torrent.size - torrent.downloaded) / dl_rate]|max %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{url_for('requests.download_details',request_id=request.id,download_id=torrent.hash)}}">{{download.title}}</a></td>
|
||||||
|
<td>{{download.quality.quality.name}}</td>
|
||||||
|
<td>
|
||||||
|
{{(torrent.progress*100)|round(2)}} % (ETA: {{[torrent.eta,eta_act]|min|round(0)|timedelta(clamp=true)}})
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
116
templates/requests/index.html
Normal file
116
templates/requests/index.html
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from 'utils.html' import custom_render_form_row,make_tabs %}
|
||||||
|
{% from 'bootstrap/utils.html' import render_icon %}
|
||||||
|
{% from 'bootstrap/form.html' import render_form, render_field, render_form_row %}
|
||||||
|
|
||||||
|
{% macro search_tab() %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{{ form.csrf_token() }}
|
||||||
|
{{ custom_render_form_row([form.query],render_args={'form_type':'horizontal','horizontal_columns':('lg',1,6)}) }}
|
||||||
|
{{ custom_render_form_row([form.search_type],render_args={'form_type':'horizontal','horizontal_columns':('lg',1,6)}) }}
|
||||||
|
{{ custom_render_form_row([form.search]) }}
|
||||||
|
</form>
|
||||||
|
{% if results %}
|
||||||
|
<form method="post">
|
||||||
|
{{ form.csrf_token() }}
|
||||||
|
{% if search_type %}
|
||||||
|
<input type="hidden" name="search_type" value="{{search_type}}">
|
||||||
|
{% endif %}
|
||||||
|
<table class="table table-sm table-bordered mt-3">
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>In Cinemas</th>
|
||||||
|
<th>Digital Release</th>
|
||||||
|
</tr>
|
||||||
|
{% for result in results %}
|
||||||
|
{% if result.tvdbId %}
|
||||||
|
{% set link="https://www.thetvdb.com/?tab=series&id=%s"|format(result.tvdbId) %}
|
||||||
|
{% elif result.imdbId %}
|
||||||
|
{% set link="https://www.imdb.com/title/%s"|format(result.imdbId) %}
|
||||||
|
{% endif %}
|
||||||
|
{% if result.path %}
|
||||||
|
{% set style="background-color: darkgreen !important;" %}
|
||||||
|
{% else %}
|
||||||
|
{% set style="" %}
|
||||||
|
{% endif %}
|
||||||
|
<tr style="{{style}}">
|
||||||
|
<td>
|
||||||
|
{% if result.path %}
|
||||||
|
<input type="checkbox" disabled>
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" name="selected[]" value="{{result|tojson|base64}}">
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{link}}">{{result.title}}</a> ({{result.year}})
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if result.inCinemas %}
|
||||||
|
{{result.inCinemas|fromiso}}
|
||||||
|
{% else %}
|
||||||
|
None
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if result.digitalRelease %}
|
||||||
|
{{result.digitalRelease|fromiso}}
|
||||||
|
{% else %}
|
||||||
|
None
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<button class="btn btn-success" type="submit">Submit Request</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro request_queue() %}
|
||||||
|
<form method="post">
|
||||||
|
{{ form.csrf_token() }}
|
||||||
|
<table class="table table-sm table-bordered mt-3">
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Requested at</th>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<th>Requested by</th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% for request in requests %}
|
||||||
|
{% set data = (request.data|fromjson) %}
|
||||||
|
{% if request.approved==True %}
|
||||||
|
<tr class="bg-success">
|
||||||
|
{% elif request.approved==False %}
|
||||||
|
<tr class="bg-danger">
|
||||||
|
{% else %}
|
||||||
|
<tr class="">
|
||||||
|
{% endif %}
|
||||||
|
<td>
|
||||||
|
{% if current_user.is_admin and request.approved!=True %}
|
||||||
|
<input type="checkbox" name="selected[]" value="{{request.item_id}}">
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{url_for('requests.details',request_id=request.id)}}" style="color: #eee; text-decoration: underline;">{{data.title}}</a> ({{data.year}})
|
||||||
|
</td>
|
||||||
|
<td>{{request.added_date}}</td>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<td>{{request.users|join(", ",attribute="user_name")}}</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<button name="approve" value="approve" class="btn btn-success" type="submit">Approve</button>
|
||||||
|
<button name="decline" value="decline" class="btn btn-danger" type="submit">Decline</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block app_content %}
|
||||||
|
<h1>Requests</h1>
|
||||||
|
{% set requests_tabs = [
|
||||||
|
('Queue',request_queue()),
|
||||||
|
('Search',search_tab()),
|
||||||
|
] %}
|
||||||
|
{{ make_tabs(requests_tabs) }}
|
||||||
|
{% endblock %}
|
|
@ -2,7 +2,7 @@
|
||||||
{% macro tv_show_results(results) -%}
|
{% 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")
|
||||||
|
|
24
views/__init__.py
Normal file
24
views/__init__.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
from .api import api # noqa
|
||||||
|
from .config import config_page # noqa
|
||||||
|
from .containers import containers_page # noqa
|
||||||
|
from .history import history_page # noqa
|
||||||
|
from .home import home_page # noqa
|
||||||
|
from .jellyfin import jellyfin_page # noqa
|
||||||
|
from .logs import logs_page # noqa
|
||||||
|
from .qbittorrent import qbittorrent_page # noqa
|
||||||
|
from .radarr import radarr_page # noqa
|
||||||
|
from .remote import remote_page # noqa
|
||||||
|
from .requests import requests_page # noqa
|
||||||
|
from .search import search_page # noqa
|
||||||
|
from .sonarr import sonarr_page # noqa
|
||||||
|
from .transcode import transcode_page # noqa
|
||||||
|
|
||||||
|
|
||||||
|
def register_blueprints(app):
|
||||||
|
for k, v in vars(sys.modules[__name__]).items():
|
||||||
|
if isinstance(v, Blueprint):
|
||||||
|
app.register_blueprint(v)
|
32
views/api/__init__.py
Normal file
32
views/api/__init__.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
from flask import Blueprint, request, flash, session, redirect, url_for
|
||||||
|
|
||||||
|
import time
|
||||||
|
from api import Client
|
||||||
|
from utils import admin_required
|
||||||
|
|
||||||
|
api = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@api.route("/add_torrent", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def add_torrent():
|
||||||
|
category = request.form.get("category")
|
||||||
|
c = Client()
|
||||||
|
hashes_1 = set(c.qbittorent.status().get("torrents", {}))
|
||||||
|
links = ""
|
||||||
|
count = 0
|
||||||
|
for link in request.form.getlist("torrent[]"):
|
||||||
|
links += link + "\n"
|
||||||
|
count += 1
|
||||||
|
c.qbittorent.add(urls=links, category=category)
|
||||||
|
for _ in range(10):
|
||||||
|
status = c.qbittorent.status().get("torrents", {})
|
||||||
|
hashes_2 = set(status)
|
||||||
|
if len(hashes_2 - hashes_1) == count:
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
else:
|
||||||
|
flash("Some torrents failed to get added to QBittorrent", "waring")
|
||||||
|
new_torrents = sorted(hashes_2 - hashes_1)
|
||||||
|
session["new_torrents"] = {h: status[h] for h in new_torrents}
|
||||||
|
return redirect(url_for("search"))
|
45
views/config/__init__.py
Normal file
45
views/config/__init__.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
from flask import Blueprint, json, render_template, request
|
||||||
|
from api import Client
|
||||||
|
from forms import ConfigForm
|
||||||
|
from utils import admin_required, handle_config, populate_form, validate_transcoding_profiles
|
||||||
|
|
||||||
|
config_page = Blueprint("config", __name__, url_prefix="/config")
|
||||||
|
|
||||||
|
|
||||||
|
@config_page.route("/", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
form = ConfigForm()
|
||||||
|
cfg = {}
|
||||||
|
populate_form(form)
|
||||||
|
if form.validate_on_submit():
|
||||||
|
skip = ["save", "test", "csrf_token"]
|
||||||
|
transcode_profiles = request.files.get("transcode_profiles")
|
||||||
|
if transcode_profiles:
|
||||||
|
try:
|
||||||
|
form.transcode_profiles.data = json.load(transcode_profiles)
|
||||||
|
validate_transcoding_profiles(form.transcode_profiles.data)
|
||||||
|
except ValueError as e:
|
||||||
|
form.transcode_profiles.data = None
|
||||||
|
form.transcode_profiles.errors = [
|
||||||
|
"Invalid json data in file {}: {}".format(
|
||||||
|
transcode_profiles.filename, e
|
||||||
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
form.transcode_profiles.data = handle_config().get("transcode_profiles", {})
|
||||||
|
if form.errors:
|
||||||
|
return render_template("config.html", form=form)
|
||||||
|
for name, field in form._fields.items():
|
||||||
|
if name in skip:
|
||||||
|
continue
|
||||||
|
cfg[name] = field.data
|
||||||
|
if form.test.data:
|
||||||
|
test_res = Client.test(cfg)
|
||||||
|
populate_form(form, cfg)
|
||||||
|
return render_template("config.html", form=form, test=test_res)
|
||||||
|
handle_config(cfg)
|
||||||
|
populate_form(form)
|
||||||
|
return render_template("config.html", form=form)
|
||||||
|
form.process()
|
||||||
|
return render_template("config.html", form=form)
|
22
views/containers/__init__.py
Normal file
22
views/containers/__init__.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
from api import Client
|
||||||
|
from utils import admin_required
|
||||||
|
|
||||||
|
containers_page = Blueprint("containers", __name__, url_prefix="/containers")
|
||||||
|
|
||||||
|
|
||||||
|
@containers_page.route("/")
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
c = Client()
|
||||||
|
containers = c.portainer.containers()
|
||||||
|
return render_template("containers/index.html", containers=containers)
|
||||||
|
|
||||||
|
|
||||||
|
@containers_page.route("/<container_id>")
|
||||||
|
@admin_required
|
||||||
|
def details(container_id):
|
||||||
|
c = Client()
|
||||||
|
container = c.portainer.containers(container_id)
|
||||||
|
return render_template("containers/details.html", container=container)
|
15
views/history/__init__.py
Normal file
15
views/history/__init__.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
from api import Client
|
||||||
|
from utils import admin_required
|
||||||
|
|
||||||
|
history_page = Blueprint("history", __name__, url_prefix="/history")
|
||||||
|
|
||||||
|
|
||||||
|
@history_page.route("/")
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
c = Client()
|
||||||
|
sonarr = c.sonarr.history()
|
||||||
|
radarr = c.radarr.history()
|
||||||
|
return render_template("history.html", sonarr=sonarr, radarr=radarr)
|
16
views/home/__init__.py
Normal file
16
views/home/__init__.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
import stats_collect
|
||||||
|
|
||||||
|
home_page = Blueprint("home", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@home_page.route("/")
|
||||||
|
def index():
|
||||||
|
stats = stats_collect.Stats()
|
||||||
|
if not (current_user.is_authenticated and current_user.is_admin):
|
||||||
|
stats["images"] = [
|
||||||
|
img for img in stats["images"] if img[0] != "Torrents"]
|
||||||
|
|
||||||
|
return render_template("index.html", fluid=True, data=stats)
|
43
views/jellyfin/__init__.py
Normal file
43
views/jellyfin/__init__.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
import stats_collect
|
||||||
|
from api import Client
|
||||||
|
from utils import login_required
|
||||||
|
|
||||||
|
jellyfin_page = Blueprint("jellyfin", __name__, url_prefix="/jellyfin")
|
||||||
|
|
||||||
|
|
||||||
|
@jellyfin_page.route("/")
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
c = Client()
|
||||||
|
stats = stats_collect.Stats()
|
||||||
|
jellyfin = {
|
||||||
|
"info": c.jellyfin.system_info(),
|
||||||
|
"status": c.jellyfin.status(),
|
||||||
|
"counts": c.jellyfin.get_counts(),
|
||||||
|
"library": stats["library"],
|
||||||
|
}
|
||||||
|
if not (current_user.is_authenticated and current_user.is_admin):
|
||||||
|
jellyfin["library"] = {}
|
||||||
|
jellyfin["status"]["HasUpdateAvailable"] = False
|
||||||
|
jellyfin["status"]["HasPendingRestart"] = False
|
||||||
|
|
||||||
|
return render_template("jellyfin/index.html", **jellyfin)
|
||||||
|
|
||||||
|
|
||||||
|
@jellyfin_page.route("/<item_id>")
|
||||||
|
@login_required
|
||||||
|
def details(item_id):
|
||||||
|
c = Client()
|
||||||
|
jellyfin = {
|
||||||
|
"info": c.jellyfin.system_info(),
|
||||||
|
"status": c.jellyfin.status(),
|
||||||
|
"item": c.jellyfin.media_info(item_id),
|
||||||
|
}
|
||||||
|
if jellyfin["item"].get("Type") == "Movie":
|
||||||
|
return render_template("jellyfin/movie.html", **jellyfin)
|
||||||
|
if jellyfin["item"].get("Type") == "Series":
|
||||||
|
return render_template("jellyfin/series.html", **jellyfin)
|
||||||
|
return render_template("jellyfin/details.html", **jellyfin)
|
19
views/logs/__init__.py
Normal file
19
views/logs/__init__.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
from api import Client
|
||||||
|
from utils import admin_required
|
||||||
|
|
||||||
|
logs_page = Blueprint("log", __name__, url_prefix="/log")
|
||||||
|
|
||||||
|
|
||||||
|
@logs_page.route("/")
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
c = Client()
|
||||||
|
logs = {
|
||||||
|
"radarr": c.radarr.log(),
|
||||||
|
"sonarr": c.sonarr.log(),
|
||||||
|
"qbt": c.qbittorent.log(),
|
||||||
|
"peers": c.qbittorent.peer_log(),
|
||||||
|
}
|
||||||
|
return render_template("logs.html", logs=logs)
|
48
views/qbittorrent/__init__.py
Normal file
48
views/qbittorrent/__init__.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
from flask import Blueprint, render_template, request, redirect
|
||||||
|
|
||||||
|
from api import Client
|
||||||
|
from utils import admin_required
|
||||||
|
|
||||||
|
qbittorrent_page = Blueprint(
|
||||||
|
"qbittorrent",
|
||||||
|
__name__,
|
||||||
|
url_prefix="/qbittorrent")
|
||||||
|
|
||||||
|
|
||||||
|
@qbittorrent_page.route("/")
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
c = Client()
|
||||||
|
qbt = c.qbittorent.status()
|
||||||
|
sort_by_choices = {
|
||||||
|
"speed": "Transfer Speed",
|
||||||
|
"eta": "Time remaining",
|
||||||
|
"state": "State",
|
||||||
|
"category": "Category",
|
||||||
|
}
|
||||||
|
return render_template(
|
||||||
|
"qbittorrent/index.html",
|
||||||
|
qbt=qbt,
|
||||||
|
status_map=c.qbittorent.status_map,
|
||||||
|
state_filter=request.args.get("state"),
|
||||||
|
sort_by=request.args.get("sort", "speed"),
|
||||||
|
sort_by_choices=sort_by_choices,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@qbittorrent_page.route("/add_trackers/<infohash>")
|
||||||
|
@admin_required
|
||||||
|
def add_trackers(infohash):
|
||||||
|
c = Client()
|
||||||
|
c.qbittorent.add_trackers(infohash)
|
||||||
|
return redirect(url_for("qbittorrent_details", infohash=infohash))
|
||||||
|
|
||||||
|
|
||||||
|
@qbittorrent_page.route("/<infohash>")
|
||||||
|
@admin_required
|
||||||
|
def details(infohash):
|
||||||
|
c = Client()
|
||||||
|
qbt = c.qbittorent.status(infohash)
|
||||||
|
return render_template(
|
||||||
|
"qbittorrent/details.html", qbt=qbt, status_map=c.qbittorent.status_map
|
||||||
|
)
|
24
views/radarr/__init__.py
Normal file
24
views/radarr/__init__.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
from api import Client
|
||||||
|
from utils import admin_required
|
||||||
|
|
||||||
|
radarr_page = Blueprint("radarr", __name__, url_prefix="/radarr")
|
||||||
|
|
||||||
|
|
||||||
|
@radarr_page.route("/")
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
c = Client()
|
||||||
|
movies = c.radarr.movies()
|
||||||
|
status = c.radarr.status()
|
||||||
|
history = c.radarr.history()
|
||||||
|
return render_template(
|
||||||
|
"radarr/index.html", movies=movies, status=status, history=history
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@radarr_page.route("/<movie_id>")
|
||||||
|
@admin_required
|
||||||
|
def details(movie_id):
|
||||||
|
return render_template("radarr/details.html")
|
98
views/remote/__init__.py
Normal file
98
views/remote/__init__.py
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.serialization import load_ssh_public_key
|
||||||
|
from flask import Blueprint, redirect, render_template, request, url_for, Markup, flash
|
||||||
|
|
||||||
|
from forms import AddSSHUser
|
||||||
|
from api import Client
|
||||||
|
from utils import admin_required
|
||||||
|
|
||||||
|
remote_page = Blueprint("remote", __name__, url_prefix="/remote")
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_fingerprint(key):
|
||||||
|
fp = hashlib.md5(base64.b64decode(key)).hexdigest()
|
||||||
|
return ":".join(a + b for a, b in zip(fp[::2], fp[1::2]))
|
||||||
|
|
||||||
|
|
||||||
|
@remote_page.route("/stop")
|
||||||
|
@admin_required
|
||||||
|
def stop():
|
||||||
|
c = Client()
|
||||||
|
session_id = request.args.get("session")
|
||||||
|
c.jellyfin.stop_session(session_id)
|
||||||
|
return redirect(url_for("remote.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@remote_page.route("/")
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
c = Client()
|
||||||
|
res = c.ssh.get("/data/.ssh/authorized_keys", io.BytesIO())
|
||||||
|
res.local.seek(0)
|
||||||
|
ssh_keys = []
|
||||||
|
for key in str(res.local.read(), "utf8").splitlines():
|
||||||
|
disabled = False
|
||||||
|
if key.startswith("#"):
|
||||||
|
key = key.lstrip("#").lstrip()
|
||||||
|
disabled = True
|
||||||
|
load_ssh_public_key(bytes(key, "utf8"))
|
||||||
|
key_type, key, name = key.split(None, 2)
|
||||||
|
ssh_keys.append(
|
||||||
|
{
|
||||||
|
"disabled": disabled,
|
||||||
|
"type": key_type,
|
||||||
|
"key": key,
|
||||||
|
"fingerprint": ssh_fingerprint(key),
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
key = request.args.get("key")
|
||||||
|
enabled = request.args.get("enabled")
|
||||||
|
if not (key is None or enabled is None):
|
||||||
|
key_file = []
|
||||||
|
for ssh_key in ssh_keys:
|
||||||
|
if ssh_key["key"] == key:
|
||||||
|
ssh_key["disabled"] = enabled == "False"
|
||||||
|
if ssh_key["disabled"]:
|
||||||
|
key_file.append("#{type} {key} {name}".format(**ssh_key))
|
||||||
|
else:
|
||||||
|
key_file.append("{type} {key} {name}".format(**ssh_key))
|
||||||
|
buf = io.BytesIO(bytes("\n".join(key_file), "utf8"))
|
||||||
|
c.ssh.put(buf, "/data/.ssh/authorized_keys", preserve_mode=False)
|
||||||
|
return redirect(url_for("remote"))
|
||||||
|
jellyfin = {
|
||||||
|
"users": c.jellyfin.get_users(),
|
||||||
|
"sessions": c.jellyfin.sessions(),
|
||||||
|
"info": c.jellyfin.system_info(),
|
||||||
|
}
|
||||||
|
return render_template(
|
||||||
|
"remote/index.html",
|
||||||
|
ssh=ssh_keys,
|
||||||
|
jellyfin=jellyfin)
|
||||||
|
|
||||||
|
|
||||||
|
@remote_page.route("/add", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def add():
|
||||||
|
form = AddSSHUser()
|
||||||
|
c = Client()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
key = load_ssh_public_key(bytes(form.data["ssh_key"], "utf8"))
|
||||||
|
rawKeyData = key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.OpenSSH,
|
||||||
|
format=serialization.PublicFormat.OpenSSH,
|
||||||
|
)
|
||||||
|
passwd = c.add_user(form.data["name"], str(rawKeyData, "utf8"))
|
||||||
|
flash(
|
||||||
|
Markup(
|
||||||
|
"".join(
|
||||||
|
[
|
||||||
|
f"<p>Name: <b>{form.data['name']}</b></p>",
|
||||||
|
f"<p>PW: <b>{passwd}</b></p>",
|
||||||
|
f"<p>FP: <b>{ssh_fingerprint(rawKeyData.split()[1])}</b></p>",
|
||||||
|
])))
|
||||||
|
return render_template("remote/add.html", form=form)
|
217
views/requests/__init__.py
Normal file
217
views/requests/__init__.py
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
current_app,
|
||||||
|
flash,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
from flask_login import current_user
|
||||||
|
from collections import defaultdict
|
||||||
|
from api import Client
|
||||||
|
from forms import RequestForm
|
||||||
|
from models import RequestItem, RequestUser
|
||||||
|
from utils import login_required, handle_config
|
||||||
|
|
||||||
|
requests_page = Blueprint("requests", __name__, url_prefix="/requests")
|
||||||
|
|
||||||
|
|
||||||
|
@requests_page.route("/<request_id>/<download_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def download_details(request_id, download_id):
|
||||||
|
c = Client()
|
||||||
|
request = RequestItem.query.filter(RequestItem.id == request_id).one_or_none()
|
||||||
|
if request is None:
|
||||||
|
flash("Unknown request ID", "danger")
|
||||||
|
return redirect(url_for("requests.details", request_id=request_id))
|
||||||
|
try:
|
||||||
|
qbt = c.qbittorent.poll(download_id)
|
||||||
|
except Exception:
|
||||||
|
flash("Unknown download ID", "danger")
|
||||||
|
return redirect(url_for("requests.details", request_id=request_id))
|
||||||
|
return render_template("qbittorrent/details.html", qbt=qbt)
|
||||||
|
|
||||||
|
|
||||||
|
@requests_page.route("/<request_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def details(request_id):
|
||||||
|
c = Client()
|
||||||
|
if current_user.is_admin:
|
||||||
|
requests = RequestItem.query
|
||||||
|
else:
|
||||||
|
user_id = current_user.get_id()
|
||||||
|
requests = RequestItem.query.filter(
|
||||||
|
RequestItem.users.any(RequestUser.user_id == user_id)
|
||||||
|
)
|
||||||
|
request = requests.filter(RequestItem.id == request_id).one_or_none()
|
||||||
|
if request is None:
|
||||||
|
flash("Unknown request ID", "danger")
|
||||||
|
return redirect(url_for("requests.index"))
|
||||||
|
RequestUser.query.filter(
|
||||||
|
(RequestUser.user_id == current_user.id)
|
||||||
|
& (RequestUser.item_id == request.item_id)
|
||||||
|
).update({RequestUser.updated: False})
|
||||||
|
current_app.db.session.commit()
|
||||||
|
jf_item = None
|
||||||
|
arr = request.arr_item
|
||||||
|
id_map = c.jellyfin.id_map()
|
||||||
|
for key_id in ["tmdbId", "imdbId", "tvdbId"]:
|
||||||
|
if key_id in arr:
|
||||||
|
key = (key_id.lower()[:-2], str(arr[key_id]))
|
||||||
|
jf_item = id_map.get(key, None)
|
||||||
|
if jf_item:
|
||||||
|
break
|
||||||
|
return render_template(
|
||||||
|
"requests/details.html", request=request, jellyfin_id=jf_item
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@requests_page.route("/", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
c = Client()
|
||||||
|
form = RequestForm()
|
||||||
|
user_id = current_user.get_id()
|
||||||
|
cfg = handle_config()
|
||||||
|
used_requests = defaultdict(int)
|
||||||
|
if current_user.is_admin:
|
||||||
|
requests = RequestItem.query
|
||||||
|
else:
|
||||||
|
requests = RequestItem.query.filter(
|
||||||
|
RequestItem.users.any(RequestUser.user_id == user_id)
|
||||||
|
)
|
||||||
|
for item in requests:
|
||||||
|
if item.approved is None:
|
||||||
|
used_requests[item.request_type] += 1
|
||||||
|
remaining = {}
|
||||||
|
remaining["movies"] = (
|
||||||
|
cfg["num_requests_per_user"]["movies"] - used_requests["radarr"]
|
||||||
|
)
|
||||||
|
remaining["shows"] = cfg["num_requests_per_user"]["shows"] - used_requests["sonarr"]
|
||||||
|
print("RQs:", used_requests, remaining)
|
||||||
|
if (
|
||||||
|
request.method == "POST"
|
||||||
|
and ("approve" in request.form)
|
||||||
|
or ("decline" in request.form)
|
||||||
|
):
|
||||||
|
approved = "approve" in request.form
|
||||||
|
declined = "decline" in request.form
|
||||||
|
if approved and declined:
|
||||||
|
flash("What the fuck?")
|
||||||
|
approved = False
|
||||||
|
declined = False
|
||||||
|
if approved or declined:
|
||||||
|
new_state = approved
|
||||||
|
print("Approved:", approved)
|
||||||
|
for item_id in request.form.getlist("selected[]"):
|
||||||
|
item = RequestItem.query.get(item_id)
|
||||||
|
if item.approved != new_state:
|
||||||
|
RequestUser.query.filter(RequestUser.item_id == item_id).update(
|
||||||
|
{RequestUser.updated: True}
|
||||||
|
)
|
||||||
|
item.approved = new_state
|
||||||
|
if new_state is True:
|
||||||
|
search_type = item.request_type
|
||||||
|
if hasattr(c, search_type):
|
||||||
|
api = getattr(c, search_type)
|
||||||
|
if hasattr(api, "add"):
|
||||||
|
result = api.add(json.loads(item.data))
|
||||||
|
print(result)
|
||||||
|
if item.request_type == "sonarr":
|
||||||
|
item.arr_id = result["seriesId"]
|
||||||
|
if item.request_type == "radarr":
|
||||||
|
item.arr_id = result["id"]
|
||||||
|
else:
|
||||||
|
flash("Invalid search type: {}".format(search_type), "danger")
|
||||||
|
current_app.db.session.merge(item)
|
||||||
|
current_app.db.session.commit()
|
||||||
|
return render_template(
|
||||||
|
"requests/index.html",
|
||||||
|
results=[],
|
||||||
|
form=form,
|
||||||
|
search_type=None,
|
||||||
|
requests=requests,
|
||||||
|
)
|
||||||
|
return redirect(url_for("requests.index"))
|
||||||
|
if request.method == "POST" and form.search.data is False:
|
||||||
|
request_type = request.form["search_type"]
|
||||||
|
for item in request.form.getlist("selected[]"):
|
||||||
|
data = str(base64.b64decode(item), "utf8")
|
||||||
|
item = json.loads(data)
|
||||||
|
item_id = "{type}/{titleSlug}/{year}".format(type=request_type, **item)
|
||||||
|
user_id = session["jf_user"].get_id()
|
||||||
|
request_entry = RequestItem.query.get(item_id)
|
||||||
|
if request_entry is None:
|
||||||
|
request_entry = RequestItem(
|
||||||
|
added_date=datetime.now(),
|
||||||
|
item_id=item_id,
|
||||||
|
users=[],
|
||||||
|
data=data,
|
||||||
|
request_type=request_type,
|
||||||
|
)
|
||||||
|
current_app.db.session.add(request_entry)
|
||||||
|
request_entry.users.append(
|
||||||
|
RequestUser(
|
||||||
|
user_id=user_id, item_id=item_id, user_name=current_user["Name"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
current_app.db.session.merge(request_entry)
|
||||||
|
current_app.db.session.commit()
|
||||||
|
return render_template(
|
||||||
|
"requests/index.html", results=[], form=form, requests=requests
|
||||||
|
)
|
||||||
|
if form and form.validate_on_submit():
|
||||||
|
c = Client()
|
||||||
|
query = form.query.data
|
||||||
|
search_type = form.search_type.data
|
||||||
|
if hasattr(c, search_type):
|
||||||
|
api = getattr(c, search_type)
|
||||||
|
if hasattr(api, "search"):
|
||||||
|
results = api.search(query)
|
||||||
|
return render_template(
|
||||||
|
"requests/index.html",
|
||||||
|
results=results,
|
||||||
|
form=form,
|
||||||
|
search_type=search_type,
|
||||||
|
)
|
||||||
|
flash("Invalid search type: {}".format(search_type), "danger")
|
||||||
|
return render_template(
|
||||||
|
"requests/index.html",
|
||||||
|
results=[],
|
||||||
|
form=form,
|
||||||
|
search_type=None,
|
||||||
|
requests=requests,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
if form.validate_on_submit():
|
||||||
|
query = form.query.data
|
||||||
|
if not (form.torrents.data or form.movies.data or form.tv_shows.data):
|
||||||
|
form.torrents.data = True
|
||||||
|
form.movies.data = True
|
||||||
|
form.tv_shows.data = True
|
||||||
|
|
||||||
|
if form.torrents.data:
|
||||||
|
results["torrents"] = c.jackett.search(
|
||||||
|
query, form.indexer.data or form.indexer.choices
|
||||||
|
)
|
||||||
|
if form.movies.data:
|
||||||
|
results["movies"] = c.radarr.search(query)
|
||||||
|
if form.tv_shows.data:
|
||||||
|
results["tv_shows"] = c.sonarr.search(query)
|
||||||
|
return render_template(
|
||||||
|
"search/index.html",
|
||||||
|
# form=form,
|
||||||
|
search_term=query,
|
||||||
|
results=results,
|
||||||
|
client=c,
|
||||||
|
group_by_tracker=form.group_by_tracker.data,
|
||||||
|
)
|
||||||
|
"""
|
62
views/search/__init__.py
Normal file
62
views/search/__init__.py
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
from urllib.parse import unquote_plus
|
||||||
|
|
||||||
|
from flask import Blueprint, json, render_template, request
|
||||||
|
|
||||||
|
from api import Client
|
||||||
|
from forms import SearchForm
|
||||||
|
from utils import admin_required
|
||||||
|
|
||||||
|
search_page = Blueprint("search", __name__, url_prefix="/search")
|
||||||
|
|
||||||
|
|
||||||
|
@search_page.route("/details", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def details():
|
||||||
|
data = {
|
||||||
|
"info": json.loads(unquote_plus(request.form["data"])),
|
||||||
|
"type": request.form["type"],
|
||||||
|
}
|
||||||
|
return render_template("search/details.html", **data)
|
||||||
|
|
||||||
|
|
||||||
|
@search_page.route("/", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
c = Client()
|
||||||
|
results = {}
|
||||||
|
params = request.args
|
||||||
|
form = SearchForm()
|
||||||
|
form.indexer.choices = c.jackett.indexers()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
query = form.query.data
|
||||||
|
if not (form.torrents.data or form.movies.data or form.tv_shows.data):
|
||||||
|
form.torrents.data = True
|
||||||
|
form.movies.data = True
|
||||||
|
form.tv_shows.data = True
|
||||||
|
|
||||||
|
if form.torrents.data:
|
||||||
|
results["torrents"] = c.jackett.search(
|
||||||
|
query, form.indexer.data or form.indexer.choices
|
||||||
|
)
|
||||||
|
if form.movies.data:
|
||||||
|
results["movies"] = c.radarr.search(query)
|
||||||
|
if form.tv_shows.data:
|
||||||
|
results["tv_shows"] = c.sonarr.search(query)
|
||||||
|
return render_template(
|
||||||
|
"search/index.html",
|
||||||
|
# form=form,
|
||||||
|
search_term=query,
|
||||||
|
results=results,
|
||||||
|
client=c,
|
||||||
|
group_by_tracker=form.group_by_tracker.data,
|
||||||
|
)
|
||||||
|
for name, field in form._fields.items():
|
||||||
|
field.default = params.get(name)
|
||||||
|
form.process()
|
||||||
|
return render_template(
|
||||||
|
"search/index.html",
|
||||||
|
form=form,
|
||||||
|
results={},
|
||||||
|
group_by_tracker=False,
|
||||||
|
sort_by="Gain",
|
||||||
|
)
|
24
views/sonarr/__init__.py
Normal file
24
views/sonarr/__init__.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
from api import Client
|
||||||
|
from utils import admin_required
|
||||||
|
|
||||||
|
sonarr_page = Blueprint("sonarr", __name__, url_prefix="/sonarr")
|
||||||
|
|
||||||
|
|
||||||
|
@sonarr_page.route("/")
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
c = Client()
|
||||||
|
series = c.sonarr.series()
|
||||||
|
status = c.sonarr.status()
|
||||||
|
history = c.sonarr.history()
|
||||||
|
return render_template(
|
||||||
|
"sonarr/index.html", series=series, status=status, history=history
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@sonarr_page.route("/<show_id>")
|
||||||
|
@admin_required
|
||||||
|
def details(show_id):
|
||||||
|
return render_template("sonarr/details.html")
|
10
views/transcode/__init__.py
Normal file
10
views/transcode/__init__.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
from utils import admin_required
|
||||||
|
transcode_page = Blueprint("transcode", __name__, url_prefix="/transcode")
|
||||||
|
|
||||||
|
|
||||||
|
@transcode_page.route("/", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def index():
|
||||||
|
return render_template("transcode/profiles.html")
|
Loading…
Reference in a new issue