+ + On: {{ join_events(item.on) }} +
++ + {{ item.request.headers.map(h => h.key).join(', ') }} +
+diff --git a/README.md b/README.md
index c315e925..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.
@@ -235,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.
@@ -312,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/HttpAPI.py b/app/library/HttpAPI.py
index 863614cd..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)
@@ -830,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/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/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/NotificationForm.vue b/ui/components/NotificationForm.vue
new file mode 100644
index 00000000..1cf3f6eb
--- /dev/null
+++ b/ui/components/NotificationForm.vue
@@ -0,0 +1,268 @@
+
+