Merge pull request #184 from arabcoders/dev
Revamped the notification system
This commit is contained in:
commit
f790257b4f
27 changed files with 1235 additions and 401 deletions
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
|
|
@ -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",
|
||||
}
|
||||
},
|
||||
|
|
|
|||
45
README.md
45
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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
310
app/library/Notifications.py
Normal file
310
app/library/Notifications.py
Normal 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
32
app/library/Singleton.py
Normal 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]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const props = defineProps({
|
||||
image: {
|
||||
type: String,
|
||||
|
|
@ -38,7 +40,6 @@ const props = defineProps({
|
|||
|
||||
const cache = useSessionCache()
|
||||
const toast = useToast()
|
||||
const config = useConfigStore()
|
||||
const url = ref()
|
||||
const error = ref(false)
|
||||
const isPreloading = ref(false)
|
||||
|
|
@ -53,8 +54,8 @@ const defaultLoader = async () => {
|
|||
return
|
||||
}
|
||||
|
||||
const response = await fetch(config.app.url_host + config.app.url_prefix + 'api/thumbnail?url=' + encodePath(props.image), {
|
||||
signal: cancelRequest.signal
|
||||
const response = await request('/api/thumbnail?url=' + encodePath(props.image), {
|
||||
signal: cancelRequest.signal,
|
||||
})
|
||||
|
||||
if (200 !== response.status) {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
const config = useConfigStore()
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const emitter = defineEmits(['closeModel'])
|
||||
const isLoading = ref(false)
|
||||
const data = ref({})
|
||||
|
|
@ -46,10 +47,10 @@ const eventFunc = e => {
|
|||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', eventFunc)
|
||||
const url = config.app.url_host + config.app.url_prefix + 'api/url/info?url=' + encodePath(props.link)
|
||||
const url = '/api/url/info?url=' + encodePath(props.link)
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await fetch(url);
|
||||
const response = await request(url, { credentials: 'include' });
|
||||
data.value = await response.json();
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
|
|
|||
|
|
@ -99,14 +99,11 @@
|
|||
<figure class="image is-3by1">
|
||||
<span v-if="'finished' === item.status" @click="playVideo(item)" class="play-overlay">
|
||||
<div class="play-icon"></div>
|
||||
<img
|
||||
:src="config.app.url_host + config.app.url_prefix + 'api/thumbnail?url=' + encodePath(item.extras.thumbnail)"
|
||||
v-if="item.extras?.thumbnail" />
|
||||
<img :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" v-if="item.extras?.thumbnail" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</span>
|
||||
<template v-else>
|
||||
<img v-if="item.extras?.thumbnail"
|
||||
:src="config.app.url_host + config.app.url_prefix + 'api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
|
||||
<img v-if="item.extras?.thumbnail" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</template>
|
||||
</figure>
|
||||
|
|
@ -276,7 +273,7 @@ const playVideo = item => {
|
|||
video_link.value = makeDownload(config, item, 'm3u8')
|
||||
video_title.value = item.title
|
||||
if (item.extras?.thumbnail) {
|
||||
video_thumbnail.value = config.app.url_host + config.app.url_prefix + 'api/thumbnail?url=' + encodePath(item.extras.thumbnail)
|
||||
video_thumbnail.value = '/api/thumbnail?url=' + encodePath(item.extras.thumbnail)
|
||||
}
|
||||
if (item.extras?.channel) {
|
||||
video_artist.value = item.extras.channel
|
||||
|
|
|
|||
|
|
@ -1,133 +1,135 @@
|
|||
<template>
|
||||
<main class="columns mt-2">
|
||||
<div class="column">
|
||||
<div class="box">
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<div class="column is-12">
|
||||
<div class="control has-icons-left">
|
||||
<input type="url" class="input" id="url" placeholder="Video or playlist link"
|
||||
:disabled="!socket.isConnected || addInProgress" v-model="url">
|
||||
<span class="icon is-small is-left">
|
||||
<i class="fa-solid fa-link" />
|
||||
</span>
|
||||
<form @submit.prevent="addDownload">
|
||||
<div class="box">
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<div class="column is-12">
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input" id="url" placeholder="Video or playlist link"
|
||||
:disabled="!socket.isConnected || addInProgress" v-model="url">
|
||||
<span class="icon is-small is-left">
|
||||
<i class="fa-solid fa-link" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<a href="#" class="button is-static">Preset</a>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="preset" class="is-fullwidth" :disabled="!socket.isConnected" v-model="selectedPreset">
|
||||
<option v-for="item in config.presets" :key="item.name" :value="item.name">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-12-mobile" v-if="!config.app.basic_mode">
|
||||
<div class="field has-addons" v-tooltip="'Folder relative to ' + config.app.download_path">
|
||||
<div class="control">
|
||||
<a href="#" class="button is-static">Save in</a>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input is-fullwidth" id="path" v-model="downloadPath" placeholder="Default"
|
||||
:disabled="!socket.isConnected" list="folders">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button type="submit" class="button is-primary"
|
||||
:class="{ 'is-loading': !socket.isConnected || addInProgress }"
|
||||
:disabled="!socket.isConnected || addInProgress || !url">
|
||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column" v-if="!config.app.basic_mode">
|
||||
<button type="button" class="button is-info" @click="showAdvanced = !showAdvanced"
|
||||
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
|
||||
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
||||
<span>Opts</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<a href="#" class="button is-static">Preset</a>
|
||||
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="output_format"
|
||||
v-tooltip="'Default Format: ' + config.app.output_template">
|
||||
Output Template
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input" v-model="output_template" id="output_format"
|
||||
placeholder="Uses default output template naming if empty.">
|
||||
</div>
|
||||
<span class="help">
|
||||
All output template naming options can be found at <NuxtLink target="_blank"
|
||||
to="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.
|
||||
</span>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="preset" class="is-fullwidth" :disabled="!socket.isConnected" v-model="selectedPreset">
|
||||
<option v-for="item in config.presets" :key="item.name" :value="item.name">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="ytdlpConfig"
|
||||
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
|
||||
JSON yt-dlp config or CLI options.
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" id="ytdlpConfig" v-model="ytdlpConfig" :disabled="!socket.isConnected"
|
||||
placeholder="--no-embed-metadata --no-embed-thumbnail"></textarea>
|
||||
</div>
|
||||
<span class="help">
|
||||
Some config fields are ignored like cookiefile, path, and output_format etc.
|
||||
Available option can be found at <NuxtLink target="_blank"
|
||||
to="https://github.com/yt-dlp/yt-dlp/blob/a0b19d319a6ce8b7059318fa17a34b144fde1785/yt_dlp/YoutubeDL.py#L194">
|
||||
this page</NuxtLink>. Warning: Use with caution some of those options can break yt-dlp or the
|
||||
frontend.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="ytdlpCookies" v-tooltip="'JSON exported cookies for downloading.'">
|
||||
yt-dlp Cookies
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" id="ytdlpCookies" v-model="ytdlpCookies"
|
||||
:disabled="!socket.isConnected"></textarea>
|
||||
</div>
|
||||
<span class="help">
|
||||
Use <NuxtLink target="_blank" to="https://github.com/jrie/flagCookies">
|
||||
flagCookies</NuxtLink> to extract cookies as JSON string.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-4-mobile has-text-left">
|
||||
<button type="button" class="button is-info" @click="emitter('getInfo', url)"
|
||||
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected || addInProgress || !url">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Information</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-6-mobile has-text-right">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button type="button" class="button is-danger" @click="resetConfig" :disabled="!socket.isConnected"
|
||||
v-tooltip="'This configuration are stored locally in your browser.'">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-trash" />
|
||||
</span>
|
||||
<span>Reset Local Configuration</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-12-mobile" v-if="!config.app.basic_mode">
|
||||
<div class="field has-addons" v-tooltip="'Folder relative to ' + config.app.download_path">
|
||||
<div class="control">
|
||||
<a href="#" class="button is-static">Save in</a>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input is-fullwidth" id="path" v-model="downloadPath" placeholder="Default"
|
||||
:disabled="!socket.isConnected" list="folders">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<button type="submit" class="button is-primary" @click="addDownload"
|
||||
:class="{ 'is-loading': !socket.isConnected || addInProgress }"
|
||||
:disabled="!socket.isConnected || addInProgress || !url">
|
||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column" v-if="!config.app.basic_mode">
|
||||
<button type="submit" class="button is-info" @click="showAdvanced = !showAdvanced"
|
||||
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
|
||||
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
||||
<span>Opts</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="output_format"
|
||||
v-tooltip="'Default Format: ' + config.app.output_template">
|
||||
Output Template
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input" v-model="output_template" id="output_format"
|
||||
placeholder="Uses default output template naming if empty.">
|
||||
</div>
|
||||
<span class="help">
|
||||
All output template naming options can be found at <NuxtLink target="_blank"
|
||||
to="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="ytdlpConfig"
|
||||
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
|
||||
JSON yt-dlp config or CLI options.
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" id="ytdlpConfig" v-model="ytdlpConfig" :disabled="!socket.isConnected"
|
||||
placeholder="--no-embed-metadata --no-embed-thumbnail"></textarea>
|
||||
</div>
|
||||
<span class="help">
|
||||
Some config fields are ignored like cookiefile, path, and output_format etc.
|
||||
Available option can be found at <NuxtLink target="_blank"
|
||||
to="https://github.com/yt-dlp/yt-dlp/blob/a0b19d319a6ce8b7059318fa17a34b144fde1785/yt_dlp/YoutubeDL.py#L194">
|
||||
this page</NuxtLink>. Warning: Use with caution some of those options can break yt-dlp or the
|
||||
frontend.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="ytdlpCookies" v-tooltip="'JSON exported cookies for downloading.'">
|
||||
yt-dlp Cookies
|
||||
</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" id="ytdlpCookies" v-model="ytdlpCookies"
|
||||
:disabled="!socket.isConnected"></textarea>
|
||||
</div>
|
||||
<span class="help">
|
||||
Use <NuxtLink target="_blank" to="https://github.com/jrie/flagCookies">
|
||||
flagCookies</NuxtLink> to extract cookies as JSON string.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-4-mobile has-text-left">
|
||||
<button type="submit" class="button is-info" @click="emitter('getInfo', url)"
|
||||
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected || addInProgress || !url">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>Information</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="column is-6-tablet is-6-mobile has-text-right">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-danger" @click="resetConfig" :disabled="!socket.isConnected"
|
||||
v-tooltip="'This configuration are stored locally in your browser.'">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-trash" />
|
||||
</span>
|
||||
<span>Reset Local Configuration</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<datalist id="folders" v-if="config?.folders">
|
||||
<option v-for="dir in config.folders" :key="dir" :value="dir" />
|
||||
|
|
@ -137,6 +139,7 @@
|
|||
|
||||
<script setup>
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const emitter = defineEmits(['getInfo'])
|
||||
const config = useConfigStore()
|
||||
|
|
@ -155,7 +158,7 @@ const addInProgress = ref(false)
|
|||
const addDownload = async () => {
|
||||
// -- send request to convert cli options to JSON
|
||||
if (ytdlpConfig.value && ytdlpConfig.value.length > 2 && !ytdlpConfig.value.trim().startsWith('{')) {
|
||||
const response = await fetch(config.app.url_host + config.app.url_prefix + 'api/yt-dlp/convert', {
|
||||
const response = await request('/api/yt-dlp/convert', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
268
ui/components/NotificationForm.vue
Normal file
268
ui/components/NotificationForm.vue
Normal 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>
|
||||
|
|
@ -54,10 +54,10 @@
|
|||
<div v-if="false === hideThumbnail" class="card-image">
|
||||
<figure class="image is-3by1" v-if="item.extras?.thumbnail">
|
||||
<img :alt="item.title"
|
||||
:src="config.app.url_host + config.app.url_prefix + 'api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
|
||||
:src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" />
|
||||
</figure>
|
||||
<figure class="image is-3by1" v-else>
|
||||
<img :src="config.app.url_host + config.app.url_prefix + 'images/placeholder.png'" />
|
||||
<img :src="'/images/placeholder.png'" />
|
||||
</figure>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@
|
|||
|
||||
<script setup>
|
||||
import { parseExpression } from 'cron-parser'
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const emitter = defineEmits(['cancel', 'submit']);
|
||||
const toast = useToast();
|
||||
|
|
@ -242,7 +243,7 @@ const checkInfo = async () => {
|
|||
|
||||
// -- send request to convert cli options to JSON
|
||||
if (form.ytdlp_config && form.ytdlp_config.length > 2 && !form.ytdlp_config.trim().startsWith('{')) {
|
||||
const response = await fetch(config.app.url_host + config.app.url_prefix + 'api/yt-dlp/convert', {
|
||||
const response = await request('/api/yt-dlp/convert', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -269,7 +270,7 @@ const checkInfo = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
//addInProgress.value = true;
|
||||
addInProgress.value = true;
|
||||
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) });
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -98,6 +103,7 @@ import 'assets/css/style.css'
|
|||
import 'assets/css/all.css'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import moment from "moment";
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const Year = new Date().getFullYear()
|
||||
const selectedTheme = useStorage('theme', (() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')())
|
||||
|
|
@ -159,7 +165,7 @@ const checkCookies = async () => {
|
|||
|
||||
try {
|
||||
isChecking.value = true
|
||||
const response = await fetch(config.app.url_host + config.app.url_prefix + 'api/youtube/auth')
|
||||
const response = await request('/api/youtube/auth')
|
||||
const data = await response.json()
|
||||
if (response.ok) {
|
||||
toast.success('Succuss. ' + data.message)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
import path from "path";
|
||||
|
||||
let extraNitro = {}
|
||||
try {
|
||||
const API_URL = import.meta.env.NUXT_API_URL;
|
||||
if (API_URL) {
|
||||
extraNitro = {
|
||||
devProxy: {
|
||||
'/api/': {
|
||||
target: API_URL,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
}
|
||||
|
||||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
devtools: { enabled: false },
|
||||
|
|
@ -48,7 +65,8 @@ export default defineNuxtConfig({
|
|||
nitro: {
|
||||
output: {
|
||||
publicDir: path.join(__dirname, 'exported')
|
||||
}
|
||||
},
|
||||
...extraNitro,
|
||||
},
|
||||
|
||||
telemetry: false,
|
||||
|
|
|
|||
300
ui/pages/notifications.vue
Normal file
300
ui/pages/notifications.vue
Normal 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>
|
||||
|
|
@ -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">
|
||||
|
|
@ -122,6 +122,7 @@ div.is-centered {
|
|||
<script setup>
|
||||
import moment from 'moment'
|
||||
import { parseExpression } from 'cron-parser'
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const toast = useToast()
|
||||
const config = useConfigStore()
|
||||
|
|
@ -151,7 +152,8 @@ watch(() => socket.isConnected, async () => {
|
|||
const reloadContent = async (fromMounted = false) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await fetch(config.app.url_host + config.app.url_prefix + 'api/tasks')
|
||||
const response = await request('/api/tasks')
|
||||
|
||||
if (fromMounted && !response.ok) {
|
||||
return
|
||||
}
|
||||
|
|
@ -173,7 +175,7 @@ const reloadContent = async (fromMounted = false) => {
|
|||
}
|
||||
}
|
||||
|
||||
const resetTask = (closeForm = false) => {
|
||||
const resetForm = (closeForm = false) => {
|
||||
task.value = {}
|
||||
taskRef.value = null
|
||||
if (closeForm) {
|
||||
|
|
@ -182,7 +184,7 @@ const resetTask = (closeForm = false) => {
|
|||
}
|
||||
|
||||
const updateTasks = async tasks => {
|
||||
const response = await fetch(config.app.url_host + config.app.url_prefix + 'api/tasks', {
|
||||
const response = await request('/api/tasks', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -241,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)
|
||||
}
|
||||
|
|
@ -267,5 +267,4 @@ const calcPath = path => {
|
|||
}
|
||||
|
||||
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ const CONFIG_KEYS = {
|
|||
ytdlp_version: '',
|
||||
max_workers: 1,
|
||||
version: '',
|
||||
url_host: '',
|
||||
url_prefix: '',
|
||||
has_cookies: false,
|
||||
basic_mode: true,
|
||||
default_preset: 'default',
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
const isConnected = ref(false);
|
||||
|
||||
const connect = () => {
|
||||
socket.value = io(runtimeConfig.public.wss)
|
||||
socket.value = io(runtimeConfig.public.wss, { withCredentials: true })
|
||||
|
||||
socket.value.on('connect', () => isConnected.value = true);
|
||||
socket.value.on('disconnect', () => isConnected.value = false);
|
||||
|
|
@ -39,7 +39,7 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
|
||||
socket.value.on('error', stream => {
|
||||
const json = JSON.parse(stream);
|
||||
toast.error(`${json.data?.id ?? json?.status}: ${json?.message}`);
|
||||
toast.error(`${json.data?.id ?? json?.type}: ${json?.message}`);
|
||||
});
|
||||
|
||||
socket.value.on('log_info', stream => {
|
||||
|
|
@ -63,13 +63,14 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
});
|
||||
|
||||
socket.value.on('canceled', stream => {
|
||||
const id = JSON.parse(stream);
|
||||
const item = JSON.parse(stream);
|
||||
const id = item._id
|
||||
|
||||
if (true !== stateStore.has('queue', id)) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.info(`Download canceled: ${ag(stateStore.get('queue', id, {}), 'title')}`);
|
||||
toast.info(`Download canceled: ${ag(stateStore.get('queue', id, {}), id)}`);
|
||||
|
||||
if (true === stateStore.has('queue', id)) {
|
||||
stateStore.remove('queue', id);
|
||||
|
|
@ -77,7 +78,8 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
});
|
||||
|
||||
socket.value.on('cleared', stream => {
|
||||
const id = JSON.parse(stream);
|
||||
const item = JSON.parse(stream);
|
||||
const id = item._id
|
||||
|
||||
if (true !== stateStore.has('history', id)) {
|
||||
return
|
||||
|
|
@ -132,5 +134,7 @@ export const useSocketStore = defineStore('socket', () => {
|
|||
connect();
|
||||
}
|
||||
|
||||
window.ws = socket.value;
|
||||
|
||||
return { connect, on, off, emit, socket, isConnected };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ const encodePath = item => {
|
|||
* And prefix the URL with the API URL and path.
|
||||
*
|
||||
* @param {string} url - The URL to request
|
||||
* @param {object} options - The request options
|
||||
* @param {RequestInit} options - The request options
|
||||
*
|
||||
* @returns {Promise<Response>} - The response from the API
|
||||
*/
|
||||
|
|
@ -254,7 +254,11 @@ const request = (url, options = {}) => {
|
|||
options.headers['Accept'] = 'application/json'
|
||||
}
|
||||
|
||||
return fetch(url.startsWith('/') ? runtimeConfig.public.domain + url : url, options)
|
||||
if (url.startsWith('/')) {
|
||||
options.credentials = 'same-origin'
|
||||
}
|
||||
|
||||
return fetch(url.startsWith('/') ? eTrim(runtimeConfig.public.domain, '/') + '/' + sTrim(url, '/') : url, options)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -353,7 +357,7 @@ const makeDownload = (config, item, base = 'api/download') => {
|
|||
baseDir += item.folder + '/';
|
||||
}
|
||||
|
||||
let url = config.app.url_host + encodePath(config.app.url_prefix + baseDir + item.filename);
|
||||
let url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`;
|
||||
return ('m3u8' === base) ? url + '.m3u8' : url;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue