diff --git a/.vscode/launch.json b/.vscode/launch.json index e73fa56c..96b9c850 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,6 +18,7 @@ "type": "node", "cwd": "${workspaceFolder}/ui", "env": { + "NUXT_API_URL": "http://localhost:8081/api/", "NUXT_PUBLIC_WSS": ":8081/", }, "console": "internalConsole" @@ -34,13 +35,13 @@ "YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads", "YTP_TEMP_PATH": "${workspaceFolder}/var/tmp", "PYDEVD_DISABLE_FILE_VALIDATION": "1", - "YTP_URL_HOST": "http://localhost:8081", "YTP_MAX_WORKERS": "2", "YTP_IGNORE_UI": "true", "YTP_PIP_IGNORE_UPDATES": "true", "YTP_LOG_LEVEL": "DEBUG", "YTP_DEBUG": "true", "YTP_YTDL_DEBUG": "false", + "YTP_ACCESS_LOG": "true", } }, { @@ -54,7 +55,6 @@ "YTP_CONFIG_PATH": "${workspaceFolder}/var/config", "YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads", "YTP_TEMP_PATH": "${workspaceFolder}/var/tmp", - "YTP_URL_HOST": "http://localhost:8081", "YTP_LOG_LEVEL": "DEBUG", } }, diff --git a/README.md b/README.md index d7db6187..3a3c1101 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ YTPTube started as a fork of [meTube](https://github.com/alexta69/metube), Since * Multi-downloads support. * Handle live streams. * Schedule Channels or Playlists to be downloaded automatically at a specific time. +* Send notification to targets based on specified events. * Support per link `yt-dlp JSON config or cli options`, `cookies` & `output format`. * Queue multiple URLs separated by comma. * A built in video player that can play any video file regardless of the format. **With support for sidecar external subtitles**. * New `/api/add_batch` endpoint that allow multiple links to be sent. * Completely redesigned the frontend UI. * Switched out of binary file storage in favor of SQLite. -* Webhook sender. It allow you to add webhook endpoints that receive events related to downloads using simple `json` file. * Basic Authentication support. * Support for curl_cffi, see [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation) * Support for both advanced and basic mode for WebUI. @@ -70,7 +70,6 @@ Certain values can be set via environment variables, using the `-e` parameter on * __YTP_DOWNLOAD_PATH__: path to where the downloads will be saved. Defaults to `/downloads` in the docker image, and `./var/downloads` otherwise. * __YTP_TEMP_PATH__: path where intermediary download files will be saved. Defaults to `/tmp` in the docker image, and `./var/tmp` otherwise. * __YTP_TEMP_KEEP__: Whether to keep the Individual video temp directory or remove it. Defaults to `false`. -* __YTP_URL_PREFIX__: base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`. * __YTP_OUTPUT_TEMPLATE__: the template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`. This will be the default for all downloads unless the request include output template. * __YTP_OUTPUT_TEMPLATE_CHAPTER__: the template for the filenames of the downloaded videos, when split into chapters via postprocessors, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s.` * __YTP_KEEP_ARCHIVE__: Whether to keep history of downloaded videos to prevent downloading same file multiple times. Defaults to `true`. @@ -102,8 +101,6 @@ Certain values can be set via environment variables, using the `-e` parameter on It's advisable to run YTPTube behind a reverse proxy, if authentication and/or HTTPS support are required. -When running behind a reverse proxy which remaps the URL (i.e. serves YTPTube under a subdirectory and not under root), don't forget to set the `YTP_URL_PREFIX` environment variable to the correct value. - ### NGINX ```nginx @@ -238,34 +235,6 @@ The `config/ytdlp.json`, is a json file which can be used to alter the default ` } ``` -### webhooks.json File - -The `config/webhooks.json`, is a json file, which can be used to add webhook endpoints that would receive events related to the downloads. - -```json5 -[ - { - // (name: string) - REQUIRED - The webhook name. - "name": "my very smart webhook receiver", - // (on: array) - OPTIONAL - List of accepted events, if left empty it will send all events. - // Allowed events ["added", "completed", "error", "not_live" ] you can choose one or all of them. - "on": [ "added", "completed", "error", "not_live" ], - "request": { - // (url: string) - REQUIRED- The webhook url - "url": "https://mysecert.webhook.com/endpoint", - // (type: string) - OPTIONAL - The request type, it can be json or form. - "type": "json", - // (method: string) - OPTIONAL - The request method, it can be POST or PUT - "method": "POST", - // (headers: dictionary) - OPTIONAL - Extra headers to include. - "headers": { - "Authorization": "Bearer my_secret_token" - } - } - ... -] -``` - ### presets.json File The `config/presets.json`, is a json file, which can be used to add custom presets for selection in WebUI. @@ -315,20 +284,20 @@ What does the basic mode do? it hides the the following features from the WebUI. ### Header -It disables the `Check cookies`, `Console`, `Tasks` and `Add` buttons. +It disables everything except the `theme switcher` and `reload` button. ### Add form -Disables everything except the `URL` and `Add` button. the default preset `YTP_DEFAULT_PRESET` will be used. The folder will be -the root download path `YTP_DOWNLOAD_PATH`. - -The add form will always be visible and un-collapsible. +* The form will always be visible and un-collapsible. +* Everything except the `URL` and `Add` button will be disabled and hidden. +* The preset will be the default preset, which can be specified via `YTP_DEFAULT_PRESET` environment variable. +* The output template will be the default template which can be specified via `YTP_OUTPUT_TEMPLATE` environment variable. +* The download path will be the default download path which can be specified via `YTP_DOWNLOAD_PATH` environment variable. ### Queue & History Disables the `Information` button. - # Social contact If you have short or quick questions, you are free to join my [discord server](https://discord.gg/G3GpVR8xpb) and ask diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index c70badde..5495219c 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -326,7 +326,7 @@ class DownloadQueue: await item.close() LOG.debug(f"Deleting from queue {itemMessage}") self.queue.delete(id) - asyncio.create_task(self.emitter.canceled(id=id, dl=item.info.serialize()), name=f"notifier-c-{id}") + asyncio.create_task(self.emitter.canceled(dl=item.info.serialize()), name=f"notifier-c-{id}") item.info.status = "canceled" item.info.error = "Canceled by user." self.done.put(item) @@ -373,7 +373,7 @@ class DownloadQueue: LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {str(e)}") self.done.delete(id) - asyncio.create_task(self.emitter.cleared(id, dl=item.info.serialize()), name=f"notifier-c-{id}") + asyncio.create_task(self.emitter.cleared(dl=item.info.serialize()), name=f"notifier-c-{id}") msg = f"Deleted completed download '{itemRef}'." if fileDeleted and filename: msg += f" and removed local file '{filename}'." @@ -475,7 +475,7 @@ class DownloadQueue: self.queue.delete(key=id) if entry.is_canceled() is True: - asyncio.create_task(self.emitter.canceled(id, dl=entry.info.serialize()), name=f"notifier-c-{id}") + asyncio.create_task(self.emitter.canceled(dl=entry.info.serialize()), name=f"notifier-c-{id}") entry.info.status = "canceled" entry.info.error = "Canceled by user." diff --git a/app/library/Emitter.py b/app/library/Emitter.py index c130e523..73fbaa9e 100644 --- a/app/library/Emitter.py +++ b/app/library/Emitter.py @@ -29,21 +29,18 @@ class Emitter: async def completed(self, dl: dict, **kwargs): await self.emit("completed", dl, **kwargs) - async def canceled(self, id: str, dl: dict | None = None, **kwargs): - await self.emit("canceled", id, **kwargs) + async def canceled(self, dl: dict, **kwargs): + await self.emit("canceled", dl, **kwargs) - async def cleared(self, id: str, dl: dict | None = None, **kwargs): - await self.emit("cleared", id, **kwargs) + async def cleared(self, dl: dict | None = None, **kwargs): + await self.emit("cleared", dl, **kwargs) async def error(self, message: str, data: dict = {}, **kwargs): - msg = {"status": "error", "message": message, "data": {}} + msg = {"type": "error", "message": message, "data": {}} if data: msg.update({"data": data}) await self.emit("error", msg, **kwargs) - async def warning(self, message: str, **kwargs): - await self.emit("error", message, **kwargs) - async def info(self, message: str, data: dict = {}, **kwargs): msg = {"type": "info", "message": message, "data": {}} if data: diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 1368d839..1c95d483 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -26,7 +26,8 @@ from .M3u8 import M3u8 from .Playlist import Playlist from .Segments import Segments from .Subtitle import Subtitle -from .Utils import StreamingError, arg_converter, calcDownloadPath, getVideoInfo, validate_url +from .Utils import StreamingError, arg_converter, calcDownloadPath, getVideoInfo, validate_url, validateUUID +from .Notifications import Notification LOG = logging.getLogger("http_api") MIME = magic.Magic(mime=True) @@ -109,7 +110,7 @@ class HttpAPI(common): continue file = os.path.join(root, file) - urlPath = f"{self.config.url_prefix}{file.replace(f'{staticDir}/', '')}" + urlPath = f"/{file.replace(f'{staticDir}/', '')}" content = open(file, "rb").read() contentType = self.extToMime.get(os.path.splitext(file)[1], MIME.from_file(file)) @@ -149,10 +150,10 @@ class HttpAPI(common): method = getattr(self, attr_name) if hasattr(method, "_http_method") and hasattr(method, "_http_path"): http_path = method._http_path - if http_path.startswith("/") and self.config.url_prefix.endswith("/"): + if http_path.startswith("/"): http_path = method._http_path[1:] - self.routes.route(method._http_method, self.config.url_prefix + http_path)(method) + self.routes.route(method._http_method, f"/{http_path}")(method) async def on_prepare(request: Request, response: Response): if "Server" in response.headers: @@ -160,14 +161,10 @@ class HttpAPI(common): if "Origin" in request.headers: response.headers["Access-Control-Allow-Origin"] = request.headers["Origin"] - response.headers["Access-Control-Allow-Headers"] = "Content-Type" - response.headers["Access-Control-Allow-Methods"] = "PATCH, PUT, POST, DELETE" + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" + response.headers["Access-Control-Allow-Methods"] = "GET, PATCH, PUT, POST, DELETE" - if self.config.url_prefix != "/": - self.routes.route("GET", "/")(lambda _: web.HTTPFound(self.config.url_prefix)) - self.routes.get(self.config.url_prefix[:-1])(lambda _: web.HTTPFound(self.config.url_prefix)) - - self.routes.static(f"{self.config.url_prefix}api/download/", self.config.download_path) + self.routes.static("/api/download/", self.config.download_path) self.preloadStatic(app) try: @@ -215,6 +212,10 @@ class HttpAPI(common): return middleware_handler + @route("OPTIONS", "/{path:.*}") + async def add_coors(self, _: Request) -> Response: + return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code) + @route("GET", "api/ping") async def ping(self, _) -> Response: await self.queue.test() @@ -564,9 +565,7 @@ class HttpAPI(common): raise web.HTTPBadRequest(text="file is required.") try: - text = await Playlist(url=f"{self.config.url_host}{self.config.url_prefix}").make( - download_path=self.config.download_path, file=file - ) + text = await Playlist(url="/").make(download_path=self.config.download_path, file=file) if isinstance(text, Response): return text except StreamingError as e: @@ -604,7 +603,7 @@ class HttpAPI(common): duration = float(duration) try: - cls = M3u8(f"{self.config.url_host}{self.config.url_prefix}") + cls = M3u8("/") if "subtitle" in mode: text = await cls.make_subtitle(self.config.download_path, file, duration) else: @@ -707,22 +706,6 @@ class HttpAPI(common): status=web.HTTPOk.status_code, ) - @route("OPTIONS", "api/add") - async def add_cors(self, _: Request) -> Response: - return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code) - - @route("OPTIONS", "api/tasks") - async def cors_add_tasks(self, _: Request) -> Response: - return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code) - - @route("OPTIONS", "api/yt-dlp/convert") - async def cors_ytdlp_convert(self, _: Request) -> Response: - return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code) - - @route("OPTIONS", "api/delete") - async def delete_cors(self, _: Request) -> Response: - return web.json_response(data={"status": "ok"}, status=web.HTTPOk.status_code) - @route("GET", "/") async def index(self, _) -> Response: if "/index.html" not in self.staticHolder: @@ -848,3 +831,69 @@ class HttpAPI(common): data={"message": f"Failed to request website. {str(e)}"}, status=web.HTTPInternalServerError.status_code, ) + + @route("GET", "api/notifications") + async def notifications(self, _: Request) -> Response: + data = { + "notifications": Notification.get_instance().getTargets(), + "allowedTypes": Notification.allowed_events, + } + + return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + + @route("POST", "api/notifications/test") + async def test_notifications(self, _: Request) -> Response: + data = {"type": "test", "message": "This is a test notification."} + await self.emitter.emit("test", data) + + return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + + @route("PUT", "api/notifications") + async def add_notifications(self, request: Request) -> Response: + post = await request.json() + if not isinstance(post, list): + return web.json_response( + {"error": "Invalid request body expecting list with dicts."}, + status=web.HTTPBadRequest.status_code, + ) + + targets: list = [] + + for item in post: + if not isinstance(item, dict): + return web.json_response( + {"error": "Invalid request body expecting list with dicts."}, + status=web.HTTPBadRequest.status_code, + ) + + if not item.get("id", None) or validateUUID(item.get("id"), version=4): + item["id"] = str(uuid.uuid4()) + + try: + Notification.validate(item) + except ValueError as e: + return web.json_response( + {"error": f"Invalid notification target settings. {str(e)}", "data": item}, + status=web.HTTPBadRequest.status_code, + ) + + targets.append(item) + + ins = Notification.get_instance() + + try: + if len(targets) < 1: + ins.clearTargets() + + ins.save(targets=targets) + ins.load() + except Exception as e: + LOG.exception(e) + return web.json_response({"error": "Failed to save tasks."}, status=web.HTTPInternalServerError.status_code) + + data = { + "notifications": targets, + "allowedTypes": Notification.allowed_events, + } + + return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) diff --git a/app/library/HttpSocket.py b/app/library/HttpSocket.py index cbbd9e40..82b292a4 100644 --- a/app/library/HttpSocket.py +++ b/app/library/HttpSocket.py @@ -54,7 +54,7 @@ class HttpSocket(common): return wrapper def attach(self, app: web.Application): - self.sio.attach(app, socketio_path=self.config.url_prefix + "socket.io") + self.sio.attach(app, socketio_path=self.config.url_socketio) for attr_name in dir(self): method = getattr(self, attr_name) @@ -159,7 +159,7 @@ class HttpSocket(common): url: str | None = data.get("url") if not url: - await self.emitter.warning("No URL provided.", to=sid) + await self.emitter.error("No URL provided.", to=sid) return preset: str = str(data.get("preset", self.config.default_preset)) @@ -172,7 +172,7 @@ class HttpSocket(common): try: ytdlp_config = json.loads(ytdlp_config) except Exception as e: - await self.emitter.warning(f"Failed to parse json yt-dlp config. {str(e)}", to=sid) + await self.emitter.error(f"Failed to parse json yt-dlp config. {str(e)}", to=sid) return status = await self.add( @@ -189,7 +189,7 @@ class HttpSocket(common): @ws_event # type: ignore async def item_cancel(self, sid: str, id: str): if not id: - await self.emitter.warning("Invalid request.", to=sid) + await self.emitter.error("Invalid request.", to=sid) return status: dict[str, str] = {} @@ -201,12 +201,12 @@ class HttpSocket(common): @ws_event # type: ignore async def item_delete(self, sid: str, data: dict): if not data: - await self.emitter.warning("Invalid request.", to=sid) + await self.emitter.error("Invalid request.", to=sid) return id: str | None = data.get("id") if not id: - await self.emitter.warning("Invalid request.", to=sid) + await self.emitter.error("Invalid request.", to=sid) return status: dict[str, str] = {} @@ -280,13 +280,13 @@ class HttpSocket(common): @ws_event async def ytdlp_convert(self, sid: str, data: dict): if not isinstance(data, dict) or "args" not in data: - await self.emitter.warning("Invalid request or no options were given.", to=sid) + await self.emitter.error("Invalid request or no options were given.", to=sid) return args: str | None = data.get("args") if not args: - await self.emitter.warning("no options were given.", to=sid) + await self.emitter.error("no options were given.", to=sid) return try: @@ -296,4 +296,4 @@ class HttpSocket(common): err = str(e).strip() err = err.split("\n")[-1] if "\n" in err else err LOG.error(f"Failed to convert args. '{err}'.") - await self.emitter.error(f"Failed to convert options. '{e}'.", to=sid) + await self.emitter.error(f"Failed to convert options. '{err}'.", to=sid) diff --git a/app/library/Notifications.py b/app/library/Notifications.py new file mode 100644 index 00000000..d5619f7e --- /dev/null +++ b/app/library/Notifications.py @@ -0,0 +1,310 @@ +import asyncio +from datetime import datetime, timezone +import json +import logging +import os +from typing import List, TypedDict +import uuid + +import httpx + +from .config import Config +from .ItemDTO import ItemDTO +from .Singleton import Singleton +from .Utils import ag, validateUUID +from .version import APP_VERSION +from .encoder import Encoder + +LOG = logging.getLogger("notifications") + + +class targetRequestHeader(TypedDict): + """Request header details for a notification target.""" + + key: str + value: str + + +class targetRequest(TypedDict): + """Request details for a notification target.""" + + type: str + method: str + url: str + headers: list[targetRequestHeader] = [] + + +class Target(TypedDict): + """Notification target details.""" + + id: str + name: str + on: List[str] + request: targetRequest + + +class Notification(metaclass=Singleton): + targets: list[Target] = [] + """Notification targets to send events to.""" + + allowed_events: tuple = ( + "added", + "completed", + "error", + "not_live", + "canceled", + "cleared", + "log_info", + "log_success", + ) + """Events that can be sent to the targets.""" + + _instance = None + + def __init__(self): + Notification._instance = self + + config = Config.get_instance() + + self._debug = config.debug + self.file: str = os.path.join(config.config_path, "notifications.json") + self._client: httpx.AsyncClient = httpx.AsyncClient() + self._encoder: Encoder = Encoder() + + if os.path.exists(self.file) and os.path.getsize(self.file) > 10: + self.load() + + @staticmethod + def get_instance() -> "Notification": + if Notification._instance is None: + Notification._instance = Notification() + + return Notification._instance + + def getTargets(self) -> list[Target]: + """Get the list of notification targets.""" + return self.targets + + def clearTargets(self) -> "Notification": + """Clear the list of notification targets.""" + self.targets.clear() + return self + + def save(self, targets: list[Target] | None = None) -> "Notification": + """ + Save notification targets to the file. + + Args: + targets (list[Target]|None): The list of targets to save. + + Returns: + Notification: The Notification instance. + """ + LOG.info(f"Saving notification targets to '{self.file}'.") + try: + with open(self.file, "w") as f: + f.write(json.dumps(targets or self.targets, indent=4)) + except Exception as e: + LOG.error(f"Error saving notification targets to '{self.file}'. '{e}'") + pass + + return self + + def load(self): + """Load or reload notification targets from the file.""" + if len(self.targets) > 0: + LOG.info("Clearing existing notification targets.") + self.targets.clear() + + modified = False + + targets = [] + + if not os.path.exists(self.file) or os.path.getsize(self.file) < 10: + return + + LOG.info(f"Loading notification targets from '{self.file}'.") + + try: + with open(self.file, "r") as f: + targets = json.load(f) + except Exception as e: + LOG.error(f"Error loading notification targets from '{self.file}'. '{e}'") + pass + + for target in targets: + try: + try: + Notification.validate(target) + except ValueError as e: + LOG.error(f"Invalid notification target '{target}'. '{e}'") + continue + + if "on" not in target: + target["on"] = [] + modified = True + + if "type" not in target["request"]: + target["request"]["type"] = "json" + modified = True + + if "method" not in target["request"]: + target["request"]["method"] = "POST" + modified = True + + if "id" not in target or validateUUID(target["id"], version=4) is False: + target["id"] = str(uuid.uuid4()) + modified = True + + target = Target( + id=target.get("id"), + name=target.get("name"), + on=target.get("on", []), + request=targetRequest( + type=target.get("request", {}).get("type", "json"), + method=target.get("request", {}).get("method", "POST"), + url=target.get("request", {}).get("url"), + headers=[ + targetRequestHeader(key=h.get("key"), value=h.get("value")) + for h in target.get("request", {}).get("headers", []) + ], + ), + ) + + self.targets.append(target) + + LOG.info( + f"Will send '{target['on'] if len(target['on']) > 0 else 'all'}' as {target['request']['type']} notification events to '{target['name']}'." + ) + except Exception as e: + LOG.error(f"Error loading notification target '{target}'. '{e}'") + pass + + if modified: + self.save() + + @staticmethod + def validate(target: Target | dict) -> bool: + """ + Validate a notification target. + + Args: + target (Target|dict): The target to validate. + + Returns: + bool: True if the target is valid, False otherwise. + """ + + if "id" not in target or validateUUID(target["id"], version=4) is False: + raise ValueError("Invalid notification target. No ID found.") + + if "name" not in target: + raise ValueError("Invalid notification target. No name found.") + + if "request" not in target: + raise ValueError("Invalid notification target. No request details found.") + + if "url" not in target["request"]: + raise ValueError("Invalid notification target. No URL found.") + + if "method" in target["request"] and target["request"]["method"].upper() not in ["POST", "PUT"]: + raise ValueError("Invalid notification target. Invalid method found.") + + if "type" in target["request"] and target["request"]["type"].lower() not in ["json", "form"]: + raise ValueError("Invalid notification target. Invalid type found.") + + if "on" in target: + if not isinstance(target["on"], list): + raise ValueError("Invalid notification target. Invalid 'on' event list found.") + + for e in target["on"]: + if e not in Notification.allowed_events: + raise ValueError(f"Invalid notification target. Invalid event '{e}' found.") + + if "headers" in target["request"]: + if not isinstance(target["request"]["headers"], list): + raise ValueError("Invalid notification target. Invalid headers list found.") + + for h in target["request"]["headers"]: + if "key" not in h: + raise ValueError("Invalid notification target. No header key found.") + if "value" not in h: + raise ValueError("Invalid notification target. No header value found.") + + return True + + async def send(self, event: str, item: ItemDTO | dict) -> list[dict]: + if len(self.targets) < 1: + return [] + + if not isinstance(item, ItemDTO) and not isinstance(item, dict): + LOG.debug(f"Received invalid item type '{type(item)}' with event '{event}'.") + return [] + + tasks = [] + + for target in self.targets: + if len(target["on"]) > 0 and event not in target["on"] and "test" != event: + continue + + tasks.append(self._send(event, target, item)) + + return await asyncio.gather(*tasks) + + async def _send(self, event: str, target: Target, item: ItemDTO | dict) -> dict: + req: targetRequest = target["request"] + + try: + itemId = ag(item, ["id", "_id"], "??") + except Exception: + itemId = "??" + + try: + LOG.info(f"Sending Notification event '{event}' id '{itemId}' to '{target.get('name')}'.") + reqBody = { + "method": req["method"].upper(), + "url": req["url"], + "headers": { + "User-Agent": f"YTPTube/{APP_VERSION}", + "Content-Type": "application/json" + if "json" == req["type"].lower() + else "application/x-www-form-urlencoded", + }, + } + + if len(req["headers"]) > 0: + for h in req["headers"]: + reqBody["headers"][h["key"]] = h["value"] + + reqBody["json" if "json" == req["type"].lower() else "data"] = { + "event": event, + "created_at": datetime.now(tz=timezone.utc).isoformat(), + "payload": item.__dict__ if isinstance(item, ItemDTO) else item, + } + + if "form" == req["type"].lower(): + reqBody["data"]["payload"] = self._encoder.encode(reqBody["data"]["payload"]) + + response = await self._client.request(**reqBody) + + respData = {"url": req["url"], "status": response.status_code, "text": response.text} + + msg = f"Notification target '{target['name']}' Responded to event '{event}' id '{itemId}' with status '{response.status_code}'." + if self._debug and respData.get("text"): + msg += f" body '{respData.get('text','??')}'." + + LOG.info(msg) + + return respData + except Exception as e: + LOG.error(f"Error sending Notification event '{event}' id '{itemId}' to '{target['name']}'. '{e}'.") + return {"url": req["url"], "status": 500, "text": str(e)} + + def emit(self, event, data, **kwargs): + if len(self.targets) < 1: + return False + + if event not in self.allowed_events and "test" != event: + return False + + return self.send(event, data) diff --git a/app/library/Singleton.py b/app/library/Singleton.py new file mode 100644 index 00000000..d786bbf4 --- /dev/null +++ b/app/library/Singleton.py @@ -0,0 +1,32 @@ +from typing import Any, Dict +import threading + + +class Singleton(type): + """ + A metaclass that creates a Singleton base class when called. + """ + + _instances: Dict[type, Any] = {} + + def __call__(cls, *args: Any, **kwargs: Any) -> Any: + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] + + +class threadSafe(type): + """ + A metaclass that creates a Singleton base class when called. + """ + + _instances: Dict[type, Any] = {} + _lock = threading.Lock() + + def __call__(cls, *args: Any, **kwargs: Any) -> Any: + with cls._lock: + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] diff --git a/app/library/Utils.py b/app/library/Utils.py index 8fe3e06c..a663cd49 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -535,3 +535,20 @@ def arg_converter(args: str) -> dict: diff["postprocessors"] = [pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]] return json.loads(json.dumps(diff)) + + +def validateUUID(uuid_str: str, version: int = 4) -> bool: + """ + Validate if the UUID is valid. + + Args: + uuid_str (str): UUID to validate. + + Returns: + bool: True if the UUID is valid. + """ + try: + uuid.UUID(uuid_str, version=version) + return True + except ValueError: + return False diff --git a/app/library/Webhooks.py b/app/library/Webhooks.py deleted file mode 100644 index 631cc9e1..00000000 --- a/app/library/Webhooks.py +++ /dev/null @@ -1,114 +0,0 @@ -import asyncio -import logging -import os -from .ItemDTO import ItemDTO -import httpx -from .version import APP_VERSION - -LOG = logging.getLogger("Webhooks") - - -class Webhooks: - targets: list[dict] = [] - allowed_events: tuple = ( - "added", - "completed", - "error", - "not_live", - ) - - def __init__(self, file: str): - if os.path.exists(file): - self.load(file) - - def load(self, file: str): - try: - if os.path.getsize(file) < 3: - raise Exception("file is empty.") - - LOG.info(f"Loading webhooks from '{file}'.") - from .Utils import load_file - - (targets, status, error) = load_file(file, list) - if not status: - raise Exception(f"{error}") - - for target in targets: - LOG.info(f"Will send '{target.get('on',['all'])}' events to '{target.get('name')}'.") - - self.targets = targets - except Exception as e: - LOG.error(f"Error loading webhooks from '{file}'. '{e}'") - pass - - async def send(self, event: str, item: ItemDTO | dict) -> list[dict]: - if len(self.targets) == 0: - return [] - - if not isinstance(item, ItemDTO) and not isinstance(item, dict): - LOG.debug(f"Received invalid item type '{type(item)}' with event '{event}'.") - return [] - - tasks = [] - - for target in self.targets: - allowed_events = target.get("on") if "on" in target else [] - if len(allowed_events) > 0 and event not in allowed_events: - continue - - tasks.append(self.__send(event, target, item)) - - return await asyncio.gather(*tasks) - - async def __send(self, event: str, target: dict, item: ItemDTO | dict) -> dict: - from .config import Config - - req: dict = target.get("request") - - try: - itemId = item.get("id", item.get("_id", "??")) - except Exception: - itemId = "??" - - try: - LOG.info(f"Sending event '{event}' id '{itemId}' to '{target.get('name')}'.") - async with httpx.AsyncClient() as client: - request_type = req.get("type", "json") - - reqBody = { - "method": req.get("method", "POST"), - "url": req.get("url"), - "headers": {"User-Agent": f"YTPTube/{APP_VERSION}"}, - } - - if req.get("headers", None): - reqBody["headers"].update(req.get("headers")) - - match request_type: - case "json": - reqBody["json"] = item.__dict__ if isinstance(item, ItemDTO) else item - reqBody["headers"]["Content-Type"] = "application/json" - case _: - reqBody["data"] = item.__dict__ if isinstance(item, ItemDTO) else item - reqBody["headers"]["Content-Type"] = "application/x-www-form-urlencoded" - - response = await client.request(**reqBody) - - respData = {"url": req.get("url"), "status": response.status_code, "text": response.text} - - msg = f"Webhook target '{target.get('name')}' Responded to event '{event}' id '{itemId}' with status '{response.status_code}'." - if Config.get_instance().debug and respData.get("text"): - msg += f" body '{respData.get('text','??')}'." - - LOG.info(msg) - - return respData - except Exception as e: - LOG.error(f"Error sending event '{event}' id '{itemId}' to '{target.get('name')}'. '{e}'") - return {"url": req.get("url"), "status": 500, "text": str(e)} - - def emit(self, event, data, **kwargs): - if len(self.targets) < 1 or event not in self.allowed_events: - return False - - return self.send(event, data) diff --git a/app/library/cache.py b/app/library/cache.py index a8a2e5d5..b2e33f02 100644 --- a/app/library/cache.py +++ b/app/library/cache.py @@ -1,27 +1,12 @@ import hashlib -import time import threading -from typing import Any, Optional, Dict, Tuple +import time +from typing import Any, Dict, Optional, Tuple + +from .Singleton import threadSafe -class SingletonMeta(type): - """ - A metaclass that creates a Singleton base class when called. - """ - - _instances: Dict[type, Any] = {} - _lock = threading.Lock() # Ensures thread-safe singleton creation - - def __call__(cls, *args: Any, **kwargs: Any) -> Any: - # Thread-safe instance creation - with cls._lock: - if cls not in cls._instances: - instance = super().__call__(*args, **kwargs) - cls._instances[cls] = instance - return cls._instances[cls] - - -class Cache(metaclass=SingletonMeta): +class Cache(metaclass=threadSafe): def __init__(self) -> None: """ Initialize the Cache. diff --git a/app/library/config.py b/app/library/config.py index 9f437531..1b2e7d94 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -29,11 +29,7 @@ class Config: temp_keep: bool = False """Keep temporary files after the download is complete.""" - url_host: str = "" - """The host to bind the server to.""" - url_prefix: str = "" - """The prefix to use for the server URL.""" - url_socketio: str = "{url_prefix}socket.io" + url_socketio: str = "/socket.io" """The URL to use for the socket.io server.""" output_template: str = "%(title)s.%(ext)s" @@ -192,9 +188,7 @@ class Config: "output_template", "ytdlp_version", "version", - "url_host", "started", - "url_prefix", "remove_files", "ui_update_title", "max_workers", @@ -275,9 +269,6 @@ class Config: if k in self._int_vars: setattr(self, k, int(v)) - if not self.url_prefix.endswith("/"): - self.url_prefix += "/" - numeric_level = getattr(logging, self.log_level.upper(), None) if not isinstance(numeric_level, int): raise ValueError(f"Invalid log level '{self.log_level}' specified.") diff --git a/app/main.py b/app/main.py index d67bc192..c91f857f 100644 --- a/app/main.py +++ b/app/main.py @@ -23,7 +23,7 @@ from library.Emitter import Emitter from library.encoder import Encoder from library.HttpAPI import HttpAPI, LOG as http_logger from library.HttpSocket import HttpSocket -from library.Webhooks import Webhooks +from library.Notifications import Notification from library.PackageInstaller import PackageInstaller LOG = logging.getLogger("app") @@ -69,10 +69,8 @@ class Main: self.http = HttpAPI(queue=queue, emitter=self.emitter, encoder=self.encoder, load_tasks=self.load_tasks) self.socket = HttpSocket(queue=queue, emitter=self.emitter, encoder=self.encoder) + self.emitter.add_emitter(Notification().emit) - WebhookFile = os.path.join(self.config.config_path, "webhooks.json") - if os.path.exists(WebhookFile): - self.emitter.add_emitter(Webhooks(WebhookFile).emit) def checkFolders(self) -> None: try: diff --git a/ui/components/FloatingImage.vue b/ui/components/FloatingImage.vue index 674c39e7..ad69b291 100644 --- a/ui/components/FloatingImage.vue +++ b/ui/components/FloatingImage.vue @@ -16,6 +16,8 @@ diff --git a/ui/components/Queue.vue b/ui/components/Queue.vue index a5edf51a..db3498e8 100644 --- a/ui/components/Queue.vue +++ b/ui/components/Queue.vue @@ -54,10 +54,10 @@
+ :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
- +
diff --git a/ui/components/TaskForm.vue b/ui/components/TaskForm.vue index c8863e1d..e2b1a1ee 100644 --- a/ui/components/TaskForm.vue +++ b/ui/components/TaskForm.vue @@ -190,6 +190,7 @@ diff --git a/ui/layouts/default.vue b/ui/layouts/default.vue index 67e4a88a..23344906 100644 --- a/ui/layouts/default.vue +++ b/ui/layouts/default.vue @@ -4,14 +4,13 @@ -