Merge pull request #342 from arabcoders/dev

Cleaning up the event dispatcher system.
This commit is contained in:
Abdulmohsen 2025-07-18 22:06:53 +03:00 committed by GitHub
commit e398321901
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 648 additions and 429 deletions

View file

@ -6,10 +6,16 @@ from queue import Empty, Queue
from .Singleton import Singleton from .Singleton import Singleton
LOG = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
class BackgroundWorker(metaclass=Singleton): 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 _instance = None
"""The instance of the Notification class.""" """The instance of the Notification class."""
@ -37,19 +43,18 @@ class BackgroundWorker(metaclass=Singleton):
except Exception as e: except Exception as e:
LOG.exception("Loop error: %s", 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: while self.running:
try: try:
fn, args, kwargs = self.queue.get(timeout=1) fn, args, kwargs = self.queue.get(timeout=1)
try: try:
LOG.debug("Running background task: %s", fn.__name__)
result = fn(*args, **kwargs) result = fn(*args, **kwargs)
if inspect.iscoroutine(result): if inspect.iscoroutine(result):
loop.call_soon_threadsafe(loop.create_task, result) loop.call_soon_threadsafe(loop.create_task, result)
except Exception as e: except Exception as e:
LOG.exception(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: except Empty:
continue continue

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)
_status: str = "Removed" if removed_files > 0 else "Cleared"
await self._notify.emit( await self._notify.emit(
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,85 +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:
@ -98,30 +23,32 @@ class Events:
SHUTDOWN = "shutdown" SHUTDOWN = "shutdown"
ADDED = "added" ADDED = "added"
UPDATE = "update"
UPDATED = "updated" UPDATED = "updated"
COMPLETED = "completed" COMPLETED = "completed"
CANCELLED = "cancelled" CANCELLED = "cancelled"
CLEARED = "cleared" CLEARED = "cleared"
ERROR = "error" CONNECTED = "connected"
STATUS = "status"
LOG_INFO = "log_info" LOG_INFO = "log_info"
LOG_WARNING = "log_warning" LOG_WARNING = "log_warning"
LOG_ERROR = "log_error" LOG_ERROR = "log_error"
LOG_SUCCESS = "log_success" LOG_SUCCESS = "log_success"
INITIAL_DATA = "initial_data"
ITEM_DELETE = "item_delete" ITEM_DELETE = "item_delete"
ITEM_CANCEL = "item_cancel" ITEM_CANCEL = "item_cancel"
ITEM_ERROR = "item_error" ITEM_ERROR = "item_error"
STATUS = "status"
CLI_CLOSE = "cli_close"
CLI_OUTPUT = "cli_output"
UPDATE = "update"
TEST = "test" TEST = "test"
ADD_URL = "add_url" ADD_URL = "add_url"
CLI_POST = "cli_post"
PAUSED = "paused" PAUSED = "paused"
CLI_POST = "cli_post"
CLI_CLOSE = "cli_close"
CLI_OUTPUT = "cli_output"
TASKS_ADD = "task_add" TASKS_ADD = "task_add"
TASK_DISPATCHED = "task_dispatched" TASK_DISPATCHED = "task_dispatched"
TASK_FINISHED = "task_finished" TASK_FINISHED = "task_finished"
@ -129,6 +56,7 @@ class Events:
PRESETS_ADD = "presets_add" PRESETS_ADD = "presets_add"
PRESETS_UPDATE = "presets_update" PRESETS_UPDATE = "presets_update"
SCHEDULE_ADD = "schedule_add" SCHEDULE_ADD = "schedule_add"
CONDITIONS_ADD = "conditions_add" CONDITIONS_ADD = "conditions_add"
@ -158,9 +86,8 @@ class Events:
""" """
return [ return [
Events.INITIAL_DATA, Events.CONNECTED,
Events.ADDED, Events.ADDED,
Events.ERROR,
Events.LOG_INFO, Events.LOG_INFO,
Events.LOG_WARNING, Events.LOG_WARNING,
Events.LOG_ERROR, Events.LOG_ERROR,
@ -185,7 +112,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 +136,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 +204,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 +299,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 +331,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 +373,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 +392,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 +409,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

@ -92,7 +92,6 @@ class Target:
class NotificationEvents: class NotificationEvents:
ADDED = Events.ADDED ADDED = Events.ADDED
COMPLETED = Events.COMPLETED COMPLETED = Events.COMPLETED
ERROR = Events.ERROR
CANCELLED = Events.CANCELLED CANCELLED = Events.CANCELLED
CLEARED = Events.CLEARED CLEARED = Events.CLEARED
LOG_INFO = Events.LOG_INFO LOG_INFO = Events.LOG_INFO
@ -308,10 +307,17 @@ class Notification(metaclass=Singleton):
msg = "Invalid notification target. Invalid 'on' event list found." msg = "Invalid notification target. Invalid 'on' event list found."
raise ValueError(msg) raise ValueError(msg)
removed_events = []
all_events = NotificationEvents.get_events().values()
for e in target["on"]: for e in target["on"]:
if e not in NotificationEvents.get_events().values(): if e not in all_events:
msg = f"Invalid notification target. Invalid event '{e}' found." removed_events.append(e)
raise ValueError(msg) 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 "headers" in target["request"]:
if not isinstance(target["request"]["headers"], list): if not isinstance(target["request"]["headers"], list):

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
@ -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()] 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") @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") @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 +71,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) await notify.emit(
Events.LOG_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,43 +43,77 @@ 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] = {}
status = await queue.cancel([data]) status = await queue.cancel([data])
status.update({"identifier": 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") @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 +121,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 +179,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 +196,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,7 +17,12 @@ 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) await notify.emit(
Events.LOG_ERROR,
title="Feature disabled",
message="Console feature is disabled.",
to=sid,
)
return return
if not data: if not data:
@ -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
@ -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) 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)

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

@ -209,7 +209,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { decode } from '~/utils/importer' import { decode } from '~/utils/importer'
import type { ConditionItem, ImportedConditionItem } from '~/@types/conditions' import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'cancel'): void (e: 'cancel'): void

