Initial commit
This commit is contained in:
		
						commit
						7523a19d1f
					
				
					 40 changed files with 3984 additions and 0 deletions
				
			
		
							
								
								
									
										149
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,149 @@ | |||
| # Ignore dynaconf secret files | ||||
| .secrets.* | ||||
| 
 | ||||
| # ---> Python | ||||
| # Byte-compiled / optimized / DLL files | ||||
| __pycache__/ | ||||
| *.py[cod] | ||||
| *$py.class | ||||
| 
 | ||||
| # C extensions | ||||
| *.so | ||||
| 
 | ||||
| # Distribution / packaging | ||||
| .Python | ||||
| build/ | ||||
| develop-eggs/ | ||||
| dist/ | ||||
| downloads/ | ||||
| eggs/ | ||||
| .eggs/ | ||||
| lib/ | ||||
| lib64/ | ||||
| parts/ | ||||
| sdist/ | ||||
| var/ | ||||
| wheels/ | ||||
| share/python-wheels/ | ||||
| *.egg-info/ | ||||
| .installed.cfg | ||||
| *.egg | ||||
| MANIFEST | ||||
| 
 | ||||
| # PyInstaller | ||||
| #  Usually these files are written by a python script from a template | ||||
| #  before PyInstaller builds the exe, so as to inject date/other infos into it. | ||||
| *.manifest | ||||
| *.spec | ||||
| 
 | ||||
| # Installer logs | ||||
| pip-log.txt | ||||
| pip-delete-this-directory.txt | ||||
| 
 | ||||
| # Unit test / coverage reports | ||||
| htmlcov/ | ||||
| .tox/ | ||||
| .nox/ | ||||
| .coverage | ||||
| .coverage.* | ||||
| .cache | ||||
| nosetests.xml | ||||
| coverage.xml | ||||
| *.cover | ||||
| *.py,cover | ||||
| .hypothesis/ | ||||
| .pytest_cache/ | ||||
| cover/ | ||||
| 
 | ||||
| # Translations | ||||
| *.mo | ||||
| *.pot | ||||
| 
 | ||||
| # Django stuff: | ||||
| *.log | ||||
| local_settings.py | ||||
| db.sqlite3 | ||||
| db.sqlite3-journal | ||||
| 
 | ||||
| # Flask stuff: | ||||
| instance/ | ||||
| .webassets-cache | ||||
| 
 | ||||
| # Scrapy stuff: | ||||
| .scrapy | ||||
| 
 | ||||
| # Sphinx documentation | ||||
| docs/_build/ | ||||
| 
 | ||||
| # PyBuilder | ||||
| .pybuilder/ | ||||
| target/ | ||||
| 
 | ||||
| # Jupyter Notebook | ||||
| .ipynb_checkpoints | ||||
| 
 | ||||
| # IPython | ||||
| profile_default/ | ||||
| ipython_config.py | ||||
| 
 | ||||
| # pyenv | ||||
| #   For a library or package, you might want to ignore these files since the code is | ||||
| #   intended to run in multiple environments; otherwise, check them in: | ||||
| # .python-version | ||||
| 
 | ||||
| # pipenv | ||||
| #   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. | ||||
| #   However, in case of collaboration, if having platform-specific dependencies or dependencies | ||||
| #   having no cross-platform support, pipenv may install dependencies that don't work, or not | ||||
| #   install all needed dependencies. | ||||
| #Pipfile.lock | ||||
| 
 | ||||
| # PEP 582; used by e.g. github.com/David-OConnor/pyflow | ||||
| __pypackages__/ | ||||
| 
 | ||||
| # Celery stuff | ||||
| celerybeat-schedule | ||||
| celerybeat.pid | ||||
| 
 | ||||
| # SageMath parsed files | ||||
| *.sage.py | ||||
| 
 | ||||
| # Environments | ||||
| .env | ||||
| .venv | ||||
| env/ | ||||
| venv/ | ||||
| ENV/ | ||||
| env.bak/ | ||||
| venv.bak/ | ||||
| 
 | ||||
| # Spyder project settings | ||||
| .spyderproject | ||||
| .spyproject | ||||
| 
 | ||||
| # Rope project settings | ||||
| .ropeproject | ||||
| 
 | ||||
| # mkdocs documentation | ||||
| /site | ||||
| 
 | ||||
| # mypy | ||||
| .mypy_cache/ | ||||
| .dmypy.json | ||||
| dmypy.json | ||||
| 
 | ||||
| # Pyre type checker | ||||
| .pyre/ | ||||
| 
 | ||||
| # pytype static type analyzer | ||||
| .pytype/ | ||||
| 
 | ||||
| # Cython debug symbols | ||||
| cython_debug/ | ||||
| 
 | ||||
| stats.json | ||||
| stats_temp.json | ||||
| config.json | ||||
| Mediadash.db | ||||
| .history | ||||
| .vscode | ||||
							
								
								
									
										3
									
								
								README.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								README.md
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | |||
| # Media Server Dashboard | ||||
| 
 | ||||
| WIP | ||||
							
								
								
									
										8
									
								
								TODO.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								TODO.md
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| - Jellyfin integration (?) | ||||
| - Webhooks for transcode queue | ||||
| - Webhook event log | ||||
| - Database models | ||||
| - Container details | ||||
| - Transcode Job queue | ||||
| - Transcode profile editor | ||||
| - DB Models | ||||
							
								
								
									
										649
									
								
								api.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										649
									
								
								api.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,649 @@ | |||
| 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} | ||||
							
								
								
									
										586
									
								
								app.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										586
									
								
								app.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,586 @@ | |||
| import sys | ||||
| from gevent import monkey | ||||
| if not "--debug" in sys.argv[1:]: | ||||
|     monkey.patch_all() | ||||
| import os | ||||
| import requests as RQ | ||||
| import json | ||||
| import re | ||||
| import io | ||||
| import hashlib | ||||
| import base64 | ||||
| import time | ||||
| import threading | ||||
| from webargs import fields | ||||
| from webargs.flaskparser import use_args | ||||
| from datetime import timedelta, datetime | ||||
| from pprint import pprint | ||||
| from urllib.parse import quote, urljoin, unquote_plus | ||||
| import pylab as PL | ||||
| from matplotlib.ticker import EngFormatter | ||||
| from base64 import b64encode | ||||
| from slugify import slugify | ||||
| from cryptography.hazmat.primitives.serialization import load_ssh_public_key | ||||
| from flask import ( | ||||
|     Flask, | ||||
|     render_template, | ||||
|     send_from_directory, | ||||
|     request, | ||||
|     send_file, | ||||
|     redirect, | ||||
|     flash, | ||||
|     url_for, | ||||
|     session, | ||||
|     jsonify, | ||||
|     Markup | ||||
| ) | ||||
| from flask_nav import Nav, register_renderer | ||||
| from flask_nav.elements import Navbar, View, Subgroup | ||||
| from flask_bootstrap import Bootstrap | ||||
| from flask_wtf.csrf import CSRFProtect | ||||
| from flask_debugtoolbar import DebugToolbarExtension | ||||
| from flask_sqlalchemy import SQLAlchemy | ||||
| 
 | ||||
| # =================== | ||||
| import stats_collect | ||||
| from forms import ConfigForm, SearchForm, TranscodeProfileForm, AddSSHUser | ||||
| from api import Client | ||||
| from models import db, TranscodeJob, Stats | ||||
| from transcode import profiles | ||||
| from utils import ( | ||||
|     BootsrapRenderer, | ||||
|     eval_expr, | ||||
|     make_tree, | ||||
|     make_placeholder_image, | ||||
|     with_application_context, | ||||
|     handle_config, | ||||
|     genpw | ||||
| ) | ||||
| 
 | ||||
| 
 | ||||
| def left_nav(): | ||||
|     links = [ | ||||
|         View("Home", "index"), | ||||
|         View("Containers", "containers", container_id=None), | ||||
|         View("qBittorrent", "qbittorrent", infohash=None), | ||||
|         View("Sonarr", "sonarr", id=None), | ||||
|         View("Radarr", "radarr", id=None), | ||||
|         View("Jellyfin", "jellyfin"), | ||||
|         View("Search", "search"), | ||||
|         View("History", "history"), | ||||
|         View("Transcode", "transcode"), | ||||
|         View("Config", "config"), | ||||
|         View("Remote", "remote"), | ||||
|         View("Log", "app_log"), | ||||
|     ] | ||||
|     return Navbar("PirateDash", *links) | ||||
| 
 | ||||
| 
 | ||||
| def create_app(): | ||||
|     templates = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") | ||||
|     app = Flask(__name__, template_folder=templates) | ||||
|     app.config.from_pyfile("config.cfg") | ||||
|     app.bootstrap = Bootstrap(app) | ||||
|     app.csrf = CSRFProtect(app) | ||||
|     app.nav = Nav(app) | ||||
|     app.toolbar = DebugToolbarExtension(app) | ||||
|     app.jinja_env.add_extension("jinja2.ext.debug") | ||||
|     app.jinja_env.add_extension("jinja2.ext.do") | ||||
|     app.jinja_env.trim_blocks = True | ||||
|     app.jinja_env.lstrip_blocks = True | ||||
|     register_renderer(app, "bootstrap4", BootsrapRenderer) | ||||
|     app.nav.register_element("left_nav", left_nav) | ||||
|     db.init_app(app) | ||||
|     app.db = db | ||||
|     return app | ||||
| 
 | ||||
| 
 | ||||
| app = create_app() | ||||
| 
 | ||||
| 
 | ||||
| @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("fromiso") | ||||
| def time_fromiso(s): | ||||
|     t = s.rstrip("Z").split(".")[0] | ||||
|     t = datetime.strptime(t, "%Y-%m-%dT%H:%M:%S") | ||||
|     try: | ||||
|         t.microsecond = int(s.rstrip("Z").split(".")[1]) | ||||
|     except: | ||||
|         pass | ||||
|     return t | ||||
| 
 | ||||
| 
 | ||||
| app.add_template_global(urljoin, "urljoin") | ||||
| 
 | ||||
| @app.template_filter("slugify") | ||||
| def make_slug(s): | ||||
|     return slugify(s, only_ascii=True) | ||||
| 
 | ||||
| 
 | ||||
| app.template_filter()(make_tree) | ||||
| app.add_template_global(handle_config, "cfg") | ||||
| 
 | ||||
| @app.before_request | ||||
| def before_request(): | ||||
|     db.create_all() | ||||
|     app.config["APP_CONFIG"] = handle_config() | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/static/<path:path>") | ||||
| def send_static(path): | ||||
|     return send_from_directory("static", path) | ||||
| 
 | ||||
| 
 | ||||
| def populate_form(form, cfg=None): | ||||
|     if cfg is None: | ||||
|         cfg = handle_config() | ||||
|     for name, field in form._fields.items(): | ||||
|         field.default = cfg.get(name) | ||||
|     form.transcode_default_profile.choices = [(None, "")] | ||||
|     form.transcode_default_profile.choices += [ | ||||
|         (k, k) for k in (cfg.get("transcode_profiles", {}) or {}).keys() | ||||
|     ] | ||||
| 
 | ||||
| 
 | ||||
| def validate_transcoding_profiles(profiles): | ||||
|     for name, data in profiles.items(): | ||||
|         for req, req_type in [("command", str), ("doc", str)]: | ||||
|             if req not in data: | ||||
|                 raise ValueError( | ||||
|                     "Profile '{}' is missing required key '{}'".format(name, req) | ||||
|                 ) | ||||
|             if not isinstance(data[req], req_type): | ||||
|                 raise ValueError( | ||||
|                     "Key '{}' of profile '{}' should be of type '{}'".format( | ||||
|                         req, name, req_type.__name__ | ||||
|                     ) | ||||
|                 ) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/config", methods=["GET", "POST"]) | ||||
| def config(): | ||||
|     form = ConfigForm() | ||||
|     cfg = {} | ||||
|     populate_form(form) | ||||
|     if form.validate_on_submit(): | ||||
|         skip = ["save", "test", "csrf_token"] | ||||
|         transcode_profiles = request.files.get("transcode_profiles") | ||||
|         if transcode_profiles: | ||||
|             try: | ||||
|                 form.transcode_profiles.data = json.load(transcode_profiles) | ||||
|                 validate_transcoding_profiles(form.transcode_profiles.data) | ||||
|             except ValueError as e: | ||||
|                 form.transcode_profiles.data = None | ||||
|                 form.transcode_profiles.errors = [ | ||||
|                     "Invalid json data in file {}: {}".format( | ||||
|                         transcode_profiles.filename, e | ||||
|                     ) | ||||
|                 ] | ||||
|         else: | ||||
|             form.transcode_profiles.data = app.config["APP_CONFIG"].get( | ||||
|                 "transcode_profiles", {} | ||||
|             ) | ||||
|         if form.errors: | ||||
|             return render_template("config.html", form=form) | ||||
|         for name, field in form._fields.items(): | ||||
|             if name in skip: | ||||
|                 continue | ||||
|             cfg[name] = field.data | ||||
|         if form.test.data: | ||||
|             test_res = Client.test(cfg) | ||||
|             populate_form(form, cfg) | ||||
|             return render_template("config.html", form=form, test=test_res) | ||||
|         handle_config(cfg) | ||||
|         populate_form(form) | ||||
|         return render_template("config.html", form=form) | ||||
|     form.process() | ||||
|     return render_template("config.html", form=form) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/search/details", methods=["GET", "POST"]) | ||||
| def details(): | ||||
|     data = { | ||||
|         "info": json.loads(unquote_plus(request.form["data"])), | ||||
|         "type": request.form["type"], | ||||
|     } | ||||
|     return render_template("search/details.html", **data) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/search", methods=["GET", "POST"]) | ||||
| def search(): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     results = {} | ||||
|     params = request.args | ||||
|     form = SearchForm() | ||||
|     form.indexer.choices = c.jackett.indexers() | ||||
|     if form.validate_on_submit(): | ||||
|         query = form.query.data | ||||
|         if not (form.torrents.data or form.movies.data or form.tv_shows.data): | ||||
|             form.torrents.data = True | ||||
|             form.movies.data = True | ||||
|             form.tv_shows.data = True | ||||
| 
 | ||||
|         if form.torrents.data: | ||||
|             results["torrents"] = c.jackett.search( | ||||
|                 query, form.indexer.data or form.indexer.choices | ||||
|             ) | ||||
|         if form.movies.data: | ||||
|             results["movies"] = c.radarr.search(query) | ||||
|         if form.tv_shows.data: | ||||
|             results["tv_shows"] = c.sonarr.search(query) | ||||
|         return render_template( | ||||
|             "search/index.html", | ||||
|             # form=form, | ||||
|             search_term=query, | ||||
|             results=results, | ||||
|             client=c, | ||||
|             group_by_tracker=form.group_by_tracker.data, | ||||
|         ) | ||||
|     for name, field in form._fields.items(): | ||||
|         field.default = params.get(name) | ||||
|     form.process() | ||||
|     return render_template( | ||||
|         "search/index.html", | ||||
|         form=form, | ||||
|         results={}, | ||||
|         group_by_tracker=False, | ||||
|         sort_by="Gain", | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/api/add_torrent", methods=["POST"]) | ||||
| def add_torrent(): | ||||
|     category=request.form.get("category") | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     hashes_1 = set(c.qbittorent.status().get("torrents", {})) | ||||
|     links = "" | ||||
|     count = 0 | ||||
|     for link in request.form.getlist("torrent[]"): | ||||
|         print(link) | ||||
|         links += link + "\n" | ||||
|         count += 1 | ||||
|     c.qbittorent.add(urls=links,category=category) | ||||
|     for _ in range(10): | ||||
|         status=c.qbittorent.status().get("torrents", {}) | ||||
|         hashes_2 = set(status) | ||||
|         if len(hashes_2 - hashes_1) == count: | ||||
|             break | ||||
|         time.sleep(0.5) | ||||
|     else: | ||||
|         flash("Some torrents failed to get added to QBittorrent", "waring") | ||||
|     new_torrents = sorted(hashes_2 - hashes_1) | ||||
|     session["new_torrents"] = {h: status[h] for h in new_torrents} | ||||
|     return redirect(url_for("search")) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/history") | ||||
| def history(): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     sonarr = c.sonarr.history() | ||||
|     radarr = c.radarr.history() | ||||
|     return render_template("history.html", sonarr=sonarr, radarr=radarr) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/sonarr", defaults={"show_id": None}) | ||||
| @app.route("/sonarr/<show_id>") | ||||
| def sonarr(show_id): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     if not show_id: | ||||
|         series = c.sonarr.series() | ||||
|         status = c.sonarr.status() | ||||
|         return render_template( | ||||
|             "sonarr/index.html", series=series, status=status, history=history | ||||
|         ) | ||||
|     return render_template("sonarr/details.html") | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/radarr", defaults={"movie_id": None}) | ||||
| @app.route("/radarr/<movie_id>") | ||||
| def radarr(movie_id): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     if movie_id is None: | ||||
|         movies = c.radarr.movies() | ||||
|         status = c.radarr.status() | ||||
|         history = c.radarr.history() | ||||
|         return render_template( | ||||
|             "radarr/index.html", movies=movies, status=status, history=history | ||||
|         ) | ||||
|     return render_template("radarr/details.html") | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/qbittorrent") | ||||
| def qbittorrent(): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     qbt = c.qbittorent.status() | ||||
|     sort_by_choices = { | ||||
|         "speed": "Transfer Speed", | ||||
|         "eta": "Time remaining", | ||||
|         "state": "State", | ||||
|         "category": "Category", | ||||
|     } | ||||
|     return render_template( | ||||
|         "qbittorrent/index.html", | ||||
|         qbt=qbt, | ||||
|         status_map=c.qbittorent.status_map, | ||||
|         state_filter=request.args.get("state"), | ||||
|         sort_by=request.args.get("sort","speed"), | ||||
|         sort_by_choices=sort_by_choices, | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/qbittorrent/add_trackers/<infohash>") | ||||
| def qbittorent_add_trackers(infohash): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     c.qbittorent.add_trackers(infohash) | ||||
|     return redirect(url_for("qbittorrent_details",infohash=infohash)) | ||||
| 
 | ||||
| @app.route("/qbittorrent/<infohash>") | ||||
| def qbittorrent_details(infohash): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     qbt = c.qbittorent.status(infohash) | ||||
|     return render_template( | ||||
|         "qbittorrent/details.html", qbt=qbt, status_map=c.qbittorent.status_map | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| from wtforms_alchemy import model_form_factory, ModelFieldList | ||||
| from flask_wtf import FlaskForm | ||||
| from wtforms.fields import FormField | ||||
| 
 | ||||
| BaseModelForm = model_form_factory(FlaskForm) | ||||
| 
 | ||||
| 
 | ||||
| class ModelForm(BaseModelForm): | ||||
|     @classmethod | ||||
|     def get_session(self): | ||||
|         return app.db.session | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/test", methods=["GET", "POST"]) | ||||
| def test(): | ||||
|     form = TranscodeProfileForm() | ||||
|     if form.validate_on_submit(): | ||||
|         print(form.data) | ||||
|     return render_template("test.html", form=form) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/placeholder") | ||||
| def placeholder(): | ||||
|     return send_file(make_placeholder_image(**request.args), mimetype="image/png") | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/containers", defaults={"container_id": None}) | ||||
| @app.route("/containers/<container_id>") | ||||
| def containers(container_id): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     if container_id: | ||||
|         container = c.portainer.containers(container_id) | ||||
|         return render_template("containers/details.html", container=container) | ||||
|     containers = c.portainer.containers() | ||||
|     return render_template("containers/index.html", containers=containers) | ||||
| 
 | ||||
| 
 | ||||
| def get_stats(): | ||||
|     if os.path.isfile("stats.lock"): | ||||
|         return None | ||||
|     try: | ||||
|         if os.path.isfile("stats.json"): | ||||
|             with open("stats.json") as fh: | ||||
|                 return json.load(fh) | ||||
|     except: | ||||
|         return None | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/transcode", methods=["GET", "POST"]) | ||||
| def transcode(): | ||||
|     return render_template("transcode/profiles.html") | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/log") | ||||
| def app_log(): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     logs = { | ||||
|         "radarr": c.radarr.log(), | ||||
|         "sonarr": c.sonarr.log(), | ||||
|         "qbt": c.qbittorent.log(), | ||||
|         "peers": c.qbittorent.peer_log(), | ||||
|     } | ||||
|     return render_template("logs.html", logs=logs) | ||||
| 
 | ||||
| 
 | ||||
| def ssh_fingerprint(key): | ||||
|     fp=hashlib.md5(base64.b64decode(key)).hexdigest() | ||||
|     return ':'.join(a+b for a,b in zip(fp[::2], fp[1::2])) | ||||
| 
 | ||||
| @app.route("/remote") | ||||
| def remote(): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     res = c.ssh.get("/data/.ssh/authorized_keys",io.BytesIO()) | ||||
|     res.local.seek(0) | ||||
|     ssh_keys=[] | ||||
|     for key in str(res.local.read(),"utf8").splitlines(): | ||||
|         disabled=False | ||||
|         if key.startswith("#"): | ||||
|             key=key.lstrip("#").lstrip() | ||||
|             disabled=True | ||||
|         try: | ||||
|             load_ssh_public_key(bytes(key,"utf8")) | ||||
|         except: | ||||
|             continue | ||||
|         key_type,key,name=key.split(None,2) | ||||
|         ssh_keys.append({ | ||||
|                 'disabled': disabled, | ||||
|                 'type':key_type, | ||||
|                 'key':key, | ||||
|                 'fingerprint': ssh_fingerprint(key), | ||||
|                 'name': name | ||||
|         }) | ||||
|     key=request.args.get("key") | ||||
|     enabled=request.args.get("enabled") | ||||
|     if not (key is None or enabled is None): | ||||
|         key_file=[] | ||||
|         for ssh_key in ssh_keys: | ||||
|             if ssh_key['key']==key: | ||||
|                 ssh_key['disabled']=enabled=="False" | ||||
|             if ssh_key['disabled']: | ||||
|                 key_file.append("#{type} {key} {name}".format(**ssh_key)) | ||||
|             else: | ||||
|                 key_file.append("{type} {key} {name}".format(**ssh_key)) | ||||
|         buf=io.BytesIO(bytes("\n".join(key_file),"utf8")) | ||||
|         c.ssh.put(buf,"/data/.ssh/authorized_keys",preserve_mode=False) | ||||
|         return redirect(url_for("remote")) | ||||
|     jellyfin_users = c.jellyfin.get_users() | ||||
|     return render_template("remote/index.html",ssh=ssh_keys,jf=jellyfin_users) | ||||
| 
 | ||||
| @app.route("/jellyfin/stop") | ||||
| def stop_stream(): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     session_id=request.args.get("session") | ||||
|     c.jellyfin.stop_session(session_id) | ||||
|     return redirect(url_for("jellyfin")) | ||||
| 
 | ||||
| @app.route("/jellyfin") | ||||
| def jellyfin(): | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     jellyfin={ | ||||
|         "users":c.jellyfin.get_users(), | ||||
|         "sessions": c.jellyfin.sessions(), | ||||
|         "info" : c.jellyfin.system_info() | ||||
|     } | ||||
|     return render_template("jellyfin/index.html",jellyfin=jellyfin) | ||||
| 
 | ||||
| @app.route("/remote/add",methods=["GET","POST"]) | ||||
| def remote_add(): | ||||
|     from cryptography.hazmat.primitives import serialization | ||||
|     form = AddSSHUser() | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     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) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/") | ||||
| def index(): | ||||
|     return render_template("index.html", fluid=True, data=get_stats()) | ||||
| 
 | ||||
| 
 | ||||
| if __name__ == "__main__": | ||||
|     stats_collector = threading.Thread( | ||||
|         None, stats_collect.loop, "stats_collector", (10,), {}, daemon=True | ||||
|     ) | ||||
|     stats_collector.start() | ||||
|     port = 5000 | ||||
|     if "--debug" in sys.argv: | ||||
|         app.run(host="0.0.0.0",port=port, debug=True) | ||||
|     else: | ||||
|         from gevent.pywsgi import WSGIServer | ||||
| 
 | ||||
|         server = WSGIServer(("0.0.0.0", port), app) | ||||
|         print("Running on {0}:{1}".format(*server.address)) | ||||
|         server.serve_forever() | ||||
							
								
								
									
										4
									
								
								config.cfg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								config.cfg
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| SECRET_KEY = b"DEADBEEF" | ||||
| SQLALCHEMY_DATABASE_URI = "sqlite:///Mediadash.db" | ||||
| SQLALCHEMY_TRACK_MODIFICATIONS = False | ||||
| MAX_CONTENT_LENGTH = 1 * 1024 * 1024 #1MB | ||||
							
								
								
									
										125
									
								
								config.example.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								config.example.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,125 @@ | |||
| { | ||||
|     "jellyfin_url": "http://127.0.0.1:8096/", | ||||
|     "jellyfin_api_key": "<Jellyfin Access Token>", | ||||
|     "qbt_url": "http://127.0.0.1:8081/", | ||||
|     "qbt_username": "<qBittorrent Username>", | ||||
|     "qbt_passwd": "<qBittorrent Password>", | ||||
|     "sonarr_url": "http://127.0.0.1:8080/sonarr/", | ||||
|     "sonarr_api_key": "<Sonarr API Key>", | ||||
|     "radarr_url": "http://127.0.0.1:8080/radarr/", | ||||
|     "radarr_api_key": "<Radarr API Key>", | ||||
|     "jackett_url": "http://127.0.0.1:9117/jackett/", | ||||
|     "jackett_api_key": "<Jakcett API Key>", | ||||
|     "portainer_url": "http://127.0.0.1:9000/", | ||||
|     "portainer_username": "<Portainer Username>", | ||||
|     "portainer_passwd": "<Portainer Username>", | ||||
|     "transcode_default_profile": "MKV Remux", | ||||
|     "transcode_profiles": { | ||||
|         "MKV Remux": { | ||||
|             "command": "-vcodec copy -acodec copy -scodec copy -map 0 -map_metadata 0 -f {format}", | ||||
|             "doc": "Remux", | ||||
|             "vars": { | ||||
|                 "format": "Conainter format" | ||||
|             }, | ||||
|             "defaults": { | ||||
|                 "format": "matroska" | ||||
|             } | ||||
|         }, | ||||
|         "H.264 transcode": { | ||||
|             "command": "-vcodec h264 -crf {crf} -preset {preset} -acodec copy  -scodec copy -map 0 -map_metadata 0", | ||||
|             "doc": "Transcode video to H.264", | ||||
|             "vars": { | ||||
|                 "crf": "Constant Rate Factor (Quality, lower is better)", | ||||
|                 "preset": "H.264 preset" | ||||
|             }, | ||||
|             "defaults": { | ||||
|                 "crf": 18, | ||||
|                 "preset": "medium" | ||||
|             }, | ||||
|             "choices": { | ||||
|                 "tune": ["animation","film","grain"], | ||||
|                 "preset": ["ultrafast","fast","medium","slow","veryslow"], | ||||
|                 "crf": {"range":[10,31]} | ||||
|             } | ||||
|         }, | ||||
|         "H.265 transcode": { | ||||
|             "command": "-vcodec hevc -crf {crf} -preset {preset} -tune {tune} -acodec copy  -scodec copy -map 0 -map_metadata 0", | ||||
|             "doc": "Transcode video to H.265", | ||||
|             "vars": { | ||||
|                 "crf": "Constant Rate Factor (Quality, lower is better)", | ||||
|                 "preset": "H.265 preset", | ||||
|                 "tune": "H.265 tune preset" | ||||
|             }, | ||||
|             "defaults": { | ||||
|                 "crf": 24, | ||||
|                 "preset": "medium", | ||||
|                 "tune": "animation" | ||||
|             }, | ||||
|             "choices": { | ||||
|                 "tune": ["animation","film","grain"], | ||||
|                 "preset": ["ultrafast","fast","medium","slow","veryslow"], | ||||
|                 "crf": {"range":[10,31]} | ||||
|             } | ||||
|         }, | ||||
|         "AAC transcode": { | ||||
|             "command": "-vcodec copy -acodec aac  -scodec copy -map 0 -map_metadata 0", | ||||
|             "doc": "Transcode audio to AAC" | ||||
|         } | ||||
|     }, | ||||
|     "jellyfin_user_config": { | ||||
|         "DisplayCollectionsView": false, | ||||
|         "DisplayMissingEpisodes": false, | ||||
|         "EnableLocalPassword": false, | ||||
|         "EnableNextEpisodeAutoPlay": true, | ||||
|         "GroupedFolders": [], | ||||
|         "HidePlayedInLatest": true, | ||||
|         "LatestItemsExcludes": [], | ||||
|         "MyMediaExcludes": [], | ||||
|         "OrderedViews": [], | ||||
|         "PlayDefaultAudioTrack": true, | ||||
|         "RememberAudioSelections": true, | ||||
|         "RememberSubtitleSelections": true, | ||||
|         "SubtitleLanguagePreference": "", | ||||
|         "SubtitleMode": "Default" | ||||
|     }, | ||||
|     "jellyfin_user_policy": { | ||||
|         "AccessSchedules": [], | ||||
|         "AuthenticationProviderId": "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", | ||||
|         "BlockUnratedItems": [], | ||||
|         "BlockedChannels": [], | ||||
|         "BlockedMediaFolders": [], | ||||
|         "BlockedTags": [], | ||||
|         "EnableAllChannels": false, | ||||
|         "EnableAllDevices": true, | ||||
|         "EnableAllFolders": false, | ||||
|         "EnableAudioPlaybackTranscoding": true, | ||||
|         "EnableContentDeletion": false, | ||||
|         "EnableContentDeletionFromFolders": [], | ||||
|         "EnableContentDownloading": true, | ||||
|         "EnableLiveTvAccess": true, | ||||
|         "EnableLiveTvManagement": true, | ||||
|         "EnableMediaConversion": true, | ||||
|         "EnableMediaPlayback": true, | ||||
|         "EnablePlaybackRemuxing": true, | ||||
|         "EnablePublicSharing": true, | ||||
|         "EnableRemoteAccess": true, | ||||
|         "EnableRemoteControlOfOtherUsers": false, | ||||
|         "EnableSharedDeviceControl": true, | ||||
|         "EnableSyncTranscoding": true, | ||||
|         "EnableUserPreferenceAccess": true, | ||||
|         "EnableVideoPlaybackTranscoding": true, | ||||
|         "EnabledChannels": [], | ||||
|         "EnabledDevices": [], | ||||
|         "EnabledFolders": [], | ||||
|         "ForceRemoteSourceTranscoding": false, | ||||
|         "InvalidLoginAttemptCount": 0, | ||||
|         "IsAdministrator": false, | ||||
|         "IsDisabled": false, | ||||
|         "IsHidden": true, | ||||
|         "LoginAttemptsBeforeLockout": -1, | ||||
|         "MaxActiveSessions": 1, | ||||
|         "PasswordResetProviderId": "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider", | ||||
|         "RemoteClientBitrateLimit": 1000000, | ||||
|         "SyncPlayAccess": "CreateAndJoinGroups" | ||||
|     } | ||||
| } | ||||
							
								
								
									
										96
									
								
								forms.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								forms.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,96 @@ | |||
| # -*- coding: utf-8 -*- | ||||
| from flask_wtf import FlaskForm | ||||
| import json | ||||
| import os | ||||
| from cryptography.hazmat.primitives.serialization import load_ssh_public_key | ||||
| from wtforms import ( | ||||
|     StringField, | ||||
|     PasswordField, | ||||
|     FieldList, | ||||
|     FloatField, | ||||
|     BooleanField, | ||||
|     SelectField, | ||||
|     SubmitField, | ||||
|     validators, | ||||
|     Field, | ||||
|     FieldList, | ||||
|     SelectMultipleField, | ||||
|     TextAreaField, | ||||
|     FieldList, | ||||
|     FormField, | ||||
| ) | ||||
| from flask_wtf.file import FileField, FileAllowed, FileRequired | ||||
| from wtforms.ext.sqlalchemy.orm import model_form | ||||
| from wtforms.fields.html5 import SearchField | ||||
| from wtforms.widgets.html5 import NumberInput | ||||
| from wtforms.widgets import TextInput, CheckboxInput, ListWidget, PasswordInput | ||||
| from wtforms.validators import ( | ||||
|     ValidationError, | ||||
|     DataRequired, | ||||
|     URL, | ||||
|     ValidationError, | ||||
|     Optional, | ||||
| ) | ||||
| 
 | ||||
| 
 | ||||
| def json_prettify(file): | ||||
|     with open(file, "r") as fh: | ||||
|         return json.dumps(json.load(fh), indent=4) | ||||
| 
 | ||||
| class SearchForm(FlaskForm): | ||||
|     query = SearchField("Query", validators=[DataRequired()]) | ||||
|     tv_shows = BooleanField("TV Shows", default=True) | ||||
|     movies = BooleanField("Movies", default=True) | ||||
|     torrents = BooleanField("Torrents", default=True) | ||||
|     indexer = SelectMultipleField(choices=[]) | ||||
|     group_by_tracker = BooleanField("Group torrents by tracker") | ||||
|     search = SubmitField("Search") | ||||
| 
 | ||||
| class HiddenPassword(PasswordField): | ||||
|     widget = PasswordInput(hide_value=False) | ||||
| 
 | ||||
| class TranscodeProfileForm(FlaskForm): | ||||
|     test = TextAreaField() | ||||
|     save = SubmitField("Save") | ||||
| 
 | ||||
| class AddSSHUser(FlaskForm): | ||||
|     name = StringField("Name", validators=[DataRequired()]) | ||||
|     ssh_key = StringField("Public key", validators=[DataRequired()]) | ||||
|     add = SubmitField("Add") | ||||
| 
 | ||||
|     def validate_ssh_key(self,field): | ||||
|         key=load_ssh_public_key(bytes(field.data,"utf8")) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| class ConfigForm(FlaskForm): | ||||
|     jellyfin_url = StringField("URL", validators=[URL()]) | ||||
|     jellyfin_api_key = StringField("API Key") | ||||
| 
 | ||||
|     qbt_url = StringField("URL", validators=[URL()]) | ||||
|     qbt_username = StringField("Username") | ||||
|     qbt_passwd = HiddenPassword("Password") | ||||
| 
 | ||||
|     sonarr_url = StringField("URL", validators=[URL()]) | ||||
|     sonarr_api_key = HiddenPassword("API key") | ||||
| 
 | ||||
|     radarr_url = StringField("URL", validators=[URL()]) | ||||
|     radarr_api_key = HiddenPassword("API key") | ||||
| 
 | ||||
|     jackett_url = StringField("URL", validators=[URL()]) | ||||
|     jackett_api_key = HiddenPassword("API key") | ||||
| 
 | ||||
|     portainer_url = StringField("URL", validators=[URL()]) | ||||
|     portainer_username = StringField("Username") | ||||
|     portainer_passwd = HiddenPassword("Password") | ||||
| 
 | ||||
|     transcode_default_profile = SelectField( | ||||
|         "Default profile", choices=[], validators=[] | ||||
|     ) | ||||
|     transcode_profiles = FileField( | ||||
|         "Transcode profiles JSON", | ||||
|         validators=[Optional(), FileAllowed(["json"], "JSON files only!")], | ||||
|     ) | ||||
| 
 | ||||
|     test = SubmitField("Test") | ||||
|     save = SubmitField("Save") | ||||
							
								
								
									
										4
									
								
								models/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								models/__init__.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| from flask_sqlalchemy import SQLAlchemy | ||||
| db = SQLAlchemy() | ||||
| from .stats import Stats | ||||
| from .transcode import TranscodeJob | ||||
							
								
								
									
										14
									
								
								models/stats.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								models/stats.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| from . import db | ||||
| from sqlalchemy import String, Float, Column, Integer, DateTime | ||||
| from datetime import datetime | ||||
| 
 | ||||
| 
 | ||||
| class Stats(db.Model): | ||||
|     id = db.Column(db.Integer, primary_key=True) | ||||
|     timestamp = db.Column(db.DateTime, default=datetime.today) | ||||
|     key = db.Column(db.String) | ||||
|     value = db.Column(db.Float) | ||||
| 
 | ||||
| class Diagrams(db.Model): | ||||
|     name = db.Column(db.String,primary_key=True) | ||||
|     data = db.Column(db.String) | ||||
							
								
								
									
										13
									
								
								models/transcode.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								models/transcode.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| 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 | ||||
| 
 | ||||
| 
 | ||||
| class TranscodeJob(db.Model): | ||||
|     id = db.Column(db.Integer, primary_key=True) | ||||
|     created = db.Column(db.DateTime, default=datetime.today) | ||||
|     status = db.Column(JSONType, default={}) | ||||
|     completed = db.Column(db.DateTime, default=None) | ||||
|     profile = db.Column(db.String, default=None) | ||||
							
								
								
									
										0
									
								
								models/users.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								models/users.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										129
									
								
								static/theme.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								static/theme.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,129 @@ | |||
| body, | ||||
| input, | ||||
| select, | ||||
| pre, | ||||
| textarea, | ||||
| tr { | ||||
|     background-color: #222 !important; | ||||
|     color: #eee; | ||||
| } | ||||
| 
 | ||||
| pre.inline { | ||||
|     display: inline; | ||||
|     margin: 0; | ||||
| } | ||||
| 
 | ||||
| th { | ||||
|     border-bottom: 1px; | ||||
| } | ||||
| 
 | ||||
| thead, table { | ||||
|     line-height: 1; | ||||
|     color: #eee; | ||||
| } | ||||
| 
 | ||||
| hr { | ||||
|     color: #eee; | ||||
|     border-color: #eee; | ||||
|     margin: 10px 0; | ||||
| } | ||||
| 
 | ||||
| p { | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
| } | ||||
| 
 | ||||
| .list-group-item { | ||||
|     background-color: #181818; | ||||
|     border-color: #eee; | ||||
| } | ||||
| 
 | ||||
| .dropdown-menu { | ||||
|     background-color: #444; | ||||
| } | ||||
| 
 | ||||
| .progress { | ||||
|     background-color: #444; | ||||
| } | ||||
| .progress-bar { | ||||
|     background-color: #f70; | ||||
| } | ||||
| 
 | ||||
| .form-control { | ||||
|     color: #eee !important; | ||||
| } | ||||
| 
 | ||||
| .form-group { | ||||
|     margin-bottom: 0; | ||||
| } | ||||
| 
 | ||||
| .btn { | ||||
|     margin-top: 10px; | ||||
| } | ||||
| .form-control-label { | ||||
|     margin-top: 10px; | ||||
| } | ||||
| 
 | ||||
| .torrent_results { | ||||
|     width: 100%; | ||||
| } | ||||
| 
 | ||||
| .nav-pills { | ||||
|     margin-top: 10px; | ||||
| } | ||||
| 
 | ||||
| h1, | ||||
| h2, | ||||
| h3 { | ||||
|     margin-top: 10px; | ||||
| } | ||||
| 
 | ||||
| /* Remove default bullets */ | ||||
| ul.file_tree, | ||||
| ul.tree, | ||||
| ul.file { | ||||
|     list-style-type: none; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
| } | ||||
| 
 | ||||
| ul.tree { | ||||
|     padding-left: 10px; | ||||
| } | ||||
| 
 | ||||
| .monospace { | ||||
|     font-family: monospace; | ||||
|     overflow: scroll; | ||||
|     max-height: 500px; | ||||
|     min-height: 500px; | ||||
|     max-width: 100%; | ||||
|     min-width: 100%; | ||||
| } | ||||
| 
 | ||||
| /* Style the caret/arrow */ | ||||
| .custom_caret { | ||||
|     cursor: pointer; | ||||
|     user-select: none; | ||||
|     /* Prevent text selection */ | ||||
| } | ||||
| 
 | ||||
| /* Create the caret/arrow with a unicode, and style it */ | ||||
| .custom_caret::before { | ||||
|     content: "[+]"; | ||||
|     display: inline-block; | ||||
| } | ||||
| 
 | ||||
| /* Rotate the caret/arrow icon when clicked on (using JavaScript) */ | ||||
| .custom_caret-down::before { | ||||
|     content: "[-]"; | ||||
| } | ||||
| 
 | ||||
| /* Hide the nested list */ | ||||
| .nested { | ||||
|     display: none; | ||||
| } | ||||
| 
 | ||||
| /* Show the nested list when the user clicks on the caret/arrow (with JavaScript) */ | ||||
| .active { | ||||
|     display: block; | ||||
| } | ||||
							
								
								
									
										409
									
								
								stats_collect.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										409
									
								
								stats_collect.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,409 @@ | |||
| import pylab as PL | ||||
| from matplotlib.ticker import EngFormatter | ||||
| from base64 import b64encode | ||||
| from api import Client | ||||
| from utils import handle_config | ||||
| import time | ||||
| import json | ||||
| import io | ||||
| import os | ||||
| from urllib.parse import quote | ||||
| from datetime import datetime | ||||
| from concurrent.futures import ThreadPoolExecutor, as_completed | ||||
| 
 | ||||
| mpl_style = "dark_background" | ||||
| 
 | ||||
| smoothness = 5 | ||||
| 
 | ||||
| 
 | ||||
| def make_svg(data, dtype): | ||||
|     data_uri = "data:{};base64,{}".format(dtype, quote(str(b64encode(data), "ascii"))) | ||||
|     return '<embed type="image/svg+xml" src="{}"/>'.format(data_uri) | ||||
| 
 | ||||
| 
 | ||||
| def make_smooth(data, window_size): | ||||
|     ret = [] | ||||
|     for i, _ in enumerate(data): | ||||
|         block = data[i : i + window_size] | ||||
|         ret.append(sum(block) / len(block)) | ||||
|     return ret | ||||
| 
 | ||||
| 
 | ||||
| def stackplot(data, names, title=None, color="#eee", unit=None, smooth=0): | ||||
|     fig = io.BytesIO() | ||||
|     with PL.style.context(mpl_style): | ||||
|         labels = [] | ||||
|         values = [] | ||||
|         for k, v in names.items(): | ||||
|             t = list(map(datetime.fromtimestamp, data["t"])) | ||||
|             if smooth: | ||||
|                 data[v] = make_smooth(data[v], smooth) | ||||
|             values.append(data[v]) | ||||
|             labels.append(k) | ||||
|         PL.stackplot(t, values, labels=labels) | ||||
|         PL.legend() | ||||
|         PL.grid(True, ls="--") | ||||
|         PL.gcf().autofmt_xdate() | ||||
|         PL.gca().margins(x=0) | ||||
|         if title: | ||||
|             PL.title(title) | ||||
|         if unit: | ||||
|             PL.gca().yaxis.set_major_formatter(EngFormatter(unit=unit)) | ||||
|         PL.tight_layout() | ||||
|         PL.savefig(fig, format="svg", transparent=True) | ||||
|         PL.clf() | ||||
|     return make_svg(fig.getvalue(), "image/svg+xml") | ||||
| 
 | ||||
| 
 | ||||
| def lineplot(data, names, title=None, color="#eee", unit=None, smooth=0): | ||||
|     fig = io.BytesIO() | ||||
|     with PL.style.context(mpl_style): | ||||
|         for k, v in names.items(): | ||||
|             t = list(map(datetime.fromtimestamp, data["t"])) | ||||
|             if smooth: | ||||
|                 data[v] = make_smooth(data[v], smooth) | ||||
|             PL.plot(t, data[v], label=k) | ||||
|         PL.legend() | ||||
|         PL.grid(True, ls="--") | ||||
|         PL.gcf().autofmt_xdate() | ||||
|         PL.gca().margins(x=0) | ||||
|         if title: | ||||
|             PL.title(title) | ||||
|         if unit: | ||||
|             PL.gca().yaxis.set_major_formatter(EngFormatter(unit=unit)) | ||||
|         PL.tight_layout() | ||||
|         PL.savefig(fig, format="svg", transparent=True) | ||||
|         PL.clf() | ||||
|     return make_svg(fig.getvalue(), "image/svg+xml") | ||||
| 
 | ||||
| 
 | ||||
| def histogram(values, bins, title=None, color="#eee", unit=""): | ||||
|     fig = io.BytesIO() | ||||
|     with PL.style.context(mpl_style): | ||||
|         PL.hist(values, bins=bins, log=True) | ||||
|         if title: | ||||
|             PL.title(title) | ||||
|         PL.grid(True, ls="--") | ||||
|         PL.gca().xaxis.set_major_formatter(EngFormatter(unit=unit)) | ||||
|         PL.gca().margins(x=0) | ||||
|         PL.tight_layout() | ||||
|         PL.savefig(fig, format="svg", transparent=True) | ||||
|         PL.clf() | ||||
|     return make_svg(fig.getvalue(), "image/svg+xml") | ||||
| 
 | ||||
| 
 | ||||
| def prc_label(label, idx, values): | ||||
|     return "{} ({}, {:.2%}%)".format(label, values[idx], values[idx] / sum(values)) | ||||
| 
 | ||||
| 
 | ||||
| def byte_labels(label, idx, values): | ||||
|     orig_values = list(values) | ||||
|     suffix = ["", "K", "M", "G", "T", "P", "E"] | ||||
|     i = 0 | ||||
|     while values[idx] > 1024 and i < len(suffix): | ||||
|         values[idx] /= 1024 | ||||
|         i += 1 | ||||
|     val = "{:.2f} {}iB".format(values[idx], suffix[i]) | ||||
|     return "{} ({}, {:.2%}%)".format(label, val, orig_values[idx] / sum(orig_values)) | ||||
| 
 | ||||
| 
 | ||||
| def byte_rate_labels(label, idx, values): | ||||
|     orig_values = list(values) | ||||
|     suffix = ["", "K", "M", "G", "T", "P", "E"] | ||||
|     i = 0 | ||||
|     while values[idx] > 1024 and i < len(suffix): | ||||
|         values[idx] /= 1024 | ||||
|         i += 1 | ||||
|     val = "{:.2f} {}iB/s".format(values[idx], suffix[i]) | ||||
|     return "{} ({})".format(label, val) | ||||
| 
 | ||||
| 
 | ||||
| def piechart(items, title=None, labelfunc=prc_label, sort=True): | ||||
|     fig = io.BytesIO() | ||||
|     labels = [] | ||||
|     values = [] | ||||
|     colors = [] | ||||
|     if sort: | ||||
|         items = sorted(items.items(), key=lambda v: v[1]) | ||||
|     else: | ||||
|         items = sorted(items.items()) | ||||
|     for k, v in items: | ||||
|         labels.append(k) | ||||
|         if isinstance(v, tuple) and len(v) == 2: | ||||
|             v, c = v | ||||
|             colors.append(c) | ||||
|         values.append(v) | ||||
|     colors = colors or None | ||||
|     for i, label in enumerate(labels): | ||||
|         labels[i] = labelfunc(label, i, values[:]) | ||||
|     with PL.style.context(mpl_style): | ||||
|         PL.pie(values, labels=labels, colors=colors, labeldistance=None) | ||||
|         PL.legend() | ||||
|         if title: | ||||
|             PL.title(title) | ||||
|         PL.tight_layout() | ||||
|         PL.savefig(fig, format="svg", transparent=True) | ||||
|         PL.clf() | ||||
|     return make_svg(fig.getvalue(), "image/svg+xml") | ||||
| 
 | ||||
| 
 | ||||
| hist = { | ||||
|     "t": [], | ||||
|     "dl": [], | ||||
|     "ul": [], | ||||
|     "dl_size": [], | ||||
|     "ul_size": [], | ||||
|     "dl_size_sess": [], | ||||
|     "ul_size_sess": [], | ||||
|     "connections": [], | ||||
|     "bw_per_conn": [], | ||||
|     "dht_nodes": [], | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| def update_qbt_hist(stats, limit=1024): | ||||
|     global hist | ||||
|     data = stats["qbt"]["status"] | ||||
|     hist["t"].append(time.time()) | ||||
|     hist["dl"].append(data["server_state"]["dl_info_speed"]) | ||||
|     hist["ul"].append(data["server_state"]["up_info_speed"]) | ||||
|     hist["dl_size"].append(data["server_state"]["alltime_dl"]) | ||||
|     hist["ul_size"].append(data["server_state"]["alltime_ul"]) | ||||
|     hist["dl_size_sess"].append(data["server_state"]["dl_info_data"]) | ||||
|     hist["ul_size_sess"].append(data["server_state"]["up_info_data"]) | ||||
|     hist["connections"].append(data["server_state"]["total_peer_connections"]) | ||||
|     hist["dht_nodes"].append(data["server_state"]["dht_nodes"]) | ||||
|     hist["bw_per_conn"].append( | ||||
|         (data["server_state"]["dl_info_speed"] + data["server_state"]["up_info_speed"]) | ||||
|         / data["server_state"]["total_peer_connections"] | ||||
|     ) | ||||
|     for k in hist: | ||||
|         hist[k] = hist[k][-limit:] | ||||
|     last_idx = 0 | ||||
|     for i, (t1, t2) in enumerate(zip(hist["t"], hist["t"][1:])): | ||||
|         if abs(t1 - t2) > (60 * 60):  # 1h | ||||
|             last_idx = i + 1 | ||||
|     for k in hist: | ||||
|         hist[k] = hist[k][last_idx:] | ||||
|     return hist | ||||
| 
 | ||||
| 
 | ||||
| def collect_stats(): | ||||
|     from collections import Counter | ||||
| 
 | ||||
|     PL.clf() | ||||
|     cfg = handle_config() | ||||
|     c = Client(cfg) | ||||
|     series={} | ||||
|     movies={} | ||||
|     data = { | ||||
|         "radarr": {"entries": c.radarr.movies(), "status": c.radarr.status()}, | ||||
|         "sonarr": { | ||||
|             "entries": c.sonarr.series(), | ||||
|             "status": c.sonarr.status(), | ||||
|             "details": {}, | ||||
|         }, | ||||
|         "qbt": {"status": c.qbittorent.status()}, | ||||
|     } | ||||
|     for show in data["sonarr"]["entries"]: | ||||
|         series[show["id"]]=show | ||||
|     for movie in data["radarr"]["entries"]: | ||||
|         movies[movie["id"]]=movie | ||||
|     torrent_states = {} | ||||
|     torrent_categories = {} | ||||
|     for torrent in data["qbt"]["status"]["torrents"].values(): | ||||
|         state = c.qbittorent.status_map.get(torrent["state"], (torrent["state"], None))[ | ||||
|             0 | ||||
|         ] | ||||
|         category = torrent["category"] or "<None>" | ||||
|         torrent_states.setdefault(state, 0) | ||||
|         torrent_categories.setdefault(category, 0) | ||||
|         torrent_states[state] += 1 | ||||
|         torrent_categories[category] += 1 | ||||
|     vbitrates = [] | ||||
|     abitrates = [] | ||||
|     acodecs = [] | ||||
|     vcodecs = [] | ||||
|     qualities = [] | ||||
|     formats = [] | ||||
|     sizes = {"Shows": 0, "Movies": 0} | ||||
|     radarr_stats = {"missing": 0, "available": 0} | ||||
|     for movie in data["radarr"]["entries"]: | ||||
|         if movie["hasFile"]: | ||||
|             radarr_stats["available"] += 1 | ||||
|         else: | ||||
|             radarr_stats["missing"] += 1 | ||||
|         sizes["Movies"] += movie.get("movieFile", {}).get("size", 0) | ||||
|         vbr = movie.get("movieFile", {}).get("mediaInfo", {}).get("videoBitrate", None) | ||||
|         abr = movie.get("movieFile", {}).get("mediaInfo", {}).get("audioBitrate", None) | ||||
|         acodec = movie.get("movieFile", {}).get("mediaInfo", {}).get("audioCodec", None) | ||||
|         vcodec = movie.get("movieFile", {}).get("mediaInfo", {}).get("videoCodec", None) | ||||
|         fmt = movie.get("movieFile", {}).get("relativePath", "").split(".")[-1].lower() | ||||
|         qual = ( | ||||
|             movie.get("movieFile", {}).get("quality", {}).get("quality", {}).get("name") | ||||
|         ) | ||||
|         if qual: | ||||
|             qualities.append(qual) | ||||
|         if acodec: | ||||
|             acodecs.append(acodec) | ||||
|         if vcodec: | ||||
|             if vcodec.lower() in ["x265", "h265", "hevc"]: | ||||
|                 vcodec = "H.265" | ||||
|             if vcodec.lower() in ["x264", "h264"]: | ||||
|                 vcodec = "H.264" | ||||
|             vcodecs.append(vcodec) | ||||
|         if vbr: | ||||
|             vbitrates.append(vbr) | ||||
|         if abr: | ||||
|             abitrates.append(abr) | ||||
|         if fmt: | ||||
|             formats.append(fmt) | ||||
|     sonarr_stats = {"missing": 0, "available": 0} | ||||
|     info_jobs = [] | ||||
|     with ThreadPoolExecutor(16) as pool: | ||||
|         for show in data["sonarr"]["entries"]: | ||||
|             info_jobs.append(pool.submit(c.sonarr.series, show["id"])) | ||||
|         for job, show in zip( | ||||
|             as_completed(info_jobs), | ||||
|             data["sonarr"]["entries"], | ||||
|         ): | ||||
|             info = job.result() | ||||
|             data["sonarr"]["details"][show["id"]] = info | ||||
|             for file in info["episodeFile"]: | ||||
|                 vbr = file.get("mediaInfo", {}).get("videoBitrate", None) | ||||
|                 abr = file.get("mediaInfo", {}).get("audioBitrate", None) | ||||
|                 acodec = file.get("mediaInfo", {}).get("audioCodec", None) | ||||
|                 vcodec = file.get("mediaInfo", {}).get("videoCodec", None) | ||||
|                 fmt = file.get("relativePath", "").split(".")[-1].lower() | ||||
|                 qual = file.get("quality", {}).get("quality", {}).get("name") | ||||
|                 sizes["Shows"] += file.get("size", 0) | ||||
|                 if qual: | ||||
|                     qualities.append(qual) | ||||
|                 if acodec: | ||||
|                     acodecs.append(acodec) | ||||
|                 if vcodec: | ||||
|                     if vcodec.lower() in ["x265", "h265", "hevc"]: | ||||
|                         vcodec = "H.265" | ||||
|                     if vcodec.lower() in ["x264", "h264"]: | ||||
|                         vcodec = "H.264" | ||||
|                     vcodecs.append(vcodec) | ||||
|                 if vbr: | ||||
|                     vbitrates.append(vbr) | ||||
|                 if abr: | ||||
|                     abitrates.append(abr) | ||||
|                 if fmt: | ||||
|                     formats.append(fmt) | ||||
|             for season in show.get("seasons", []): | ||||
|                 stats = season.get("statistics", {}) | ||||
|                 sonarr_stats["missing"] += ( | ||||
|                     stats["totalEpisodeCount"] - stats["episodeFileCount"] | ||||
|                 ) | ||||
|                 sonarr_stats["available"] += stats["episodeFileCount"] | ||||
|     hist = update_qbt_hist(data) | ||||
|     sonarr_stats["available"] = (sonarr_stats["available"], "#5f5") | ||||
|     sonarr_stats["missing"] = (sonarr_stats["missing"], "#f55") | ||||
|     radarr_stats["available"] = (radarr_stats["available"], "#5f5") | ||||
|     radarr_stats["missing"] = (radarr_stats["missing"], "#f55") | ||||
|     imgs = [ | ||||
|         [ | ||||
|             "Media", | ||||
|             histogram([vbitrates], "auto", "Video Bitrate", unit="b/s"), | ||||
|             histogram([abitrates], "auto", "Audio Bitrate", unit="b/s"), | ||||
|             piechart(dict(Counter(vcodecs)), "Video codecs"), | ||||
|             piechart(dict(Counter(acodecs)), "Audio codecs"), | ||||
|             piechart(dict(Counter(formats)), "Container formats"), | ||||
|             piechart(dict(Counter(qualities)), "Quality"), | ||||
|             piechart(sizes, "Disk usage", byte_labels), | ||||
|             piechart(sonarr_stats, "Episodes"), | ||||
|             piechart(radarr_stats, "Movies"), | ||||
|         ], | ||||
|         [ | ||||
|             "Torrents", | ||||
|             piechart(torrent_states, "Torrents"), | ||||
|             piechart(torrent_categories, "Torrent categories"), | ||||
|             piechart( | ||||
|                 {"Upload": hist["ul"][-1]+0.0, "Download": hist["dl"][-1]+0.0}, | ||||
|                 "Bandwidth utilization", | ||||
|                 byte_rate_labels, | ||||
|                 sort=False, | ||||
|             ), | ||||
|             stackplot( | ||||
|                 hist, | ||||
|                 {"Download": "dl", "Upload": "ul"}, | ||||
|                 "Transfer speed", | ||||
|                 unit="b/s", | ||||
|                 smooth=smoothness, | ||||
|             ), | ||||
|             stackplot( | ||||
|                 hist, | ||||
|                 {"Download": "dl_size_sess", "Upload": "ul_size_sess"}, | ||||
|                 "Transfer volume (Session)", | ||||
|                 unit="b", | ||||
|             ), | ||||
|             stackplot( | ||||
|                 hist, | ||||
|                 {"Download": "dl_size", "Upload": "ul_size"}, | ||||
|                 "Transfer volume (Total)", | ||||
|                 unit="b", | ||||
|             ), | ||||
|             lineplot( | ||||
|                 hist, | ||||
|                 {"Connections": "connections"}, | ||||
|                 "Peers", | ||||
|                 unit=None, | ||||
|                 smooth=smoothness, | ||||
|             ), | ||||
|             lineplot( | ||||
|                 hist, | ||||
|                 {"Bandwidth per connection": "bw_per_conn"}, | ||||
|                 "Connections", | ||||
|                 unit="b/s", | ||||
|                 smooth=smoothness, | ||||
|             ), | ||||
|             lineplot(hist, {"DHT Nodes": "dht_nodes"}, "DHT", unit=None), | ||||
|         ], | ||||
|     ] | ||||
|     calendar = {"movies":[],"episodes":[]} | ||||
|     for movie in c.radarr.calendar(): | ||||
|         calendar["movies"].append(movie) | ||||
|     for episode in c.sonarr.calendar(): | ||||
|         t = episode['airDateUtc'].rstrip("Z").split(".")[0] | ||||
|         t = datetime.strptime(t, "%Y-%m-%dT%H:%M:%S") | ||||
|         episode['hasAired']=datetime.today()>t | ||||
|         calendar["episodes"].append({"episode":episode,"series":series[episode["seriesId"]]}) | ||||
|     return {"data": data, "images": imgs, "hist": hist,"calendar": calendar} | ||||
| 
 | ||||
| 
 | ||||
| if os.path.isfile("stats.json"): | ||||
|     with open("stats.json", "r") as of: | ||||
|         try: | ||||
|             hist = json.load(of)["hist"] | ||||
|         except Exception as e: | ||||
|             print("Error loading history:", str(e)) | ||||
| 
 | ||||
| 
 | ||||
| def update(): | ||||
|     print("Updating...") | ||||
|     try: | ||||
|         stats = collect_stats() | ||||
|     except Exception as e: | ||||
|         print("Error collectin statistics:", str(e)) | ||||
|         stats = None | ||||
|     if stats: | ||||
|         with open("stats_temp.json", "w") as of: | ||||
|             json.dump(stats, of) | ||||
|         open("stats.lock", "w").close() | ||||
|         if os.path.isfile("stats.json"): | ||||
|             os.unlink("stats.json") | ||||
|         os.rename("stats_temp.json", "stats.json") | ||||
|         os.unlink("stats.lock") | ||||
|         print("Done!") | ||||
| 
 | ||||
| def loop(seconds): | ||||
|     while True: | ||||
|         update() | ||||
|         time.sleep(seconds) | ||||
| 
 | ||||
| 
 | ||||
| if __name__=="__main__": | ||||
|     update() | ||||
							
								
								
									
										40
									
								
								templates/base.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								templates/base.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,40 @@ | |||
| {% from 'bootstrap/utils.html' import render_messages %} | ||||
| 
 | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
|     <head> | ||||
|         {% block head %} | ||||
|             <meta charset="utf-8"> | ||||
|             <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||||
|             {% block styles %} | ||||
|                 {{ bootstrap.load_css() }} | ||||
|                 <link rel="stylesheet" href="{{url_for('static', filename='theme.css')}}"> | ||||
|             {% endblock %} | ||||
|             <title>MediaDash</title> | ||||
|         {% endblock %} | ||||
|     </head> | ||||
|     <body> | ||||
|         {% block navbar %} | ||||
|         <nav class="navbar sticky-top navbar-expand-lg navbar-dark" style="background-color: #222;"> | ||||
|             <a class="navbar-brand" href="/">MediaDash</a> | ||||
|             <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar_main" aria-controls="navbar_main" aria-expanded="false" aria-label="Toggle navigation"> | ||||
|                 <span class="navbar-toggler-icon"></span> | ||||
|             </button> | ||||
|             <div class="collapse navbar-collapse" id="navbar_main"> | ||||
|                 {{nav.left_nav.render(renderer='bootstrap4')}} | ||||
|             </div> | ||||
|             </nav> | ||||
|         </div> | ||||
|         {% endblock %} | ||||
|         {% block content %} | ||||
|         <div class={{"container-fluid" if fluid else "container"}}> | ||||
|             {{render_messages()}} | ||||
|             {% block app_content %}{% endblock %} | ||||
|         </div> | ||||
|         {% endblock %} | ||||
| 
 | ||||
|         {% block scripts %} | ||||
|             {{ bootstrap.load_js(with_popper=False) }} | ||||
|         {% endblock %} | ||||
|     </body> | ||||
| </html> | ||||
							
								
								
									
										70
									
								
								templates/config.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								templates/config.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,70 @@ | |||
| {% extends "base.html" %} | ||||
| {% from 'utils.html' import custom_render_form_row,make_tabs %} | ||||
| {% from 'bootstrap/form.html' import render_form, render_field, render_form_row %} | ||||
| 
 | ||||
| {% set col_size = ('lg',2,6) %} | ||||
| {% set col_size_seq = ('lg',10,1) %} | ||||
| 
 | ||||
| {% macro render_fields(fields) %} | ||||
|     {% for field in fields %} | ||||
|         {% if field is sequence %} | ||||
|             {{ custom_render_form_row(field|list,col_map={'transcode_edit':('lg',1),'transcode_new':('lg',1)},render_args={'form_type':'horizontal'}) }} | ||||
|         {% else %} | ||||
|             {{ custom_render_form_row([field],render_args={'form_type':'horizontal','horizontal_columns':col_size}) }} | ||||
|         {% endif %} | ||||
|     {%  endfor %} | ||||
| {% endmacro %} | ||||
| 
 | ||||
| {% set config_tabs = [] %} | ||||
| {% for name, fields in [ | ||||
|     ('Jellyfin',[form.jellyfin_url,form.jellyfin_username,form.jellyfin_passwd]), | ||||
|     ('QBittorrent',[form.qbt_url,form.qbt_username,form.qbt_passwd]), | ||||
|     ('Sonarr',[form.sonarr_url,form.sonarr_api_key]), | ||||
|     ('Radarr',[form.radarr_url,form.radarr_api_key]), | ||||
|     ('Portainer',[form.portainer_url,form.portainer_username,form.portainer_passwd]), | ||||
|     ('Jackett',[form.jackett_url,form.jackett_api_key]), | ||||
|     ('Transcode',[form.transcode_default_profile,form.transcode_profiles]), | ||||
| ] %} | ||||
|     {% do config_tabs.append((name,render_fields(fields))) %} | ||||
| {% endfor %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
| <h1>{{title}}</h1> | ||||
| {% if test %} | ||||
| {% if test.success %} | ||||
| <div class="alert alert-success" role="danger"> | ||||
|     <h4>Sucess</h4> | ||||
| </div> | ||||
| {% else %} | ||||
| <div class="alert alert-danger" role="danger"> | ||||
|     {% for module,error in test.errors.items() %} | ||||
|         {% if error %} | ||||
|             <h4>{{module}}</h4> | ||||
|             {% if error is mapping %} | ||||
|                 {% for key,value in error.items() %} | ||||
|                     <p><b>{{key}}</b>: {{value}}</p> | ||||
|                 {% endfor %} | ||||
|             {% else %} | ||||
|                 <b>{{error}}</b> | ||||
|             {% endif %} | ||||
|         {% endif %} | ||||
|     {% endfor %} | ||||
| </div> | ||||
| {% endif %} | ||||
| {% endif %} | ||||
| {% for field in form %} | ||||
|     {% for error in field.errors %} | ||||
|         <div class="alert alert-danger" role="danger">{{error}}</div> | ||||
|     {% endfor %} | ||||
| {% endfor %} | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         <form method="post" class="form" enctype="multipart/form-data"> | ||||
|             {{ form.csrf_token() }} | ||||
|             {{ make_tabs(config_tabs) }} | ||||
|             {{ custom_render_form_row([form.test, form.save],button_map={'test':'primary','save':'success'},col_map={'test':0,'primary':0},render_args={'form_type':'horizontal'})}} | ||||
|         </form> | ||||
|         {# render_form(form, form_type ="horizontal", button_map={'test':'primary','save':'success'}) #} | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										14
									
								
								templates/containers/details.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								templates/containers/details.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| {% extends "base.html" %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
|     <h1> | ||||
|         <a href="{{config.APP_CONFIG.portainer_url}}#/containers/{{container.Id}}"> | ||||
|             {{container.Config.Labels["com.docker.compose.project"]}}/{{container.Config.Labels["com.docker.compose.service"]}} | ||||
|         </a> | ||||
|     </h1> | ||||
|      | ||||
|     <h4>Env</h4> | ||||
|     <pre>{{container.Config.Env|join("\n")}}</pre> | ||||
|      | ||||
|     <pre>{{container|tojson(indent=4)}}</pre> | ||||
| {% endblock %} | ||||
							
								
								
									
										58
									
								
								templates/containers/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								templates/containers/index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,58 @@ | |||
| {% extends "base.html" %} | ||||
| {% from "utils.html" import make_tabs %} | ||||
| 
 | ||||
| {% macro container_row(info) %} | ||||
|     <div class="row"> | ||||
|         <div class="col"> | ||||
|             Image | ||||
|         </div> | ||||
|         <div class="col"> | ||||
|             <a href="{{urljoin('https://hub.docker.com/r/',info.Image)}}">{{info.Image}}</a> | ||||
|         </div> | ||||
|         <div class="col"> | ||||
|             Status | ||||
|         </div> | ||||
|         <div class="col"> | ||||
|             {{info.Status}} | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="row"> | ||||
|         <div class="col"> | ||||
|             Id: <a href="{{'{}#/containers/{}'.format(config.APP_CONFIG.portainer_url,info.Id)}}">{{info.Id}}</a> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="row"> | ||||
|         <pre>{{info|tojson(indent=4)}}</pre> | ||||
|     </div> | ||||
| {% endmacro %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
|     <h1> | ||||
|         <a href="{{config.APP_CONFIG.portainer_url}}">Portainer</a> | ||||
|     </h1> | ||||
|     <table class="table table-sm"> | ||||
|         <thead> | ||||
|             <tr> | ||||
|                 <th scope="col">Name</th> | ||||
|                 <th scope="col">Image</th> | ||||
|                 <th scope="col">Status</th> | ||||
|             </tr> | ||||
|         </thead> | ||||
|         {% for container in containers %} | ||||
|             {% set label = container.Labels["com.docker.compose.service"] %} | ||||
|                 <tr> | ||||
|                     <td> | ||||
|                         <a href="{{url_for('containers',container_id=container.Id)}}"> | ||||
|                             {{container.Labels["com.docker.compose.project"]}}/{{container.Labels["com.docker.compose.service"]}} | ||||
|                         </a> | ||||
|                     </td> | ||||
|                     <td> | ||||
|                         <a href="{{urljoin('https://hub.docker.com/r/',container.Image)}}">{{container.Image}}</a> | ||||
|                     </td> | ||||
|                     <td> | ||||
|                         {{container.Status}} | ||||
|                     </td> | ||||
|                 </tr> | ||||
|         {% endfor %} | ||||
|     </table> | ||||
| {% endblock %} | ||||
							
								
								
									
										65
									
								
								templates/history.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								templates/history.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,65 @@ | |||
| {%- extends "base.html" -%} | ||||
| {%- from 'utils.html' import make_tabs -%} | ||||
| 
 | ||||
| {%- macro default(event,source) -%} | ||||
|     <h5>Unknown ({{source}})</h5> | ||||
|     <pre>{{event|tojson(indent=4)}}</pre> | ||||
| {%- endmacro -%} | ||||
| 
 | ||||
| {%- macro downloadFolderImported(event,source) -%} | ||||
|     [{{event.seriesId}}/{{event.episodeId}}] Imported {{event.data.droppedPath}} from {{event.data.downloadClientName}} to {{event.data.importedPath}} | ||||
| {%- endmacro -%} | ||||
| 
 | ||||
| {%- macro grabbed(event,source) -%} | ||||
|     [{{event.seriesId}}/{{event.episodeId}}] Grabbed <a href="{{event.data.guid}}">{{event.sourceTitle}}</a> | ||||
| {%- endmacro -%} | ||||
| 
 | ||||
| {%- macro episodeFileDeleted(event,source) -%} | ||||
|     [{{event.seriesId}}/{{event.episodeId}}] Deleted {{event.sourceTitle}} because {{event.data.reason}} | ||||
| {%- endmacro -%} | ||||
| 
 | ||||
| {%- macro episodeFileRenamed(event,source) -%} | ||||
|     [{{event.seriesId}}/{{event.episodeId}}] Renamed {{event.data.sourcePath}} to {{event.data.path}} | ||||
| {%- endmacro -%} | ||||
| 
 | ||||
| {%- macro movieFileDeleted(event,source) -%} | ||||
|     Renamed {{event.data.sourcePath}} to {{event.data.path}} | ||||
| {%- endmacro -%} | ||||
| 
 | ||||
| {%- macro movieFileRenamed(event,source) -%} | ||||
|     <h5>renamed</h5> | ||||
|     <pre>{{event|tojson(indent=4)}}</pre> | ||||
| {%- endmacro -%} | ||||
| 
 | ||||
| {%- macro downloadFailed(event,source) -%} | ||||
|     <h5>downloadFailed</h5> | ||||
|     <pre>{{event|tojson(indent=4)}}</pre> | ||||
| {%- endmacro -%} | ||||
| 
 | ||||
| {%- set handlers = { | ||||
|     'downloadFolderImported': downloadFolderImported, | ||||
|     'grabbed': grabbed, | ||||
|     'episodeFileDeleted': episodeFileDeleted, | ||||
|     'episodeFileRenamed': episodeFileRenamed, | ||||
|     'movieFileDeleted': movieFileDeleted, | ||||
|     'movieFileRenamed': movieFileRenamed, | ||||
|     'downloadFailed': downloadFailed, | ||||
|     None: default | ||||
| } -%} | ||||
| 
 | ||||
| {%- macro history_page(history,source) -%} | ||||
|     <pre> | ||||
|         {%- for entry in history.records -%} | ||||
|             {{handlers.get(entry.eventType,handlers[None])(entry,source)}}{{'\n'}} | ||||
|         {%- endfor -%} | ||||
|     </pre> | ||||
| {%- endmacro -%} | ||||
| 
 | ||||
| {%- block app_content -%} | ||||
| <h2>History</h2> | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         {{make_tabs([('Sonarr',history_page(sonarr,'sonarr')),('Radarr',history_page(radarr,'radarr'))])}} | ||||
|     </div> | ||||
| </div> | ||||
| {%- endblock -%} | ||||
							
								
								
									
										122
									
								
								templates/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								templates/index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,122 @@ | |||
| {% extends "base.html" %} | ||||
| 
 | ||||
| {% 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 upcoming(data) %} | ||||
|     <div class="container"> | ||||
|     <div class="row"> | ||||
|         <div class="col-lg"> | ||||
|             <h3>Movies</h3> | ||||
|             <table class="table table-sm"> | ||||
|                 <tr> | ||||
|                     <th>Title</th> | ||||
|                     <th>In Cinemas</th> | ||||
|                     <th>Digital Release</th> | ||||
|                 </tr> | ||||
|                 {% for movie in data.calendar.movies %} | ||||
|                     {% if movie.isAvailable and movie.hasFile %} | ||||
|                         {% set row_class = "bg-success" %} | ||||
|                     {% elif movie.isAvailable and not movie.hasFile %} | ||||
|                         {% set row_class = "bg-danger" %} | ||||
|                     {% elif not movie.isAvailable and movie.hasFile %} | ||||
|                         {% set row_class = "bg-primary" %} | ||||
|                     {% elif not movie.isAvailable and not movie.hasFile %} | ||||
|                         {% set row_class = "bg-info" %} | ||||
|                     {% endif %} | ||||
|                     <tr class={{row_class}}> | ||||
|                         <td> | ||||
|                         <a href="{{urljoin(config.APP_CONFIG.radarr_url,'movie/'+movie.titleSlug)}}" style="color: #eee; text-decoration: underline;"> | ||||
|                             {{movie.title}} | ||||
|                         </a> | ||||
|                         </td> | ||||
|                         <td>{{movie.inCinemas|fromiso|ago_dt_utc_human(rnd=0)}}</td> | ||||
|                         <td>{{movie.digitalRelease|fromiso|ago_dt_utc_human(rnd=0)}}</td> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|             </table> | ||||
|             <h3>Episodes</h3> | ||||
| 
 | ||||
|             <table class="table table-sm"> | ||||
|                 <tr> | ||||
|                     <th>Season | Episode Number</th> | ||||
|                     <th>Show</th> | ||||
|                     <th>Title</th> | ||||
|                     <th>Air Date</th> | ||||
|                 </tr> | ||||
|                 {% for entry in data.calendar.episodes %} | ||||
|                 {% if entry.episode.hasAired and entry.episode.hasFile %} | ||||
|                     {% set row_class = "bg-success" %} | ||||
|                 {% elif entry.episode.hasAired and not entry.episode.hasFile %} | ||||
|                     {% set row_class = "bg-danger" %} | ||||
|                 {% elif not entry.episode.hasAired and entry.episode.hasFile %} | ||||
|                     {% set row_class = "bg-primary" %} | ||||
|                 {% elif not entry.episode.hasAired and not entry.episode.hasFile %} | ||||
|                     {% set row_class = "bg-info" %} | ||||
|                 {% endif %} | ||||
|                 <tr class={{row_class}}> | ||||
|                     <td>{{entry.episode.seasonNumber}} | {{entry.episode.episodeNumber}}</td> | ||||
|                     <td> | ||||
|                         <a href="{{urljoin(config.APP_CONFIG.sonarr_url,'series/'+entry.series.titleSlug)}}" style="color: #eee; text-decoration: underline;"> | ||||
|                             {{entry.series.title}} | ||||
|                         </a> | ||||
|                     </td> | ||||
|                     <td>{{entry.episode.title}}</td> | ||||
|                     <td>{{entry.episode.airDateUtc|fromiso|ago_dt_utc_human(rnd=0)}}</td> | ||||
|                 </tr> | ||||
|                 {% endfor %} | ||||
|             </table> | ||||
|         </div> | ||||
|     </div> | ||||
|     </div> | ||||
| {% endmacro %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
|     {% if data is none %} | ||||
|         <h2>No Data available!</h2> | ||||
|     {% else %} | ||||
|         {% set tabs = [] %} | ||||
|         {% do tabs.append(("Upcoming",[upcoming(data)])) %} | ||||
|         {% for row in data.images %} | ||||
|             {% if row[0] is string %} | ||||
|                 {% set title=row[0] %} | ||||
|                 {% set row=row[1:] %} | ||||
|                 {% do tabs.append((title,row)) %} | ||||
|             {% endif %} | ||||
|         {% endfor %} | ||||
|         {{make_tabs(tabs)}} | ||||
|     {% endif %} | ||||
| {% endblock %} | ||||
							
								
								
									
										121
									
								
								templates/jellyfin/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								templates/jellyfin/index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,121 @@ | |||
| {% 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={{jellyfin.info.LocalAddress}}>Jellyfin</a> v{{jellyfin.info.Version}}</h2> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col-lg"> | ||||
|         <h4>Active Streams</h4> | ||||
|         <table class="table table-sm"> | ||||
|             <tr> | ||||
|                 <th>Episode</th> | ||||
|                 <th>Show</th> | ||||
|                 <th>Language</th> | ||||
|                 <th>User</th> | ||||
|                 <th>Device</th> | ||||
|                 <th>Mode</th> | ||||
|             </tr> | ||||
|             {% for session in jellyfin.sessions %} | ||||
|                 {% if "NowPlayingItem" in session %} | ||||
|                 {% with np=session.NowPlayingItem, ps=session.PlayState%} | ||||
|                 <tr> | ||||
|                     <td> | ||||
|                         {% if session.SupportsMediaControl %} | ||||
|                         <a href="{{url_for('stop_stream',session=session.Id)}}"> | ||||
|                             {{render_icon("stop-circle")}} | ||||
|                         </a> | ||||
|                         {% endif %} | ||||
|                         <a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.Id}}"> | ||||
|                             {{np.Name}} | ||||
|                         </a> | ||||
|                         ({{(ps.PositionTicks/10_000_000)|timedelta(digits=0)}}/{{(np.RunTimeTicks/10_000_000)|timedelta(digits=0)}}) | ||||
|                         {% if ps.IsPaused %} | ||||
|                             (Paused) | ||||
|                         {% endif %} | ||||
|                     </td> | ||||
|                     <td> | ||||
|                         <a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.SeriesId}}"> | ||||
|                             {{np.SeriesName}} | ||||
|                         </a> | ||||
|                         <a href="{{cfg().jellyfin_url}}web/index.html#!/details?id={{np.SeasonId}}"> | ||||
|                             ({{np.SeasonName}}) | ||||
|                         </a> | ||||
|                          | ||||
|                     </td> | ||||
|                     <td> | ||||
|                         {% if ("AudioStreamIndex" in ps) and ("SubtitleStreamIndex" in ps) %} | ||||
|                             {{np.MediaStreams[ps.AudioStreamIndex].Language or "None"}}/{{np.MediaStreams[ps.SubtitleStreamIndex].Language or "None"}} | ||||
|                         {% else %} | ||||
|                             Unk/Unk | ||||
|                         {% endif %} | ||||
|                     </td> | ||||
|                     <td> | ||||
|                         <a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{session.UserId}}"> | ||||
|                             {{session.UserName}} | ||||
|                         </a> | ||||
|                     </td> | ||||
|                     <td> | ||||
|                         {{session.DeviceName}} | ||||
|                     </td> | ||||
|                     <td> | ||||
|                         {% if ps.PlayMethod =="Transcode" %} | ||||
|                             <p title="{{session.TranscodingInfo.Bitrate|filesizeformat(binary=False)}}/s | {{session.TranscodingInfo.CompletionPercentage|round(2)}}%"> | ||||
|                                 {{ps.PlayMethod}} | ||||
|                             </p> | ||||
|                         {% else %} | ||||
|                         <p> | ||||
|                             {{ps.PlayMethod}} | ||||
|                         </p> | ||||
|                         {% endif %} | ||||
|                     </td> | ||||
|                 </tr> | ||||
|                 {% endwith %} | ||||
|             {% endif %} | ||||
|             {% endfor %} | ||||
|         </table> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col-lg"> | ||||
|         <h4>Users</h4> | ||||
|         <table class="table table-sm"> | ||||
|             <tr> | ||||
|                 <th>Name</th> | ||||
|                 <th>Last Login</th> | ||||
|                 <th>Last Active</th> | ||||
|                 <th>Bandwidth Limit</th> | ||||
|             </tr> | ||||
|             {% for user in jellyfin.users|sort(attribute="LastLoginDate",reverse=True) %} | ||||
|                 <tr> | ||||
|                     <td> | ||||
|                         <a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{user.Id}}"> | ||||
|                             {{user.Name}} | ||||
|                         </a> | ||||
|                     </td> | ||||
|                     <td> | ||||
|                     {% if "LastLoginDate" in user %} | ||||
|                             {{user.LastLoginDate|fromiso|ago_dt_utc(2)}} ago | ||||
|                     {% else %} | ||||
|                         Never | ||||
|                     {% endif %} | ||||
|                     </td> | ||||
|                     <td> | ||||
|                     {% if "LastActivityDate" in user %} | ||||
|                             {{user.LastActivityDate|fromiso|ago_dt_utc(2)}} ago | ||||
|                     {% else %} | ||||
|                         Never | ||||
|                     {% endif %} | ||||
|                     </td> | ||||
|                     <td>{{user.Policy.RemoteClientBitrateLimit|filesizeformat(binary=False)}}/s</td> | ||||
|                 </tr> | ||||
|             {% endfor %} | ||||
|         </table> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| {% endblock %} | ||||
							
								
								
									
										39
									
								
								templates/logs.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								templates/logs.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,39 @@ | |||
| {% extends "base.html" %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
|     <div class="row"> | ||||
|         <h2>QBittorrent</h2> | ||||
|         <div class="monospace"> | ||||
|             {% set t_first = logs.qbt[0].timestamp %} | ||||
|             {% for message in logs.qbt if "WebAPI login success" not in message.message %} | ||||
|                 {%set type={1: 'status' , 2: 'info', 4: 'warning', 8:'danger'}.get(message.type,none) %} | ||||
|                 {%set type_name={1: 'NORMAL' , 2: 'INFO', 4: 'WARNING', 8:'CRITICAL'}.get(message.type,none) %} | ||||
|                 <p class="text-{{type}}"> | ||||
|                     [{{((message.timestamp-t_first)/1000) | timedelta}}|{{type_name}}] {{message.message.strip()}} | ||||
|                 </p> | ||||
|             {% endfor %} | ||||
|         </div> | ||||
|          | ||||
|         <h2>Sonarr</h2> | ||||
|         <div class="monospace"> | ||||
|             {% set t_first = (logs.sonarr.records[0].time)|fromiso %} | ||||
|             {% for message in logs.sonarr.records %} | ||||
|                 {%set type={'warn': 'warning', 'error':'danger'}.get(message.level,message.level) %} | ||||
|                 <p class="text-{{type}}"> | ||||
|                     [{{message.time | fromiso | ago_dt}}|{{message.logger}}|{{message.level|upper}}] {{message.message.strip()}} | ||||
|                 </p> | ||||
|             {% endfor %} | ||||
|         </div> | ||||
| 
 | ||||
|         <h2>Radarr</h2> | ||||
|         <div class="monospace"> | ||||
|             {% set t_first = (logs.radarr.records[0].time)|fromiso %} | ||||
|             {% for message in logs.radarr.records %} | ||||
|                 {%set type={'warn': 'warning', 8:'danger'}.get(message.level,message.level) %} | ||||
|                 <p class="text-{{type}}"> | ||||
|                     [{{message.time | fromiso | ago_dt}}|{{message.logger}}|{{message.level|upper}}] {{message.message.strip()}} | ||||
|                 </p> | ||||
|             {% endfor %} | ||||
|         </div> | ||||
|     </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										236
									
								
								templates/qbittorrent/details.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										236
									
								
								templates/qbittorrent/details.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,236 @@ | |||
| {% extends "base.html" %} | ||||
| {% from "utils.html" import render_tree %} | ||||
| 
 | ||||
| 
 | ||||
| {% block scripts %} | ||||
| {{super()}} | ||||
| <script lang="text/javascript"> | ||||
|     var toggler = document.getElementsByClassName("custom_caret"); | ||||
|     var i; | ||||
|     for (i = 0; i < toggler.length; i++) { | ||||
|         toggler[i].addEventListener("click", function () { | ||||
|             this.parentElement.querySelector(".nested").classList.toggle("active"); | ||||
|             this.classList.toggle("custom_caret-down"); | ||||
|         }); | ||||
|     } | ||||
| </script> | ||||
| {% endblock %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         <h1> | ||||
|             <a href="{{qbt.info.magnet_uri}}" title="{{qbt.info.hash}}">{{qbt.info.name}}</a> | ||||
|         </h1> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         <div class="progress" style="width: 100%;"> | ||||
|             <div class="progress-bar progress-bar-striped progress-bar-animated" | ||||
|                 style="width: {{(qbt.info.progress*100)|round(2)}}%;" role="progressbar" | ||||
|                 aria-valuenow="{{(qbt.info.progress*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100"> | ||||
|                 {{(qbt.info.progress*100)|round(2)}} % | ||||
|             </div> | ||||
|             <div class="progress-bar progress-bar-striped progress-bar-animated bg-primary" | ||||
|                         style="width: {{((([qbt.info.availability,1]|min)-qbt.info.progress)*100)|round(2)}}%;" role="progressbar" | ||||
|                         aria-valuenow="{{((([qbt.info.availability,1]|min)-qbt.info.progress)*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100"> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         <span class="badge badge-{{qbt.info.state[1]}}"> | ||||
|             {{qbt.info.state[0]}} | ||||
|         </span> | ||||
|         {% if qbt.info.category %} | ||||
|             <span class="badge badge-light">{{qbt.info.category}}</span> | ||||
|         {% endif %} | ||||
|         </div> | ||||
| </div> | ||||
| 
 | ||||
| <h2>Info</h2> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         Total Size | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.info.size|filesizeformat(binary=True)}} ({{[0,qbt.info.size-qbt.info.downloaded]|max|filesizeformat(binary=True)}} left) | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         Files | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.files|count}} | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         Downloaded | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.info.downloaded|filesizeformat(binary=True)}} ({{qbt.info.dlspeed|filesizeformat(binary=True)}}/s) | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         Uploaded | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.info.uploaded|filesizeformat(binary=True)}} ({{qbt.info.upspeed|filesizeformat(binary=True)}}/s) | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| <h2>Health</h2> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         Last Active | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.info.last_activity|ago(clamp=True)}} Ago  | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         Age | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.info.added_on|ago}} | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| 
 | ||||
