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