refactor: remove socket.io dependency

This commit is contained in:
arabcoders 2026-01-21 21:06:07 +03:00
parent ce7b59a8be
commit 501455029c
20 changed files with 963 additions and 702 deletions

View file

@ -202,6 +202,7 @@
"vconvert",
"Vitest",
"writedescription",
"WSEP",
"xerror",
"youtu",
"youtubepot",

129
API.md
View file

@ -72,6 +72,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [DELETE /api/conditions/{id}](#delete-apiconditionsid)
- [POST /api/conditions/test](#post-apiconditionstest)
- [GET /api/logs](#get-apilogs)
- [GET /api/logs/stream](#get-apilogsstream)
- [GET /api/notifications/](#get-apinotifications)
- [GET /api/notifications/events/](#get-apinotificationsevents)
- [POST /api/notifications/](#post-apinotifications)
@ -82,6 +83,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/yt-dlp/archive\_id/](#post-apiyt-dlparchive_id)
- [POST /api/notifications/test](#post-apinotificationstest)
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
- [POST /api/system/terminal](#post-apisystemterminal)
- [POST /api/system/pause](#post-apisystempause)
- [POST /api/system/resume](#post-apisystemresume)
- [POST /api/system/shutdown](#post-apisystemshutdown)
@ -95,8 +97,8 @@ This document describes the available endpoints and their usage. All endpoints r
- [Message Format](#message-format)
- [Core Events](#core-events)
- [Connection Events](#connection-events)
- [`connect` (Built-in)](#connect-built-in)
- [`disconnect` (Built-in)](#disconnect-built-in)
- [`connect` (Server → Client)](#connect-server--client)
- [`disconnect` (Server → Client)](#disconnect-server--client)
- [`configuration` (Server → Client)](#configuration-server--client)
- [`config_update` (Server → Client)](#config_update-server--client)
- [`connected` (Server → Client)](#connected-server--client)
@ -106,7 +108,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [`log_success` (Server → Client)](#log_success-server--client)
- [`log_warning` (Server → Client)](#log_warning-server--client)
- [`log_error` (Server → Client)](#log_error-server--client)
- [`log_lines` (Server → Client)](#log_lines-server--client)
- [Download Queue Events](#download-queue-events)
- [`item_added` (Server → Client)](#item_added-server--client)
- [`item_updated` (Server → Client)](#item_updated-server--client)
@ -117,10 +118,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [`item_status` (Server → Client)](#item_status-server--client)
- [`paused` (Server → Client)](#paused-server--client)
- [`resumed` (Server → Client)](#resumed-server--client)
- [Terminal/CLI Events](#terminalcli-events)
- [`cli_post` (Client → Server)](#cli_post-client--server)
- [`cli_output` (Server → Client)](#cli_output-server--client)
- [`cli_close` (Server → Client)](#cli_close-server--client)
- [Error Responses](#error-responses)
---
@ -1726,6 +1723,26 @@ Binary image data with appropriate headers
---
### GET /api/logs/stream
**Purpose**: Stream live log lines via Server-Sent Events (SSE).
**Response**:
- `Content-Type: text/event-stream`
- Emits `log_lines` events with a log line payload.
**Event Payload**:
```json
{
"id": "<sha256>",
"line": "<log line>",
"datetime": "2024-01-01T12:00:00.000000+00:00"
}
```
- Returns `404 Not Found` if file logging is not enabled or the log file is missing.
---
### GET /api/notifications/
**Purpose**: Retrieve notification targets with pagination.
@ -1904,6 +1921,33 @@ or an error:
---
### POST /api/system/terminal
**Purpose**: Stream yt-dlp CLI output via Server-Sent Events (SSE). Requires `YTP_CONSOLE_ENABLED=true`.
**Body**:
```json
{
"command": "--help"
}
```
**Response**:
- `Content-Type: text/event-stream`
- Emits `output` events for stdout/stderr and a final `close` event when the process exits.
**Event Payloads**:
```json
{ "type": "stdout", "line": "<output line>" }
```
```json
{ "exitcode": 0 }
```
- `403 Forbidden` if console is disabled.
- `400 Bad Request` if the request body is invalid.
---
### POST /api/system/pause
**Purpose**: Pause all non-active downloads in the queue.
@ -2013,7 +2057,7 @@ or an error:
## WebSocket API
The WebSocket API provides real-time bidirectional communication between the client and server using Socket.IO protocol. It enables live updates for downloads, queue status, notifications, and terminal access.
The WebSocket API provides real-time bidirectional communication between the client and server WebSockets. It enables live updates for downloads, queue status, and notifications.
> ![IMPORTANT]
> The WebSocket API is unstable and many events will be moved to REST endpoints in future releases.
@ -2021,20 +2065,9 @@ The WebSocket API provides real-time bidirectional communication between the cli
### Connection
**URL**: `ws://localhost:8081/socket.io/` (development) or `https://yourdomain.com/socket.io/` (production)
**URL**: `ws://localhost:8081/ws` (development) or `wss://yourdomain.com/ws` (production)
The client automatically connects to the WebSocket server and receives a `connected` event with initial state. The connection uses automatic reconnection with exponential backoff (default: up to 30 attempts, 5s delay).
**Connection Options**:
```javascript
{
transports: ['websocket', 'polling'], // Fallback to long-polling if WebSocket unavailable
withCredentials: true, // Include cookies for authentication
reconnection: true, // Enable automatic reconnection
reconnectionAttempts: 30, // Max reconnection attempts
reconnectionDelay: 5000 // Initial reconnection delay in ms
}
```
The client automatically connects to the WebSocket server and receives a `connected` event with initial state. The frontend wrapper handles reconnection (default: up to 50 attempts, 5s delay).
### Authentication
@ -2047,37 +2080,42 @@ If Basic Authentication is configured, include credentials when establishing the
2. **Via Query Parameter**:
```
ws://localhost:8081/socket.io/?apikey=<base64_urlsafe("<username>:<password>")>
ws://localhost:8081/ws?apikey=<base64_urlsafe("<username>:<password>")>
```
### Message Format
All WebSocket messages are JSON-encoded and follow a consistent structure:
All WebSocket messages are JSON-encoded and follow a consistent envelope:
**Server-to-Client (Event)** - Emitted by server, received by client:
**Server-to-Client (Event Envelope)**
```json
{
"id": "unique-event-id",
"created_at": "2024-01-15T10:30:00.000000+00:00",
"event": "item_added",
"title": "Item Queued",
"message": "Video added to download queue",
"data": {...}
"data": {
"id": "unique-event-id",
"created_at": "2024-01-15T10:30:00.000000+00:00",
"event": "item_added",
"title": "Item Queued",
"message": "Video added to download queue",
"data": {"_id": "abc123", "title": "..."}
}
}
```
> **Note**: Every message follows the same envelope and `data` is always a JSON object.
### Core Events
#### Connection Events
##### `connect` (Built-in)
##### `connect` (Server → Client)
Fired when WebSocket connection is established. No data payload.
```typescript
socket.on('connect', () => console.log('WebSocket connected'));
```
##### `disconnect` (Built-in)
##### `disconnect` (Server → Client)
Fired when WebSocket connection is closed. No data payload.
```typescript
@ -2140,12 +2178,7 @@ Warning notification message.
##### `log_error` (Server → Client)
Error notification message.
##### `log_lines` (Server → Client)
Continuous application log lines (requires subscription event).
**Data Fields**:
- `line`: Log line content
- `timestamp`: Log timestamp
For continuous log streaming, use `GET /api/logs/stream` via SSE.
### Download Queue Events
@ -2239,28 +2272,6 @@ Emitted when the download queue is paused.
#### `resumed` (Server → Client)
Emitted when the download queue is resumed.
### Terminal/CLI Events
The terminal feature requires `YTP_CONSOLE_ENABLED=true`.
#### `cli_post` (Client → Server)
Execute a command via yt-dlp CLI.
**Data**: Command arguments as string (without `yt-dlp` prefix)
#### `cli_output` (Server → Client)
Output line from the CLI command execution.
**Data Fields**:
- `type`: Output type (`stdout` or `stderr`)
- `line`: Output line content
#### `cli_close` (Server → Client)
Emitted when CLI command execution completes.
**Data Fields**:
- `exitcode`: Command exit code (0 = success, non-zero = error)
---
## Error Responses

View file

@ -75,3 +75,9 @@ def format_validation_errors(exc: ValidationError) -> list[dict[str, Any]]:
}
for error in exc.errors()
]
def gen_random(length: int = 16) -> str:
import secrets
return "".join(secrets.token_urlsafe(length)[:length])

View file

@ -1,11 +1,12 @@
import asyncio
import datetime
import logging
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from app.features.core.utils import gen_random
from .BackgroundWorker import BackgroundWorker
from .Singleton import Singleton
@ -50,10 +51,6 @@ class Events:
PAUSED: str = "paused"
RESUMED: str = "resumed"
CLI_POST: str = "cli_post"
CLI_CLOSE: str = "cli_close"
CLI_OUTPUT: str = "cli_output"
TASKS_ADD: str = "task_add"
TASK_DISPATCHED: str = "task_dispatched"
TASK_FINISHED: str = "task_finished"
@ -61,9 +58,6 @@ class Events:
SCHEDULE_ADD: str = "schedule_add"
SUBSCRIBED: str = "subscribed"
UNSUBSCRIBED: str = "unsubscribed"
def get_all() -> list:
"""
Get all the events.
@ -101,8 +95,6 @@ class Events:
Events.ITEM_STATUS,
Events.PAUSED,
Events.RESUMED,
Events.CLI_CLOSE,
Events.CLI_OUTPUT,
]
def only_debug() -> list:
@ -113,7 +105,7 @@ class Events:
list: The list of debug events.
"""
return [Events.ITEM_UPDATED, Events.CLI_OUTPUT]
return [Events.ITEM_UPDATED]
@dataclass(kw_only=True)
@ -122,7 +114,7 @@ class Event:
Event is a data transfer object that represents an event that was emitted.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False)
id: str = field(default_factory=lambda: str(gen_random(16)), init=False)
"""The id of the event."""
created_at: str = field(default_factory=lambda: str(datetime.datetime.now(tz=datetime.UTC).isoformat()))
@ -258,7 +250,7 @@ class EventBus(metaclass=Singleton):
event = [event]
if not name:
name = str(uuid.uuid4())
name = gen_random(12)
for e in event:
if e not in all_events:

View file

@ -1,12 +1,13 @@
import functools
import json
import logging
from pathlib import Path
from typing import Any
import socketio
from aiohttp import web
from app.library.router import RouteType, get_routes
from app.features.core.utils import gen_random
from app.library.router import Route, RouteType, get_routes
from app.library.Services import Services
from app.library.Utils import load_modules
@ -18,13 +19,55 @@ from .ItemDTO import Item
LOG: logging.Logger = logging.getLogger("socket_api")
class WebSocketHub:
def __init__(self, encoder: Encoder):
self._encoder: Encoder = encoder
self._clients: dict[str, web.WebSocketResponse] = {}
def add(self, sid: str, ws: web.WebSocketResponse) -> None:
self._clients[sid] = ws
def remove(self, sid: str) -> None:
self._clients.pop(sid, None)
async def emit(self, event: str, data: Any, to: str | None = None) -> None:
payload: str = self._encoder.encode({"event": event, "data": data})
if to:
await self._send(to, payload)
return
for sid in list(self._clients.keys()):
await self._send(sid, payload)
async def disconnect(self, sid: str) -> None:
ws: web.WebSocketResponse | None = self._clients.pop(sid, None)
if ws and not ws.closed:
await ws.close()
async def disconnect_all(self) -> None:
for sid in list(self._clients.keys()):
await self.disconnect(sid)
async def _send(self, sid: str, payload: str) -> None:
ws: web.WebSocketResponse | None = self._clients.get(sid)
if not ws or ws.closed:
self._clients.pop(sid, None)
return
try:
await ws.send_str(payload)
except ConnectionResetError:
self._clients.pop(sid, None)
class HttpSocket:
"""
This class is used to handle WebSocket events.
"""
config: Config
sio: socketio.AsyncServer
sio: WebSocketHub
di_context: dict[str, Any] = {}
def __init__(
@ -32,26 +75,18 @@ class HttpSocket:
root_path: Path,
encoder: Encoder | None = None,
config: Config | None = None,
sio: socketio.AsyncServer | None = None,
sio: WebSocketHub | None = None,
):
self.config = config or Config.get_instance()
self._notify = EventBus.get_instance()
self.sio = sio or socketio.AsyncServer(
async_handlers=True,
async_mode="aiohttp",
cors_allowed_origins="*",
transports=["websocket", "polling"],
logger=self.config.debug,
engineio_logger=self.config.debug,
ping_interval=10,
ping_timeout=5,
)
encoder = encoder or Encoder()
self.sio = sio or WebSocketHub(encoder=encoder)
self.rootPath: Path = root_path
async def event_handler(e: Event, _, **kwargs):
await self.sio.emit(event=e.event, data=encoder.encode(e), **kwargs)
payload = json.loads(encoder.encode(e))
await self.sio.emit(event=e.event, data=payload, **kwargs)
services: Services = Services.get_instance()
services.add_all(
@ -85,38 +120,86 @@ class HttpSocket:
async def on_shutdown(self, _: web.Application):
LOG.debug("Shutting down socket server.")
for sid in self.sio.manager.get_participants("/", None):
LOG.debug(f"Disconnecting client '{sid}'.")
await self.sio.disconnect(sid[0], namespace="/")
await self.sio.disconnect_all()
LOG.debug("Socket server shutdown complete.")
def attach(self, app: web.Application):
app.on_shutdown.append(self.on_shutdown)
self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
base_path: str = self.config.base_path.rstrip("/")
ws_path: str = f"{base_path}/ws" if base_path else "/ws"
async def event_handler(data: Event, _):
if data and data.data:
await Services.get_instance().get("queue").add(item=Item.format(data.data))
if not (data and data.data):
return
await Services.get_instance().get("queue").add(item=Item.format(data.data))
self._notify.subscribe(Events.ADD_URL, event_handler, f"{__class__.__name__}.add")
load_modules(self.rootPath, self.rootPath / "routes" / "socket")
for route in get_routes(RouteType.SOCKET).values():
socket_routes: dict[str, Route] = {route.path: route for route in get_routes(RouteType.SOCKET).values()}
for route in socket_routes.values():
if self.config.debug:
LOG.debug(
f"Add ({route.name}) {route.method.value if isinstance(route.method, RouteType) else route.method}: {route.path}."
)
self.sio.on(route.path)(HttpSocket._injector(route.handler, route.path))
@staticmethod
def _injector(func, event: str):
async def wrapper(sid, data=None, **kwargs):
if not data:
data = {}
return await Services.get_instance().handle_async(func, sid=sid, data=data, event=event, **kwargs)
async def handle_message(sid: str, message: str) -> None:
try:
payload = json.loads(message)
except json.JSONDecodeError:
LOG.debug("Invalid websocket payload received.")
return
return wrapper
event = payload.get("event") if isinstance(payload, dict) else None
if not event or not isinstance(event, str):
LOG.debug("Missing websocket event name.")
return
if not (route := socket_routes.get(event)):
LOG.debug(f"Unknown websocket event '{event}'.")
return
data = payload.get("data") if isinstance(payload, dict) else None
await Services.get_instance().handle_async(route.handler, sid=sid, data=data, event=event)
async def handle_connect(sid: str) -> None:
if not (route := socket_routes.get("connect")):
return
await Services.get_instance().handle_async(route.handler, sid=sid, event="connect")
async def handle_disconnect(sid: str, reason: str | None) -> None:
if not (route := socket_routes.get("disconnect")):
return
await Services.get_instance().handle_async(route.handler, sid=sid, data=reason, event="disconnect")
async def ws_handler(request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse(heartbeat=10)
await ws.prepare(request)
sid: str = gen_random(14)
self.sio.add(sid, ws)
LOG.debug(f"WebSocket client '{sid}' connected.")
await handle_connect(sid)
try:
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
await handle_message(sid, msg.data)
elif msg.type == web.WSMsgType.ERROR:
LOG.error(f"WebSocket connection closed with exception {ws.exception()}")
finally:
await handle_disconnect(sid, getattr(ws, "close_reason", None))
self.sio.remove(sid)
LOG.debug(f"WebSocket client '{sid}' disconnected.")
return ws
if self.config.debug:
LOG.debug(f"Add (ws) GET: {ws_path}.")
app.router.add_get(ws_path, ws_handler, name="ws")

View file

@ -1,3 +1,5 @@
import asyncio
import logging
from pathlib import Path
from aiohttp import web
@ -6,7 +8,9 @@ from aiohttp.web import Request, Response
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.router import route
from app.library.Utils import read_logfile
from app.library.Utils import read_logfile, tail_log
LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", "api/logs/", "logs")
@ -48,3 +52,56 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("GET", "api/logs/stream", "logs.stream")
async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse:
if not config.file_logging:
return web.json_response(
data={"error": "File logging is not enabled."},
status=web.HTTPNotFound.status_code,
)
log_file = Path(config.config_path) / "logs" / "app.log"
if not log_file.exists():
return web.json_response(
data={"error": "Log file is not available."},
status=web.HTTPNotFound.status_code,
)
response = web.StreamResponse(
status=web.HTTPOk.status_code,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
await response.prepare(request)
async def emit_log(data: dict) -> None:
if request.transport is None or request.transport.is_closing():
raise asyncio.CancelledError
payload = f"event: log_lines\ndata: {encoder.encode(data)}\n\n"
await response.write(payload.encode("utf-8"))
log_task: asyncio.Task[None] = asyncio.create_task(tail_log(file=log_file, emitter=emit_log), name="log_stream")
try:
LOG.debug("Log streaming connected.")
while not log_task.done():
await asyncio.sleep(0.5)
if request.transport is None or request.transport.is_closing():
log_task.cancel()
break
await log_task
except asyncio.CancelledError:
pass
finally:
LOG.debug("Log streaming disconnected.")
try:
await response.write_eof()
except ConnectionResetError:
pass
return response

View file

@ -1,6 +1,10 @@
import asyncio
import errno
import logging
import os
import shlex
import time
from typing import TYPE_CHECKING
from aiohttp import web
from aiohttp.web import Request, Response
@ -13,6 +17,11 @@ from app.library.Events import EventBus, Events
from app.library.router import route
from app.library.UpdateChecker import UpdateChecker
if TYPE_CHECKING:
from asyncio import Task
from asyncio.events import AbstractEventLoop
from asyncio.subprocess import Process
LOG: logging.Logger = logging.getLogger(__name__)
@ -178,3 +187,169 @@ async def check_updates(config: Config, encoder: Encoder, update_checker: Update
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
LOG: logging.Logger = logging.getLogger(__name__)
@route("POST", "api/system/terminal", "system.terminal")
async def stream_terminal(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse:
if not config.console_enabled:
return web.json_response(
{"error": "Console feature is disabled."},
status=web.HTTPForbidden.status_code,
)
if not request.can_read_body:
return web.json_response(
{"error": "Request body is required."},
status=web.HTTPBadRequest.status_code,
)
payload = await request.json()
if not isinstance(payload, dict):
return web.json_response(
{"error": "Invalid request payload."},
status=web.HTTPBadRequest.status_code,
)
raw_command = payload.get("command")
if not isinstance(raw_command, str) or not raw_command.strip():
return web.json_response(
{"error": "Command is required."},
status=web.HTTPBadRequest.status_code,
)
response = web.StreamResponse(
status=web.HTTPOk.status_code,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
await response.prepare(request)
async def emit_event(event: str, data: dict) -> None:
if request.transport is None or request.transport.is_closing():
return
payload: str = f"event: {event}\ndata: {encoder.encode(data)}\n\n"
await response.write(payload.encode("utf-8"))
returncode: int = -1
try:
LOG.info("Cli command from client. '%s'", raw_command)
args: list[str] = ["yt-dlp", *shlex.split(raw_command, posix=os.name != "nt")]
env_vars: dict[str, str] = os.environ.copy()
env_vars.update(
{
"PWD": config.download_path,
"FORCE_COLOR": "1",
"PYTHONUNBUFFERED": "1",
}
)
if "nt" != os.name:
env_vars.update(
{
"TERM": "xterm-256color",
"LANG": "en_US.UTF-8",
"LC_ALL": "en_US.UTF-8",
"SHELL": "/bin/bash",
}
)
try:
import pty
master_fd, slave_fd = pty.openpty()
stdin_arg = asyncio.subprocess.DEVNULL
stdout_arg = stderr_arg = slave_fd
use_pty = True
except ImportError:
use_pty = False
master_fd = slave_fd = None
stdin_arg = asyncio.subprocess.DEVNULL
stdout_arg = asyncio.subprocess.PIPE
stderr_arg = asyncio.subprocess.STDOUT
creationflags = 0
if os.name == "nt":
import subprocess
creationflags = subprocess.CREATE_NO_WINDOW
proc: Process = await asyncio.create_subprocess_exec(
*args,
cwd=config.download_path,
stdin=stdin_arg,
stdout=stdout_arg,
stderr=stderr_arg,
env=env_vars,
creationflags=creationflags,
)
if use_pty:
assert slave_fd is not None
try:
os.close(slave_fd)
except Exception as e:
LOG.error("Error closing PTY. '%s'.", str(e))
async def reader() -> None:
if use_pty is False:
assert proc.stdout is not None
async for raw_line in proc.stdout:
line = raw_line.rstrip(b"\n")
await emit_event("output", {"type": "stdout", "line": line.decode("utf-8", errors="replace")})
return
assert master_fd is not None
read_fd = master_fd
loop: AbstractEventLoop = asyncio.get_running_loop()
buffer: bytes = b""
while True:
try:
chunk: bytes = await loop.run_in_executor(None, lambda: os.read(read_fd, 1024))
except OSError as e:
if e.errno == errno.EIO:
break
raise
if not chunk:
if buffer:
await emit_event(
"output",
{"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
)
break
buffer += chunk
*lines, buffer = buffer.split(b"\n")
for line in lines:
await emit_event(
"output",
{"type": "stdout", "line": line.decode("utf-8", errors="replace")},
)
if master_fd is None:
return
try:
os.close(master_fd)
except Exception as e:
LOG.error("Error closing PTY. '%s'.", str(e))
read_task: Task = asyncio.create_task(reader(), name="cli_reader")
returncode = await proc.wait()
await read_task
except Exception as e:
LOG.error("CLI execute exception was thrown.")
LOG.exception(e)
await emit_event("output", {"type": "stderr", "line": str(e)})
finally:
await emit_event("close", {"exitcode": returncode})
await response.write_eof()
return response

View file

@ -1,24 +1,20 @@
import asyncio
import logging
from pathlib import Path
from typing import Any
import socketio
from app.features.dl_fields.service import DLFields
from app.library.config import Config
from app.library.downloads import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.HttpSocket import WebSocketHub
from app.library.Presets import Presets
from app.library.router import RouteType, route
from app.library.Utils import list_folders, tail_log
from app.library.Utils import list_folders
LOG: logging.Logger = logging.getLogger(__name__)
class _Data:
subscribers: dict[str, list[str]] = {}
log_task: asyncio.Task | None = None
@route(RouteType.SOCKET, "connect", "socket_connect")
@ -62,99 +58,14 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
@route(RouteType.SOCKET, "disconnect", "socket_disconnect")
async def disconnect(sio: socketio.AsyncServer, sid: str, data: str = None):
async def disconnect(sio: WebSocketHub, sid: str, data: str | None = None): # noqa: ARG001
"""
Handle client disconnection.
Args:
sio (socketio.AsyncServer): The Socket.IO server instance.
sio (WebSocketHub): The WebSocket hub instance.
sid (str): The session ID of the client.
data (str): The reason for disconnection.
"""
LOG.debug(f"Client '{sid}' disconnected. {data}")
for event in _Data.subscribers:
if sid in _Data.subscribers[event]:
await unsubscribe(sio=sio, sid=sid, data=event)
@route(RouteType.SOCKET, "subscribe", "socket_subscribe")
async def subscribe(config: Config, notify: EventBus, sio: socketio.AsyncServer, sid: str, data: str | Any):
"""
Subscribe to a specific event.
Args:
config (Config): The configuration instance.
notify (EventBus): The event bus to use for notifications.
sio (socketio.AsyncServer): The Socket.IO server instance.
sid (str): The session ID of the client.
data (str): The event to subscribe to.
"""
if not isinstance(data, str) or not data:
notify.emit(
Events.LOG_ERROR,
title="Subscription Error",
message="Invalid event type was expecting a string.",
to=sid,
)
return
if data not in _Data.subscribers:
_Data.subscribers[data] = []
if sid not in _Data.subscribers[data]:
_Data.subscribers[data].append(sid)
LOG.debug(f"Client '{sid}' subscribed to event '{data}'.")
await sio.emit(Events.SUBSCRIBED, data={"event": data}, to=sid)
async def emit_logs(data: dict):
await subscribe_emit(sio=sio, event="log_lines", data=data)
if "log_lines" == data and _Data.log_task is None:
log_file = Path(config.config_path) / "logs" / "app.log"
LOG.debug(f"Starting tailing '{log_file!s}'.")
_Data.log_task = asyncio.create_task(tail_log(file=log_file, emitter=emit_logs), name="tail_log")
@route(RouteType.SOCKET, "unsubscribe", "socket_unsubscribe")
async def unsubscribe(sio: socketio.AsyncServer, sid: str, data: str):
"""
Unsubscribe from a specific event.
Args:
sio (socketio.AsyncServer): The Socket.IO server instance.
sid (str): The session ID of the client.
data (str): The event to unsubscribe from.
"""
if data not in _Data.subscribers:
return
if sid not in _Data.subscribers[data]:
return
_Data.subscribers[data].remove(sid)
await sio.emit(Events.UNSUBSCRIBED, data={"event": data}, to=sid)
LOG.debug(f"Client '{sid}' unsubscribed from event '{data}'.")
if "log_lines" != data or not _Data.log_task or "log_lines" not in _Data.subscribers:
return
if len(_Data.subscribers["log_lines"]) < 1:
try:
LOG.debug("Stopping log tailing task.")
_Data.log_task.cancel()
_Data.log_task = None
except asyncio.CancelledError:
pass
async def subscribe_emit(sio: socketio.AsyncServer, event: str, data: dict):
if event not in _Data.subscribers or len(_Data.subscribers[event]) < 1:
return
for sid in _Data.subscribers[event]:
await sio.emit(event=event, data=data, to=sid)

View file

@ -1,146 +0,0 @@
import logging
import os
from typing import TYPE_CHECKING
from app.library.config import Config
from app.library.Events import EventBus, Events
from app.library.router import RouteType, route
if TYPE_CHECKING:
from asyncio import Task
from asyncio.events import AbstractEventLoop
from asyncio.subprocess import Process
LOG: logging.Logger = logging.getLogger(__name__)
@route(RouteType.SOCKET, "cli_post", "socket_cli_post")
async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
if not config.console_enabled:
notify.emit(
Events.LOG_ERROR,
title="Feature disabled",
message="Console feature is disabled.",
to=sid,
)
return
if not data:
notify.emit(Events.CLI_CLOSE, data={"exitcode": 0}, to=sid)
return
import asyncio
import errno
import shlex
import subprocess # ignore
returncode: int = -1
try:
LOG.info(f"Cli command from client '{sid}'. '{data}'")
args: list[str] = ["yt-dlp", *shlex.split(data, posix=os.name != "nt")]
_env: dict[str, str] = os.environ.copy()
_env.update(
{
"PWD": config.download_path,
"FORCE_COLOR": "1",
"PYTHONUNBUFFERED": "1",
}
)
if "nt" != os.name:
_env.update(
{
"TERM": "xterm-256color",
"LANG": "en_US.UTF-8",
"LC_ALL": "en_US.UTF-8",
"SHELL": "/bin/bash",
}
)
try:
import pty
master_fd, slave_fd = pty.openpty()
stdin_arg = asyncio.subprocess.DEVNULL
stdout_arg = stderr_arg = slave_fd
use_pty = True
except ImportError:
use_pty = False
master_fd = slave_fd = None
stdin_arg = asyncio.subprocess.DEVNULL
stdout_arg = asyncio.subprocess.PIPE
stderr_arg = asyncio.subprocess.STDOUT
proc: Process = await asyncio.create_subprocess_exec(
*args,
cwd=config.download_path,
stdin=stdin_arg,
stdout=stdout_arg,
stderr=stderr_arg,
env=_env,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
if use_pty:
try:
os.close(slave_fd)
except Exception as e:
LOG.error(f"Error closing PTY. '{e!s}'.")
async def reader(sid: str):
if use_pty is False:
assert proc.stdout is not None
async for raw_line in proc.stdout:
line = raw_line.rstrip(b"\n")
notify.emit(
Events.CLI_OUTPUT,
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
to=sid,
)
return
loop: AbstractEventLoop = asyncio.get_running_loop()
buffer: bytes = b""
while True:
try:
chunk: bytes = await loop.run_in_executor(None, lambda: os.read(master_fd, 1024))
except OSError as e:
if e.errno == errno.EIO:
break
raise
if not chunk:
if buffer:
notify.emit(
Events.CLI_OUTPUT,
data={"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
to=sid,
)
break
buffer += chunk
*lines, buffer = buffer.split(b"\n")
for line in lines:
notify.emit(
Events.CLI_OUTPUT,
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
to=sid,
)
try:
os.close(master_fd)
except Exception as e:
LOG.error(f"Error closing PTY. '{e!s}'.")
read_task: Task = asyncio.create_task(reader(sid=sid), name=f"cli_reader_{sid}")
returncode: int = await proc.wait()
await read_task
except Exception as e:
LOG.error(f"CLI execute exception was thrown for client '{sid}'.")
LOG.exception(e)
notify.emit(Events.CLI_OUTPUT, data={"type": "stderr", "line": str(e)}, to=sid)
finally:
notify.emit(Events.CLI_CLOSE, data={"exitcode": returncode}, to=sid)

View file

@ -105,7 +105,6 @@ class TestEvents:
assert isinstance(debug_events, list)
assert Events.ITEM_UPDATED in debug_events
assert Events.CLI_OUTPUT in debug_events
class TestEvent:
@ -783,10 +782,6 @@ class TestEventBus:
assert Events.TEST in bus._listeners
assert len(bus._listeners[Events.TEST]) == 1
# Name should be a UUID
subscriber_name = bus._listeners[Events.TEST][0][0]
assert len(subscriber_name) == 36 # UUID string length
@patch("app.library.config.Config")
@patch("app.library.BackgroundWorker.BackgroundWorker")
def test_event_bus_unsubscribe_single_event(self, mock_bg_worker, mock_config):

View file

@ -13,7 +13,6 @@ dependencies = [
# NonWindows
"python-magic>=0.4.27; sys_platform != 'win32'",
# Crossplatform
"python-socketio>=5.11.1",
"aiohttp>=3.9.3",
"coloredlogs>=15.0.1",
"aiocron>=1.8",

View file

@ -112,18 +112,19 @@
</template>
<script setup lang="ts">
import { fetchEventSource } from '@microsoft/fetch-event-source'
import type { EventSourceMessage } from '@microsoft/fetch-event-source'
import '@xterm/xterm/css/xterm.css'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { useStorage } from '@vueuse/core'
import { disableOpacity, enableOpacity } from '~/utils'
import { disableOpacity, enableOpacity, parse_api_error, uri } from '~/utils'
import InputAutocomplete from '~/components/InputAutocomplete.vue'
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
import { useDialog } from '~/composables/useDialog'
import type { AutoCompleteOptions } from '~/types/autocomplete'
const config = useConfigStore()
const socket = useSocketStore()
const toast = useNotification()
const dialog = useDialog()
@ -137,6 +138,7 @@ const isLoading = ref<boolean>(false)
const storedCommand = useStorage<string>('console_command', '')
const commandHistory = useStorage<string[]>('console_command_history', [])
const isHistoryCollapsed = useStorage<boolean>('console_history_collapsed', false)
const sseController = ref<AbortController | null>(null)
const MAX_HISTORY_ITEMS = 50
const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags
@ -228,6 +230,87 @@ const handle_event = () => {
terminalFit.value?.fit()
}
const handleStreamMessage = (event: EventSourceMessage) => {
if (!terminal.value) {
return
}
let payload: { type?: string; line?: string; exitcode?: number } | null = null
if (event.data) {
try {
payload = JSON.parse(event.data) as { type?: string; line?: string; exitcode?: number }
} catch {
payload = null
}
}
if ('output' === event.event) {
terminal.value.writeln(payload?.line ?? '')
return
}
if ('close' === event.event) {
isLoading.value = false
sseController.value?.abort()
}
}
const startStream = async (cmd: string) => {
sseController.value?.abort()
const controller = new AbortController()
sseController.value = controller
isLoading.value = true
try {
await fetchEventSource(uri('/api/system/terminal'), {
method: 'POST',
body: JSON.stringify({ command: cmd }),
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
credentials: 'same-origin',
signal: controller.signal,
onopen: async (response) => {
if (response.ok) {
return
}
let message = response.statusText || 'Failed to start command stream.'
try {
message = await parse_api_error(response.clone().json())
} catch {
try {
const text = await response.text()
if (text) {
message = text
}
} catch {
message = response.statusText || 'Failed to start command stream.'
}
}
throw new Error(message)
},
onmessage: handleStreamMessage,
onerror: (error) => {
if (controller.signal.aborted) {
return
}
terminal.value?.writeln(`Error: ${error}`)
isLoading.value = false
},
})
} catch (error) {
if (!controller.signal.aborted) {
terminal.value?.writeln(`Error: ${error}`)
isLoading.value = false
}
} finally {
if (controller === sseController.value) {
sseController.value = null
}
}
}
const ensureTerminal = async () => {
if (terminal.value) {
return
@ -284,8 +367,7 @@ const runCommand = async () => {
return
}
socket.emit('cli_post', cmd)
isLoading.value = true
await startStream(cmd)
terminal.value?.writeln(`user@YTPTube ~`)
terminal.value?.writeln(`$ yt-dlp ${command.value}`)
storedCommand.value = ''
@ -342,24 +424,11 @@ const clearHistory = async () => {
const removeFromHistory = (index: number) => commandHistory.value = commandHistory.value.filter((_, i) => i !== index)
const writer = (s: string) => {
if (!terminal.value) {
return
}
terminal.value.writeln(JSON.parse(s).data.line)
}
const loader = () => isLoading.value = false
watch(isMultiLineInput, () => focusInput())
onMounted(async () => {
document.addEventListener('resize', handle_event);
focusInput()
socket.off('cli_close', loader)
socket.off('cli_output', writer)
socket.on('cli_close', loader)
socket.on('cli_output', writer)
disableOpacity()
@ -372,8 +441,7 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
socket.off('cli_close', loader)
socket.off('cli_output', writer)
sseController.value?.abort()
document.removeEventListener('resize', handle_event)
enableOpacity()
});

View file

@ -110,14 +110,16 @@ code {
</template>
<script setup lang="ts">
import { fetchEventSource } from '@microsoft/fetch-event-source'
import type { EventSourceMessage } from '@microsoft/fetch-event-source'
import moment from 'moment'
import { useStorage } from '@vueuse/core'
import type { log_line } from '~/types/logs'
import { parse_api_error, uri } from '~/utils'
let scrollTimeout: NodeJS.Timeout | null = null
const toast = useNotification()
const socket = useSocketStore()
const config = useConfigStore()
const route = useRoute()
@ -126,6 +128,7 @@ const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker')
const textWrap = useStorage<boolean>('logs_wrap', true)
const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
const sseController = ref<AbortController | null>(null)
const logs = ref<Array<log_line>>([])
const offset = ref<number>(0)
@ -269,8 +272,27 @@ const scrollToBottom = (fast = false) => {
})
}
const log_handler = (data: log_line) => {
logs.value.push(data)
const handleStreamMessage = (event: EventSourceMessage) => {
if ('log_lines' !== event.event) {
return
}
if (!event.data) {
return
}
let payload: log_line | null = null
try {
payload = JSON.parse(event.data) as log_line
} catch {
payload = null
}
if (!payload) {
return
}
logs.value.push(payload)
nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
@ -279,28 +301,71 @@ const log_handler = (data: log_line) => {
})
}
const startLogStream = async () => {
sseController.value?.abort()
const controller = new AbortController()
sseController.value = controller
try {
await fetchEventSource(uri('/api/logs/stream'), {
method: 'GET',
headers: {
'Accept': 'text/event-stream',
},
credentials: 'same-origin',
signal: controller.signal,
onopen: async (response) => {
if (response.ok) {
return
}
let message = response.statusText || 'Failed to start log stream.'
try {
message = await parse_api_error(response.clone().json())
} catch {
try {
const text = await response.text()
if (text) {
message = text
}
} catch {
message = response.statusText || 'Failed to start log stream.'
}
}
throw new Error(message)
},
onmessage: handleStreamMessage,
onerror: (error) => {
if (controller.signal.aborted) {
return
}
console.error('Log stream error:', error)
},
})
} catch (error) {
if (!controller.signal.aborted) {
console.error('Log stream error:', error)
}
} finally {
if (controller === sseController.value) {
sseController.value = null
}
}
}
onMounted(async () => {
await fetchLogs()
socket.on('log_lines', log_handler)
socket.emit('subscribe', 'log_lines')
await startLogStream()
if (bg_enable.value) {
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
}
})
onBeforeUnmount(() => {
socket.emit('unsubscribe', 'log_lines')
socket.off('log_lines', log_handler)
sseController.value?.abort()
if (bg_enable.value) {
document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
}
})
onBeforeUnmount(() => {
socket.emit('unsubscribe', 'log_lines')
socket.off('log_lines', log_handler)
if (scrollTimeout) clearTimeout(scrollTimeout)
})
useHead({ title: 'Logs' })

View file

@ -472,6 +472,7 @@ import Modal from '~/components/Modal.vue'
import { useConfirm } from '~/composables/useConfirm'
import TaskInspect from '~/components/TaskInspect.vue'
import type { task_item, exported_task, error_response } from '~/types/tasks'
import type { WSEP } from '~/types/sockets'
import { sleep } from '~/utils'
import { useSessionCache } from '~/utils/cache'
@ -959,12 +960,11 @@ const runNow = async (item: task_item, mass: boolean = false) => {
onBeforeUnmount(() => socket.off('item_status', statusHandler))
const statusHandler = async (stream: string) => {
const json = JSON.parse(stream)
const { status, msg } = json.data
const statusHandler = async (payload: WSEP['item_status']) => {
const { status, msg } = payload.data || {}
if ('error' === status) {
toast.error(msg)
toast.error(msg ?? 'Unknown error')
return
}
}

View file

@ -1,42 +1,103 @@
import { io, type Socket as IOSocket, type SocketOptions, type ManagerOptions } from "socket.io-client"
import { ref, readonly } from 'vue'
import { defineStore } from 'pinia'
import type { ConfigState } from "~/types/config";
import type { StoreItem } from "~/types/store";
import type { ConfigUpdatePayload } from "~/types/sockets";
import type {
ConfigUpdatePayload,
WebSocketClientEmits,
WebSocketEnvelope,
WSEP as WSEP
} from "~/types/sockets";
export type connectionStatus = 'connected' | 'disconnected' | 'connecting';
type SocketHandler = (...args: unknown[]) => void
type HandlerRegistry = Map<SocketHandler, SocketHandler>
type KnownEvent = keyof WSEP
export const useSocketStore = defineStore('socket', () => {
const runtimeConfig = useRuntimeConfig()
const config = useConfigStore()
const stateStore = useStateStore()
const toast = useNotification()
const socket = ref<IOSocket | null>(null)
const socket = ref<WebSocket | null>(null)
const isConnected = ref<boolean>(false)
const connectionStatus = ref<connectionStatus>('disconnected')
const error = ref<string | null>(null)
const error_count = ref<number>(0)
const wasHidden = ref<boolean>(false)
const reconnectTimeout = ref<NodeJS.Timeout | null>(null)
const manualDisconnect = ref<boolean>(false)
const reconnectAttempts = ref<number>(0)
const emit = (event: string, data?: any): any => socket.value?.emit(event, data)
const on = (event: string | string[], callback: (...args: any[]) => void, withEvent: boolean = false) => {
if (!Array.isArray(event)) {
event = [event]
const handlers = new Map<string, HandlerRegistry>()
const emit = <K extends keyof WebSocketClientEmits>(event: K, data: WebSocketClientEmits[K]): void => {
if (!socket.value || WebSocket.OPEN !== socket.value.readyState) {
return
}
event.forEach(e => socket.value?.on(e, (...args) => true === withEvent ? callback(e, ...args) : callback(...args)))
socket.value.send(JSON.stringify({ event, data }))
}
const off = (event: string | string[], callback?: (...args: any[]) => void): any => {
if (!Array.isArray(event)) {
event = [event]
}
event.forEach(e => socket.value?.off(e, callback));
function on<K extends KnownEvent>(event: K, callback: (payload: WSEP[K]) => void): void
function on<K extends KnownEvent>(event: K[], callback: (payload: WSEP[K]) => void): void
function on<K extends KnownEvent>(
event: K | K[],
callback: (event: K, payload: WSEP[K]) => void,
withEvent: true
): void
function on(event: string | string[], callback: SocketHandler, withEvent?: boolean): void
function on(event: string | string[], callback: SocketHandler, withEvent: boolean = false): void {
const events = Array.isArray(event) ? event : [event]
events.forEach((eventName) => {
if (!handlers.has(eventName)) {
handlers.set(eventName, new Map())
}
const registry = handlers.get(eventName) as HandlerRegistry
const handler = true === withEvent
? (payload: unknown) => callback(eventName, payload)
: (payload: unknown) => callback(payload)
registry.set(callback, handler)
})
}
const getSessionId = (): string | null => socket.value?.id || null
function off<K extends KnownEvent>(event: K, callback?: (payload: WSEP[K]) => void): void
function off<K extends KnownEvent>(event: K[], callback?: (payload: WSEP[K]) => void): void
function off(event: string | string[], callback?: SocketHandler): void
function off(event: string | string[], callback?: SocketHandler): void {
const events = Array.isArray(event) ? event : [event]
events.forEach((eventName) => {
const registry = handlers.get(eventName)
if (!registry) {
return
}
if (!callback) {
registry.clear()
handlers.delete(eventName)
return
}
registry.delete(callback)
if (0 === registry.size) {
handlers.delete(eventName)
}
})
}
const getSessionId = (): string | null => null
const dispatch = (eventName: string, payload: unknown): void => {
const registry = handlers.get(eventName)
if (!registry) {
return
}
registry.forEach((handler) => handler(payload))
}
const handleVisibilityChange = () => {
if (document.hidden) {
@ -78,219 +139,266 @@ export const useSocketStore = defineStore('socket', () => {
}
}
const scheduleReconnect = () => {
if (true === manualDisconnect.value || true === isConnected.value) {
return
}
if (reconnectAttempts.value >= 50) {
return
}
if (null !== reconnectTimeout.value) {
return
}
reconnectTimeout.value = setTimeout(() => {
reconnectAttempts.value += 1
reconnectTimeout.value = null
connect()
}, 5000)
}
const reconnect = () => {
if (true === isConnected.value) {
return;
return
}
connect();
connectionStatus.value = 'connecting';
connect()
connectionStatus.value = 'connecting'
}
const disconnect = () => {
manualDisconnect.value = true
if (null === socket.value) {
return;
return
}
socket.value.disconnect();
socket.value = null;
isConnected.value = false;
connectionStatus.value = 'disconnected';
cleanupVisibilityListener();
socket.value.close()
socket.value = null
isConnected.value = false
connectionStatus.value = 'disconnected'
cleanupVisibilityListener()
}
const buildWsUrl = (): string => {
const basePath = runtimeConfig.app.baseURL.replace(/\/$/, '')
const wsPath = `${basePath}/ws?_=${Date.now()}`
const configuredBase = runtimeConfig.public.wss?.trim()
if (configuredBase) {
return new URL(wsPath, configuredBase).toString()
}
const scheme = 'https:' === window.location.protocol ? 'wss' : 'ws'
return new URL(wsPath, `${scheme}://${window.location.host}`).toString()
}
const connect = () => {
const opts = {
transports: ['websocket', 'polling'],
withCredentials: true,
reconnection: true,
reconnectionAttempts: 50,
reconnectionDelay: 5000,
tryAllTransports: true,
timeout: 10000 * 5,
} 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`;
} else {
window.ws = socket.value;
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
return
}
connectionStatus.value = 'connecting';
socket.value = io(url, opts)
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
return
}
on("connect_error", (e: any) => {
isConnected.value = false
if (null === e || undefined === e) {
error.value = 'Connection error: Unknown error';
return;
}
error.value = `Connection error: ${e.type || 'Unknown'}: ${e.message || 'Unknown error'}`;
error_count.value += 1
});
manualDisconnect.value = false
connectionStatus.value = 'connecting'
socket.value = new WebSocket(buildWsUrl())
on('connect', () => {
if ('development' === runtimeConfig.public?.APP_ENV) {
window.ws = socket.value
}
socket.value.addEventListener('open', () => {
isConnected.value = true
connectionStatus.value = 'connected';
error.value = null;
connectionStatus.value = 'connected'
error.value = null
error_count.value = 0
});
reconnectAttempts.value = 0
dispatch('connect', null)
})
on('disconnect', () => {
socket.value.addEventListener('close', () => {
isConnected.value = false
connectionStatus.value = 'disconnected';
error.value = 'Disconnected from server.';
});
on('configuration', stream => {
const json = JSON.parse(stream)
config.setAll({
app: json.data.config,
presets: json.data.presets,
dl_fields: json.data.dl_fields,
paused: Boolean(json.data.paused)
} as Partial<ConfigState>)
connectionStatus.value = 'disconnected'
error.value = 'Disconnected from server.'
dispatch('disconnect', null)
scheduleReconnect()
})
on('connected', stream => {
const json = JSON.parse(stream);
if (!json?.data) {
return;
}
if (json.data?.folders) {
config.add('folders', json.data.folders)
}
if (typeof json.data?.history_count === 'number') {
stateStore.setHistoryCount(json.data.history_count)
}
error.value = null;
socket.value.addEventListener('error', () => {
isConnected.value = false
connectionStatus.value = 'disconnected'
error.value = 'Connection error: Unknown error'
error_count.value += 1
dispatch('connect_error', { message: 'Unknown error' })
scheduleReconnect()
})
on('active_queue', stream => {
const json = JSON.parse(stream);
if (!json?.data?.queue) {
return;
}
stateStore.addAll('queue', json.data.queue || {})
})
on('item_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, {} as StoreItem), 'title')}`);
});
on(['log_info', 'log_success', 'log_warning', 'log_error'], (event: string, stream: string) => {
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;
}
}, true);
on('item_cancelled', (stream: string) => {
const item = JSON.parse(stream);
const id = item.data._id
if (true !== stateStore.has('queue', id)) {
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
let payload: WebSocketEnvelope | null = null
try {
payload = JSON.parse(event.data)
} catch {
return
}
toast.warning(`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`);
if (true === stateStore.has('queue', id)) {
stateStore.remove('queue', id);
}
});
on('item_deleted', (stream: string) => {
const item = JSON.parse(stream);
const id = item.data._id
if (true !== stateStore.has('history', id)) {
if (!payload?.event || 'string' != typeof payload.event) {
return
}
stateStore.remove('history', id);
});
on('item_updated', (stream: string) => {
const json = JSON.parse(stream);
const id = json.data._id;
if (true === stateStore.has('history', id)) {
stateStore.update('history', id, json.data);
return;
}
if (true === stateStore.has('queue', id)) {
stateStore.update('queue', id, json.data);
}
});
on('item_moved', (stream: string) => {
const json = JSON.parse(stream);
const to = json.data.to;
const id = json.data.item._id;
if ('queue' === to) {
if (true === stateStore.has('history', id)) {
stateStore.remove('history', id);
let data = payload.data
if ('string' === typeof data) {
try {
data = JSON.parse(data)
} catch {
data = payload.data
}
stateStore.add('queue', id, json.data.item);
}
if ('history' === to) {
if (true === stateStore.has('queue', id)) {
stateStore.remove('queue', id);
}
stateStore.add('history', id, json.data.item);
}
});
on(['paused', 'resumed'], (event: string, data: string) => {
const json = JSON.parse(data);
const pausedState = Boolean(json.data.paused);
config.update('paused', pausedState);
if ('resumed' === event) {
toast.success('Download queue resumed.');
return;
}
toast.warning('Download queue paused.', { timeout: 10000 });
}, true);
on('config_update', (stream: string) => {
const json = JSON.parse(stream) as { data: ConfigUpdatePayload }
if (!json?.data) {
return
}
config.patch(json.data.feature, json.data.action, json.data.data)
dispatch(payload.event, data)
})
setupVisibilityListener();
setupVisibilityListener()
}
on('configuration', (data: WSEP['configuration']) => {
config.setAll({
app: data.data.config,
presets: data.data.presets,
dl_fields: data.data.dl_fields,
paused: Boolean(data.data.paused)
} as unknown as Partial<ConfigState>)
})
on('connected', (data: WSEP['connected']) => {
if (!data?.data) {
return
}
if (data.data.folders) {
config.add('folders', data.data.folders)
}
if ('number' === typeof data.data.history_count) {
stateStore.setHistoryCount(data.data.history_count)
}
error.value = null
})
on('active_queue', (data: WSEP['active_queue']) => {
if (!data.data.queue) {
return
}
stateStore.addAll('queue', data.data.queue || {})
})
on('item_added', (data: WSEP['item_added']) => {
stateStore.add('queue', data.data._id, data.data)
toast.success(`Item queued: ${ag(stateStore.get('queue', data.data._id, {} as StoreItem), 'title')}`)
})
on(['log_info', 'log_success', 'log_warning', 'log_error'], (event, data: WSEP['log_info']) => {
const message = 'string' === typeof data?.message
? data.message
: String((data?.data as Record<string, unknown>)?.message ?? '')
const extra = (data?.data as Record<string, unknown>)?.data || data?.data || {}
switch (event) {
case 'log_info':
toast.info(message, extra)
break
case 'log_success':
toast.success(message, extra)
break
case 'log_warning':
toast.warning(message, extra)
break
case 'log_error':
toast.error(message, extra)
break
}
}, true)
on('item_cancelled', (data: WSEP['item_cancelled']) => {
const id = data.data._id
if (true !== stateStore.has('queue', id)) {
return
}
toast.warning(`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`)
if (true === stateStore.has('queue', id)) {
stateStore.remove('queue', id)
}
})
on('item_deleted', (data: WSEP['item_deleted']) => {
const id = data.data._id
if (true !== stateStore.has('history', id)) {
return
}
stateStore.remove('history', id)
})
on('item_updated', (data: WSEP['item_updated']) => {
const id = data.data._id
if (true === stateStore.has('history', id)) {
stateStore.update('history', id, data.data)
return
}
if (true === stateStore.has('queue', id)) {
stateStore.update('queue', id, data.data)
}
})
on('item_moved', (data: WSEP['item_moved']) => {
const to = data.data.to
const id = data.data.item._id
if ('queue' === to) {
if (true === stateStore.has('history', id)) {
stateStore.remove('history', id)
}
stateStore.add('queue', id, data.data.item)
}
if ('history' === to) {
if (true === stateStore.has('queue', id)) {
stateStore.remove('queue', id)
}
stateStore.add('history', id, data.data.item)
}
})
on(['paused', 'resumed'], (event, data: WSEP['paused']) => {
const pausedState = Boolean(data.data.paused)
config.update('paused', pausedState)
if ('resumed' === event) {
toast.success('Download queue resumed.')
return
}
toast.warning('Download queue paused.', { timeout: 10000 })
}, true)
on('config_update', (data: WSEP['config_update']) => {
const configUpdate = data.data as ConfigUpdatePayload
if (!configUpdate) {
return
}
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data)
})
if (false === isConnected.value) {
connect();
connect()
}
return {
@ -301,5 +409,5 @@ export const useSocketStore = defineStore('socket', () => {
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
error: readonly(error) as Readonly<Ref<string | null>>,
error_count: readonly(error_count) as Readonly<Ref<number>>,
};
});
}
})

View file

@ -2,6 +2,6 @@ export { }
declare global {
interface Window {
ws?: unknown
ws?: WebSocket
}
}

View file

@ -1,3 +1,6 @@
import type { AppConfig, ConfigState } from "./config"
import type { StoreItem } from "./store"
export type Event = {
id: string
created_at: string
@ -7,6 +10,54 @@ export type Event = {
data: Record<string, unknown>
}
export type EventPayload<T> = Event & { data: T }
export type WebSocketEnvelope<T = unknown> = {
event: string
data: T
}
export type WSEP = {
connect: null
disconnect: null
connect_error: { message?: string }
configuration: EventPayload<{
config: AppConfig
presets: ConfigState['presets']
dl_fields: ConfigState['dl_fields']
paused: boolean
}>
connected: EventPayload<{
folders?: string[]
history_count?: number
queue?: Record<string, StoreItem>
}>
active_queue: EventPayload<{
queue?: Record<string, StoreItem>
}>
item_added: EventPayload<StoreItem>
item_updated: EventPayload<StoreItem>
item_cancelled: EventPayload<StoreItem>
item_deleted: EventPayload<StoreItem>
item_moved: EventPayload<{ to: 'queue' | 'history'; item: StoreItem }>
item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>
paused: EventPayload<{ paused?: boolean }>
resumed: EventPayload<{ paused?: boolean }>
log_info: EventPayload<Record<string, unknown>>
log_success: EventPayload<Record<string, unknown>>
log_warning: EventPayload<Record<string, unknown>>
log_error: EventPayload<Record<string, unknown>>
config_update: EventPayload<ConfigUpdatePayload>
}
export type WebSocketClientEmits = {
add_url: Record<string, unknown>
item_cancel: string
item_delete: { id: string; remove_file?: boolean }
item_start: string | string[]
item_pause: string | string[]
}
type ConfigUpdateAction = 'create' | 'update' | 'delete' | 'replace'
type ConfigFeature = 'presets' | 'dl_fields' | 'conditions'

View file

@ -19,6 +19,7 @@
},
"web-types": "./web-types.json",
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@pinia/nuxt": "^0.11.3",
"@vueuse/core": "^14.1.0",
"@vueuse/nuxt": "^14.1.0",
@ -35,7 +36,6 @@
"moment": "^2.30.1",
"nuxt": "^4.2.2",
"pinia": "^3.0.4",
"socket.io-client": "^4.8.3",
"vue": "^3.5.26",
"vue-router": "^4.6.4",
"vue-toastification": "2.0.0-rc.5"

View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@microsoft/fetch-event-source':
specifier: ^2.0.1
version: 2.0.1
'@pinia/nuxt':
specifier: ^0.11.3
version: 0.11.3(magicast@0.5.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)))
@ -56,9 +59,6 @@ importers:
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
socket.io-client:
specifier: ^4.8.3
version: 4.8.3
vue:
specifier: ^3.5.26
version: 3.5.26(typescript@5.9.3)
@ -600,6 +600,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@microsoft/fetch-event-source@2.0.1':
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@ -1336,9 +1339,6 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
'@speed-highlight/core@1.2.14':
resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==}
@ -2233,13 +2233,6 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
engine.io-client@6.6.4:
resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==}
engine.io-parser@5.2.3:
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
engines: {node: '>=10.0.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@ -3748,14 +3741,6 @@ packages:
smob@1.5.0:
resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==}
socket.io-client@4.8.3:
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
engines: {node: '>=10.0.0'}
socket.io-parser@4.2.5:
resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==}
engines: {node: '>=10.0.0'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -4367,18 +4352,6 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
ws@8.18.3:
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
@ -4406,10 +4379,6 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xmlhttprequest-ssl@2.1.2:
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
engines: {node: '>=0.4.0'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -4981,6 +4950,8 @@ snapshots:
- encoding
- supports-color
'@microsoft/fetch-event-source@2.0.1': {}
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.8.1
@ -5751,8 +5722,6 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@socket.io/component-emitter@3.1.2': {}
'@speed-highlight/core@1.2.14': {}
'@standard-schema/spec@1.1.0': {}
@ -6697,20 +6666,6 @@ snapshots:
encodeurl@2.0.0: {}
engine.io-client@6.6.4:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
engine.io-parser: 5.2.3
ws: 8.18.3
xmlhttprequest-ssl: 2.1.2
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
engine.io-parser@5.2.3: {}
entities@4.5.0: {}
entities@6.0.1:
@ -8483,24 +8438,6 @@ snapshots:
smob@1.5.0: {}
socket.io-client@4.8.3:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
engine.io-client: 6.6.4
socket.io-parser: 4.2.5
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
socket.io-parser@4.2.5:
dependencies:
'@socket.io/component-emitter': 3.1.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@ -9110,8 +9047,6 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.1.2
ws@8.18.3: {}
ws@8.19.0: {}
wsl-utils@0.1.0:
@ -9126,8 +9061,6 @@ snapshots:
xmlchars@2.2.0:
optional: true
xmlhttprequest-ssl@2.1.2: {}
y18n@5.0.8: {}
yallist@3.1.1: {}

48
uv.lock
View file

@ -189,15 +189,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/68/665246c0469147cc431fcd6450ef03873de644c8d1510c25b1dfa80ce8ec/bgutil_ytdlp_pot_provider-1.2.2-py3-none-any.whl", hash = "sha256:f2ff0f340e36503f24119192c7dfa1c1957f37520e78764a3cdf3d99db46633a", size = 9983 },
]
[[package]]
name = "bidict"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764 },
]
[[package]]
name = "brotli"
version = "1.2.0"
@ -1322,18 +1313,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 },
]
[[package]]
name = "python-engineio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "simple-websocket" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/5a/349caac055e03ef9e56ed29fa304846063b1771ee54ab8132bf98b29491e/python_engineio-4.13.0.tar.gz", hash = "sha256:f9c51a8754d2742ba832c24b46ed425fdd3064356914edd5a1e8ffde76ab7709", size = 92194 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/74/c655a6eda0fd188d490c14142a0f0380655ac7099604e1fbf8fa1a97f0a1/python_engineio-4.13.0-py3-none-any.whl", hash = "sha256:57b94eac094fa07b050c6da59f48b12250ab1cd920765f4849963e3d89ad9de3", size = 59676 },
]
[[package]]
name = "python-magic"
version = "0.4.27"
@ -1352,19 +1331,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c2/094e3d62b906d952537196603a23aec4bcd7c6126bf80eb14e6f9f4be3a2/python_magic_bin-0.4.14-py2.py3-none-win_amd64.whl", hash = "sha256:90be6206ad31071a36065a2fc169c5afb5e0355cbe6030e87641c6c62edc2b69", size = 409299 },
]
[[package]]
name = "python-socketio"
version = "5.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bidict" },
{ name = "python-engineio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b8/55/5d8af5884283b58e4405580bcd84af1d898c457173c708736e065f10ca4a/python_socketio-5.16.0.tar.gz", hash = "sha256:f79403c7f1ba8b84460aa8fe4c671414c8145b21a501b46b676f3740286356fd", size = 127120 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/d2/2ccc2b69a187b80fda3152745670cfba936704f296a9fa54c6c8ac694d12/python_socketio-5.16.0-py3-none-any.whl", hash = "sha256:d95802961e15c7bd54ecf884c6e7644f81be8460f0a02ee66b473df58088ee8a", size = 79607 },
]
[[package]]
name = "pytz"
version = "2025.2"
@ -1650,18 +1616,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 },
]
[[package]]
name = "simple-websocket"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842 },
]
[[package]]
name = "six"
version = "1.17.0"
@ -2023,7 +1977,6 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "python-magic", marker = "sys_platform != 'win32'" },
{ name = "python-magic-bin", marker = "sys_platform == 'win32'" },
{ name = "python-socketio" },
{ name = "regex" },
{ name = "selenium" },
{ name = "sqlalchemy" },
@ -2077,7 +2030,6 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "python-magic", marker = "sys_platform != 'win32'", specifier = ">=0.4.27" },
{ name = "python-magic-bin", marker = "sys_platform == 'win32'" },
{ name = "python-socketio", specifier = ">=5.11.1" },
{ name = "regex" },
{ name = "selenium", specifier = ">=4.35.0" },
{ name = "sqlalchemy", specifier = ">=2.0.45" },