diff --git a/app/library/BackgroundWorker.py b/app/library/BackgroundWorker.py index 108d78a7..f2c59ad3 100644 --- a/app/library/BackgroundWorker.py +++ b/app/library/BackgroundWorker.py @@ -6,10 +6,16 @@ from queue import Empty, Queue from .Singleton import Singleton -LOG = logging.getLogger(__name__) +LOG: logging.Logger = logging.getLogger(__name__) class BackgroundWorker(metaclass=Singleton): + """ + Background worker to run tasks in a separate thread. + This class uses a queue to submit tasks that will be executed in the background. + It is designed to run in a separate thread and uses asyncio to handle asynchronous tasks. + """ + _instance = None """The instance of the Notification class.""" @@ -37,19 +43,18 @@ class BackgroundWorker(metaclass=Singleton): except Exception as e: LOG.exception("Loop error: %s", e) - threading.Thread(target=_loop_runner, daemon=True).start() + threading.Thread(target=_loop_runner, daemon=True, name="Background Runner").start() while self.running: try: fn, args, kwargs = self.queue.get(timeout=1) try: - LOG.debug("Running background task: %s", fn.__name__) result = fn(*args, **kwargs) if inspect.iscoroutine(result): loop.call_soon_threadsafe(loop.create_task, result) except Exception as e: LOG.exception(e) - LOG.error(f"Error in background task: {fn.__name__}") + LOG.error(f"Failed to run '{fn.__name__}'. {e!s}") except Empty: continue diff --git a/app/library/Download.py b/app/library/Download.py index 5e568abb..f482605b 100644 --- a/app/library/Download.py +++ b/app/library/Download.py @@ -492,10 +492,10 @@ class Download: if "error" == self.info.status and "error" in status: self.info.error = status.get("error") await self._notify.emit( - Events.ERROR, - data={"message": self.info.error, "data": self.info}, + Events.LOG_ERROR, + data=self.info, title="Download Error", - message=f"Error in download task '{self.info.title}': {self.info.error}", + message=f"'{self.info.title}' failed to download: {self.info.error}", ) if "downloaded_bytes" in status and status.get("downloaded_bytes") > 0: diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 559b08e8..900c1e25 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -19,8 +19,6 @@ from .config import Config from .DataStore import DataStore, StoreType from .Download import Download from .Events import EventBus, Events -from .Events import info as event_info -from .Events import warning as event_warning from .ItemDTO import Item, ItemDTO from .Presets import Presets from .Scheduler import Scheduler @@ -452,39 +450,48 @@ class DownloadQueue(metaclass=Singleton): try: dlInfo: Download = Download(info=dl, info_dict=entry if item.auto_start else None, logs=logs) + notifyTitle: str | None = None + notifyMessage: str | None = None text_logs: str = "" if filtered_logs := extract_ytdlp_logs(logs): text_logs = f" Logs: {', '.join(filtered_logs)}" if "is_upcoming" == entry.get("live_status"): - NotifyEvent = Events.COMPLETED + notifyEvent = Events.COMPLETED + notifyTitle = "Upcoming Premiere" if is_premiere else "Upcoming Live Stream" + notifyMessage = f"{'Premiere video' if is_premiere else 'Stream' } '{dlInfo.info.title}' is not available yet. {text_logs}" + dlInfo.info.status = "not_live" - dlInfo.info.msg = f"{'Premiere video' if is_premiere else 'Stream' } is not available yet." + text_logs + dlInfo.info.msg = notifyMessage.replace(f" '{dlInfo.info.title}'", "") + await self._notify.emit( Events.LOG_INFO, - data=event_info(dlInfo.info.msg, {"lowPriority": True}), - title="Premiere video" if is_premiere else "Live Stream", - message=f"Item '{dlInfo.info.title}' is not available yet. {dlInfo.info.msg}", + data={"lowPriority": True}, + title=notifyTitle, + message=notifyMessage, ) + itemDownload: Download = self.done.put(dlInfo) elif len(entry.get("formats", [])) < 1: - availability: str = entry.get("availability", "public") - msg: str = "No formats found." - if availability and availability not in ("public",): - msg += f" Availability is set for '{availability}'." + ava: str = entry.get("availability", "public") + notifyTitle = "Download Error" + notifyMessage: str = f"No formats for '{dl.title}'." + if ava and ava not in ("public",): + notifyMessage += f" Availability is set for '{ava}'." - dlInfo.info.error = msg + text_logs + dlInfo.info.error = notifyMessage.replace(f" for '{dl.title}'.", ".") + text_logs dlInfo.info.status = "error" itemDownload = self.done.put(dlInfo) - NotifyEvent = Events.COMPLETED + notifyEvent = Events.COMPLETED await self._notify.emit( Events.LOG_WARNING, - data=event_warning(f"No formats found for '{dl.title}'."), - title="No Formats Found", - message=f"No formats found for '{dl.title}'. {dlInfo.info.error}", + data={"logs": text_logs}, + title=notifyTitle, + message=notifyMessage, ) elif is_premiere and self.config.prevent_live_premiere: + notifyTitle = "Premiere Video" dlInfo.info.error = "Premiering right now." _requeue = True @@ -496,6 +503,7 @@ class DownloadQueue(metaclass=Singleton): ) starts_in = starts_in + timedelta(minutes=5, seconds=dl.extras.get("duration", 0)) dlInfo.info.error += f" Download will start at {starts_in.astimezone().isoformat()}." + notifyMessage = dlInfo.info.error.strip() _requeue = False except Exception as e: LOG.error(f"Failed to parse live_in date '{release_in}'. {e!s}") @@ -503,25 +511,24 @@ class DownloadQueue(metaclass=Singleton): else: dlInfo.info.error += f" Delaying download by '{300+dl.extras.get('duration',0)}' seconds." + notifyMessage = dlInfo.info.error.strip() + if _requeue: - NotifyEvent = Events.ADDED + notifyEvent = Events.ADDED itemDownload = self.queue.put(dlInfo) if item.auto_start: self.event.set() - else: - LOG.debug(f"Item {itemDownload.info.name()} is not set to auto-start.") else: dlInfo.info.status = "not_live" itemDownload = self.done.put(dlInfo) - NotifyEvent = Events.COMPLETED - await self._notify.emit( - Events.LOG_INFO, - data=event_info(f"'{dl.title}' is {dlInfo.info.error}.", {"lowPriority": True}), - title="Item Not Live", - message=f"Item '{dl.title}' is not live. {dlInfo.info.error}", - ) + notifyEvent = Events.COMPLETED + notifyTitle = "Item Not Live" + notifyMessage = f"Item '{dlInfo.info.title}' is not live." + await self._notify.emit(Events.LOG_INFO, title=notifyTitle, message=notifyMessage) else: - NotifyEvent = Events.ADDED + notifyEvent = Events.ADDED + notifyTitle = "Item Added" + notifyMessage = f"Item '{dlInfo.info.title}' has been added to the download queue." itemDownload = self.queue.put(dlInfo) if item.auto_start: self.event.set() @@ -529,10 +536,10 @@ class DownloadQueue(metaclass=Singleton): LOG.debug(f"Item {itemDownload.info.name()} is not set to auto-start.") await self._notify.emit( - NotifyEvent, + notifyEvent, data=itemDownload.info.serialize(), - title="Item Added" if Events.ADDED == NotifyEvent else None, - message=f"Added '{itemDownload.info.title}'." if NotifyEvent == Events.ADDED else None, + title=notifyTitle, + message=notifyMessage, ) return {"status": "ok"} @@ -740,14 +747,18 @@ class DownloadQueue(metaclass=Singleton): self.queue.delete(id) await self._notify.emit( Events.CANCELLED, - data=item.info.serialize(), + data=item.info, title="Download Cancelled", message=f"Download '{item.info.title}' has been cancelled.", ) item.info.status = "cancelled" - # item.info.error = "Cancelled by user." self.done.put(item) - await self._notify.emit(Events.COMPLETED, data=item.info.serialize()) + await self._notify.emit( + Events.COMPLETED, + data=item.info, + title="Download Cancelled", + message=f"Download '{item.info.title}' has been cancelled.", + ) LOG.info(f"Deleted from queue {item_ref}") status[id] = "ok" @@ -815,11 +826,13 @@ class DownloadQueue(metaclass=Singleton): LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {e!s}") self.done.delete(id) + + _status: str = "Removed" if removed_files > 0 else "Cleared" await self._notify.emit( Events.CLEARED, - data=item.info.serialize(), - title="Download Cleared", - message=f"Cleared download '{item.info.title}' from history.", + data=item.info, + title=f"Download {_status}", + message=f"{_status} '{item.info.title}' from history.", ) msg = f"Deleted completed download '{itemRef}'." @@ -857,6 +870,27 @@ class DownloadQueue(metaclass=Singleton): return items + def get_item(self, id: str) -> Download | None: + """ + Get a specific item from the download queue or history. + + Args: + id (str): The ID of the item to retrieve. + + Returns: + Download | None: The requested item if found, otherwise None. + + """ + try: + return self.queue.get(key=id) + except KeyError: + pass + + try: + return self.done.get(key=id) + except KeyError: + return None + async def _download_pool(self) -> None: """ Create a pool of workers to download the files. @@ -931,25 +965,25 @@ class DownloadQueue(metaclass=Singleton): LOG.debug(f"Download Task '{id}' is completed. Removing from queue.") self.queue.delete(key=id) + notifyTitle: str | None = None + notifyMessage: str | None = None + if entry.is_cancelled() is True: await self._notify.emit( Events.CANCELLED, - data=entry.info.serialize(), + data=entry.info, title="Download Cancelled", message=f"Download '{entry.info.title}' has been cancelled.", ) entry.info.status = "cancelled" - # entry.info.error = "Cancelled by user." + notifyTitle = "Download Cancelled" + notifyMessage = f"Download '{entry.info.title}' has been cancelled." + elif entry.info.status == "finished": + notifyTitle = "Download Completed" + notifyMessage = f"Download '{entry.info.title}' has been completed." self.done.put(value=entry) - await self._notify.emit( - Events.COMPLETED, - data=entry.info.serialize(), - title="Download Completed" if entry.info.status == "finished" else None, - message=f"Download '{entry.info.title}' has been completed." - if entry.info.status == "finished" - else None, - ) + await self._notify.emit(Events.COMPLETED, data=entry.info, title=notifyTitle, message=notifyMessage) else: LOG.warning(f"Download '{id}' not found in queue.") diff --git a/app/library/Events.py b/app/library/Events.py index cbeb62f9..8d287fe6 100644 --- a/app/library/Events.py +++ b/app/library/Events.py @@ -6,85 +6,10 @@ from collections.abc import Awaitable from dataclasses import dataclass, field from typing import Any +from .BackgroundWorker import BackgroundWorker from .Singleton import Singleton -LOG = logging.getLogger("Events") - - -def error(msg: str, data: dict | None = None) -> dict: - """ - Create an error message. - - Args: - msg (str): The message. - data (dict|None): The data to include in the message. - - Returns: - dict : The message wrapped in a dictionary. - - """ - return message("error", msg, data) - - -def warning(msg: str, data: dict | None = None) -> dict: - """ - Create an error message. - - Args: - msg (str): The message. - data (dict|None): The data to include in the message. - - Returns: - dict : The message wrapped in a dictionary. - - """ - return message("warning", msg, data) - - -def info(msg: str, data: dict | None = None) -> dict: - """ - Create an info message. - - Args: - msg (str): The message. - data (dict|None): The data to include in the message. - - Returns: - dict : The message wrapped in a dictionary. - - """ - return message("info", msg, data) - - -def success(msg: str, data: dict | None = None) -> dict: - """ - Create a success message. - - Args: - msg (str): The message. - data (dict|None): The data to include in the message. - - Returns: - dict : The message wrapped in a dictionary. - - """ - return message("success", msg, data) - - -def message(type: str, message: str, data: dict | None = None) -> dict: - """ - Create a message. - - Args: - type (str): The type of the message. - message (str): The message. - data (dict|None): The data to include in the message. - - Returns: - dict : The message wrapped in a dictionary. - - """ - return {"type": type, "message": message, "data": data if data else {}} +LOG: logging.Logger = logging.getLogger("Events") class Events: @@ -98,30 +23,32 @@ class Events: SHUTDOWN = "shutdown" ADDED = "added" + UPDATE = "update" UPDATED = "updated" COMPLETED = "completed" CANCELLED = "cancelled" CLEARED = "cleared" - ERROR = "error" + CONNECTED = "connected" + STATUS = "status" + LOG_INFO = "log_info" LOG_WARNING = "log_warning" LOG_ERROR = "log_error" LOG_SUCCESS = "log_success" - INITIAL_DATA = "initial_data" ITEM_DELETE = "item_delete" ITEM_CANCEL = "item_cancel" ITEM_ERROR = "item_error" - STATUS = "status" - CLI_CLOSE = "cli_close" - CLI_OUTPUT = "cli_output" - UPDATE = "update" + TEST = "test" ADD_URL = "add_url" - CLI_POST = "cli_post" PAUSED = "paused" + CLI_POST = "cli_post" + CLI_CLOSE = "cli_close" + CLI_OUTPUT = "cli_output" + TASKS_ADD = "task_add" TASK_DISPATCHED = "task_dispatched" TASK_FINISHED = "task_finished" @@ -129,6 +56,7 @@ class Events: PRESETS_ADD = "presets_add" PRESETS_UPDATE = "presets_update" + SCHEDULE_ADD = "schedule_add" CONDITIONS_ADD = "conditions_add" @@ -158,9 +86,8 @@ class Events: """ return [ - Events.INITIAL_DATA, + Events.CONNECTED, Events.ADDED, - Events.ERROR, Events.LOG_INFO, Events.LOG_WARNING, Events.LOG_ERROR, @@ -185,7 +112,7 @@ class Events: list: The list of debug events. """ - return [Events.UPDATED] + return [Events.UPDATED, Events.CLI_OUTPUT] @dataclass(kw_only=True) @@ -209,7 +136,7 @@ class Event: message: str | None = None """The message of the event, if any.""" - data: any + data: Any """The data that was passed to the event.""" def serialize(self) -> dict: @@ -277,12 +204,16 @@ class EventBus(metaclass=Singleton): debug: bool = False """Whether to log debug messages or not.""" + _offload: BackgroundWorker + """The background worker to offload tasks to.""" + def __init__(self): EventBus._instance = self from .config import Config self.debug = Config.get_instance().debug + self._offload = BackgroundWorker.get_instance() @staticmethod def get_instance() -> "EventBus": @@ -368,13 +299,24 @@ class EventBus(metaclass=Singleton): return self - def sync_emit(self, event: str, data: Any, loop=None, wait: bool = True, **kwargs): + def sync_emit( + self, + event: str, + data: Any | None = None, + title: str | None = None, + message: str | None = None, + loop=None, + wait: bool = True, + **kwargs, + ): """ Emit event and (optionally) wait for results. Args: event (str): The event to emit. - data (Any): The data to pass to the event. + data (Any|None): The data to pass to the event. + title (str | None): The title of the event, if any. + message (str | None): The message of the event, if any. loop (asyncio.AbstractEventLoop | None): The event loop to use. If None, the current running loop is used. wait (bool): Whether to wait for the results of the event handlers. Defaults to True. **kwargs: Additional keyword arguments to pass to the event @@ -389,8 +331,11 @@ class EventBus(metaclass=Singleton): if event not in self._listeners: return [] + if not data: + data = {} + async def emit_all(): - ev = Event(event=event, data=data) + ev = Event(event=event, title=title, message=message, data=data) LOG.debug(f"Emitting event '{ev.id}: {ev.event}'.", extra={"data": data}) res: list = [] @@ -428,14 +373,14 @@ class EventBus(metaclass=Singleton): return fut.result() if wait else fut async def emit( - self, event: str, data: Any, title: str | None = None, message: str | None = None, **kwargs + self, event: str, data: Any | None = None, title: str | None = None, message: str | None = None, **kwargs ) -> Awaitable: """ Emit an event. Args: event (str): The event to emit. - data (Any): The data to pass to the event. + data (Any|None): The data to pass to the event. title (str | None): The title of the event, if any. message (str | None): The message of the event, if any. **kwargs: The keyword arguments to pass to the event. @@ -447,7 +392,10 @@ class EventBus(metaclass=Singleton): if event not in self._listeners: return [] - ev = Event(event=event, data=data, title=title, message=message) + if not data: + data = {} + + ev = Event(event=event, title=title, message=message, data=data) if self.debug or event not in Events.only_debug(): LOG.debug(f"Emitting event '{ev.id}: {ev.event}'.", extra={"data": data}) @@ -461,3 +409,35 @@ class EventBus(metaclass=Singleton): LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.") return asyncio.gather(*tasks) + + def offload( + self, event: str, title: str | None = None, message: str | None = None, data: Any | None = None, **kwargs + ) -> None: + """ + Offload an dispatching event to a background worker. + + Args: + event (str): The event to offload. + data (Any|None): The data to pass to the event. + title (str | None): The title of the event, if any. + message (str | None): The message of the event, if any. + **kwargs: Additional keyword arguments to pass to the event. + + """ + if event not in self._listeners: + return + + if not data: + data = {} + + ev = Event(event=event, title=title, message=message, data=data) + + if self.debug or event not in Events.only_debug(): + LOG.debug(f"Offloading event '{ev.id}: {ev.event}'.", extra={"data": data}) + + for handler in self._listeners[event].values(): + try: + self._offload.submit(handler.handle, ev, **kwargs) + except Exception as e: + LOG.exception(e) + LOG.error(f"Failed to offload event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.") diff --git a/app/library/HttpSocket.py b/app/library/HttpSocket.py index 988608de..61c2105e 100644 --- a/app/library/HttpSocket.py +++ b/app/library/HttpSocket.py @@ -55,7 +55,7 @@ class HttpSocket: self.rootPath = root_path def emit(e: Event, _, **kwargs): - return self.sio.emit(event=e.event, data=encoder.encode(e.data), **kwargs) + return self.sio.emit(event=e.event, data=encoder.encode(e), **kwargs) services = Services.get_instance() services.add_all( diff --git a/app/library/Notifications.py b/app/library/Notifications.py index 295daa85..58656a58 100644 --- a/app/library/Notifications.py +++ b/app/library/Notifications.py @@ -92,7 +92,6 @@ class Target: class NotificationEvents: ADDED = Events.ADDED COMPLETED = Events.COMPLETED - ERROR = Events.ERROR CANCELLED = Events.CANCELLED CLEARED = Events.CLEARED LOG_INFO = Events.LOG_INFO @@ -308,10 +307,17 @@ class Notification(metaclass=Singleton): msg = "Invalid notification target. Invalid 'on' event list found." raise ValueError(msg) + removed_events = [] + all_events = NotificationEvents.get_events().values() for e in target["on"]: - if e not in NotificationEvents.get_events().values(): - msg = f"Invalid notification target. Invalid event '{e}' found." - raise ValueError(msg) + if e not in all_events: + removed_events.append(e) + target["on"].remove(e) + continue + + if len(removed_events) > 0 and len(target["on"]) < 1: + msg: str = f"Invalid notification target. Invalid events '{', '.join(removed_events)}' found." + raise ValueError(msg) if "headers" in target["request"]: if not isinstance(target["request"]["headers"], list): diff --git a/app/library/Tasks.py b/app/library/Tasks.py index 287b3ec5..2be0c8e9 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -15,7 +15,7 @@ from app.library.Services import Services from .config import Config from .encoder import Encoder -from .Events import EventBus, Events, error, success +from .Events import EventBus, Events from .Scheduler import Scheduler from .Singleton import Singleton from .Utils import init_class, validate_url @@ -325,7 +325,7 @@ class Tasks(metaclass=Singleton): "template": template, "cli": cli, }, - title=f"Task '{task.name}' started", + title="Tasks", message=f"Task '{task.name}' started at '{timeNow}'", id=task.id, ) @@ -337,19 +337,16 @@ class Tasks(metaclass=Singleton): await self._notify.emit( Events.LOG_SUCCESS, - data=success( - f"Task '{task.name}' completed in '{ended - started:.2f}' seconds.", data={"lowPriority": True} - ), - title=f"Task '{task.name}' completed", - message=f"Task '{task.name}' completed at '{timeNow}'", + data={"lowPriority": True}, + title="Task completed", + message=f"Task '{task.name}' completed in '{ended - started:.2f}'.", ) except Exception as e: LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.") await self._notify.emit( - Events.ERROR, - data=error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'."), - title=f"Task '{task.name}' failed", - message=str(e), + Events.LOG_ERROR, + title="Task failed", + message=f"Failed to execute '{task.name}'. '{e!s}'", ) diff --git a/app/main.py b/app/main.py index 0999d9cf..6fc1f71c 100644 --- a/app/main.py +++ b/app/main.py @@ -111,7 +111,12 @@ class Main: host = host or self._config.host port = port or self._config.port - EventBus.get_instance().sync_emit(Events.STARTUP, data={"app": self._app}) + EventBus.get_instance().sync_emit( + Events.STARTUP, + data={"app": self._app}, + title="Application Startup", + message="The application is starting up.", + ) Scheduler.get_instance().attach(self._app) self._socket.attach(self._app) @@ -123,7 +128,12 @@ class Main: Notification.get_instance().attach(self._app) Conditions.get_instance().attach(self._app) - EventBus.get_instance().sync_emit(Events.LOADED, data={"app": self._app}) + EventBus.get_instance().sync_emit( + Events.LOADED, + data={"app": self._app}, + title="Application Loaded", + message="The application has loaded all components.", + ) def started(_): LOG.info("=" * 40) @@ -132,7 +142,14 @@ class Main: loop = asyncio.get_event_loop() - EventBus.get_instance().sync_emit(Events.STARTED, data={"app": self._app}, loop=loop, wait=False) + EventBus.get_instance().sync_emit( + Events.STARTED, + data={"app": self._app}, + title="Application Started", + message="The application has started successfully.", + loop=loop, + wait=False, + ) if loop and self._config.debug: loop.set_debug(True) diff --git a/app/routes/api/notifications.py b/app/routes/api/notifications.py index 81b975ee..fd655f8f 100644 --- a/app/routes/api/notifications.py +++ b/app/routes/api/notifications.py @@ -5,7 +5,7 @@ from aiohttp import web from aiohttp.web import Request, Response from app.library.encoder import Encoder -from app.library.Events import EventBus, Events, message +from app.library.Events import EventBus, Events from app.library.Notifications import Notification, NotificationEvents from app.library.router import route from app.library.Utils import validate_uuid @@ -104,8 +104,6 @@ async def notification_test(encoder: Encoder, notify: EventBus) -> Response: Response: The response object. """ - data = message("test", "This is a test notification.") + await notify.emit(Events.TEST, title="Test Notification", message="This is a test notification.") - await notify.emit(Events.TEST, data=data, title="Test Notification", message="This is a test notification.") - - return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode) + return web.json_response(data={}, status=web.HTTPOk.status_code, dumps=encoder.encode) diff --git a/app/routes/socket/connection.py b/app/routes/socket/connection.py index 192a719d..28407a4d 100644 --- a/app/routes/socket/connection.py +++ b/app/routes/socket/connection.py @@ -1,12 +1,13 @@ import asyncio import logging from pathlib import Path +from typing import Any import socketio from app.library.config import Config from app.library.DownloadQueue import DownloadQueue -from app.library.Events import EventBus, Events, error +from app.library.Events import EventBus, Events from app.library.Presets import Presets from app.library.router import RouteType, route from app.library.Utils import tail_log @@ -30,7 +31,13 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s data["folders"] = [folder.name for folder in Path(config.download_path).iterdir() if folder.is_dir()] - await notify.emit(Events.INITIAL_DATA, data=data, to=sid) + await notify.emit( + Events.CONNECTED, + data=data, + title="Client connected", + message=f"Client '{sid}' connected.", + to=sid, + ) @route(RouteType.SOCKET, "disconnect", "socket_disconnect") @@ -52,7 +59,7 @@ async def disconnect(sio: socketio.AsyncServer, sid: str, data: str = None): @route(RouteType.SOCKET, "subscribe", "socket_subscribe") -async def subscribe(config: Config, notify: EventBus, sio: socketio.AsyncServer, sid: str, data: str): +async def subscribe(config: Config, notify: EventBus, sio: socketio.AsyncServer, sid: str, data: str | Any): """ Subscribe to a specific event. @@ -64,8 +71,13 @@ async def subscribe(config: Config, notify: EventBus, sio: socketio.AsyncServer, data (str): The event to subscribe to. """ - if not isinstance(data, str): - await notify.emit(Events.ERROR, data=error("Invalid event."), to=sid) + if not isinstance(data, str) or not data: + await notify.emit( + Events.LOG_ERROR, + title="Subscription Error", + message="Invalid event type was expecting a string.", + to=sid, + ) return if data not in _Data.subscribers: diff --git a/app/routes/socket/history.py b/app/routes/socket/history.py index b969e5b7..77ada6fa 100644 --- a/app/routes/socket/history.py +++ b/app/routes/socket/history.py @@ -7,7 +7,7 @@ import anyio from app.library.config import Config from app.library.DownloadQueue import DownloadQueue -from app.library.Events import EventBus, Events, error +from app.library.Events import EventBus, Events from app.library.ItemDTO import Item from app.library.router import RouteType, route from app.library.Utils import is_downloaded @@ -43,43 +43,77 @@ async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict): url: str | None = data.get("url") if not url: - await notify.emit(Events.ERROR, data=error("No URL provided.", data={"unlock": True}), to=sid) + await notify.emit( + Events.LOG_ERROR, + title="Invalid URL", + message="Please provide a valid URL to add to the download queue.", + to=sid, + ) return try: + status = await queue.add(item=Item.format(data)) await notify.emit( event=Events.STATUS, - data=await queue.add(item=Item.format(data)), + title="Adding URL", + message=f"Adding URL '{url}' to the download queue.", + data=status, to=sid, ) except ValueError as e: LOG.exception(e) - await notify.emit(Events.ERROR, data=error(str(e)), to=sid) - return + await notify.emit(Events.LOG_ERROR, title="Error Adding URL", message=str(e), to=sid) @route(RouteType.SOCKET, "item_cancel", "item_cancel") async def item_cancel(queue: DownloadQueue, notify: EventBus, sid: str, data: str): if not data: - await notify.emit(Events.ERROR, data=error("Invalid request."), to=sid) + await notify.emit( + Events.LOG_ERROR, + title="Invalid Request", + message="No item ID provided to cancel.", + to=sid, + ) return status: dict[str, str] = {} status = await queue.cancel([data]) status.update({"identifier": data}) - await notify.emit(Events.ITEM_CANCEL, data=status) + await notify.emit( + Events.ITEM_CANCEL, data=status, title="Item Cancelled", message=f"Item '{data}': has been cancelled." + ) @route(RouteType.SOCKET, "item_delete", "item_delete") async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: dict): if not data: - await notify.emit(Events.ERROR, data=error("Invalid request."), to=sid) + await notify.emit( + Events.LOG_ERROR, + title="Invalid Request", + message="No item ID provided to delete.", + to=sid, + ) return id: str | None = data.get("id") if not id: - await notify.emit(Events.ERROR, data=error("Invalid request."), to=sid) + await notify.emit( + Events.LOG_ERROR, + title="Invalid Request", + message="No item ID provided to delete.", + to=sid, + ) + return + + item = queue.get_item(id=id) + if not item: + await notify.emit( + Events.LOG_ERROR, + title="Item Not Found", + message=f"Item with ID '{id}' not found.", + to=sid, + ) return status: dict[str, str] = {} @@ -87,7 +121,7 @@ async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: di status.update({"identifier": id}) await notify.emit( - Events.ITEM_DELETE, data=status, title="Item Deleted", message=f"Item with ID '{id}' has been deleted." + Events.ITEM_DELETE, data=status, title="Item Deleted", message=f"Item '{item.info.title}': has been deleted." ) @@ -145,7 +179,12 @@ async def archive_item(config: Config, data: dict): @route(RouteType.SOCKET, "item_start", "item_start") async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None: if not data: - await notify.emit(Events.ERROR, data=error("Invalid request."), to=sid) + await notify.emit( + Events.LOG_ERROR, + title="Invalid Request", + message="No items provided to start.", + to=sid, + ) return if isinstance(data, str): @@ -157,7 +196,12 @@ async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: lis @route(RouteType.SOCKET, "item_pause", "item_pause") async def item_pause(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None: if not data: - await notify.emit(Events.ERROR, data=error("Invalid request."), to=sid) + await notify.emit( + Events.LOG_ERROR, + title="Invalid Request", + message="No items provided to pause.", + to=sid, + ) return if isinstance(data, str): diff --git a/app/routes/socket/terminal.py b/app/routes/socket/terminal.py index 17c2214b..affc0cbe 100644 --- a/app/routes/socket/terminal.py +++ b/app/routes/socket/terminal.py @@ -3,7 +3,7 @@ import os from typing import TYPE_CHECKING from app.library.config import Config -from app.library.Events import EventBus, Events, error +from app.library.Events import EventBus, Events from app.library.router import RouteType, route if TYPE_CHECKING: @@ -17,7 +17,12 @@ LOG: logging.Logger = logging.getLogger(__name__) @route(RouteType.SOCKET, "cli_post", "socket_cli_post") async def cli_post(config: Config, notify: EventBus, sid: str, data: str): if not config.console_enabled: - await notify.emit(Events.ERROR, data=error("Console is disabled."), to=sid) + await notify.emit( + Events.LOG_ERROR, + title="Feature disabled", + message="Console feature is disabled.", + to=sid, + ) return if not data: @@ -29,6 +34,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str): import shlex import subprocess # ignore + returncode: int = -1 try: LOG.info(f"Cli command from client '{sid}'. '{data}'") @@ -36,16 +42,22 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str): _env: dict[str, str] = os.environ.copy() _env.update( { - "TERM": "xterm-256color", - "LANG": "en_US.UTF-8", - "SHELL": "/bin/bash", - "LC_ALL": "en_US.UTF-8", "PWD": config.download_path, "FORCE_COLOR": "1", "PYTHONUNBUFFERED": "1", } ) + if "nt" != os.name: + _env.update( + { + "TERM": "xterm-256color", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US.UTF-8", + "SHELL": "/bin/bash", + } + ) + try: import pty @@ -126,10 +138,9 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str): returncode: int = await proc.wait() await read_task - - await notify.emit(Events.CLI_CLOSE, data={"exitcode": returncode}, to=sid) except Exception as e: LOG.error(f"CLI execute exception was thrown for client '{sid}'.") LOG.exception(e) await notify.emit(Events.CLI_OUTPUT, data={"type": "stderr", "line": str(e)}, to=sid) - await notify.emit(Events.CLI_CLOSE, data={"exitcode": -1}, to=sid) + finally: + await notify.emit(Events.CLI_CLOSE, data={"exitcode": returncode}, to=sid) diff --git a/ui/assets/css/style.css b/ui/assets/css/style.css index 83fa61ca..fc1277a8 100644 --- a/ui/assets/css/style.css +++ b/ui/assets/css/style.css @@ -336,3 +336,7 @@ hr { .fa-spin-10 { --fa-animation-iteration-count: 10; } + +.Vue-Toastification__toast-body { + user-select: none; +} diff --git a/ui/components/ConditionForm.vue b/ui/components/ConditionForm.vue index 97537ab6..9ba0f309 100644 --- a/ui/components/ConditionForm.vue +++ b/ui/components/ConditionForm.vue @@ -209,7 +209,7 @@ diff --git a/ui/components/NewDownload.vue b/ui/components/NewDownload.vue index 6f6f70cc..83813fbe 100644 --- a/ui/components/NewDownload.vue +++ b/ui/components/NewDownload.vue @@ -231,7 +231,7 @@