View file

@ -23,7 +23,7 @@
<footer class="modal-card-foot p-5"> <footer class="modal-card-foot p-5">
<div class="field is-grouped" style="width:100%"> <div class="field is-grouped" style="width:100%">
<div class="control is-expanded"> <div class="control is-expanded">
<button class="button is-fullwidth" :class="confirm_button_color" @click="handleConfirm"> <button ref="confirmBtn" class="button is-fullwidth" :class="confirm_button_color" @click="handleConfirm">
{{ confirm_button_label }} {{ confirm_button_label }}
</button> </button>
</div> </div>
@ -83,21 +83,25 @@ const emit = defineEmits<{
(e: 'cancel'): void (e: 'cancel'): void
}>() }>()
const confirmBtn = ref<HTMLButtonElement | null>(null)
const selected = reactive<Record<string, boolean>>({}) const selected = reactive<Record<string, boolean>>({})
watch(() => props.visible, visible => { watch(() => props.visible, async visible => {
if (visible && props.options) { if (!visible) {
return
}
if (props.options) {
for (const opt of props.options) { for (const opt of props.options) {
selected[opt.key] ??= false selected[opt.key] ??= false
} }
} }
})
function handleConfirm() { await nextTick()
emit('confirm', { ...selected }) confirmBtn.value?.focus()
} }, { immediate: true })
function cancel() {
emit('cancel') const handleConfirm = () => emit('confirm', { ...selected })
} const cancel = () => emit('cancel')
</script> </script>

View file

@ -231,7 +231,7 @@
<script setup lang="ts"> <script setup lang="ts">
import 'assets/css/bulma-switch.css' import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { item_request } from '~/@types/item' import type { item_request } from '~/types/item'
import { getSeparatorsName, separators } from '~/utils/utils' import { getSeparatorsName, separators } from '~/utils/utils'
const props = defineProps<{ item?: Partial<item_request> }>() const props = defineProps<{ item?: Partial<item_request> }>()

View file

