refactor: remove socket.io dependency
This commit is contained in:
parent
ce7b59a8be
commit
501455029c
20 changed files with 963 additions and 702 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -202,6 +202,7 @@
|
|||
"vconvert",
|
||||
"Vitest",
|
||||
"writedescription",
|
||||
"WSEP",
|
||||
"xerror",
|
||||
"youtu",
|
||||
"youtubepot",
|
||||
|
|
|
|||
129
API.md
129
API.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ dependencies = [
|
|||
# Non‐Windows
|
||||
"python-magic>=0.4.27; sys_platform != 'win32'",
|
||||
# Cross‐platform
|
||||
"python-socketio>=5.11.1",
|
||||
"aiohttp>=3.9.3",
|
||||
"coloredlogs>=15.0.1",
|
||||
"aiocron>=1.8",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
|
|
|||
2
ui/app/types/globals.d.ts
vendored
2
ui/app/types/globals.d.ts
vendored
|
|
@ -2,6 +2,6 @@ export { }
|
|||
|
||||
declare global {
|
||||
interface Window {
|
||||
ws?: unknown
|
||||
ws?: WebSocket
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
ui/app/types/sockets.d.ts
vendored
51
ui/app/types/sockets.d.ts
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
48
uv.lock
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue