revamped the notification system

This commit is contained in:
ArabCoders 2025-01-25 22:24:13 +03:00
parent 922d194d7a
commit ddc216ccb6
13 changed files with 1024 additions and 187 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)

32
app/library/Singleton.py Normal file
View file

@ -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]

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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:

View file

@ -0,0 +1,268 @@
<template>
<main class="columns mt-2">
<div class="column">
<form id="taskForm" @submit.prevent="checkInfo()">
<div class="box">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<h1 class="title is-6" style="border-bottom: 1px solid #dbdbdb;">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span>
</span>
</h1>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name">
Target name
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress" required>
<span class="icon is-small is-left"><i class="fa-solid fa-user" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The notification target name, this is used to identify the target in the logs and
notifications.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="url">
Target URL
</label>
<div class="control has-icons-left">
<input type="url" class="input" id="url" v-model="form.request.url" :disabled="addInProgress"
required>
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The URL to send the notification to.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="method">
Request method
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress">
<option v-for="item, index in requestMethods" :key="`${index}-${item}`" :value="item">
{{ item }}
</option>
</select>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
The request method to use when sending the notification. This can be any of the standard HTTP
methods.
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="type">
Request Type
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress">
<option v-for="item, index in requestType" :key="`${index}-${item}`" :value="item">
{{ ucFirst(item) }}
</option>
</select>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
The request type to use when sending the notification. This can be <code>JSON</code> or
<code>FORM</code> request.
</span>
</span>
</div>
</div>
<div class="column is-12 is-clearfix">
<div class="field">
<label class="label is-inline" for="on">
Select Events
<template v-if="form.on.length > 0">
- <NuxtLink @click="form.on = []">Clear selection</NuxtLink>
</template>
</label>
<div class="control has-icons-left">
<div class="select is-multiple is-fullwidth">
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple>
<option v-for="item, index in allowedEvents" :key="`${index}-${item}`" :value="item">
{{ item }}
</option>
</select>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-paper-plane" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
Subscribe to the events you want to listen for. When the event is triggered, the notification will
be sent to the target URL. If no events are selected, the notification will be sent for all events.
</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline is-unselectable">
Optional Headers - <button type="button" class="has-text-link"
@click="form.request.headers.push({ key: '', value: '' });">Add Header
</button>
</label>
<div class="columns is-multiline is-mobile">
<template v-for="_, key in form.request.headers" :key="key">
<div class="column is-5">
<div class="field">
<div class="control has-icons-left">
<input type="text" class="input" v-model="form.request.headers[key].key"
:disabled="addInProgress" required>
<span class="icon is-small is-left"><i class="fa-solid fa-key" /></span>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The header key to send with the notification.</span>
</span>
</div>
<div class="column is-6">
<div class="field">
<div class="control has-icons-left">
<input type="text" class="input" v-model="form.request.headers[key].value"
:disabled="addInProgress" required>
<span class="icon is-small is-left"><i class="fa-solid fa-v" /></span>
</div>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The header value to send with the notification.</span>
</span>
</div>
<div class="column is-1">
<div class="control">
<button type="button" class="button is-danger" @click="form.request.headers.splice(key, 1)"
:disabled="addInProgress">
<span class="icon"><i class="fa-solid fa-trash" /></span>
</button>
</div>
</div>
</template>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-exclamation" /></span>
<span class="has-text-danger">
If header key or value is empty, the header will not be sent.
</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field is-grouped is-grouped-right">
<p class="control">
<button class="button is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="taskForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</p>
<p class="control">
<button class="button is-danger" @click="emitter('cancel')" :disabled="addInProgress" type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</p>
</div>
</div>
</div>
</div>
</form>
</div>
</main>
</template>
<script setup>
import { request } from '~/utils/index'
const emitter = defineEmits(['cancel', 'submit']);
const toast = useToast();
const addInProgress = ref(false);
const props = defineProps({
reference: {
type: String,
required: false,
default: null,
},
allowedEvents: {
type: Array,
required: true,
},
item: {
type: Object,
required: true,
}
})
const form = reactive(props.item);
const requestMethods = ['POST', 'PUT'];
const requestType = ['json', 'form'];
const checkInfo = async () => {
const required = ['name', 'request.url', 'request.method', 'request.type'];
for (const key of required) {
if (key.includes('.')) {
const [parent, child] = key.split('.');
if (!form[parent][child]) {
toast.error(`The field ${parent}.${child} is required.`);
return;
}
} else if (!form[key]) {
toast.error(`The field ${key} is required.`);
return;
}
}
try {
new URL(form.request.url);
} catch (e) {
toast.error('Invalid URL');
return;
}
// -- validate headers
for (const header of form.request.headers) {
if (!header.key || !header.value) {
form.request.headers.splice(form.request.headers.indexOf(header), 1);
return;
}
}
addInProgress.value = true;
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) });
}
</script>

View file

@ -270,7 +270,7 @@ const checkInfo = async () => {
}
}
//addInProgress.value = true;
addInProgress.value = true;
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) });
}
</script>

View file

@ -4,14 +4,13 @@
<div class="navbar-brand pl-5">
<NuxtLink class="navbar-item has-tooltip-bottom" to="/"
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
<span class="icon-text">
<span class="icon"><i class="fas fa-home"></i></span>
<span>
<span class="icon"> <i class="fas fa-home" /></span>
<span :class="socket.isConnected ? 'has-text-success' : 'has-text-danger'"><b>YTPTube</b></span>
</span>
</NuxtLink>
</div>
<div class="navbar-end is-flex">
<div class="navbar-end is-flex" style="flex-flow:wrap">
<div class="navbar-item" v-if="socket.isConnected && config.app.has_cookies && !config.app.basic_mode">
<button class="button is-dark" @click="checkCookies" v-tooltip="'Check youtube cookies status.'"
:disabled="isChecking">
@ -48,6 +47,12 @@
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode" v-tooltip.bottom="'Notifications'">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/notifications">
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
</NuxtLink>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="selectedTheme = 'light'" v-if="'dark' === selectedTheme"
v-tooltip="'Switch to light theme'">
@ -59,7 +64,7 @@
</button>
</div>
<div class="navbar-item">
<div class="navbar-item is-hidden-mobile">
<button class="button is-dark" @click="reloadPage">
<span class="icon"><i class="fas fa-refresh"></i></span>
</button>

300
ui/pages/notifications.vue Normal file
View file

@ -0,0 +1,300 @@
<style scoped>
table.is-fixed {
table-layout: fixed;
}
div.table-container {
overflow: hidden;
}
div.is-centered {
justify-content: center;
}
</style>
<template>
<div>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
<span>Notifications</span>
</span>
</span>
<div class="is-pulled-right" v-if="!toggleForm">
<div class="field is-grouped">
<p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = true"
v-tooltip="'Add new notification target.'">
<span class="icon"><i class="fas fa-add"></i></span>
</button>
</p>
<p class="control" v-if="notifications.length > 0">
<button class="button is-warning" @click="sendTest" v-tooltip="'Send test notification.'"
:class="{ 'is-loading': isLoading }" :disabled="!socket.isConnected || isLoading">
<span class="icon"><i class="fas fa-paper-plane"></i></span>
</button>
</p>
<p class="control" v-if="notifications.length > 0">
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading || notifications.length < 1">
<span class="icon"><i class="fas fa-refresh"></i></span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">
Send notifications to your servers based on specified events.
</span>
</div>
</div>
<div class="column is-12" v-if="toggleForm">
<NotificationForm :reference="targetRef" :item="target" @cancel="resetForm(true);" @submit="updateItem"
:allowedEvents="allowedEvents" />
</div>
<div class="column is-12" v-if="!toggleForm">
<div class="columns is-multiline" v-if="notifications && notifications.length > 0">
<div class="column is-6" v-for="item in notifications" :key="item.id">
<div class="card">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block">
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
</div>
<div class="card-header-icon">
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'"
@click.prevent="copyText(item.request.url)">
<span class="icon"><i class="fa-solid fa-copy" /></span>
</a>
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
</button>
</div>
</header>
<div class="card-content">
<div class="content">
<p>
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
<span>On: {{ join_events(item.on) }}</span>
</p>
<p v-if="item.request?.headers && item.request.headers.length > 0">
<span class="icon"><i class="fa-solid fa-heading" /></span>
<span>{{ item.request.headers.map(h => h.key).join(', ') }}</span>
</p>
</div>
</div>
<div class="card-content" v-if="item?.raw">
<div class="content">
<pre><code>{{ filterItem(item) }}</code></pre>
</div>
</div>
<div class="card-footer">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);">
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span>Edit</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</button>
</div>
</div>
</div>
</div>
</div>
<Message title="No Endpoints" class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
v-if="!notifications || notifications.length < 1">
There are no notifications endpoints configured to receive web notifications.
</Message>
</div>
</div>
</div>
</template>
<script setup>
import { request } from '~/utils/index'
const toast = useToast()
const config = useConfigStore()
const socket = useSocketStore()
const allowedEvents = ref([])
const notifications = ref([])
const target = ref({})
const targetRef = ref('')
const toggleForm = ref(false)
const isLoading = ref(false)
const initialLoad = ref(true)
watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) {
return
}
await navigateTo('/')
})
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(true)
initialLoad.value = false
}
})
const reloadContent = async (fromMounted = false) => {
try {
isLoading.value = true
const response = await request('/api/notifications')
if (fromMounted && !response.ok) {
return
}
notifications.value = []
const data = await response.json()
if (data.length < 1) {
return
}
allowedEvents.value = data.allowedTypes
notifications.value = data.notifications
} catch (e) {
if (fromMounted) {
return
}
console.error(e)
toast.error('Failed to fetch notifications.')
} finally {
isLoading.value = false
}
}
const resetForm = (closeForm = false) => {
target.value = {
name: '',
on: [],
request: {
method: 'POST',
url: '',
type: 'json',
headers: [],
},
}
targetRef.value = null
if (closeForm) {
toggleForm.value = false
}
}
const updateData = async notifications => {
const response = await request('/api/notifications', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(notifications),
})
const data = await response.json()
if (200 !== response.status) {
toast.error(`Failed to update notifications. ${data.error}`);
return false
}
notifications.value = data.notifications
return true
}
const deleteItem = async item => {
if (true !== confirm(`Are you sure you want to delete notification target (${item.name})?`)) {
return
}
const index = notifications.value.findIndex(i => i?.id === item.id)
if (index > -1) {
notifications.value.splice(index, 1)
} else {
toast.error('Notification target not found.')
await reloadContent()
return
}
const status = await updateData(notifications.value)
if (!status) {
return
}
toast.success('Notification target deleted.')
}
const updateItem = async ({ reference, item }) => {
if (reference) {
const index = notifications.value.findIndex(i => i?.id === reference)
if (index > -1) {
notifications.value[index] = item
}
} else {
notifications.value.push(item)
}
const status = await updateData(notifications.value)
if (!status) {
return
}
toast.success(`Notification target ${reference ? 'updated' : 'added'}.`)
resetForm(true)
}
const filterItem = item => {
const { raw, ...rest } = item
return JSON.stringify(rest, null, 2)
}
const editItem = item => {
target.value = item
targetRef.value = item.id
toggleForm.value = true
}
const join_events = events => (!events || events.length < 1) ? 'ALL' : events.map(e => ucFirst(e)).join(', ')
const sendTest = async () => {
if (true !== confirm('Are you sure you want to send a test notification?')) {
return
}
try {
isLoading.value = true
const response = await request('/api/notifications/test', { method: 'POST' })
if (200 !== response.status) {
const data = await response.json()
toast.error(`Failed to send test notification. ${data.error}`);
return
}
toast.success('Test notification sent.')
} catch (e) {
console.error(e)
toast.error(`Failed to send test notification. ${e.message}`);
} finally {
isLoading.value = false
}
}
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
</script>

View file

@ -25,7 +25,7 @@ div.is-centered {
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button class="button is-primary" @click="resetTask(false); toggleForm = !toggleForm">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm">
<span class="icon"><i class="fas fa-add"></i></span>
</button>
</p>
@ -46,7 +46,7 @@ div.is-centered {
</div>
<div class="column is-12" v-if="toggleForm">
<TaskForm :reference="taskRef" :task="task" @cancel="resetTask(true);" @submit="updateItem" />
<TaskForm :reference="taskRef" :task="task" @cancel="resetForm(true);" @submit="updateItem" />
</div>
<div class="column is-12" v-if="!toggleForm">
@ -175,7 +175,7 @@ const reloadContent = async (fromMounted = false) => {
}
}
const resetTask = (closeForm = false) => {
const resetForm = (closeForm = false) => {
task.value = {}
taskRef.value = null
if (closeForm) {
@ -243,13 +243,11 @@ const updateItem = async ({ reference, task }) => {
}
toast.success('Task updated')
resetTask(true)
toggleForm.value = false
resetForm(true)
}
const filterItem = item => {
// -- remove the raw key from the item. before showing it to user
const { raw, ...rest } = item
return JSON.stringify(rest, null, 2)
}
@ -269,5 +267,4 @@ const calcPath = path => {
}
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
</script>