| <div class="row"> | ||||
| 
 | ||||
|     <div class="col"> | ||||
|         Avg. DL rate | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{(qbt.info.downloaded/((qbt.info.added_on|ago).total_seconds()))|filesizeformat(binary=True)}}/s | ||||
|         (A: {{(qbt.info.downloaded/qbt.info.time_active)|filesizeformat(binary=True)}}/s) | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         Avg. UL rate | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{(qbt.info.uploaded/((qbt.info.added_on|ago).total_seconds()))|filesizeformat(binary=True)}}/s | ||||
|         (A: {{(qbt.info.uploaded/qbt.info.time_active)|filesizeformat(binary=True)}}/s) | ||||
|     </div> | ||||
| 
 | ||||
| </div> | ||||
| <div class="row"> | ||||
| 
 | ||||
|     <div class="col"> | ||||
|         ETC (DL rate while active) | ||||
|     </div> | ||||
|      | ||||
|     <div class="col"> | ||||
|         {% set dl_rate_act = (qbt.info.downloaded/qbt.info.time_active) %} | ||||
|         {% if dl_rate_act>0 %} | ||||
|             {{((qbt.info.size-qbt.info.downloaded)/dl_rate_act)|round(0)|timedelta(clamp=true)}} | ||||
|         {% else %} | ||||
|             N/A | ||||
|         {% endif %} | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="col"> | ||||
|         ETC (avg. DL rate) | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {% set dl_rate = (qbt.info.downloaded/((qbt.info.added_on|ago(clamp=True)).total_seconds())) %} | ||||
|         {% if dl_rate>0 %} | ||||
|             {{((qbt.info.size-qbt.info.downloaded)/dl_rate)|round(0)|timedelta(clamp=true)}} | ||||
|         {% else %} | ||||
|             N/A | ||||
|         {% endif %} | ||||
|     </div> | ||||
| 
 | ||||
