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