Cleaning up the event dispatcher system.

This commit is contained in:
arabcoders 2025-07-18 21:06:09 +03:00
parent 84f623f2e4
commit 09c5d7794d
13 changed files with 275 additions and 188 deletions

View file

@ -492,10 +492,10 @@ class Download:
if "error" == self.info.status and "error" in status: if "error" == self.info.status and "error" in status:
self.info.error = status.get("error") self.info.error = status.get("error")
await self._notify.emit( await self._notify.emit(
Events.ERROR, Events.LOG_ERROR,
data={"message": self.info.error, "data": self.info}, data=self.info,
title="Download Error", 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: if "downloaded_bytes" in status and status.get("downloaded_bytes") > 0:

View file

@ -19,8 +19,6 @@ from .config import Config
from .DataStore import DataStore, StoreType from .DataStore import DataStore, StoreType
from .Download import Download from .Download import Download
from .Events import EventBus, Events 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 .ItemDTO import Item, ItemDTO
from .Presets import Presets from .Presets import Presets
from .Scheduler import Scheduler from .Scheduler import Scheduler
@ -452,39 +450,48 @@ class DownloadQueue(metaclass=Singleton):
try: try:
dlInfo: Download = Download(info=dl, info_dict=entry if item.auto_start else None, logs=logs) 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 = "" text_logs: str = ""
if filtered_logs := extract_ytdlp_logs(logs): if filtered_logs := extract_ytdlp_logs(logs):
text_logs = f" Logs: {', '.join(filtered_logs)}" text_logs = f" Logs: {', '.join(filtered_logs)}"
if "is_upcoming" == entry.get("live_status"): 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.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( await self._notify.emit(
Events.LOG_INFO, Events.LOG_INFO,
data=event_info(dlInfo.info.msg, {"lowPriority": True}), data={"lowPriority": True},
title="Premiere video" if is_premiere else "Live Stream", title=notifyTitle,
message=f"Item '{dlInfo.info.title}' is not available yet. {dlInfo.info.msg}", message=notifyMessage,
) )
itemDownload: Download = self.done.put(dlInfo) itemDownload: Download = self.done.put(dlInfo)
elif len(entry.get("formats", [])) < 1: elif len(entry.get("formats", [])) < 1:
availability: str = entry.get("availability", "public") ava: str = entry.get("availability", "public")
msg: str = "No formats found." notifyTitle = "Download Error"
if availability and availability not in ("public",): notifyMessage: str = f"No formats for '{dl.title}'."
msg += f" Availability is set for '{availability}'." 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" dlInfo.info.status = "error"
itemDownload = self.done.put(dlInfo) itemDownload = self.done.put(dlInfo)
NotifyEvent = Events.COMPLETED notifyEvent = Events.COMPLETED
await self._notify.emit( await self._notify.emit(
Events.LOG_WARNING, Events.LOG_WARNING,
data=event_warning(f"No formats found for '{dl.title}'."), data={"logs": text_logs},
title="No Formats Found", title=notifyTitle,
message=f"No formats found for '{dl.title}'. {dlInfo.info.error}", message=notifyMessage,
) )
elif is_premiere and self.config.prevent_live_premiere: elif is_premiere and self.config.prevent_live_premiere:
notifyTitle = "Premiere Video"
dlInfo.info.error = "Premiering right now." dlInfo.info.error = "Premiering right now."
_requeue = True _requeue = True
@ -496,6 +503,7 @@ class DownloadQueue(metaclass=Singleton):
) )
starts_in = starts_in + timedelta(minutes=5, seconds=dl.extras.get("duration", 0)) 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()}." dlInfo.info.error += f" Download will start at {starts_in.astimezone().isoformat()}."
notifyMessage = dlInfo.info.error.strip()
_requeue = False _requeue = False
except Exception as e: except Exception as e:
LOG.error(f"Failed to parse live_in date '{release_in}'. {e!s}") LOG.error(f"Failed to parse live_in date '{release_in}'. {e!s}")
@ -503,25 +511,24 @@ class DownloadQueue(metaclass=Singleton):
else: else:
dlInfo.info.error += f" Delaying download by '{300+dl.extras.get('duration',0)}' seconds." dlInfo.info.error += f" Delaying download by '{300+dl.extras.get('duration',0)}' seconds."
notifyMessage = dlInfo.info.error.strip()
if _requeue: if _requeue:
NotifyEvent = Events.ADDED notifyEvent = Events.ADDED
itemDownload = self.queue.put(dlInfo) itemDownload = self.queue.put(dlInfo)
if item.auto_start: if item.auto_start:
self.event.set() self.event.set()
else:
LOG.debug(f"Item {itemDownload.info.name()} is not set to auto-start.")
else: else:
dlInfo.info.status = "not_live" dlInfo.info.status = "not_live"
itemDownload = self.done.put(dlInfo) itemDownload = self.done.put(dlInfo)
NotifyEvent = Events.COMPLETED notifyEvent = Events.COMPLETED
await self._notify.emit( notifyTitle = "Item Not Live"
Events.LOG_INFO, notifyMessage = f"Item '{dlInfo.info.title}' is not live."
data=event_info(f"'{dl.title}' is {dlInfo.info.error}.", {"lowPriority": True}), await self._notify.emit(Events.LOG_INFO, title=notifyTitle, message=notifyMessage)
title="Item Not Live",
message=f"Item '{dl.title}' is not live. {dlInfo.info.error}",
)
else: 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) itemDownload = self.queue.put(dlInfo)
if item.auto_start: if item.auto_start:
self.event.set() self.event.set()
@ -529,10 +536,10 @@ class DownloadQueue(metaclass=Singleton):
LOG.debug(f"Item {itemDownload.info.name()} is not set to auto-start.") LOG.debug(f"Item {itemDownload.info.name()} is not set to auto-start.")
await self._notify.emit( await self._notify.emit(
NotifyEvent, notifyEvent,
data=itemDownload.info.serialize(), data=itemDownload.info.serialize(),
title="Item Added" if Events.ADDED == NotifyEvent else None, title=notifyTitle,
message=f"Added '{itemDownload.info.title}'." if NotifyEvent == Events.ADDED else None, message=notifyMessage,
) )
return {"status": "ok"} return {"status": "ok"}
@ -740,14 +747,18 @@ class DownloadQueue(metaclass=Singleton):
self.queue.delete(id) self.queue.delete(id)
await self._notify.emit( await self._notify.emit(
Events.CANCELLED, Events.CANCELLED,
data=item.info.serialize(), data=item.info,
title="Download Cancelled", title="Download Cancelled",
message=f"Download '{item.info.title}' has been cancelled.", message=f"Download '{item.info.title}' has been cancelled.",
) )
item.info.status = "cancelled" item.info.status = "cancelled"
# item.info.error = "Cancelled by user."
self.done.put(item) 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}") LOG.info(f"Deleted from queue {item_ref}")
status[id] = "ok" status[id] = "ok"
@ -815,11 +826,13 @@ class DownloadQueue(metaclass=Singleton):
LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {e!s}") LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {e!s}")
self.done.delete(id) self.done.delete(id)
await self._notify.emit(
_status: str = "Removed" if removed_files > 0 else "Cleared"
self._notify.offload(
Events.CLEARED, Events.CLEARED,
data=item.info.serialize(), data=item.info,
title="Download Cleared", title=f"Download {_status}",
message=f"Cleared download '{item.info.title}' from history.", message=f"{_status} '{item.info.title}' from history.",
) )
msg = f"Deleted completed download '{itemRef}'." msg = f"Deleted completed download '{itemRef}'."
@ -857,6 +870,27 @@ class DownloadQueue(metaclass=Singleton):
return items 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: async def _download_pool(self) -> None:
""" """
Create a pool of workers to download the files. 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.") LOG.debug(f"Download Task '{id}' is completed. Removing from queue.")
self.queue.delete(key=id) self.queue.delete(key=id)
notifyTitle: str | None = None
notifyMessage: str | None = None
if entry.is_cancelled() is True: if entry.is_cancelled() is True:
await self._notify.emit( await self._notify.emit(
Events.CANCELLED, Events.CANCELLED,
data=entry.info.serialize(), data=entry.info,
title="Download Cancelled", title="Download Cancelled",
message=f"Download '{entry.info.title}' has been cancelled.", message=f"Download '{entry.info.title}' has been cancelled.",
) )
entry.info.status = "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) self.done.put(value=entry)
await self._notify.emit( await self._notify.emit(Events.COMPLETED, data=entry.info, title=notifyTitle, message=notifyMessage)
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,
)
else: else:
LOG.warning(f"Download '{id}' not found in queue.") LOG.warning(f"Download '{id}' not found in queue.")

View file

@ -6,86 +6,10 @@ from collections.abc import Awaitable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from .BackgroundWorker import BackgroundWorker
from .Singleton import Singleton from .Singleton import Singleton
LOG = logging.getLogger("Events") LOG: logging.Logger = 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 {}}
class Events: class Events:
""" """
@ -185,7 +109,7 @@ class Events:
list: The list of debug events. list: The list of debug events.
""" """
return [Events.UPDATED] return [Events.UPDATED, Events.CLI_OUTPUT]
@dataclass(kw_only=True) @dataclass(kw_only=True)
@ -209,7 +133,7 @@ class Event:
message: str | None = None message: str | None = None
"""The message of the event, if any.""" """The message of the event, if any."""
data: any data: Any
"""The data that was passed to the event.""" """The data that was passed to the event."""
def serialize(self) -> dict: def serialize(self) -> dict:
@ -277,12 +201,16 @@ class EventBus(metaclass=Singleton):
debug: bool = False debug: bool = False
"""Whether to log debug messages or not.""" """Whether to log debug messages or not."""
_offload: BackgroundWorker
"""The background worker to offload tasks to."""
def __init__(self): def __init__(self):
EventBus._instance = self EventBus._instance = self
from .config import Config from .config import Config
self.debug = Config.get_instance().debug self.debug = Config.get_instance().debug
self._offload = BackgroundWorker.get_instance()
@staticmethod @staticmethod
def get_instance() -> "EventBus": def get_instance() -> "EventBus":
@ -368,13 +296,24 @@ class EventBus(metaclass=Singleton):
return self 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. Emit event and (optionally) wait for results.
Args: Args:
event (str): The event to emit. 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. 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. wait (bool): Whether to wait for the results of the event handlers. Defaults to True.
**kwargs: Additional keyword arguments to pass to the event **kwargs: Additional keyword arguments to pass to the event
@ -389,8 +328,11 @@ class EventBus(metaclass=Singleton):
if event not in self._listeners: if event not in self._listeners:
return [] return []
if not data:
data = {}
async def emit_all(): 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}) LOG.debug(f"Emitting event '{ev.id}: {ev.event}'.", extra={"data": data})
res: list = [] res: list = []
@ -428,14 +370,14 @@ class EventBus(metaclass=Singleton):
return fut.result() if wait else fut return fut.result() if wait else fut
async def emit( 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: ) -> Awaitable:
""" """
Emit an event. Emit an event.
Args: Args:
event (str): The event to emit. 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. title (str | None): The title of the event, if any.
message (str | None): The message of the event, if any. message (str | None): The message of the event, if any.
**kwargs: The keyword arguments to pass to the event. **kwargs: The keyword arguments to pass to the event.
@ -447,7 +389,10 @@ class EventBus(metaclass=Singleton):
if event not in self._listeners: if event not in self._listeners:
return [] 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(): if self.debug or event not in Events.only_debug():
LOG.debug(f"Emitting event '{ev.id}: {ev.event}'.", extra={"data": data}) LOG.debug(f"Emitting event '{ev.id}: {ev.event}'.", extra={"data": data})
@ -461,3 +406,35 @@ class EventBus(metaclass=Singleton):
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.") LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
return asyncio.gather(*tasks) 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}'.")

View file

@ -55,7 +55,7 @@ class HttpSocket:
self.rootPath = root_path self.rootPath = root_path
def emit(e: Event, _, **kwargs): 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 = Services.get_instance()
services.add_all( services.add_all(

View file

@ -15,7 +15,7 @@ from app.library.Services import Services
from .config import Config from .config import Config
from .encoder import Encoder from .encoder import Encoder
from .Events import EventBus, Events, error, success from .Events import EventBus, Events
from .Scheduler import Scheduler from .Scheduler import Scheduler
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import init_class, validate_url from .Utils import init_class, validate_url
@ -325,7 +325,7 @@ class Tasks(metaclass=Singleton):
"template": template, "template": template,
"cli": cli, "cli": cli,
}, },
title=f"Task '{task.name}' started", title="Tasks",
message=f"Task '{task.name}' started at '{timeNow}'", message=f"Task '{task.name}' started at '{timeNow}'",
id=task.id, id=task.id,
) )
@ -337,19 +337,16 @@ class Tasks(metaclass=Singleton):
await self._notify.emit( await self._notify.emit(
Events.LOG_SUCCESS, Events.LOG_SUCCESS,
data=success( data={"lowPriority": True},
f"Task '{task.name}' completed in '{ended - started:.2f}' seconds.", data={"lowPriority": True} title="Task completed",
), message=f"Task '{task.name}' completed in '{ended - started:.2f}'.",
title=f"Task '{task.name}' completed",
message=f"Task '{task.name}' completed at '{timeNow}'",
) )
except Exception as e: except Exception as e:
LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.") LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
await self._notify.emit( await self._notify.emit(
Events.ERROR, Events.LOG_ERROR,
data=error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'."), title="Task failed",
title=f"Task '{task.name}' failed", message=f"Failed to execute '{task.name}'. '{e!s}'",
message=str(e),
) )

View file

@ -111,7 +111,12 @@ class Main:
host = host or self._config.host host = host or self._config.host
port = port or self._config.port 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) Scheduler.get_instance().attach(self._app)
self._socket.attach(self._app) self._socket.attach(self._app)
@ -123,7 +128,12 @@ class Main:
Notification.get_instance().attach(self._app) Notification.get_instance().attach(self._app)
Conditions.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(_): def started(_):
LOG.info("=" * 40) LOG.info("=" * 40)
@ -132,7 +142,14 @@ class Main:
loop = asyncio.get_event_loop() 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: if loop and self._config.debug:
loop.set_debug(True) loop.set_debug(True)

View file

@ -5,7 +5,7 @@ from aiohttp import web
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
from app.library.encoder import Encoder 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.Notifications import Notification, NotificationEvents
from app.library.router import route from app.library.router import route
from app.library.Utils import validate_uuid from app.library.Utils import validate_uuid
@ -104,8 +104,6 @@ async def notification_test(encoder: Encoder, notify: EventBus) -> Response:
Response: The response object. 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={}, status=web.HTTPOk.status_code, dumps=encoder.encode)
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -1,12 +1,13 @@
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any
import socketio import socketio
from app.library.config import Config from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue 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.Presets import Presets
from app.library.router import RouteType, route from app.library.router import RouteType, route
from app.library.Utils import tail_log from app.library.Utils import tail_log
@ -52,7 +53,7 @@ async def disconnect(sio: socketio.AsyncServer, sid: str, data: str = None):
@route(RouteType.SOCKET, "subscribe", "socket_subscribe") @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. Subscribe to a specific event.
@ -64,8 +65,13 @@ async def subscribe(config: Config, notify: EventBus, sio: socketio.AsyncServer,
data (str): The event to subscribe to. data (str): The event to subscribe to.
""" """
if not isinstance(data, str): if not isinstance(data, str) or not data:
await notify.emit(Events.ERROR, data=error("Invalid event."), to=sid) notify.offload(
Events.ERROR,
title="Subscription Error",
message="Invalid event type was expecting a string.",
to=sid,
)
return return
if data not in _Data.subscribers: if data not in _Data.subscribers:

View file

@ -7,7 +7,7 @@ import anyio
from app.library.config import Config from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue 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.ItemDTO import Item
from app.library.router import RouteType, route from app.library.router import RouteType, route
from app.library.Utils import is_downloaded from app.library.Utils import is_downloaded
@ -43,25 +43,37 @@ async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
url: str | None = data.get("url") url: str | None = data.get("url")
if not 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 return
try: try:
status = await queue.add(item=Item.format(data))
await notify.emit( await notify.emit(
event=Events.STATUS, 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, to=sid,
) )
except ValueError as e: except ValueError as e:
LOG.exception(e) LOG.exception(e)
await notify.emit(Events.ERROR, data=error(str(e)), to=sid) await notify.emit(Events.LOG_ERROR, title="Error Adding URL", message=str(e), to=sid)
return
@route(RouteType.SOCKET, "item_cancel", "item_cancel") @route(RouteType.SOCKET, "item_cancel", "item_cancel")
async def item_cancel(queue: DownloadQueue, notify: EventBus, sid: str, data: str): async def item_cancel(queue: DownloadQueue, notify: EventBus, sid: str, data: str):
if not data: 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 return
status: dict[str, str] = {} status: dict[str, str] = {}
@ -74,12 +86,32 @@ async def item_cancel(queue: DownloadQueue, notify: EventBus, sid: str, data: st
@route(RouteType.SOCKET, "item_delete", "item_delete") @route(RouteType.SOCKET, "item_delete", "item_delete")
async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: dict): async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
if not data: 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 return
id: str | None = data.get("id") id: str | None = data.get("id")
if not 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 return
status: dict[str, str] = {} status: dict[str, str] = {}
@ -87,7 +119,7 @@ async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: di
status.update({"identifier": id}) status.update({"identifier": id})
await notify.emit( 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 +177,12 @@ async def archive_item(config: Config, data: dict):
@route(RouteType.SOCKET, "item_start", "item_start") @route(RouteType.SOCKET, "item_start", "item_start")
async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None: async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
if not data: 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 return
if isinstance(data, str): if isinstance(data, str):
@ -157,7 +194,12 @@ async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: lis
@route(RouteType.SOCKET, "item_pause", "item_pause") @route(RouteType.SOCKET, "item_pause", "item_pause")
async def item_pause(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None: async def item_pause(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
if not data: 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 return
if isinstance(data, str): if isinstance(data, str):

View file

@ -3,7 +3,7 @@ import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.library.config import Config 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 from app.library.router import RouteType, route
if TYPE_CHECKING: if TYPE_CHECKING:
@ -17,11 +17,16 @@ LOG: logging.Logger = logging.getLogger(__name__)
@route(RouteType.SOCKET, "cli_post", "socket_cli_post") @route(RouteType.SOCKET, "cli_post", "socket_cli_post")
async def cli_post(config: Config, notify: EventBus, sid: str, data: str): async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
if not config.console_enabled: if not config.console_enabled:
await notify.emit(Events.ERROR, data=error("Console is disabled."), to=sid) notify.offload(
Events.LOG_ERROR,
title="Feature disabled",
message="Console feature is disabled.",
to=sid,
)
return return
if not data: if not data:
await notify.emit(Events.CLI_CLOSE, data={"exitcode": 0}, to=sid) notify.offload(Events.CLI_CLOSE, data={"exitcode": 0}, to=sid)
return return
import asyncio import asyncio
@ -29,6 +34,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
import shlex import shlex
import subprocess # ignore import subprocess # ignore
returncode: int = -1
try: try:
LOG.info(f"Cli command from client '{sid}'. '{data}'") 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: dict[str, str] = os.environ.copy()
_env.update( _env.update(
{ {
"TERM": "xterm-256color",
"LANG": "en_US.UTF-8",
"SHELL": "/bin/bash",
"LC_ALL": "en_US.UTF-8",
"PWD": config.download_path, "PWD": config.download_path,
"FORCE_COLOR": "1", "FORCE_COLOR": "1",
"PYTHONUNBUFFERED": "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: try:
import pty import pty
@ -81,7 +93,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
assert proc.stdout is not None assert proc.stdout is not None
async for raw_line in proc.stdout: async for raw_line in proc.stdout:
line = raw_line.rstrip(b"\n") line = raw_line.rstrip(b"\n")
await notify.emit( notify.offload(
Events.CLI_OUTPUT, Events.CLI_OUTPUT,
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")}, data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
to=sid, to=sid,
@ -100,7 +112,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
if not chunk: if not chunk:
if buffer: if buffer:
await notify.emit( notify.offload(
Events.CLI_OUTPUT, Events.CLI_OUTPUT,
data={"type": "stdout", "line": buffer.decode("utf-8", errors="replace")}, data={"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
to=sid, to=sid,
@ -111,7 +123,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
*lines, buffer = buffer.split(b"\n") *lines, buffer = buffer.split(b"\n")
for line in lines: for line in lines:
await notify.emit( notify.offload(
Events.CLI_OUTPUT, Events.CLI_OUTPUT,
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")}, data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
to=sid, to=sid,
@ -126,10 +138,9 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
returncode: int = await proc.wait() returncode: int = await proc.wait()
await read_task await read_task
await notify.emit(Events.CLI_CLOSE, data={"exitcode": returncode}, to=sid)
except Exception as e: except Exception as e:
LOG.error(f"CLI execute exception was thrown for client '{sid}'.") LOG.error(f"CLI execute exception was thrown for client '{sid}'.")
LOG.exception(e) LOG.exception(e)
await notify.emit(Events.CLI_OUTPUT, data={"type": "stderr", "line": str(e)}, to=sid) notify.offload(Events.CLI_OUTPUT, data={"type": "stderr", "line": str(e)}, to=sid)
await notify.emit(Events.CLI_CLOSE, data={"exitcode": -1}, to=sid) finally:
notify.offload(Events.CLI_CLOSE, data={"exitcode": returncode}, to=sid)

View file

@ -336,3 +336,7 @@ hr {
.fa-spin-10 { .fa-spin-10 {
--fa-animation-iteration-count: 10; --fa-animation-iteration-count: 10;
} }
.Vue-Toastification__toast-body {
user-select: none;
}

View file

@ -173,9 +173,9 @@ const writer = (s: string) => {
return return
} }
const data = JSON.parse(s) const json = JSON.parse(s)
terminal.value.writeln(data.line) terminal.value.writeln(json.data.line)
} }
const loader = () => isLoading.value = false const loader = () => isLoading.value = false

View file

@ -686,7 +686,8 @@ const runNow = async (item: task_item, mass: boolean = false) => {
onBeforeUnmount(() => socket.off('status', statusHandler)) onBeforeUnmount(() => socket.off('status', statusHandler))
const statusHandler = async (stream: string) => { const statusHandler = async (stream: string) => {
const { status, msg } = JSON.parse(stream) const json = JSON.parse(stream)
const { status, msg } = json.data
if ('error' === status) { if ('error' === status) {
toast.error(msg) toast.error(msg)