| </div> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         Total active time | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.info.time_active|timedelta}} | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="col"> | ||||
|         Availability | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {% if qbt.info.availability==-1 %} | ||||
|             N/A | ||||
|         {% else %} | ||||
|             {{(qbt.info.availability*100)|round(2)}} % | ||||
|         {% endif %} | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| <h2>Swarm</h2> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         Seeds | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.info.num_seeds}} | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         Leechers | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.info.num_leechs}} | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         Last seen completed | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.info.seen_complete|ago}} Ago | ||||
|     </div> | ||||
|     <div class="col"></div> | ||||
|     <div class="col"></div> | ||||
| </div> | ||||
| 
 | ||||
| 
 | ||||
| <h2>Files</h2> | ||||
| 
 | ||||
| {{render_tree(qbt.files|sort(attribute='name')|list|make_tree)}} | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         <h2>Trackers</h2> | ||||
|         <a href="{{url_for('qbittorent_add_trackers',infohash=qbt.info.hash)}}"> | ||||
|             <span class="badge badge-primary">Add default trackers</span> | ||||
|         </a> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| {% for tracker in qbt.trackers|sort(attribute='total_peers', reverse=true) %} | ||||
|     <div class="row"> | ||||
|         <div class="col"> | ||||
|             {% if tracker.has_url %}  | ||||
|                 <a href="{{tracker.url}}">{{tracker.name}}</a> | ||||
|             {% else %} | ||||
|                 {{tracker.name}} | ||||
|             {% endif %} | ||||
|             {% if tracker.message %} | ||||
|                 <code>{{tracker.message}}</code> | ||||
|             {% endif %} | ||||
|         </div> | ||||
|         <div class="col"> | ||||
|             <span class="badge badge-{{tracker.status[1]}}">{{tracker.status[0]}}</span> | ||||
|             (S: {{tracker.num_seeds[1]}}, L: {{tracker.num_leeches[1]}}, P: {{tracker.num_peers[1]}}, D: {{tracker.num_downloaded[1]}}) | ||||
|         </div> | ||||
|     </div> | ||||
| {% endfor %} | ||||
| 
 | ||||
| {% endblock %} | ||||
							
								
								
									
										138
									
								
								templates/qbittorrent/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								templates/qbittorrent/index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,138 @@ | |||
| {% extends "base.html" %} | ||||
| 
 | ||||
| {% macro torrent_entry(torrent) %} | ||||
|     {% set state_label,badge_type = status_map[torrent.state] or (torrent.state,'light') %} | ||||
|      | ||||
|     <li class="list-group-item"> | ||||
|         <a href="{{url_for('qbittorrent_details',infohash=torrent.hash)}}">{{torrent.name|truncate(75)}}</a> | ||||
|         (DL: {{torrent.dlspeed|filesizeformat(binary=true)}}/s, UL: {{torrent.upspeed|filesizeformat(binary=true)}}/s) | ||||
|         <span class="badge badge-{{badge_type}}">{{state_label}}</span> | ||||
|         {% if torrent.category %} | ||||
|             <span class="badge badge-light">{{torrent.category}}</span> | ||||
|         {% endif %} | ||||
|         <div style="margin-top: 5px"></div> | ||||
|         <div class="progress" style="width: 100%;"> | ||||
|             <div class="progress-bar progress-bar-striped progress-bar-animated" | ||||
|                 style="width: {{(torrent.progress*100)|round(2)}}%;" role="progressbar" | ||||
|                 aria-valuenow="{{(torrent.progress*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100"> | ||||
|             </div> | ||||
|             <div class="progress-bar progress-bar-striped progress-bar-animated bg-primary" | ||||
|                         style="width: {{((([torrent.availability,1]|min)-torrent.progress)*100)|round(2)}}%;" role="progressbar" | ||||
|                         aria-valuenow="{{((([torrent.availability,1]|min)-torrent.progress)*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100"> | ||||
|             </div> | ||||
|             <small class="justify-content-center d-flex position-absolute w-100">{{(torrent.progress*100)|round(2)}} % (ETA: {{[torrent.eta,torrent.eta_act]|min|round(0)|timedelta(clamp=true)}})</small> | ||||
|         </div> | ||||
|     </li> | ||||
| {% endmacro %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
| 
 | ||||
| <h2> | ||||
|     <a href="{{config.APP_CONFIG.qbt_url}}">QBittorrent</a> | ||||
|     {{qbt.version}} | ||||
|     (DL: {{qbt.server_state.dl_info_speed|filesizeformat(binary=True)}}/s, | ||||
|     UL: {{qbt.server_state.up_info_speed|filesizeformat(binary=True)}}/s) | ||||
| </h2> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         Total Uploaded | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.server_state.alltime_ul|filesizeformat(binary=True)}} | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         Total Downloaded | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.server_state.alltime_dl|filesizeformat(binary=True)}} | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         Session Uploaded | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.server_state.up_info_data|filesizeformat(binary=True)}} | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         Session Downloaded | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.server_state.dl_info_data|filesizeformat(binary=True)}} | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         Torrents | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.torrents|length}} | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         Total Queue Size | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|         {{qbt.torrents.values()|map(attribute='size')|sum|filesizeformat(binary=true)}} | ||||
|     </div> | ||||
| </div> | ||||
| <hr /> | ||||
| 
 | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         <form method="GET"> | ||||
|             <select class="form-control" name="sort" onchange="this.parentElement.submit()"> | ||||
|                 <option value="">Sort by</option> | ||||
|                 {% for key,value in sort_by_choices.items() %} | ||||
|                     <option value="{{key}}">{{value}}</option> | ||||
|                 {% endfor %} | ||||
|             </select> | ||||
|         </form> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| {% for state,torrents in qbt.torrents.values()|sort(attribute='state')|groupby('state') %} | ||||
|     {% set state_label,badge_type = status_map[state] or (state,'light') %} | ||||
|     <div class="row"> | ||||
|         <div class="col"> | ||||
|             <a href={{url_for("qbittorrent",state=state)}} >{{state_label}}</a> | ||||
|         </div> | ||||
|         <div class="col"> | ||||
|             {{torrents|length}} | ||||
|         </div> | ||||
|     </div> | ||||
| {% endfor %} | ||||
| 
 | ||||
| {% if state_filter %} | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         <a href={{url_for("qbittorrent")}}>[Clear filter]</a> | ||||
|     </div> | ||||
|     <div class="col"> | ||||
|     </div> | ||||
| </div> | ||||
| {% endif %} | ||||
| 
 | ||||
| <hr /> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         <ul style="padding-bottom: 10px;" class="list-group"> | ||||
|         {% for torrent in qbt.torrents.values()|sort(attribute=sort_by,reverse=True) %} | ||||
|             {% set state_label,badge_type = status_map[torrent.state] or (torrent.state,'light') %} | ||||
|             {% if state_filter %} | ||||
|                 {% if torrent.state==state_filter %} | ||||
|                     {{torrent_entry(torrent)}} | ||||
|                 {% endif %} | ||||
|             {% else %} | ||||
|                 {{torrent_entry(torrent)}} | ||||
|             {% endif %} | ||||
|         {% endfor %} | ||||
|         </ul> | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										0
									
								
								templates/radarr/details.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								templates/radarr/details.html
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										28
									
								
								templates/radarr/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								templates/radarr/index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| {% extends "base.html" %} | ||||
| {% from 'utils.html' import make_tabs %} | ||||
| 
 | ||||
