diff --git a/API.md b/API.md index b5c1755f..fc477369 100644 --- a/API.md +++ b/API.md @@ -26,6 +26,10 @@ This document describes the available endpoints and their usage. All endpoints r - [POST /api/history/{id}](#post-apihistoryid) - [GET /api/history/{id}](#get-apihistoryid) - [GET /api/history](#get-apihistory) + - [GET /api/history/live](#get-apihistorylive) + - [POST /api/history/start](#post-apihistorystart) + - [POST /api/history/pause](#post-apihistorypause) + - [POST /api/history/cancel](#post-apihistorycancel) - [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive) - [POST /api/history/{id}/archive](#post-apihistoryidarchive) - [GET /api/archiver](#get-apiarchiver) @@ -99,7 +103,6 @@ This document describes the available endpoints and their usage. All endpoints r - [Connection Events](#connection-events) - [`connect` (Server → Client)](#connect-server--client) - [`disconnect` (Server → Client)](#disconnect-server--client) - - [`configuration` (Server → Client)](#configuration-server--client) - [`config_update` (Server → Client)](#config_update-server--client) - [`connected` (Server → Client)](#connected-server--client) - [`active_queue` (Server → Client)](#active_queue-server--client) @@ -599,6 +602,124 @@ GET /api/history?type=queue&status=pending&order=ASC --- +### GET /api/history/live +**Purpose**: Get live queue data with real-time download progress. + +This endpoint returns the current state of active downloads from memory. + +**Response**: +```json +{ + "history_count": 0, // total number of completed items in history + "queue":{ + "id": "abc123", + "url": "https://example.com/video", + "title": "Video Title", + "status": "downloading", + "progress": 45.6, + "speed": "2.5 MiB/s", + "eta": "00:05:23", + ... + }, + ... +} +``` + +--- + +### POST /api/history/start +**Purpose**: Start one or more downloads in the queue. + +**Body**: +```json +{ + "ids": ["", "", ...] +} +``` + +**Response**: +```json +{ + "": "started", + "": "started", + ... +} +``` + +**Error Responses**: +- `400 Bad Request` if `ids` is missing or not an array: + ```json + { "error": "ids is required and must be an array." } + ``` + +**Notes**: +- Items must exist in the queue +- Sets `auto_start: true` for the specified items + +--- + +### POST /api/history/pause +**Purpose**: Pause one or more downloads in the queue. + +**Body**: +```json +{ + "ids": ["", "", ...] +} +``` + +**Response**: +```json +{ + "": "paused", + "": "paused", + ... +} +``` + +**Error Responses**: +- `400 Bad Request` if `ids` is missing or not an array: + ```json + { "error": "ids is required and must be an array." } + ``` + +**Notes**: +- Items must exist in the queue +- Sets `auto_start: false` for the specified items + +--- + +### POST /api/history/cancel +**Purpose**: Cancel one or more downloads and move them to history. + +**Body**: +```json +{ + "ids": ["", "", ...] +} +``` + +**Response**: +```json +{ + "": "ok", + "": "ok", + ... +} +``` + +**Error Responses**: +- `400 Bad Request` if `ids` is missing or not an array: + ```json + { "error": "ids is required and must be an array." } + ``` + +**Notes**: +- Items must exist in the queue +- Stops active downloads if they are currently running + +--- + ### DELETE /api/history/{id}/archive **Purpose**: Remove an item from archive file, allowing it to be re-downloaded. @@ -2122,15 +2243,6 @@ Fired when WebSocket connection is closed. No data payload. socket.on('disconnect', (reason: string) => console.log('WebSocket disconnected:', reason)); ``` -##### `configuration` (Server → Client) -Sends the current application configuration. - -**Data Fields**: -- `config`: Global configuration object -- `presets`: Available download presets -- `dl_fields`: Available download fields -- `paused`: Queue pause status (boolean) - ##### `config_update` (Server → Client) Emitted when configuration-backed resources change (presets, dl fields, conditions, notifications). diff --git a/app/routes/api/history.py b/app/routes/api/history.py index f0e6e1be..2ebdaaa8 100644 --- a/app/routes/api/history.py +++ b/app/routes/api/history.py @@ -116,6 +116,29 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, c ) +@route("GET", "api/history/live", "items_live") +async def items_live(queue: DownloadQueue, encoder: Encoder) -> Response: + """ + Get live queue data + + Args: + queue (DownloadQueue): The download queue instance. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object with live queue items. + + """ + return web.json_response( + data={ + "queue": (await queue.get("queue"))["queue"], + "history_count": await queue.done.get_total_count(), + }, + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + + @route("DELETE", "api/history/", "items_delete") async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response: """ @@ -390,6 +413,84 @@ async def items_add(request: Request, queue: DownloadQueue, encoder: Encoder) -> return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode) +@route("POST", "api/history/start", "items_start") +async def items_start(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response: + """ + Start one or more queued downloads. + + Args: + request (Request): The request object. + queue (DownloadQueue): The download queue instance. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object. + + """ + data = await request.json() + if not (ids := data.get("ids", [])): + return web.json_response(data={"error": "ids array is required."}, status=web.HTTPBadRequest.status_code) + + if not isinstance(ids, list): + return web.json_response(data={"error": "ids must be an array."}, status=web.HTTPBadRequest.status_code) + + status: dict[str, str] = await queue.start_items(ids) + + return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=encoder.encode) + + +@route("POST", "api/history/pause", "items_pause") +async def items_pause(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response: + """ + Pause one or more queued downloads. + + Args: + request (Request): The request object. + queue (DownloadQueue): The download queue instance. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object. + + """ + data = await request.json() + if not (ids := data.get("ids", [])): + return web.json_response(data={"error": "ids array is required."}, status=web.HTTPBadRequest.status_code) + + if not isinstance(ids, list): + return web.json_response(data={"error": "ids must be an array."}, status=web.HTTPBadRequest.status_code) + + status: dict[str, str] = await queue.pause_items(ids) + + return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=encoder.encode) + + +@route("POST", "api/history/cancel", "items_cancel") +async def items_cancel(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response: + """ + Cancel one or more queued downloads. + + Args: + request (Request): The request object. + queue (DownloadQueue): The download queue instance. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object. + + """ + data = await request.json() + if not (ids := data.get("ids", [])): + return web.json_response(data={"error": "ids array is required."}, status=web.HTTPBadRequest.status_code) + + if not isinstance(ids, list): + return web.json_response(data={"error": "ids must be an array."}, status=web.HTTPBadRequest.status_code) + + status: dict[str, str] = await queue.cancel(ids) + + return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=encoder.encode) + + @route("POST", r"api/history/{id}/archive", "history.item.archive.add") async def item_archive_add(request: Request, queue: DownloadQueue, notify: EventBus) -> Response: """ diff --git a/app/routes/api/system.py b/app/routes/api/system.py index 84530365..c44bffab 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -4,18 +4,22 @@ import logging import os import shlex import time +from pathlib import Path from typing import TYPE_CHECKING from aiohttp import web from aiohttp.web import Request, Response from aiohttp.web_runner import GracefulExit +from app.features.dl_fields.service import DLFields from app.library.config import Config from app.library.downloads import DownloadQueue from app.library.encoder import Encoder from app.library.Events import EventBus, Events +from app.library.Presets import Presets from app.library.router import route from app.library.UpdateChecker import UpdateChecker +from app.library.Utils import list_folders if TYPE_CHECKING: from asyncio import Task @@ -25,6 +29,39 @@ if TYPE_CHECKING: LOG: logging.Logger = logging.getLogger(__name__) +@route("GET", "api/system/configuration", "system.configuration") +async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response: + """ + Pause non-active downloads. + + Args: + queue (DownloadQueue): The download queue instance. + config (Config): The config instance. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object. + + """ + return web.json_response( + data={ + "app": config.frontend(), + "presets": Presets.get_instance().get_all(), + "dl_fields": await DLFields.get_instance().get_all_serialized(), + "paused": queue.is_paused(), + "folders": list_folders( + path=Path(config.download_path), + base=Path(config.download_path), + depth_limit=config.download_path_depth - 1, + ), + "history_count": await queue.done.get_total_count(), + "queue": (await queue.get("queue"))["queue"], + }, + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + + @route("POST", "api/system/pause", "system.pause") async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response: """ @@ -189,9 +226,6 @@ async def check_updates(config: Config, encoder: Encoder, update_checker: Update ) -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: diff --git a/app/routes/socket/connection.py b/app/routes/socket/connection.py index 57e8a1de..c05690c1 100644 --- a/app/routes/socket/connection.py +++ b/app/routes/socket/connection.py @@ -1,71 +1,15 @@ import logging -from pathlib import Path -from app.features.dl_fields.service import DLFields -from app.library.config import Config -from app.library.downloads import DownloadQueue -from app.library.Events import EventBus, Events -from app.library.HttpSocket import WebSocketHub -from app.library.Presets import Presets from app.library.router import RouteType, route -from app.library.Utils import list_folders LOG: logging.Logger = logging.getLogger(__name__) -class _Data: - subscribers: dict[str, list[str]] = {} - - @route(RouteType.SOCKET, "connect", "socket_connect") -async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: str): - notify.emit( - Events.CONFIGURATION, - data={ - "config": config.frontend(), - "presets": Presets.get_instance().get_all(), - "dl_fields": await DLFields.get_instance().get_all_serialized(), - "paused": queue.is_paused(), - }, - title="Client connected", - message=f"Client '{sid}' connected.", - to=sid, - ) - - notify.emit( - Events.CONNECTED, - data={ - "folders": list_folders( - path=Path(config.download_path), - base=Path(config.download_path), - depth_limit=config.download_path_depth - 1, - ), - "history_count": await queue.done.get_total_count(), - "queue": (await queue.get("queue"))["queue"], - }, - title="Sending initial download data", - message=f"Sending initial download data to client '{sid}'.", - to=sid, - ) - - notify.emit( - Events.ACTIVE_QUEUE, - data={"queue": (await queue.get("queue"))["queue"]}, - title="Sending initial active queue data", - message=f"Sending active queue data to client '{sid}'.", - to=sid, - ) +async def connect(sid: str): + pass @route(RouteType.SOCKET, "disconnect", "socket_disconnect") -async def disconnect(sio: WebSocketHub, sid: str, data: str | None = None): # noqa: ARG001 - """ - Handle client disconnection. - - Args: - sio (WebSocketHub): The WebSocket hub instance. - sid (str): The session ID of the client. - data (str): The reason for disconnection. - - """ - LOG.debug(f"Client '{sid}' disconnected. {data}") +async def disconnect(sid: str): + pass diff --git a/ui/app/components/History.vue b/ui/app/components/History.vue index 2978ba0a..668291cf 100644 --- a/ui/app/components/History.vue +++ b/ui/app/components/History.vue @@ -163,7 +163,7 @@
@@ -207,7 +207,7 @@ - + Add to download form @@ -335,7 +335,7 @@
@@ -584,7 +583,7 @@ watch(showCompleted, async isShown => { }) onMounted(async () => { - if (showCompleted.value && !paginationInfo.value.isLoaded && socket.isConnected) { + if (showCompleted.value) { try { await stateStore.loadPaginated('history', 1, config.app.default_pagination, 'DESC', true) } catch (error) { @@ -701,10 +700,7 @@ const deleteSelectedItems = async () => { return } - await stateStore.deleteItems('history', { - ids: [...selectedElms.value], - removeFile: config.app.remove_files - }) + await stateStore.removeItems('history', [...selectedElms.value], config.app.remove_files) selectedElms.value = [] } @@ -803,7 +799,7 @@ const retryIncomplete = async () => { if ('finished' === item.status) { continue } - retryItem(item) + await retryItem(item) } } @@ -831,7 +827,7 @@ const archiveItem = async (item: StoreItem, opts = {}) => { if (!(opts as any)?.remove_history) { return } - socket.emit('item_delete', { id: item._id, remove_file: false }) + await stateStore.removeItems('history', [item._id], false) } const removeItem = async (item: StoreItem) => { @@ -853,7 +849,7 @@ const removeItem = async (item: StoreItem) => { } } -const retryItem = (item: StoreItem, re_add: boolean = false, remove_file: boolean = false) => { +const retryItem = async (item: StoreItem, re_add: boolean = false, remove_file: boolean = false) => { const item_req: Partial = { url: item.url, preset: item.preset, @@ -865,7 +861,7 @@ const retryItem = (item: StoreItem, re_add: boolean = false, remove_file: boolea auto_start: item.auto_start, } - socket.emit('item_delete', { id: item._id, remove_file: remove_file }) + await stateStore.removeItems('history', [item._id], remove_file) if (selectedElms.value.includes(item._id || '')) { selectedElms.value = selectedElms.value.filter(i => i !== item._id) @@ -876,7 +872,7 @@ const retryItem = (item: StoreItem, re_add: boolean = false, remove_file: boolea emitter('add_new', item_req) return } - socket.emit('add_url', item_req) + await stateStore.addDownload(item_req) } const pImg = (e: Event) => { @@ -990,12 +986,12 @@ const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, rem } if (opts?.re_add) { - retryItem(item, true, file_delete) + await retryItem(item, true, file_delete) return } if (opts?.remove_history) { - socket.emit('item_delete', { id: item._id, remove_file: file_delete }) + await stateStore.removeItems('history', [item._id], file_delete) } } diff --git a/ui/app/components/Queue.vue b/ui/app/components/Queue.vue index d6cbf309..74f3c5f8 100644 --- a/ui/app/components/Queue.vue +++ b/ui/app/components/Queue.vue @@ -1,4 +1,20 @@