@ -72,7 +72,7 @@ hr {
<script setup lang="ts"> <script setup lang="ts">
import moment from 'moment' import moment from 'moment'
import type { changelogs, changeset } from '~/@types/changelogs' import type { changelogs, changeset } from '~/types/changelogs'
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()

View file

@ -117,7 +117,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { request } from '~/utils/index' import { request } from '~/utils/index'
import { encode } from '~/utils/importer' import { encode } from '~/utils/importer'
import type { ConditionItem, ImportedConditionItem } from '~/@types/conditions' import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()

View file

@ -63,6 +63,7 @@ import { useStorage } from '@vueuse/core'
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const toast = useNotification()
const bg_enable = useStorage<boolean>('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95) const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
@ -88,14 +89,15 @@ watch(() => config.app.basic_mode, async () => {
return return
} }
await navigateTo('/') await navigateTo('/')
}) }, { immediate: true })
watch(() => config.app.console_enabled, async () => { watch(() => config.app.console_enabled, async () => {
if (config.app.console_enabled) { if (config.app.console_enabled) {
return return
} }
toast.error('Console is disabled in the configuration. Please enable it to use this feature.')
await navigateTo('/') await navigateTo('/')
}) }, { immediate: true })
const handle_event = () => { const handle_event = () => {
if (!terminal.value) { if (!terminal.value) {
@ -171,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

@ -80,7 +80,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { item_request } from '~/@types/item' import type { item_request } from '~/types/item'
const config = useConfigStore() const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()

View file

@ -114,7 +114,7 @@ import moment from 'moment'
import { request } from '~/utils/index' import { request } from '~/utils/index'
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from 'vue'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { log_line } from '~/@types/logs' import type { log_line } from '~/types/logs'
let scrollTimeout: NodeJS.Timeout | null = null let scrollTimeout: NodeJS.Timeout | null = null

View file

@ -330,7 +330,7 @@ import { useStorage } from '@vueuse/core'
import { CronExpressionParser } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
import { request, sleep } from '~/utils/index' import { request, sleep } from '~/utils/index'
import { encode } from '~/utils/importer' import { encode } from '~/utils/importer'
import type { task_item, exported_task, error_response } from '~/@types/tasks' import type { task_item, exported_task, error_response } from '~/types/tasks'
const box = useConfirm() const box = useConfirm()
const toast = useNotification() const toast = useNotification()
@ -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)

View file

@ -1,5 +1,5 @@
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { ConfigState } from '~/@types/config'; import type { ConfigState } from '~/types/config';
export const useConfigStore = defineStore('config', () => { export const useConfigStore = defineStore('config', () => {
const state = reactive<ConfigState>({ const state = reactive<ConfigState>({

View file

@ -1,163 +0,0 @@
import { io } from "socket.io-client";
import { ag } from "~/utils/index"
export const useSocketStore = defineStore('socket', () => {
const runtimeConfig = useRuntimeConfig()
const config = useConfigStore()
const stateStore = useStateStore()
const toast = useNotification()
const socket = ref(null)
const isConnected = ref(false)
const connect = () => {
let opts = {
transports: ['websocket', 'polling'],
withCredentials: true,
}
let url = runtimeConfig.public.wss
if ('development' !== runtimeConfig.public?.APP_ENV) {
url = window.origin;
opts.path = `${runtimeConfig.app.baseURL.replace(/\/$/, '')}/socket.io`;
}
socket.value = io(url, opts)
socket.value.on('connect', () => isConnected.value = true);
socket.value.on('disconnect', () => isConnected.value = false);
socket.value.on('initial_data', stream => {
const initialData = JSON.parse(stream)
config.setAll({
app: initialData['config'],
tasks: initialData['tasks'],
folders: initialData['folders'],
presets: initialData['presets'],
paused: Boolean(initialData['paused'])
})
stateStore.addAll('queue', initialData['queue'] ?? {})
stateStore.addAll('history', initialData['done'] ?? {})
})
socket.value.on('added', stream => {
const item = JSON.parse(stream);
stateStore.add('queue', item._id, item);
toast.success(`Item queued: ${ag(stateStore.get('queue', item._id, {}), 'title')}`);
});
socket.value.on('error', stream => {
const json = JSON.parse(stream);
toast.error(`${json.data?.id ?? json?.type}: ${json?.message}`, json.data || {});
});
socket.value.on('log_info', stream => {
const json = JSON.parse(stream);
toast.info(json?.message, json.data || {});
});
socket.value.on('log_success', stream => {
const json = JSON.parse(stream);
toast.success(json?.message, json.data || {});
});
socket.value.on('log_warning', stream => {
const json = JSON.parse(stream);
toast.warning(json?.message, json.data || {});
});
socket.value.on('log_error', stream => {
const json = JSON.parse(stream);
toast.error(json?.message, json.data || {});
});
socket.value.on('completed', stream => {
const item = JSON.parse(stream);
if (true === stateStore.has('queue', item._id)) {
stateStore.remove('queue', item._id);
}
stateStore.add('history', item._id, item);
});
socket.value.on('cancelled', stream => {
const item = JSON.parse(stream);
const id = item._id
if (true !== stateStore.has('queue', id)) {
return
}
toast.warning(`Download cancelled: ${ag(stateStore.get('queue', id, {}), 'title')}`);
if (true === stateStore.has('queue', id)) {
stateStore.remove('queue', id);
}
});
socket.value.on('cleared', stream => {
const item = JSON.parse(stream);
const id = item._id
if (true !== stateStore.has('history', id)) {
return
}
stateStore.remove('history', id);
});
socket.value.on("updated", stream => {
const data = JSON.parse(stream);
if (true === stateStore.has('history', data._id)) {
stateStore.update('history', data._id, data);
return;
}
let dl = stateStore.get('queue', data._id, {});
data.deleting = dl?.deleting;
stateStore.update('queue', data._id, data);
});
socket.value.on("update", stream => {
const data = JSON.parse(stream);
if (true === stateStore.has('history', data._id)) {
stateStore.update('history', data._id, data);
return;
}
});
socket.value.on('paused', data => {
const json = JSON.parse(data);
const pausedState = Boolean(json.paused);
config.update('paused', pausedState);
if (false === pausedState) {
toast.success('Download queue resumed.');
return;
}
toast.warning('Download queue paused.', {
timeout: 10000,
});
});
socket.value.on('presets_update', data => config.update('presets', JSON.parse(data)));
}
const on = (event, callback) => socket.value.on(event, callback);
const off = (event, callback) => socket.value.off(event, callback);
const emit = (event, data) => socket.value.emit(event, data);
if (false === isConnected.value) {
connect();
}
window.ws = socket.value;
return { connect, on, off, emit, socket, isConnected };
});

157
ui/stores/SocketStore.ts Normal file
View file

@ -0,0 +1,157 @@
import { io } from "socket.io-client";
import { ag } from "~/utils/index"
import type { Socket as IOSocket, SocketOptions } from "socket.io-client"
import type { ManagerOptions } from "socket.io-client";
export const useSocketStore = defineStore('socket', () => {
const runtimeConfig = useRuntimeConfig()
const config = useConfigStore()
const stateStore = useStateStore()
const toast = useNotification()
const socket = ref<IOSocket | null>(null)
const isConnected = ref<boolean>(false)
const connect = () => {
let opts = {
transports: ['websocket', 'polling'],
withCredentials: true,
} as Partial<ManagerOptions & SocketOptions>
let url = runtimeConfig.public.wss
if ('development' !== runtimeConfig.public?.APP_ENV) {
url = window.origin;
opts.path = `${runtimeConfig.app.baseURL.replace(/\/$/, '')}/socket.io`;
}
socket.value = io(url, opts)
socket.value.on('connect', () => isConnected.value = true);
socket.value.on('disconnect', () => isConnected.value = false);
socket.value.on('connected', stream => {
const json = JSON.parse(stream)
config.setAll({
app: json.data.config,
tasks: json.data.tasks,
folders: json.data.folders,
presets: json.data.presets,
paused: Boolean(json.data.paused)
})
stateStore.addAll('queue', json.data.queue || {})
stateStore.addAll('history', json.data.done || {})
})
socket.value.on('added', stream => {
const json = JSON.parse(stream);
stateStore.add('queue', json.data._id, json.data);
toast.success(`Item queued: ${ag(stateStore.get('queue', json.data._id, {}), 'title')}`);
});
['log_info', 'log_success', 'log_warning', 'log_error'].forEach(event => socket.value?.on(event, stream => {
const json = JSON.parse(stream);
const message = json?.message || json?.data?.message;
const data = json.data?.data || json.data || {};
switch (event) {
case 'log_info':
toast.info(message, data);
break;
case 'log_success':
toast.success(message, data);
break;
case 'log_warning':
toast.warning(message, data);
break;
case 'log_error':
toast.error(message, data);
break;
}
}));
socket.value.on('completed', stream => {
const json = JSON.parse(stream);
if (true === stateStore.has('queue', json.data._id)) {
stateStore.remove('queue', json.data._id);
}
stateStore.add('history', json.data._id, json.data);
});
socket.value.on('cancelled', stream => {
const item = JSON.parse(stream);
const id = item.data._id
if (true !== stateStore.has('queue', id)) {
return
}
toast.warning(`Download cancelled: ${ag(stateStore.get('queue', id, {}), 'title')}`);
if (true === stateStore.has('queue', id)) {
stateStore.remove('queue', id);
}
});
socket.value.on('cleared', stream => {
const item = JSON.parse(stream);
const id = item.data._id
if (true !== stateStore.has('history', id)) {
return
}
stateStore.remove('history', id);
});
socket.value.on("updated", stream => {
const json = JSON.parse(stream);
const id = json.data._id;
if (true === stateStore.has('history', id)) {
stateStore.update('history', id, json.data);
return;
}
stateStore.update('queue', id, json.data);
});
socket.value.on("update", stream => {
const json = JSON.parse(stream);
if (true === stateStore.has('history', json.data._id)) {
stateStore.update('history', json.data._id, json.data);
return;
}
});
socket.value.on('paused', data => {
const json = JSON.parse(data);
const pausedState = Boolean(json.data.paused);
config.update('paused', pausedState);
if (false === pausedState) {
toast.success('Download queue resumed.');
return;
}
toast.warning('Download queue paused.', { timeout: 10000 });
});
socket.value.on('presets_update', (data: string) => config.update('presets', JSON.parse(data).data || []));
}
const emit = (event: string, data?: any): any => socket.value?.emit(event, data)
const on = (event: string, callback: (...args: any[]) => void): any => socket.value?.on(event, callback)
const off = (event: string, callback: (...args: any[]) => void): any => socket.value?.off(event, callback)
if (false === isConnected.value) {
connect();
}
window.ws = socket.value;
return { connect, on, off, emit, socket, isConnected };
});

View file

@ -1,43 +0,0 @@
export const useStateStore = defineStore('state', () => {
const state = reactive({
queue: {},
history: {}
});
const actions = {
add(type, key, value) {
state[type][key] = value;
},
update(type, key, value) {
state[type][key] = value;
},
remove(type, key) {
if (state[type][key]) {
delete state[type][key];
}
},
get(type, key, defaultValue = null) {
return state[type][key] || defaultValue;
},
has(type, key) {
return !!state[type][key];
},
clearAll(type) {
state[type] = {};
},
addAll(type, data) {
state[type] = data;
},
move(fromType, toType, key) {
if (state[fromType][key]) {
state[toType][key] = state[fromType][key];
delete state[fromType][key];
}
},
count(type) {
return Object.keys(state[type]).length;
}
}
return { ...toRefs(state), ...actions };
});

59
ui/stores/StateStore.ts Normal file
View file

@ -0,0 +1,59 @@
import { defineStore } from 'pinia'
import { reactive, toRefs } from 'vue'
import type { StoreItem } from '~/types/store'
type StateType = 'queue' | 'history'
type KeyType = string
type ValueType = StoreItem
interface State {
queue: Record<KeyType, ValueType>
history: Record<KeyType, ValueType>
}
export const useStateStore = defineStore('state', () => {
const state = reactive<State>({ queue: {}, history: {} })
const add = (type: StateType, key: KeyType, value: ValueType): void => {
state[type][key] = value
}
const update = (type: StateType, key: KeyType, value: ValueType): void => {
state[type][key] = value
}
const remove = (type: StateType, key: KeyType): void => {
if (state[type][key]) {
delete state[type][key]
}
}
const get = (type: StateType, key: KeyType, defaultValue: ValueType | null | object = null): ValueType | null => {
return state[type][key] || defaultValue
}
const has = (type: StateType, key: KeyType): boolean => {
return !!state[type][key]
}
const clearAll = (type: StateType): void => {
state[type] = {}
}
const addAll = (type: StateType, data: Record<KeyType, ValueType>): void => {
state[type] = data
}
const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
if (state[fromType][key]) {
state[toType][key] = state[fromType][key]
delete state[fromType][key]
}
}
const count = (type: StateType): number => {
return Object.keys(state[type]).length
}
return { ...toRefs(state), add, update, remove, get, has, clearAll, addAll, move, count }
})

View file

@ -2,6 +2,9 @@
// https://nuxt.com/docs/guide/concepts/typescript // https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json", "extends": "./.nuxt/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"allowImportingTsExtensions": false "allowImportingTsExtensions": false,
"types": [
"./types/globals"
]
} }
} }

7
ui/types/globals.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
export { }
declare global {
interface Window {
ws?: unknown
}
}

8
ui/types/sockets.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
export type Event = {
id: str
created_at: str
event: str
title: str | null = null
message: str | null = null
data: Any = { }
}

76
ui/types/store.d.ts vendored Normal file
View file

@ -0,0 +1,76 @@
export type ItemStatus = 'finished' | 'preparing' | 'error' | 'cancelled' | 'downloading' | 'postprocessing' | 'not_live' | null;
export type StoreItem = {
/** Unique identifier for the item */
_id: string
/** Error message if available */
error: string | null
/** Item source id */
id: string
/** Title of the item */
title: string
/** URL of the item */
url: string
/** Preset used for the item */
preset: string
/** Folder where the item is saved */
folder: string
/** Download directory */
download_dir: string
/** Temporary directory for the item */
temp_dir: string
/** Status of the item */
status: ItemStatus
/** If the item has cookies */
cookies: string
/** If the item has custom output_template */
template: string
/** If the item has custom output_template for chapters */
template_chapter: string
/** When the item was created */
timestamp: number
/** If the item is a live stream */
is_live: boolean
/** ISO 8601 formatted start time of the item */
datetime: string
/** Live stream start time if available */
live_in: string | null
/** File size of the item if available */
file_size: number | null
/** Custom yt-dlp command line arguments */
cli: string
/** If the item is auto-started */
auto_start: boolean
/** Options for the item */
options: Record<string, unknown>
/** Extras for the item */
extras: {
/** Which channel the item belongs to */
channel?: string
/** The video duration if available */
duration?: number | null
/** The video release date if available */
release_in?: string
/** The video thumbnail URL if available */
thumbnail?: string
/** The uploader of the item if available */
uploader?: string
}
/** The item temporary filename */
tmpfilename?: string | null
/** The item filename */
filename?: string | null
/** Actual file size of the item if available */
total_bytes?: number | null
/** Estimated total bytes for the item if available */
total_bytes_estimate?: number | null
/** Downloaded bytes of the item if available */
downloaded_bytes?: number | null
/** Message attached with the item if available */
msg?: string | null
/** Progress percentage of the item if available */
percent?: number | null
/** Speed of the item download if available */
speed?: number | null
/** Time remaining for the item download if available */
eta?: number | null
}

View file

@ -13,7 +13,7 @@ const getValue = (obj) => 'function' === typeof obj ? obj() : obj
/** /**
* Get value from object or function and return default value if it's undefined or null * Get value from object or function and return default value if it's undefined or null
* *
* @param {Object|Array} obj The object to get the value from. * @param {Object|Array|any} obj The object to get the value from.
* @param {string} path The path to the value. * @param {string} path The path to the value.
* @param {*} defaultValue The default value to return if the path is not found. * @param {*} defaultValue The default value to return if the path is not found.
* *

View file

@ -1,4 +1,4 @@
import type { convert_args_response } from "~/@types/responses"; import type { convert_args_response } from "~/types/responses";
const separators = [ const separators = [
{ name: 'Comma', value: ',', }, { name: 'Comma', value: ',', },