| {% macro movie_list() %} | ||||
|     {% for movie in movies|sort(attribute='sortTitle') %} | ||||
|     <h6> | ||||
|         <a href="{{urljoin(config.APP_CONFIG.radarr_url,'movie/'+movie.titleSlug)}}">{{movie.title}}</a> | ||||
|         ({{movie.year}}) | ||||
|         {% for genre in movie.genres %} | ||||
|             <span class="badge badge-secondary">{{genre}}</span> | ||||
|         {% endfor %} | ||||
|         <span class="badge badge-info">{{movie.status|title}}</span> | ||||
|     </h6> | ||||
|     {% endfor %} | ||||
| {% endmacro %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
| <h2> | ||||
|     <a href="{{config.APP_CONFIG.radarr_url}}">Radarr</a> | ||||
|     v{{status.version}} ({{movies|count}} Movies) | ||||
| </h2> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         {{movie_list()}} | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										23
									
								
								templates/remote/add.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								templates/remote/add.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | |||
| {% extends "base.html" %} | ||||
| {% from 'utils.html' import custom_render_form_row,make_tabs %} | ||||
| {% from 'bootstrap/form.html' import render_form, render_field, render_form_row %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
| 
 | ||||
| {% if form %} | ||||
| <h1>Grant remote access</h1> | ||||
| {% endif %} | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col-lg"> | ||||
|         <form method="post" class="form"> | ||||
|             {{form.csrf_token()}} | ||||
|             {{custom_render_form_row([form.name])}} | ||||
|             {{custom_render_form_row([form.ssh_key])}} | ||||
|             {{custom_render_form_row([form.add])}} | ||||
|         </form> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| 
 | ||||
| {% endblock %} | ||||
							
								
								
									
										78
									
								
								templates/remote/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								templates/remote/index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,78 @@ | |||
| {% 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> | ||||
| Remote access <a href={{url_for("remote_add")}}>{{render_icon("person-plus-fill")}}</a> | ||||
| </h1> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col-lg"> | ||||
|         <h4>SSH</h4> | ||||
|         <table class="table table-sm"> | ||||
|             <tr> | ||||
|                 <th></th> | ||||
|                 <th>Type</th> | ||||
|                 <th>Key fingerprint</th> | ||||
|                 <th>Name</th> | ||||
|             </tr> | ||||
|             {% for key in ssh %} | ||||
|                 <tr {{ {"class":"text-muted" if key.disabled else none}|xmlattr }}> | ||||
|                     <td> | ||||
|                         {% if key.disabled %} | ||||
|                             <a href="{{url_for("remote",enabled=True,key=key.key)}}">{{render_icon("person-x-fill",color='danger')}}</a> | ||||
|                         {% else %} | ||||
|                             <a href="{{url_for("remote",enabled=False,key=key.key)}}">{{render_icon("person-check-fill",color='success')}}</a> | ||||
|                         {% endif %} | ||||
|                     </td> | ||||
|                     <td>{{key.type}}</td> | ||||
|                     <td title="{{key.key}}">{{key.fingerprint}}</td> | ||||
|                     <td>{{key.name}}</td> | ||||
|                 </tr> | ||||
|             {% endfor %} | ||||
|         </table> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col-lg"> | ||||
|         <h4><a href="{{cfg().jellyfin_url}}web/index.html#!/userprofiles.html">Jellyfin</a></h4> | ||||
|         <table class="table table-sm"> | ||||
|             <tr> | ||||
|                 <th>Name</th> | ||||
|                 <th>Last Login</th> | ||||
|                 <th>Last Active</th> | ||||
|                 <th>Bandwidth Limit</th> | ||||
|             </tr> | ||||
|             {% for user in jf|sort(attribute="LastLoginDate",reverse=True) %} | ||||
|                 <tr> | ||||
|                     <td> | ||||
|                         <a href="{{cfg().jellyfin_url}}web/index.html#!/useredit.html?userId={{user.Id}}"> | ||||
|                             {{user.Name}} | ||||
|                         </a> | ||||
|                     </td> | ||||
|                     <td> | ||||
|                     {% if "LastLoginDate" in user %} | ||||
|                             {{user.LastLoginDate|fromiso|ago_dt_utc(2)}} ago | ||||
|                     {% else %} | ||||
|                         Never | ||||
|                     {% endif %} | ||||
|                     </td> | ||||
|                     <td> | ||||
|                     {% if "LastActivityDate" in user %} | ||||
|                             {{user.LastActivityDate|fromiso|ago_dt_utc(2)}} ago | ||||
|                     {% else %} | ||||
|                         Never | ||||
|                     {% endif %} | ||||
|                     </td> | ||||
|                     <td>{{user.Policy.RemoteClientBitrateLimit|filesizeformat(binary=False)}}/s</td> | ||||
|                 </tr> | ||||
|             {% endfor %} | ||||
|         </table> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| {% endblock %} | ||||
							
								
								
									
										10
									
								
								templates/search/details.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								templates/search/details.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,10 @@ | |||
| {% extends "base.html" %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
| <h1>{{info.title}} ({{info.year}})</h1> | ||||
| <h2>{{info.hasFile}}</h2> | ||||
| <p>{{info.id}}</p> | ||||
| <pre> | ||||
|     {{info|tojson(indent=4)}} | ||||
| </pre> | ||||
| {% endblock %} | ||||
							
								
								
									
										18
									
								
								templates/search/include/movie.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								templates/search/include/movie.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | |||
| {% macro movie_results(results) -%} | ||||
| <div class="d-flex flex-wrap"> | ||||
|     {%for result in results %} | ||||
|         <form action="search/details" method="POST"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|             <input type="hidden" name="type" value="movie"/> | ||||
|             <input type="hidden" name="data" value="{{result|tojson|urlencode}}"/> | ||||
|             <a style="cursor: pointer" onclick="this.parentElement.submit()"> | ||||
|             {% for poster in result.images|selectattr('coverType','eq','poster') %} | ||||
|                 <img class="img-fluid poster" src="{{poster.url}}" title="{{result.title}}"> | ||||
|             {% else %} | ||||
|                 <img class="img-fluid poster" src="{{url_for('placeholder',width=333, height=500, text=result.title, wrap = 15)}}" title="{{result.title}}" /> | ||||
|             {% endfor %} | ||||
|             </a> | ||||
|         </form> | ||||
|     {% endfor %} | ||||
| </div> | ||||
| {% endmacro %} | ||||
							
								
								
									
										123
									
								
								templates/search/include/torrent.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								templates/search/include/torrent.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,123 @@ | |||
| 
 | ||||
| {% macro torrent_result_row(result,with_tracker=false) -%} | ||||
| <tr> | ||||
|     <td colspan="{{4 if with_tracker else 3}}"> | ||||
|         <hr/> | ||||
|     </td> | ||||
| </tr> | ||||
| <tr> | ||||
|     <td colspan="{{4 if with_tracker else 3}}"> | ||||
|         <input type="checkbox" id="torrent_selected" name="torrent[]" value="{{result.Link or result.MagnetUri}}"> | ||||
|         <a href="{{result.Link or result.MagnetUri}}"> | ||||
|             {{result.Title}} | ||||
|         </a> | ||||
|         {% if result.DownloadVolumeFactor==0.0 %} | ||||
|             <span class="badge badge-success">Freeleech</span> | ||||
|         {% endif %} | ||||
|         {% if result.UploadVolumeFactor > 1.0 %} | ||||
|             <span class="badge badge-success">UL x{{result.UploadVolumeFactor}}</span> | ||||
|         {% endif %} | ||||
|     </td> | ||||
| </tr> | ||||
| <tr> | ||||
|     <td> | ||||
|         {{result.CategoryDesc}} | ||||
|     </td> | ||||
|     <td> | ||||
|         {{result.Size|filesizeformat}} | ||||
|     </td> | ||||
|     {% if with_tracker %} | ||||
|     <td> | ||||
|         <a href="{{result.Guid}}"> | ||||
|             {{result.Tracker}} | ||||
|         </a> | ||||
|     </td> | ||||
|     {% endif %} | ||||
|     <td> | ||||
|         ({{result.Seeders}}/{{result.Peers}}/{{ "?" if result.Grabs is none else result.Grabs}}) | ||||
|     </td> | ||||
| </tr> | ||||
| {% endmacro %} | ||||
| 
 | ||||
| {% macro torrent_result_grouped(results) %} | ||||
| {% if results %} | ||||
|     <table class="torrent_results"> | ||||
|         {% for tracker,results in results.Results|groupby(attribute="Tracker") %} | ||||
|             <thead> | ||||
|                 <tr> | ||||
|                     <th> | ||||
|                         <h2>{{tracker}} ({{results|length}})</h2> | ||||
|                     </th> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <th colspan="{{4 if with_tracker else 3}}"> | ||||
|                         Name | ||||
|                     </th> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <th> | ||||
|                         Category | ||||
|                     </th> | ||||
|                     <th> | ||||
|                         Size | ||||
|                     </th> | ||||
|                     <th> | ||||
|                         Seeds/Peers/Grabs | ||||
|                     </th> | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             {%for result in results|sort(attribute='Gain',reverse=true) %} | ||||
|                 {{ torrent_result_row(result,with_tracker=false) }} | ||||
|             {% endfor %} | ||||
|         {% endfor %} | ||||
|         <tr> | ||||
|             <td colspan="{{4 if with_tracker else 3}}"> | ||||
|                 <hr/> | ||||
|             </td> | ||||
|         </tr> | ||||
|     </table> | ||||
| {% endif %} | ||||
| {% endmacro %} | ||||
| 
 | ||||
| 
 | ||||
| {% macro torrent_results(results,group_by_tracker=false) %} | ||||
| <form action="/api/add_torrent" method="POST"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|     <input type="text" class="form-control" id="category" name="category" placeholder="Category"/> | ||||
|     <span class="badge badge-primary" style="cursor: pointer" onclick="this.parentElement.submit()"> | ||||
|         Add selected to QBittorrent | ||||
|     </span> | ||||
|     {% if group_by_tracker %} | ||||
|         {{ torrent_result_grouped(results) }} | ||||
|     {% else %} | ||||
|         {% if results %} | ||||
|         <table class="torrent_results"> | ||||
|             <thead> | ||||
|                 <tr> | ||||
|                     <th colspan="{{4 if with_tracker else 3}}"> | ||||
|                         Name | ||||
|                     </th> | ||||
|                 </tr> | ||||
|                 <tr> | ||||
|                     <th> | ||||
|                         Category | ||||
|                     </th> | ||||
|                     <th> | ||||
|                         Size | ||||
|                     </th> | ||||
|                     <th> | ||||
|                         Tracker | ||||
|                     </th> | ||||
|                     <th> | ||||
|                         Seeds/Peers/Grabs | ||||
|                     </th> | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             {% for result in results.Results|sort(attribute='Gain',reverse=true) %} | ||||
|                 {{ torrent_result_row(result,with_tracker=true) }} | ||||
|             {% endfor %} | ||||
|         </table> | ||||
|         {% endif %} | ||||
|     {% endif %} | ||||
| </form> | ||||
| {% endmacro %} | ||||
							
								
								
									
										23
									
								
								templates/search/include/tv_show.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								templates/search/include/tv_show.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | |||
| 
 | ||||
| {% macro tv_show_results(results) -%} | ||||
|     <div class="d-flex flex-wrap"> | ||||
|     {% for result in results %} | ||||
|         <form action="search/details" method="POST"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> | ||||
|             <input type="hidden" name="type" value="show"/> | ||||
|             <input type="hidden" name="data" value="{{result|tojson|urlencode}}" /> | ||||
|             <a style="cursor: pointer" onclick="this.parentElement.submit()"> | ||||
|             {% for banner in result.images|selectattr('coverType','eq','banner') %} | ||||
|                 <img class="img-fluid banner" src="{{client.sonarr.url.rsplit("/",2)[0]+banner.url}}" title="{{result.title}}" /> | ||||
|             {% else %} | ||||
|                 {% set poster=(result.images|selectattr('coverType','eq','poster')|first) %} | ||||
|                 {% if poster %} | ||||
|                     {% set poster=(client.sonarr.url.rsplit("/",2)[0]+poster.url) %} | ||||
|                 {% endif %} | ||||
|                 <img class="img-fluid banner" src="{{url_for('placeholder',width=758, height=140, poster=poster, text=result.title)}}" title="{{result.title}}" /> | ||||
|             {% endfor %} | ||||
|             </a> | ||||
|         </form> | ||||
|     {% endfor %} | ||||
|     </div> | ||||
| {% endmacro %} | ||||
							
								
								
									
										64
									
								
								templates/search/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								templates/search/index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,64 @@ | |||
| {% extends "base.html" %} | ||||
| {% from "utils.html" import make_tabs, custom_render_form_row %} | ||||
| {% from 'bootstrap/form.html' import render_form, render_field, render_form_row %} | ||||
| {% from 'bootstrap/table.html' import render_table %} | ||||
| {% from 'search/include/tv_show.html' import tv_show_results with context %} | ||||
| {% from 'search/include/movie.html' import movie_results with context %} | ||||
| {% from 'search/include/torrent.html' import torrent_results with context %} | ||||
| {% block styles %} | ||||
| {{super()}} | ||||
| <style> | ||||
|     .poster { | ||||
|         height: 500px; | ||||
|         object-fit: cover; | ||||
|     } | ||||
| </style> | ||||
| {% endblock %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
| 
 | ||||
| {% if form %} | ||||
| <h1>Search</h1> | ||||
| {% endif %} | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col-lg"> | ||||
|         {% if session.new_torrents %} | ||||
|             <div class="alert alert-success alert-dismissible fade show" role="alert"> | ||||
|             {% for torrent in session.pop('new_torrents',{}).values() %} | ||||
|                 <p> | ||||
|                     Added <a class="alert-link" href="{{url_for('qbittorrent',infohash=torrent.hash)}}">{{torrent.name}}</a> | ||||
|                 </p> | ||||
|             {% endfor %} | ||||
|             </div> | ||||
|         {% endif %} | ||||
|         {% if form %} | ||||
|             <form method="post" class="form"> | ||||
|                 {{form.csrf_token()}} | ||||
|                 {{custom_render_form_row([form.query],render_args={'form_type':'horizontal','horizontal_columns':('lg',1,11)})}} | ||||
|                 {{custom_render_form_row([form.tv_shows,form.movies,form.torrents])}} | ||||
|                 {{custom_render_form_row([form.group_by_tracker])}} | ||||
|                 {{custom_render_form_row([form.indexer])}} | ||||
|                 {{custom_render_form_row([form.search])}} | ||||
|             </form> | ||||
|             {% else %} | ||||
|                 <h1>Search results for '{{search_term}}'</h1> | ||||
|             {% endif %} | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| {% set search_results = [ | ||||
|     (results.tv_shows,"tv","TV Shows",tv_show_results,{}), | ||||
|     (results.movies,"movie","Movies",movie_results,{}), | ||||
|     (results.torrents,"torrent","Torrents",torrent_results,{"group_by_tracker":group_by_tracker}), | ||||
| ] %} | ||||
| 
 | ||||
| {% if results %} | ||||
|     {% set tabs = [] %} | ||||
|     {% for results,id_name,label,func,kwargs in search_results if results %} | ||||
|         {% do tabs.append((label,func(results,**kwargs))) %} | ||||
|     {% endfor %} | ||||
|     {{make_tabs(tabs)}} | ||||
| {% endif %} | ||||
| 
 | ||||
| {% endblock %} | ||||
							
								
								
									
										0
									
								
								templates/sonarr/details.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								templates/sonarr/details.html
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										28
									
								
								templates/sonarr/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								templates/sonarr/index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| {% extends "base.html" %} | ||||
| {% from 'utils.html' import make_tabs %} | ||||
| 
 | ||||
| {% macro series_list() %} | ||||
|     {% for show in series|sort(attribute='sortTitle') %} | ||||
|         <h6> | ||||
|             <a href="{{urljoin(config.APP_CONFIG.sonarr_url,'series/'+show.titleSlug)}}">{{show.title}}</a> | ||||
|             ({{show.year}}) | ||||
|             {% for genre in show.genres %} | ||||
|                 <span class="badge badge-secondary">{{genre}}</span> | ||||
|             {% endfor %} | ||||
|             <span class="badge badge-info">{{show.status|title}}</span> | ||||
|         </h6> | ||||
|     {% endfor %} | ||||
| {% endmacro %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
| <h2> | ||||
|     <a href="{{config.APP_CONFIG.sonarr_url}}">Sonarr</a> | ||||
|     v{{status.version}} ({{series|count}} Shows) | ||||
| </h2> | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         {{series_list()}} | ||||
|     </div> | ||||
| </div> | ||||
| {% endblock %} | ||||
							
								
								
									
										43
									
								
								templates/test.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								templates/test.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | |||
| {% extends "base.html" %} | ||||
| {% from 'bootstrap/form.html' import render_form %} | ||||
| 
 | ||||
| {% block scripts %} | ||||
| {{super()}} | ||||
| <script lang="text/javascript"> | ||||
|     let prog=0; | ||||
|     function setPrograb(selector,value) { | ||||
|         let rv=Math.round(prog*100,2)/100; | ||||
|         $(selector).attr('style','width: '+rv+"%;"); | ||||
|         $(selector).attr('aria-valuenow',rv); | ||||
|         $(selector).text(rv+' %'); | ||||
|     } | ||||
|     setInterval(() => { | ||||
|         for (var i=0;i<100;++i) { | ||||
|             prog=Math.random()*100; | ||||
|             setPrograb("#prog_test_bar_"+i,prog); | ||||
|         } | ||||
|     },1000) | ||||
| </script> | ||||
| {% endblock %} | ||||
| 
 | ||||
| 
 | ||||
| {% block app_content__ %} | ||||
|     <div class="row"> | ||||
|         {{render_form(form)}} | ||||
|     </div> | ||||
| {% endblock %} | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| {% block app_content %} | ||||
|     {% for i in range(100) %} | ||||
|         <div class="row"> | ||||
|             <div id="prog_test_{{i}}" class="progress" style="width: 100%;"> | ||||
|                 <div id="prog_test_bar_{{i}}" class="progress-bar progress-bar-striped progress-bar-animated" | ||||
|                     style="width: 0%;" role="progressbar" | ||||
|                     aria-valuenow="" aria-valuemin="0" aria-valuemax="100"> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     {% endfor %} | ||||
| {% endblock %} | ||||
							
								
								
									
										30
									
								
								templates/transcode/profiles.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								templates/transcode/profiles.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,30 @@ | |||
| {% extends "base.html" %} | ||||
| {% from 'utils.html' import make_tabs %} | ||||
| {% from 'bootstrap/form.html' import render_form, render_field, render_form_row %} | ||||
| 
 | ||||
| {% macro profile_list() %} | ||||
|     {% for name, cfg in config.APP_CONFIG.transcode_profiles.items() %} | ||||
|         <h3>{{name}}</h3> | ||||
|         <h5>{{cfg.doc}}</h5> | ||||
|         <pre>ffmpeg -i <infile> {{cfg.command}} <outfile></pre> | ||||
|         {% if cfg.vars %} | ||||
|             {% for var,doc in cfg.vars.items() %} | ||||
|                 <p> | ||||
|                     <pre class="inline">{{var}}</pre> | ||||
|                     ({{doc}}{% if cfg.defaults[var] %}, Default: <pre class="inline">{{cfg.defaults[var]}}</pre>{% endif %})</p> | ||||
|             {% endfor %} | ||||
|         {% endif %} | ||||
|         <hr> | ||||
|     {% endfor %} | ||||
| {% endmacro %} | ||||
| 
 | ||||
| {% block app_content %} | ||||
| 
 | ||||
| <div class="row"> | ||||
|     <div class="col"> | ||||
|         <h1>Transcode profiles</h1> | ||||
|         {{profile_list()}} | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| {% endblock %} | ||||
							
								
								
									
										85
									
								
								templates/utils.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								templates/utils.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,85 @@ | |||
| 
 | ||||
| {% from 'bootstrap/form.html' import render_field %} | ||||
| 
 | ||||
| {% macro custom_render_form_row(fields, row_class='form-row', col_class_default='col', col_map={}, button_map={}, button_style='', button_size='', render_args={}) %} | ||||
|     <div class="{{ row_class }}"> | ||||
|     {% for field in fields %} | ||||
|         {% if field.name in col_map %} | ||||
|             {% set col_class = col_map[field.name] %} | ||||
|         {% else %} | ||||
|             {% set col_class = col_class_default %} | ||||
|         {% endif %} | ||||
|         <div class="{{ col_class }}"> | ||||
|             {{ render_field(field, button_map=button_map, button_style=button_style, button_size=button_size, **render_args) }} | ||||
|         </div> | ||||
|     {% endfor %} | ||||
|     </div> | ||||
| {% endmacro %} | ||||
| 
 | ||||
| {% macro make_tabs(tabs)%} | ||||
|     {% set tabs_id = tabs|tojson|hash %} | ||||
|     <div class="row"> | ||||
|         <div class="col-lg"> | ||||
|             <ul class="nav nav-pills mb-3" id="pills-tab" role="tablist"> | ||||
|             {% for label,tab in tabs if tab %} | ||||
|                 {% set id_name = [loop.index,tabs_id ]|join("-") %} | ||||
|                 {% if not (loop.first and loop.last) %} | ||||
|                     <li class="nav-item"> | ||||
|                         <a class="nav-link {{'active' if loop.first}}" id="nav-{{id_name}}-tab" data-toggle="pill" href="#pills-{{id_name}}" role="tab" aria-controls="pills-{{id_name}}" aria-selected="{{loop.first}}"> | ||||
|                             {{label}} | ||||
|                         </a> | ||||
|                     </li> | ||||
|                 {% endif %} | ||||
|             {% endfor %} | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
|     <div class="row"> | ||||
|         <div class="col-lg"> | ||||
|             <div class="tab-content" id="searchResults"> | ||||
|                 {% for label,tab in tabs if tab %} | ||||
|                     {% set id_name = [loop.index,tabs_id ]|join("-") %} | ||||
|                     <div class="tab-pane fade {{'show active' if loop.first}}" id="pills-{{id_name}}" role="tabpanel" aria-labelledby="nav-{{id_name}}-tab"> | ||||
|                         {{ tab|safe }} | ||||
|                     </div> | ||||
|                 {% endfor %} | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| {% endmacro %} | ||||
| 
 | ||||
| {% macro render_tree(tree) -%} | ||||
| <ul class="file_tree"> | ||||
|     {% for node,children in tree.items() recursive %} | ||||
|         {% if node=="__info__" or not children is mapping -%}  | ||||
|             {% set file = children %} | ||||
|             <li> | ||||
|                 <div class="row" style="margin-left: 10px;"> | ||||
|                     <div class="progress" style="width: 100%;"> | ||||
|                         <div class="progress-bar progress-bar-striped progress-bar-animated" | ||||
|                             style="width: {{(file.progress*100)|round(2)}}%;" role="progressbar" | ||||
|                             aria-valuenow="{{(file.progress*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100"> | ||||
|                             {{(file.progress*100)|round(2)}} % ({{file.size|filesizeformat(binary=True)}}) | ||||
|                         </div> | ||||
|                         <div class="progress-bar progress-bar-striped progress-bar-animated bg-primary" | ||||
|                             style="width: {{(((file.availability or 1) - file.progress)*100)|round(2)}}%;" role="progressbar" | ||||
|                             aria-valuenow="{{(((file.availability or 1) - file.progress)*100)|round(2)}}" aria-valuemin="0" aria-valuemax="100"> | ||||
|                         </div>  | ||||
|                     </div> | ||||
|                 </div>  | ||||
|             </li> | ||||
|         {% else -%} | ||||
|             <li class="tree"> | ||||
|                 <span class="{{'custom_caret' if children.items() else '' }}"> | ||||
|                     {{node}} | ||||
|                 </span> | ||||
|                 {% if children.items() -%} | ||||
|                     <ul class="tree nested"> | ||||
|                         {{loop(children.items())}} | ||||
|                     </ul> | ||||
|                 {% endif %} | ||||
|             </li> | ||||
|         {% endif %} | ||||
|     {% endfor %} | ||||
| </ul> | ||||
| {% endmacro %} | ||||
							
								
								
									
										143
									
								
								transcode.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								transcode.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,143 @@ | |||
| import subprocess as SP | ||||
| import json | ||||
| import shlex | ||||
| import time | ||||
| import os | ||||
| import io | ||||
| import sys | ||||
| import uuid | ||||
| from tqdm import tqdm | ||||
| from utils import handle_config | ||||
| 
 | ||||
| profiles = handle_config().get("transcode_profiles", {}) | ||||
| 
 | ||||
| profiles[None] = { | ||||
|     "command": "-vcodec copy -acodec copy -scodec copy -f null", | ||||
|     "doc": "null output for counting frames", | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| def ffprobe(file): | ||||
|     cmd = [ | ||||
|         "ffprobe", | ||||
|         "-v", | ||||
|         "error", | ||||
|         "-print_format", | ||||
|         "json", | ||||
|         "-show_format", | ||||
|         "-show_streams", | ||||
|         file, | ||||
|     ] | ||||
|     try: | ||||
|         out = SP.check_output(cmd) | ||||
|     except KeyboardInterrupt: | ||||
|         raise | ||||
|     except: | ||||
|         return file, None | ||||
|     return file, json.loads(out) | ||||
| 
 | ||||
| 
 | ||||
| def make_ffmpeg_command_line(infile, outfile, profile=None, **kwargs): | ||||
|     default_opts = ["-v", "error", "-y", "-nostdin"] | ||||
|     ffmpeg = ( | ||||
|         "C:\\Users\\Earthnuker\\scoop\\apps\\ffmpeg-nightly\\current\\bin\\ffmpeg.exe" | ||||
|     ) | ||||
|     cmdline = profile["command"] | ||||
|     opts = profile.get("defaults", {}).copy() | ||||
|     opts.update(kwargs) | ||||
| 
 | ||||
|     if isinstance(cmdline, str): | ||||
|         cmdline = shlex.split(cmdline) | ||||
|     cmdline = list(cmdline or []) | ||||
|     cmdline += ["-progress", "-", "-nostats"] | ||||
|     ret = [ffmpeg, *default_opts, "-i", infile, *cmdline, outfile] | ||||
|     ret = [v.format(**opts) for v in ret] | ||||
|     return ret | ||||
| 
 | ||||
| 
 | ||||
| def count_frames(file, **kwargs): | ||||
|     total_frames = None | ||||
|     for state in run_transcode(file, os.devnull, None): | ||||
|         if state.get("progress") == "end": | ||||
|             total_frames = int(state.get("frame", -1)) | ||||
|     if total_frames is None: | ||||
|         return total_frames | ||||
|     if total_frames <= 0: | ||||
|         total_frames = None | ||||
|     return total_frames | ||||
| 
 | ||||
| 
 | ||||
| def run_transcode(file, outfile, profile, job_id=None, **kwargs): | ||||
|     job_id = job_id or str(uuid.uuid4()) | ||||
|     stderr_fh = None | ||||
|     if outfile != os.devnull: | ||||
|         stderr_fh = open("{}.log".format(job_id), "w") | ||||
|     proc = SP.Popen( | ||||
|         make_ffmpeg_command_line(file, outfile, profiles[profile], **kwargs), | ||||
|         stdout=SP.PIPE, | ||||
|         stderr=stderr_fh, | ||||
|         encoding="utf8", | ||||
|     ) | ||||
|     state = {} | ||||
|     poll = None | ||||
|     while poll is None: | ||||
|         poll = proc.poll() | ||||
|         state["ret"] = poll | ||||
|         if outfile != os.devnull: | ||||
|             with open("{}.log".format(job_id), "r") as tl: | ||||
|                 state["stderr"] = tl.read() | ||||
|         line = proc.stdout.readline().strip() | ||||
|         if not line: | ||||
|             continue | ||||
|         try: | ||||
|             key, val = line.split("=", 1) | ||||
|         except ValueError: | ||||
|             print(line) | ||||
|             continue | ||||
|         key = key.strip() | ||||
|         val = val.strip() | ||||
|         state[key] = val | ||||
|         if key == "progress": | ||||
|             yield state | ||||
|     if stderr_fh: | ||||
|         stderr_fh.close() | ||||
|         os.unlink(stderr_fh.name) | ||||
|     yield state | ||||
| 
 | ||||
| 
 | ||||
| def transcode(file, outfile, profile, job_id=None, **kwargs): | ||||
|     from pprint import pprint | ||||
| 
 | ||||
|     info = ffprobe(file) | ||||
|     frames = count_frames(file) | ||||
|     progbar = tqdm(desc="Processing {}".format(outfile), total=frames, unit=" frames", disable=False,leave=False) | ||||
|     for state in run_transcode(file, outfile, profile, job_id, **kwargs): | ||||
|         if "frame" in state: | ||||
|             progbar.n = int(state["frame"]) | ||||
|             progbar.update(0) | ||||
|         state["total_frames"] = frames | ||||
|         state["file"] = file | ||||
|         state["outfile"] = outfile | ||||
|         # progbar.write(state["stderr"]) | ||||
|         yield state | ||||
|     progbar.close() | ||||
| 
 | ||||
| 
 | ||||
| def preview_command(file, outfile, profile, **kwargs): | ||||
|     return make_ffmpeg_command_line(file, outfile, profiles[profile], **kwargs) | ||||
| 
 | ||||
| 
 | ||||
| if __name__ == "__main__": | ||||
|     file = sys.argv[1] | ||||
|     for profile in ["H.265 transcode", "H.264 transcode"]: | ||||
|         for preset in ["ultrafast", "fast", "medium", "slow", "veryslow"]: | ||||
|             for crf in list(range(10, 54, 4))[::-1]: | ||||
|                 outfile = os.path.join("E:\\","transcode",profile,"{}_{}.mkv".format(crf, preset)) | ||||
|                 os.makedirs(os.path.dirname(outfile), exist_ok=True) | ||||
|                 if os.path.isfile(outfile): | ||||
|                     print("Skipping",outfile) | ||||
|                     continue | ||||
|                 for _ in transcode( | ||||
|                     file, outfile, profile, "transcode", preset=preset, crf=crf | ||||
|                 ): | ||||
|                     pass | ||||
							
								
								
									
										196
									
								
								utils.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								utils.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,196 @@ | |||
| from flask_nav.renderers import Renderer, SimpleRenderer | ||||
| from dominate import tags | ||||
| import asteval | ||||
| import operator as op | ||||
| import textwrap | ||||
| import math | ||||
| import sys | ||||
| import random | ||||
| import string | ||||
| from functools import wraps | ||||
| from urllib.request import urlopen | ||||
| from io import BytesIO | ||||
| import subprocess as SP | ||||
| import shlex | ||||
| import json | ||||
| import os | ||||
| 
 | ||||
| from PIL import Image | ||||
| from PIL import ImageFont | ||||
| from PIL import ImageDraw | ||||
| 
 | ||||
| 
 | ||||
| def handle_config(cfg=None): | ||||
|     if cfg is None: | ||||
|         if os.path.isfile("config.json"): | ||||
|             with open("config.json") as fh: | ||||
|                 return json.load(fh) | ||||
|     with open("config.json", "w") as fh: | ||||
|         cfg = json.dump(cfg, fh, indent=4) | ||||
|     return | ||||
| 
 | ||||
| 
 | ||||
| def with_application_context(app): | ||||
|     def inner(func): | ||||
|         @wraps(func) | ||||
|         def wrapper(*args, **kwargs): | ||||
|             with app.app_context(): | ||||
|                 return func(*args, **kwargs) | ||||
| 
 | ||||
|         return wrapper | ||||
| 
 | ||||
|     return inner | ||||
| 
 | ||||
| 
 | ||||
| def getsize(text, font_size): | ||||
|     font = ImageFont.truetype("arial.ttf", font_size) | ||||
|     return font.getsize_multiline(text) | ||||
| 
 | ||||
| 
 | ||||
| def does_text_fit(text, width, height, font_size): | ||||
|     w, h = getsize(text, font_size) | ||||
|     return w < width and h < height | ||||
| 
 | ||||
| 
 | ||||
| def make_placeholder_image(text, width, height, poster=None, wrap=0): | ||||
|     width = int(width) | ||||
|     height = int(height) | ||||
|     wrap = int(wrap) | ||||
|     font_size = 1 | ||||
|     bounds = (0, 1) | ||||
|     if wrap: | ||||
|         text = textwrap.fill(text, wrap) | ||||
|     while True: | ||||
|         if not does_text_fit(text, width, height, bounds[1]): | ||||
|             break | ||||
|         bounds = (bounds[1], bounds[1] * 2) | ||||
|     prev_bounds = None | ||||
|     while True: | ||||
|         if does_text_fit(text, width, height, bounds[1]): | ||||
|             bounds = (int(round(sum(bounds) / 2, 0)), bounds[1]) | ||||
|         else: | ||||
|             bounds = (bounds[0], int(round(sum(bounds) / 2, 0))) | ||||
|         if prev_bounds == bounds: | ||||
|             break | ||||
|         prev_bounds = bounds | ||||
|     font_size = bounds[0] | ||||
|     io = BytesIO() | ||||
|     im = Image.new("RGBA", (width, height), "#222") | ||||
|     draw = ImageDraw.Draw(im) | ||||
|     font = ImageFont.truetype("arial.ttf", font_size) | ||||
|     w, h = getsize(text, font_size) | ||||
|     if poster: | ||||
|         try: | ||||
|             with urlopen(poster) as fh: | ||||
|                 poster = Image.open(fh) | ||||
|         except Exception as e: | ||||
|             poster = None | ||||
|         else: | ||||
|             poster_size = poster.size | ||||
|             factor = width / poster_size[0] | ||||
|             new_size = ( | ||||
|                 math.ceil(poster_size[0] * factor), | ||||
|                 math.ceil(poster_size[1] * factor), | ||||
|             ) | ||||
|             poster = poster.resize(new_size) | ||||
|             mid = -int((poster.size[1] - height) / 2) | ||||
|             im.paste(poster, (0, mid)) | ||||
|     draw.text(((width - w) / 2, (height - h) / 2), text, fill="#eee", font=font) | ||||
|     im.save(io, "PNG") | ||||
|     io.seek(0) | ||||
|     return io | ||||
| 
 | ||||
| 
 | ||||
| def make_tree(files, child_key="children"): | ||||
|     tree = {} | ||||
|     for file in files: | ||||
|         root = tree | ||||
|         parts = file["name"].split("/") | ||||
|         for item in parts: | ||||
|             if item not in root: | ||||
|                 root[item] = {} | ||||
|             prev_root = root | ||||
|             root = root[item] | ||||
|         prev_root[item] = {"__info__": file} | ||||
|     return tree | ||||
| 
 | ||||
| 
 | ||||
| class BootsrapRenderer(Renderer): | ||||
|     def visit_Navbar(self, node): | ||||
|         sub = [] | ||||
|         for item in node.items: | ||||
|             sub.append(self.visit(item)) | ||||
|         ret = tags.ul(sub, cls="navbar-nav mr-auto") | ||||
|         return ret | ||||
| 
 | ||||
|     def visit_View(self, node): | ||||
|         classes = ["nav-link"] | ||||
|         if node.active: | ||||
|             classes.append("active") | ||||
|         return tags.li( | ||||
|             tags.a(node.text, href=node.get_url(), cls=" ".join(classes)), | ||||
|             cls="nav-item", | ||||
|         ) | ||||
| 
 | ||||
|     def visit_Subgroup(self, node): | ||||
|         url = "#" | ||||
|         classes = [] | ||||
|         child_active = False | ||||
|         if node.title == "": | ||||
|             active = False | ||||
|             for item in node.items: | ||||
|                 if item.active: | ||||
|                     classes.append("active") | ||||
|                     break | ||||
|             node, *children = node.items | ||||
|             for c in children: | ||||
|                 if c.active: | ||||
|                     child_active = True | ||||
|                     break | ||||
|             node.items = children | ||||
|             node.title = node.text | ||||
|             url = node.get_url() | ||||
|         dropdown = tags.ul( | ||||
|             [ | ||||
|                 tags.li( | ||||
|                     tags.a( | ||||
|                         item.text, | ||||
|                         href=item.get_url(), | ||||
|                         cls="nav-link active" if item.active else "nav-link", | ||||
|                         style="", | ||||
|                     ), | ||||
|                     cls="nav-item", | ||||
|                 ) | ||||
|                 for item in node.items | ||||
|             ], | ||||
|             cls="dropdown-menu ", | ||||
|         ) | ||||
|         link = tags.a( | ||||
|             node.title, | ||||
|             href=url, | ||||
|             cls="nav-link active" if node.active else "nav-link", | ||||
|             style="", | ||||
|         ) | ||||
|         toggle = tags.a( | ||||
|             [], | ||||
|             cls="dropdown-toggle nav-link active" | ||||
|             if child_active | ||||
|             else "dropdown-toggle nav-link", | ||||
|             data_toggle="dropdown", | ||||
|             href="#", | ||||
|             style="padding-left: 0px; padding-top: 10px", | ||||
|         ) | ||||
|         # almost the same as visit_Navbar, but written a bit more concise | ||||
|         return [link, tags.li([toggle, dropdown], cls="dropdown nav-item")] | ||||
| 
 | ||||
| 
 | ||||
| def eval_expr(expr, ctx=None): | ||||
|     aeval = asteval.Interpreter(minimal=True, use_numpy=False, symtable=ctx) | ||||
|     return aeval(expr) | ||||
| 
 | ||||
| 
 | ||||
| def sort_by(values, expr): | ||||
|     return sorted(value, key=lambda v: eval_expr(expr, v)) | ||||
| 
 | ||||
| def genpw(num=20): | ||||
|     return "".join(random.choice(string.ascii_lowercase+string.ascii_uppercase+string.digits) for _ in range(num)) | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue