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",
|
"vconvert",
|
||||||
"Vitest",
|
"Vitest",
|
||||||
"writedescription",
|
"writedescription",
|
||||||
|
"WSEP",
|
||||||
"xerror",
|
"xerror",
|
||||||
"youtu",
|
"youtu",
|
||||||
"youtubepot",
|
"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)
|
- [DELETE /api/conditions/{id}](#delete-apiconditionsid)
|
||||||
- [POST /api/conditions/test](#post-apiconditionstest)
|
- [POST /api/conditions/test](#post-apiconditionstest)
|
||||||
- [GET /api/logs](#get-apilogs)
|
- [GET /api/logs](#get-apilogs)
|
||||||
|
- [GET /api/logs/stream](#get-apilogsstream)
|
||||||
- [GET /api/notifications/](#get-apinotifications)
|
- [GET /api/notifications/](#get-apinotifications)
|
||||||
- [GET /api/notifications/events/](#get-apinotificationsevents)
|
- [GET /api/notifications/events/](#get-apinotificationsevents)
|
||||||
- [POST /api/notifications/](#post-apinotifications)
|
- [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/yt-dlp/archive\_id/](#post-apiyt-dlparchive_id)
|
||||||
- [POST /api/notifications/test](#post-apinotificationstest)
|
- [POST /api/notifications/test](#post-apinotificationstest)
|
||||||
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
|
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
|
||||||
|
- [POST /api/system/terminal](#post-apisystemterminal)
|
||||||
- [POST /api/system/pause](#post-apisystempause)
|
- [POST /api/system/pause](#post-apisystempause)
|
||||||
- [POST /api/system/resume](#post-apisystemresume)
|
- [POST /api/system/resume](#post-apisystemresume)
|
||||||
- [POST /api/system/shutdown](#post-apisystemshutdown)
|
- [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)
|
- [Message Format](#message-format)
|
||||||
- [Core Events](#core-events)
|
- [Core Events](#core-events)
|
||||||
- [Connection Events](#connection-events)
|
- [Connection Events](#connection-events)
|
||||||
- [`connect` (Built-in)](#connect-built-in)
|
- [`connect` (Server → Client)](#connect-server--client)
|
||||||
- [`disconnect` (Built-in)](#disconnect-built-in)
|
- [`disconnect` (Server → Client)](#disconnect-server--client)
|
||||||
- [`configuration` (Server → Client)](#configuration-server--client)
|
- [`configuration` (Server → Client)](#configuration-server--client)
|
||||||
- [`config_update` (Server → Client)](#config_update-server--client)
|
- [`config_update` (Server → Client)](#config_update-server--client)
|
||||||
- [`connected` (Server → Client)](#connected-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_success` (Server → Client)](#log_success-server--client)
|
||||||
- [`log_warning` (Server → Client)](#log_warning-server--client)
|
- [`log_warning` (Server → Client)](#log_warning-server--client)
|
||||||
- [`log_error` (Server → Client)](#log_error-server--client)
|
- [`log_error` (Server → Client)](#log_error-server--client)
|
||||||
- [`log_lines` (Server → Client)](#log_lines-server--client)
|
|
||||||
- [Download Queue Events](#download-queue-events)
|
- [Download Queue Events](#download-queue-events)
|
||||||
- [`item_added` (Server → Client)](#item_added-server--client)
|
- [`item_added` (Server → Client)](#item_added-server--client)
|
||||||
- [`item_updated` (Server → Client)](#item_updated-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)
|
- [`item_status` (Server → Client)](#item_status-server--client)
|
||||||
- [`paused` (Server → Client)](#paused-server--client)
|
- [`paused` (Server → Client)](#paused-server--client)
|
||||||
- [`resumed` (Server → Client)](#resumed-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)
|
- [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/
|
### GET /api/notifications/
|
||||||
**Purpose**: Retrieve notification targets with pagination.
|
**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
|
### POST /api/system/pause
|
||||||
**Purpose**: Pause all non-active downloads in the queue.
|
**Purpose**: Pause all non-active downloads in the queue.
|
||||||
|
|
||||||
|
|
@ -2013,7 +2057,7 @@ or an error:
|
||||||
|
|
||||||
## WebSocket API
|
## 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]
|
> ![IMPORTANT]
|
||||||
> The WebSocket API is unstable and many events will be moved to REST endpoints in future releases.
|
> 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
|
### 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).
|
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).
|
||||||
|
|
||||||
**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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
|
|
@ -2047,37 +2080,42 @@ If Basic Authentication is configured, include credentials when establishing the
|
||||||
|
|
||||||
2. **Via Query Parameter**:
|
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
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
"id": "unique-event-id",
|
|
||||||
"created_at": "2024-01-15T10:30:00.000000+00:00",
|
|
||||||
"event": "item_added",
|
"event": "item_added",
|
||||||
"title": "Item Queued",
|
"data": {
|
||||||
"message": "Video added to download queue",
|
"id": "unique-event-id",
|
||||||
"data": {...}
|
"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
|
### Core Events
|
||||||
|
|
||||||
#### Connection Events
|
#### Connection Events
|
||||||
|
|
||||||
##### `connect` (Built-in)
|
##### `connect` (Server → Client)
|
||||||
Fired when WebSocket connection is established. No data payload.
|
Fired when WebSocket connection is established. No data payload.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
socket.on('connect', () => console.log('WebSocket connected'));
|
socket.on('connect', () => console.log('WebSocket connected'));
|
||||||
```
|
```
|
||||||
|
|
||||||
##### `disconnect` (Built-in)
|
##### `disconnect` (Server → Client)
|
||||||
Fired when WebSocket connection is closed. No data payload.
|
Fired when WebSocket connection is closed. No data payload.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -2140,12 +2178,7 @@ Warning notification message.
|
||||||
##### `log_error` (Server → Client)
|
##### `log_error` (Server → Client)
|
||||||
Error notification message.
|
Error notification message.
|
||||||
|
|
||||||
##### `log_lines` (Server → Client)
|
For continuous log streaming, use `GET /api/logs/stream` via SSE.
|
||||||
Continuous application log lines (requires subscription event).
|
|
||||||
|
|
||||||
**Data Fields**:
|
|
||||||
- `line`: Log line content
|
|
||||||
- `timestamp`: Log timestamp
|
|
||||||
|
|
||||||
### Download Queue Events
|
### Download Queue Events
|
||||||
|
|
||||||
|
|
@ -2239,28 +2272,6 @@ Emitted when the download queue is paused.
|
||||||
#### `resumed` (Server → Client)
|
#### `resumed` (Server → Client)
|
||||||
Emitted when the download queue is resumed.
|
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
|
## Error Responses
|
||||||
|
|
|
||||||
|
|
@ -75,3 +75,9 @@ def format_validation_errors(exc: ValidationError) -> list[dict[str, Any]]:
|
||||||
}
|
}
|
||||||
for error in exc.errors()
|
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 asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.features.core.utils import gen_random
|
||||||
|
|
||||||
from .BackgroundWorker import BackgroundWorker
|
from .BackgroundWorker import BackgroundWorker
|
||||||
from .Singleton import Singleton
|
from .Singleton import Singleton
|
||||||
|
|
||||||
|
|
@ -50,10 +51,6 @@ class Events:
|
||||||
PAUSED: str = "paused"
|
PAUSED: str = "paused"
|
||||||
RESUMED: str = "resumed"
|
RESUMED: str = "resumed"
|
||||||
|
|
||||||
CLI_POST: str = "cli_post"
|
|
||||||
CLI_CLOSE: str = "cli_close"
|
|
||||||
CLI_OUTPUT: str = "cli_output"
|
|
||||||
|
|
||||||
TASKS_ADD: str = "task_add"
|
TASKS_ADD: str = "task_add"
|
||||||
TASK_DISPATCHED: str = "task_dispatched"
|
TASK_DISPATCHED: str = "task_dispatched"
|
||||||
TASK_FINISHED: str = "task_finished"
|
TASK_FINISHED: str = "task_finished"
|
||||||
|
|
@ -61,9 +58,6 @@ class Events:
|
||||||
|
|
||||||
SCHEDULE_ADD: str = "schedule_add"
|
SCHEDULE_ADD: str = "schedule_add"
|
||||||
|
|
||||||
SUBSCRIBED: str = "subscribed"
|
|
||||||
UNSUBSCRIBED: str = "unsubscribed"
|
|
||||||
|
|
||||||
def get_all() -> list:
|
def get_all() -> list:
|
||||||
"""
|
"""
|
||||||
Get all the events.
|
Get all the events.
|
||||||
|
|
@ -101,8 +95,6 @@ class Events:
|
||||||
Events.ITEM_STATUS,
|
Events.ITEM_STATUS,
|
||||||
Events.PAUSED,
|
Events.PAUSED,
|
||||||
Events.RESUMED,
|
Events.RESUMED,
|
||||||
Events.CLI_CLOSE,
|
|
||||||
Events.CLI_OUTPUT,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def only_debug() -> list:
|
def only_debug() -> list:
|
||||||
|
|
@ -113,7 +105,7 @@ class Events:
|
||||||
list: The list of debug events.
|
list: The list of debug events.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return [Events.ITEM_UPDATED, Events.CLI_OUTPUT]
|
return [Events.ITEM_UPDATED]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
|
|
@ -122,7 +114,7 @@ class Event:
|
||||||
Event is a data transfer object that represents an event that was emitted.
|
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."""
|
"""The id of the event."""
|
||||||
|
|
||||||
created_at: str = field(default_factory=lambda: str(datetime.datetime.now(tz=datetime.UTC).isoformat()))
|
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]
|
event = [event]
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
name = str(uuid.uuid4())
|
name = gen_random(12)
|
||||||
|
|
||||||
for e in event:
|
for e in event:
|
||||||
if e not in all_events:
|
if e not in all_events:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import functools
|
import functools
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import socketio
|
|
||||||
from aiohttp import web
|
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.Services import Services
|
||||||
from app.library.Utils import load_modules
|
from app.library.Utils import load_modules
|
||||||
|
|
||||||
|
|
@ -18,13 +19,55 @@ from .ItemDTO import Item
|
||||||
LOG: logging.Logger = logging.getLogger("socket_api")
|
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:
|
class HttpSocket:
|
||||||
"""
|
"""
|
||||||
This class is used to handle WebSocket events.
|
This class is used to handle WebSocket events.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
config: Config
|
config: Config
|
||||||
sio: socketio.AsyncServer
|
sio: WebSocketHub
|
||||||
di_context: dict[str, Any] = {}
|
di_context: dict[str, Any] = {}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -32,26 +75,18 @@ class HttpSocket:
|
||||||
root_path: Path,
|
root_path: Path,
|
||||||
encoder: Encoder | None = None,
|
encoder: Encoder | None = None,
|
||||||
config: Config | None = None,
|
config: Config | None = None,
|
||||||
sio: socketio.AsyncServer | None = None,
|
sio: WebSocketHub | None = None,
|
||||||
):
|
):
|
||||||
self.config = config or Config.get_instance()
|
self.config = config or Config.get_instance()
|
||||||
self._notify = EventBus.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()
|
encoder = encoder or Encoder()
|
||||||
|
self.sio = sio or WebSocketHub(encoder=encoder)
|
||||||
self.rootPath: Path = root_path
|
self.rootPath: Path = root_path
|
||||||
|
|
||||||
async def event_handler(e: Event, _, **kwargs):
|
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: Services = Services.get_instance()
|
||||||
services.add_all(
|
services.add_all(
|
||||||
|
|
@ -85,38 +120,86 @@ class HttpSocket:
|
||||||
|
|
||||||
async def on_shutdown(self, _: web.Application):
|
async def on_shutdown(self, _: web.Application):
|
||||||
LOG.debug("Shutting down socket server.")
|
LOG.debug("Shutting down socket server.")
|
||||||
|
await self.sio.disconnect_all()
|
||||||
for sid in self.sio.manager.get_participants("/", None):
|
|
||||||
LOG.debug(f"Disconnecting client '{sid}'.")
|
|
||||||
await self.sio.disconnect(sid[0], namespace="/")
|
|
||||||
|
|
||||||
LOG.debug("Socket server shutdown complete.")
|
LOG.debug("Socket server shutdown complete.")
|
||||||
|
|
||||||
def attach(self, app: web.Application):
|
def attach(self, app: web.Application):
|
||||||
app.on_shutdown.append(self.on_shutdown)
|
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, _):
|
async def event_handler(data: Event, _):
|
||||||
if data and data.data:
|
if not (data and data.data):
|
||||||
await Services.get_instance().get("queue").add(item=Item.format(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")
|
self._notify.subscribe(Events.ADD_URL, event_handler, f"{__class__.__name__}.add")
|
||||||
|
|
||||||
load_modules(self.rootPath, self.rootPath / "routes" / "socket")
|
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:
|
if self.config.debug:
|
||||||
LOG.debug(
|
LOG.debug(
|
||||||
f"Add ({route.name}) {route.method.value if isinstance(route.method, RouteType) else route.method}: {route.path}."
|
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
|
async def handle_message(sid: str, message: str) -> None:
|
||||||
def _injector(func, event: str):
|
try:
|
||||||
async def wrapper(sid, data=None, **kwargs):
|
payload = json.loads(message)
|
||||||
if not data:
|
except json.JSONDecodeError:
|
||||||
data = {}
|
LOG.debug("Invalid websocket payload received.")
|
||||||
return await Services.get_instance().handle_async(func, sid=sid, data=data, event=event, **kwargs)
|
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 pathlib import Path
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
@ -6,7 +8,9 @@ from aiohttp.web import Request, Response
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
from app.library.router import route
|
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")
|
@route("GET", "api/logs/", "logs")
|
||||||
|
|
@ -48,3 +52,56 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
|
||||||
status=web.HTTPOk.status_code,
|
status=web.HTTPOk.status_code,
|
||||||
dumps=encoder.encode,
|
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 asyncio
|
||||||
|
import errno
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
import time
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import Request, Response
|
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.router import route
|
||||||
from app.library.UpdateChecker import UpdateChecker
|
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__)
|
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,
|
status=web.HTTPOk.status_code,
|
||||||
dumps=encoder.encode,
|
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
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import socketio
|
|
||||||
|
|
||||||
from app.features.dl_fields.service import DLFields
|
from app.features.dl_fields.service import DLFields
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
from app.library.downloads import DownloadQueue
|
from app.library.downloads import DownloadQueue
|
||||||
from app.library.Events import EventBus, Events
|
from app.library.Events import EventBus, Events
|
||||||
|
from app.library.HttpSocket import WebSocketHub
|
||||||
from app.library.Presets import Presets
|
from app.library.Presets import Presets
|
||||||
from app.library.router import RouteType, route
|
from app.library.router import RouteType, route
|
||||||
from app.library.Utils import list_folders, tail_log
|
from app.library.Utils import list_folders
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _Data:
|
class _Data:
|
||||||
subscribers: dict[str, list[str]] = {}
|
subscribers: dict[str, list[str]] = {}
|
||||||
log_task: asyncio.Task | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@route(RouteType.SOCKET, "connect", "socket_connect")
|
@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")
|
@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.
|
Handle client disconnection.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
sio (socketio.AsyncServer): The Socket.IO server instance.
|
sio (WebSocketHub): The WebSocket hub instance.
|
||||||
sid (str): The session ID of the client.
|
sid (str): The session ID of the client.
|
||||||
data (str): The reason for disconnection.
|
data (str): The reason for disconnection.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
LOG.debug(f"Client '{sid}' disconnected. {data}")
|
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 isinstance(debug_events, list)
|
||||||
assert Events.ITEM_UPDATED in debug_events
|
assert Events.ITEM_UPDATED in debug_events
|
||||||
assert Events.CLI_OUTPUT in debug_events
|
|
||||||
|
|
||||||
|
|
||||||
class TestEvent:
|
class TestEvent:
|
||||||
|
|
@ -783,10 +782,6 @@ class TestEventBus:
|
||||||
assert Events.TEST in bus._listeners
|
assert Events.TEST in bus._listeners
|
||||||
assert len(bus._listeners[Events.TEST]) == 1
|
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.config.Config")
|
||||||
@patch("app.library.BackgroundWorker.BackgroundWorker")
|
@patch("app.library.BackgroundWorker.BackgroundWorker")
|
||||||
def test_event_bus_unsubscribe_single_event(self, mock_bg_worker, mock_config):
|
def test_event_bus_unsubscribe_single_event(self, mock_bg_worker, mock_config):
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ dependencies = [
|
||||||
# Non‐Windows
|
# Non‐Windows
|
||||||
"python-magic>=0.4.27; sys_platform != 'win32'",
|
"python-magic>=0.4.27; sys_platform != 'win32'",
|
||||||
# Cross‐platform
|
# Cross‐platform
|
||||||
"python-socketio>=5.11.1",
|
|
||||||
"aiohttp>=3.9.3",
|
"aiohttp>=3.9.3",
|
||||||
"coloredlogs>=15.0.1",
|
"coloredlogs>=15.0.1",
|
||||||
"aiocron>=1.8",
|
"aiocron>=1.8",
|
||||||
|
|
|
||||||
|
|
@ -112,18 +112,19 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 '@xterm/xterm/css/xterm.css'
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { useStorage } from '@vueuse/core'
|
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 InputAutocomplete from '~/components/InputAutocomplete.vue'
|
||||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||||
import { useDialog } from '~/composables/useDialog'
|
import { useDialog } from '~/composables/useDialog'
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const socket = useSocketStore()
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
|
@ -137,6 +138,7 @@ const isLoading = ref<boolean>(false)
|
||||||
const storedCommand = useStorage<string>('console_command', '')
|
const storedCommand = useStorage<string>('console_command', '')
|
||||||
const commandHistory = useStorage<string[]>('console_command_history', [])
|
const commandHistory = useStorage<string[]>('console_command_history', [])
|
||||||
const isHistoryCollapsed = useStorage<boolean>('console_history_collapsed', false)
|
const isHistoryCollapsed = useStorage<boolean>('console_history_collapsed', false)
|
||||||
|
const sseController = ref<AbortController | null>(null)
|
||||||
const MAX_HISTORY_ITEMS = 50
|
const MAX_HISTORY_ITEMS = 50
|
||||||
|
|
||||||
const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags
|
const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags
|
||||||
|
|
@ -228,6 +230,87 @@ const handle_event = () => {
|
||||||
terminalFit.value?.fit()
|
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 () => {
|
const ensureTerminal = async () => {
|
||||||
if (terminal.value) {
|
if (terminal.value) {
|
||||||
return
|
return
|
||||||
|
|
@ -284,8 +367,7 @@ const runCommand = async () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit('cli_post', cmd)
|
await startStream(cmd)
|
||||||
isLoading.value = true
|
|
||||||
terminal.value?.writeln(`user@YTPTube ~`)
|
terminal.value?.writeln(`user@YTPTube ~`)
|
||||||
terminal.value?.writeln(`$ yt-dlp ${command.value}`)
|
terminal.value?.writeln(`$ yt-dlp ${command.value}`)
|
||||||
storedCommand.value = ''
|
storedCommand.value = ''
|
||||||
|
|
@ -342,24 +424,11 @@ const clearHistory = async () => {
|
||||||
|
|
||||||
const removeFromHistory = (index: number) => commandHistory.value = commandHistory.value.filter((_, i) => i !== index)
|
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())
|
watch(isMultiLineInput, () => focusInput())
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('resize', handle_event);
|
document.addEventListener('resize', handle_event);
|
||||||
focusInput()
|
focusInput()
|
||||||
socket.off('cli_close', loader)
|
|
||||||
socket.off('cli_output', writer)
|
|
||||||
socket.on('cli_close', loader)
|
|
||||||
socket.on('cli_output', writer)
|
|
||||||
|
|
||||||
disableOpacity()
|
disableOpacity()
|
||||||
|
|
||||||
|
|
@ -372,8 +441,7 @@ onMounted(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
socket.off('cli_close', loader)
|
sseController.value?.abort()
|
||||||
socket.off('cli_output', writer)
|
|
||||||
document.removeEventListener('resize', handle_event)
|
document.removeEventListener('resize', handle_event)
|
||||||
enableOpacity()
|
enableOpacity()
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -110,14 +110,16 @@ code {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||||
|
import type { EventSourceMessage } from '@microsoft/fetch-event-source'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import type { log_line } from '~/types/logs'
|
import type { log_line } from '~/types/logs'
|
||||||
|
import { parse_api_error, uri } from '~/utils'
|
||||||
|
|
||||||
let scrollTimeout: NodeJS.Timeout | null = null
|
let scrollTimeout: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const socket = useSocketStore()
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
|
@ -126,6 +128,7 @@ const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker')
|
||||||
const textWrap = useStorage<boolean>('logs_wrap', true)
|
const textWrap = useStorage<boolean>('logs_wrap', true)
|
||||||
const bg_enable = useStorage<boolean>('random_bg', true)
|
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
||||||
|
const sseController = ref<AbortController | null>(null)
|
||||||
|
|
||||||
const logs = ref<Array<log_line>>([])
|
const logs = ref<Array<log_line>>([])
|
||||||
const offset = ref<number>(0)
|
const offset = ref<number>(0)
|
||||||
|
|
@ -269,8 +272,27 @@ const scrollToBottom = (fast = false) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const log_handler = (data: log_line) => {
|
const handleStreamMessage = (event: EventSourceMessage) => {
|
||||||
logs.value.push(data)
|
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(() => {
|
nextTick(() => {
|
||||||
if (autoScroll.value && bottomMarker.value) {
|
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 () => {
|
onMounted(async () => {
|
||||||
await fetchLogs()
|
await fetchLogs()
|
||||||
socket.on('log_lines', log_handler)
|
await startLogStream()
|
||||||
socket.emit('subscribe', 'log_lines')
|
|
||||||
if (bg_enable.value) {
|
if (bg_enable.value) {
|
||||||
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
|
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
socket.emit('unsubscribe', 'log_lines')
|
sseController.value?.abort()
|
||||||
socket.off('log_lines', log_handler)
|
|
||||||
if (bg_enable.value) {
|
if (bg_enable.value) {
|
||||||
document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.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)
|
if (scrollTimeout) clearTimeout(scrollTimeout)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useHead({ title: 'Logs' })
|
useHead({ title: 'Logs' })
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,7 @@ import Modal from '~/components/Modal.vue'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
import TaskInspect from '~/components/TaskInspect.vue'
|
import TaskInspect from '~/components/TaskInspect.vue'
|
||||||
import type { task_item, exported_task, error_response } from '~/types/tasks'
|
import type { task_item, exported_task, error_response } from '~/types/tasks'
|
||||||
|
import type { WSEP } from '~/types/sockets'
|
||||||
import { sleep } from '~/utils'
|
import { sleep } from '~/utils'
|
||||||
import { useSessionCache } from '~/utils/cache'
|
import { useSessionCache } from '~/utils/cache'
|
||||||
|
|
||||||
|
|
@ -959,12 +960,11 @@ const runNow = async (item: task_item, mass: boolean = false) => {
|
||||||
|
|
||||||
onBeforeUnmount(() => socket.off('item_status', statusHandler))
|
onBeforeUnmount(() => socket.off('item_status', statusHandler))
|
||||||
|
|
||||||
const statusHandler = async (stream: string) => {
|
const statusHandler = async (payload: WSEP['item_status']) => {
|
||||||
const json = JSON.parse(stream)
|
const { status, msg } = payload.data || {}
|
||||||
const { status, msg } = json.data
|
|
||||||
|
|
||||||
if ('error' === status) {
|
if ('error' === status) {
|
||||||
toast.error(msg)
|
toast.error(msg ?? 'Unknown error')
|
||||||
return
|
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 { ref, readonly } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { ConfigState } from "~/types/config";
|
import type { ConfigState } from "~/types/config";
|
||||||
import type { StoreItem } from "~/types/store";
|
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';
|
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', () => {
|
export const useSocketStore = defineStore('socket', () => {
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig()
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const stateStore = useStateStore()
|
const stateStore = useStateStore()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
|
|
||||||
const socket = ref<IOSocket | null>(null)
|
const socket = ref<WebSocket | null>(null)
|
||||||
const isConnected = ref<boolean>(false)
|
const isConnected = ref<boolean>(false)
|
||||||
const connectionStatus = ref<connectionStatus>('disconnected')
|
const connectionStatus = ref<connectionStatus>('disconnected')
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const error_count = ref<number>(0)
|
const error_count = ref<number>(0)
|
||||||
const wasHidden = ref<boolean>(false)
|
const wasHidden = ref<boolean>(false)
|
||||||
const reconnectTimeout = ref<NodeJS.Timeout | null>(null)
|
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 handlers = new Map<string, HandlerRegistry>()
|
||||||
const on = (event: string | string[], callback: (...args: any[]) => void, withEvent: boolean = false) => {
|
|
||||||
if (!Array.isArray(event)) {
|
const emit = <K extends keyof WebSocketClientEmits>(event: K, data: WebSocketClientEmits[K]): void => {
|
||||||
event = [event]
|
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 => {
|
function on<K extends KnownEvent>(event: K, callback: (payload: WSEP[K]) => void): void
|
||||||
if (!Array.isArray(event)) {
|
function on<K extends KnownEvent>(event: K[], callback: (payload: WSEP[K]) => void): void
|
||||||
event = [event]
|
function on<K extends KnownEvent>(
|
||||||
}
|
event: K | K[],
|
||||||
event.forEach(e => socket.value?.off(e, callback));
|
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 = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.hidden) {
|
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 = () => {
|
const reconnect = () => {
|
||||||
if (true === isConnected.value) {
|
if (true === isConnected.value) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
connect();
|
connect()
|
||||||
connectionStatus.value = 'connecting';
|
connectionStatus.value = 'connecting'
|
||||||
}
|
}
|
||||||
|
|
||||||
const disconnect = () => {
|
const disconnect = () => {
|
||||||
|
manualDisconnect.value = true
|
||||||
if (null === socket.value) {
|
if (null === socket.value) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
socket.value.disconnect();
|
socket.value.close()
|
||||||
socket.value = null;
|
socket.value = null
|
||||||
isConnected.value = false;
|
isConnected.value = false
|
||||||
connectionStatus.value = 'disconnected';
|
connectionStatus.value = 'disconnected'
|
||||||
cleanupVisibilityListener();
|
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 connect = () => {
|
||||||
const opts = {
|
if (socket.value && WebSocket.OPEN === socket.value.readyState) {
|
||||||
transports: ['websocket', 'polling'],
|
return
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionStatus.value = 'connecting';
|
if (socket.value && WebSocket.CONNECTING === socket.value.readyState) {
|
||||||
socket.value = io(url, opts)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
on("connect_error", (e: any) => {
|
manualDisconnect.value = false
|
||||||
isConnected.value = false
|
connectionStatus.value = 'connecting'
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
|
socket.value = new WebSocket(buildWsUrl())
|
||||||
|
|
||||||
on('connect', () => {
|
if ('development' === runtimeConfig.public?.APP_ENV) {
|
||||||
|
window.ws = socket.value
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.value.addEventListener('open', () => {
|
||||||
isConnected.value = true
|
isConnected.value = true
|
||||||
connectionStatus.value = 'connected';
|
connectionStatus.value = 'connected'
|
||||||
error.value = null;
|
error.value = null
|
||||||
error_count.value = 0
|
error_count.value = 0
|
||||||
});
|
reconnectAttempts.value = 0
|
||||||
|
dispatch('connect', null)
|
||||||
|
})
|
||||||
|
|
||||||
on('disconnect', () => {
|
socket.value.addEventListener('close', () => {
|
||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
connectionStatus.value = 'disconnected';
|
connectionStatus.value = 'disconnected'
|
||||||
error.value = 'Disconnected from server.';
|
error.value = 'Disconnected from server.'
|
||||||
});
|
dispatch('disconnect', null)
|
||||||
|
scheduleReconnect()
|
||||||
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>)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
on('connected', stream => {
|
socket.value.addEventListener('error', () => {
|
||||||
const json = JSON.parse(stream);
|
isConnected.value = false
|
||||||
if (!json?.data) {
|
connectionStatus.value = 'disconnected'
|
||||||
return;
|
error.value = 'Connection error: Unknown error'
|
||||||
}
|
error_count.value += 1
|
||||||
|
dispatch('connect_error', { message: 'Unknown error' })
|
||||||
if (json.data?.folders) {
|
scheduleReconnect()
|
||||||
config.add('folders', json.data.folders)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof json.data?.history_count === 'number') {
|
|
||||||
stateStore.setHistoryCount(json.data.history_count)
|
|
||||||
}
|
|
||||||
|
|
||||||
error.value = null;
|
|
||||||
})
|
})
|
||||||
|
|
||||||
on('active_queue', stream => {
|
socket.value.addEventListener('message', (event: MessageEvent<string>) => {
|
||||||
const json = JSON.parse(stream);
|
let payload: WebSocketEnvelope | null = null
|
||||||
if (!json?.data?.queue) {
|
try {
|
||||||
return;
|
payload = JSON.parse(event.data)
|
||||||
}
|
} catch {
|
||||||
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)) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.warning(`Download cancelled: ${ag(stateStore.get('queue', id, {} as StoreItem), 'title')}`);
|
if (!payload?.event || 'string' != typeof payload.event) {
|
||||||
|
|
||||||
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)) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stateStore.remove('history', id);
|
let data = payload.data
|
||||||
});
|
if ('string' === typeof data) {
|
||||||
|
try {
|
||||||
on('item_updated', (stream: string) => {
|
data = JSON.parse(data)
|
||||||
const json = JSON.parse(stream);
|
} catch {
|
||||||
const id = json.data._id;
|
data = payload.data
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
stateStore.add('queue', id, json.data.item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('history' === to) {
|
dispatch(payload.event, data)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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) {
|
if (false === isConnected.value) {
|
||||||
connect();
|
connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -301,5 +409,5 @@ export const useSocketStore = defineStore('socket', () => {
|
||||||
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
|
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
|
||||||
error: readonly(error) as Readonly<Ref<string | null>>,
|
error: readonly(error) as Readonly<Ref<string | null>>,
|
||||||
error_count: readonly(error_count) as Readonly<Ref<number>>,
|
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 {
|
declare global {
|
||||||
interface Window {
|
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 = {
|
export type Event = {
|
||||||
id: string
|
id: string
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|
@ -7,6 +10,54 @@ export type Event = {
|
||||||
data: Record<string, unknown>
|
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 ConfigUpdateAction = 'create' | 'update' | 'delete' | 'replace'
|
||||||
|
|
||||||
type ConfigFeature = 'presets' | 'dl_fields' | 'conditions'
|
type ConfigFeature = 'presets' | 'dl_fields' | 'conditions'
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
},
|
},
|
||||||
"web-types": "./web-types.json",
|
"web-types": "./web-types.json",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"@vueuse/nuxt": "^14.1.0",
|
"@vueuse/nuxt": "^14.1.0",
|
||||||
|
|
@ -35,7 +36,6 @@
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nuxt": "^4.2.2",
|
"nuxt": "^4.2.2",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"socket.io-client": "^4.8.3",
|
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"vue-toastification": "2.0.0-rc.5"
|
"vue-toastification": "2.0.0-rc.5"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@microsoft/fetch-event-source':
|
||||||
|
specifier: ^2.0.1
|
||||||
|
version: 2.0.1
|
||||||
'@pinia/nuxt':
|
'@pinia/nuxt':
|
||||||
specifier: ^0.11.3
|
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)))
|
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:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
|
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:
|
vue:
|
||||||
specifier: ^3.5.26
|
specifier: ^3.5.26
|
||||||
version: 3.5.26(typescript@5.9.3)
|
version: 3.5.26(typescript@5.9.3)
|
||||||
|
|
@ -600,6 +600,9 @@ packages:
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@microsoft/fetch-event-source@2.0.1':
|
||||||
|
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@0.2.12':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
|
|
@ -1336,9 +1339,6 @@ packages:
|
||||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@socket.io/component-emitter@3.1.2':
|
|
||||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
|
||||||
|
|
||||||
'@speed-highlight/core@1.2.14':
|
'@speed-highlight/core@1.2.14':
|
||||||
resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==}
|
resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==}
|
||||||
|
|
||||||
|
|
@ -2233,13 +2233,6 @@ packages:
|
||||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||||
engines: {node: '>= 0.8'}
|
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:
|
entities@4.5.0:
|
||||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
@ -3748,14 +3741,6 @@ packages:
|
||||||
smob@1.5.0:
|
smob@1.5.0:
|
||||||
resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==}
|
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:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -4367,18 +4352,6 @@ packages:
|
||||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||||
engines: {node: '>=12'}
|
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:
|
ws@8.19.0:
|
||||||
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
@ -4406,10 +4379,6 @@ packages:
|
||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
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:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -4981,6 +4950,8 @@ snapshots:
|
||||||
- encoding
|
- encoding
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@microsoft/fetch-event-source@2.0.1': {}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@0.2.12':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.8.1
|
'@emnapi/core': 1.8.1
|
||||||
|
|
@ -5751,8 +5722,6 @@ snapshots:
|
||||||
|
|
||||||
'@sindresorhus/merge-streams@4.0.0': {}
|
'@sindresorhus/merge-streams@4.0.0': {}
|
||||||
|
|
||||||
'@socket.io/component-emitter@3.1.2': {}
|
|
||||||
|
|
||||||
'@speed-highlight/core@1.2.14': {}
|
'@speed-highlight/core@1.2.14': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
@ -6697,20 +6666,6 @@ snapshots:
|
||||||
|
|
||||||
encodeurl@2.0.0: {}
|
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@4.5.0: {}
|
||||||
|
|
||||||
entities@6.0.1:
|
entities@6.0.1:
|
||||||
|
|
@ -8483,24 +8438,6 @@ snapshots:
|
||||||
|
|
||||||
smob@1.5.0: {}
|
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-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
|
|
@ -9110,8 +9047,6 @@ snapshots:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
strip-ansi: 7.1.2
|
strip-ansi: 7.1.2
|
||||||
|
|
||||||
ws@8.18.3: {}
|
|
||||||
|
|
||||||
ws@8.19.0: {}
|
ws@8.19.0: {}
|
||||||
|
|
||||||
wsl-utils@0.1.0:
|
wsl-utils@0.1.0:
|
||||||
|
|
@ -9126,8 +9061,6 @@ snapshots:
|
||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
xmlhttprequest-ssl@2.1.2: {}
|
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "1.2.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-magic"
|
name = "python-magic"
|
||||||
version = "0.4.27"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "pytz"
|
name = "pytz"
|
||||||
version = "2025.2"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "six"
|
name = "six"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
|
|
@ -2023,7 +1977,6 @@ dependencies = [
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "python-magic", marker = "sys_platform != 'win32'" },
|
{ name = "python-magic", marker = "sys_platform != 'win32'" },
|
||||||
{ name = "python-magic-bin", marker = "sys_platform == 'win32'" },
|
{ name = "python-magic-bin", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "python-socketio" },
|
|
||||||
{ name = "regex" },
|
{ name = "regex" },
|
||||||
{ name = "selenium" },
|
{ name = "selenium" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
|
|
@ -2077,7 +2030,6 @@ requires-dist = [
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||||
{ name = "python-magic", marker = "sys_platform != 'win32'", specifier = ">=0.4.27" },
|
{ name = "python-magic", marker = "sys_platform != 'win32'", specifier = ">=0.4.27" },
|
||||||
{ name = "python-magic-bin", marker = "sys_platform == 'win32'" },
|
{ name = "python-magic-bin", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "python-socketio", specifier = ">=5.11.1" },
|
|
||||||
{ name = "regex" },
|
{ name = "regex" },
|
||||||
{ name = "selenium", specifier = ">=4.35.0" },
|
{ name = "selenium", specifier = ">=4.35.0" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.45" },
|
{ name = "sqlalchemy", specifier = ">=2.0.45" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue