diff --git a/API.md b/API.md index ba5d5a29..963c251a 100644 --- a/API.md +++ b/API.md @@ -27,6 +27,9 @@ This document describes the available endpoints and their usage. All endpoints r - [GET /api/history](#get-apihistory) - [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive) - [POST /api/history/{id}/archive](#post-apihistoryidarchive) + - [GET /api/archiver](#get-apiarchiver) + - [POST /api/archiver](#post-apiarchiver) + - [DELETE /api/archiver](#delete-apiarchiver) - [GET /api/tasks](#get-apitasks) - [PUT /api/tasks](#put-apitasks) - [POST /api/tasks/{id}/mark](#post-apitasksidmark) @@ -401,6 +404,81 @@ or an error: --- +### GET /api/archiver +**Purpose**: Read entries from the download archive associated with a preset. + +**Query Parameters**: +- `preset` (required): Preset name. +- `ids` (optional): Comma-separated list of archive IDs to filter. If omitted, returns all entries. + +**Response**: +```json +{ + "file": "/path/to/archive.log", + "items": ["youtube ABC123", "vimeo XYZ789"], + "count": 2 +} +``` +or an error: +```json +{ "error": "Preset '' does not provide a download_archive." } +``` + +- `400 Bad Request` if `preset` is missing/invalid or the preset has no `download_archive`. + +--- + +### POST /api/archiver +**Purpose**: Append archive IDs to the download archive resolved by a preset. + +**Body**: +```json +{ + "preset": "", + "items": ["youtube ABC123", "vimeo XYZ789"], + "skip_check": false +} +``` + +**Response**: +```json +{ + "file": "/path/to/archive.log", + "status": true, + "items": ["youtube ABC123", "vimeo XYZ789"] +} +``` +Notes: +- Returns `200 OK` if any new entries were added, `304 Not Modified` if no-op (all already present or invalid). +- `400 Bad Request` if `preset` is missing/invalid, the preset has no `download_archive`, or `items` is empty. + +--- + +### DELETE /api/archiver +**Purpose**: Remove archive IDs from the download archive resolved by a preset. + +**Body**: +```json +{ + "preset": "", + "items": ["youtube ABC123", "vimeo XYZ789"] +} +``` + +**Response**: +```json +{ + "file": "/path/to/archive.log", + "status": true, + "items": ["youtube ABC123", "vimeo XYZ789"] +} +``` +Notes: +- Returns `200 OK` if the archive file changed, `304 Not Modified` if it was unchanged. +- `400 Bad Request` if `preset` is missing/invalid, the preset has no `download_archive`, or `items` is empty. + +--- + ### GET /api/tasks **Purpose**: Retrieves the scheduled tasks from the internal `Tasks` manager. diff --git a/app/routes/api/archiver.py b/app/routes/api/archiver.py new file mode 100644 index 00000000..c732f5a9 --- /dev/null +++ b/app/routes/api/archiver.py @@ -0,0 +1,210 @@ +import logging +from collections.abc import Iterable + +from aiohttp import web +from aiohttp.web import Request, Response + +from app.library.Archiver import Archiver +from app.library.Presets import Presets +from app.library.router import route +from app.library.YTDLPOpts import YTDLPOpts + +LOG: logging.Logger = logging.getLogger(__name__) + + +def _get_archive_file_from_preset(preset: str) -> str | None: + """ + Resolve the archive file path for a given preset. + + Validates that the preset exists and that applying the preset results + in yt-dlp options that contain a 'download_archive' path. + """ + if not preset or not Presets.get_instance().has(preset): + return None + + try: + opts: dict = YTDLPOpts.get_instance().preset(preset).get_all() + except Exception as e: + LOG.error(f"Failed to build yt-dlp opts for preset '{preset}'. {e!s}") + return None + + if not (archive_file := opts.get("download_archive")): + return None + + if not isinstance(archive_file, str) or len(archive_file.strip()) < 1: + return None + + return archive_file.strip() + + +def _normalize_ids(items: Iterable[str] | None) -> tuple[list[str], list[str]]: + """ + Validate and normalize archive IDs. + + - Trims whitespace + - Enforces that each ID has at least two whitespace-separated tokens + (e.g., "youtube ABC123") as required by yt-dlp's archive format + - De-duplicates while preserving order + + Returns a tuple: (valid_ids, invalid_inputs) + """ + if not items: + return ([], []) + + seen: set[str] = set() + valid: list[str] = [] + invalid: list[str] = [] + + for raw in items: + if raw is None: + continue + + s = str(raw).strip() + if not s: + continue + + if len(s.split()) < 2: + invalid.append(s) + continue + + if s in seen: + continue + + seen.add(s) + valid.append(s) + + return (valid, invalid) + + +@route("GET", "api/archiver/", "archiver") +async def archiver_get(request: Request) -> Response: + """ + Read IDs from the download archive for a given preset. + + Query params: + - preset: required preset name/id + - ids: optional comma-separated list to filter; when omitted, returns all + """ + preset: str | None = request.query.get("preset") + if not preset: + return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code) + + archive_file: str | None = _get_archive_file_from_preset(preset) + if not archive_file: + return web.json_response( + data={"error": f"Preset '{preset}' does not provide a download_archive."}, + status=web.HTTPBadRequest.status_code, + ) + + ids_param: str | None = request.query.get("ids") + ids: list[str] = [] + if ids_param: + ids_list = [s.strip() for s in ids_param.split(",") if s and s.strip()] + ids, invalid = _normalize_ids(ids_list) + if invalid: + return web.json_response( + data={"error": "invalid ids provided.", "invalid_items": invalid}, + status=web.HTTPBadRequest.status_code, + ) + + try: + data: list[str] = Archiver.get_instance().read(archive_file, ids or None) + return web.json_response( + data={"file": archive_file, "items": data, "count": len(data)}, status=web.HTTPOk.status_code + ) + except Exception as e: + LOG.exception(e) + return web.json_response( + data={"error": f"Failed to read archive file for preset '{preset}'.", "message": str(e)}, + status=web.HTTPInternalServerError.status_code, + ) + + +@route("POST", "api/archiver/", "archiver_add") +async def archiver_add(request: Request) -> Response: + """ + Append IDs to the download archive for a given preset. + + Body: { "preset": string, "items": [string, ...], "skip_check": bool? } + """ + post = await request.json() + preset: str | None = post.get("preset") if isinstance(post, dict) else None + if not preset: + return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code) + + archive_file: str | None = _get_archive_file_from_preset(preset) + if not archive_file: + return web.json_response( + data={"error": f"Preset '{preset}' does not provide a download_archive."}, + status=web.HTTPBadRequest.status_code, + ) + + items, invalid = _normalize_ids((post or {}).get("items", [])) if isinstance(post, dict) else ([], []) + if invalid: + return web.json_response( + data={"error": "invalid ids provided.", "invalid_items": invalid}, + status=web.HTTPBadRequest.status_code, + ) + if len(items) < 1: + return web.json_response( + data={"error": "items is required and must be a non-empty list."}, status=web.HTTPBadRequest.status_code + ) + + skip_check: bool = bool(post.get("skip_check", False)) if isinstance(post, dict) else False + + try: + status: bool = Archiver.get_instance().add(archive_file, items, skip_check=skip_check) + return web.json_response( + data={"file": archive_file, "status": status, "items": items}, + status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code, + ) + except Exception as e: + LOG.exception(e) + return web.json_response( + data={"error": f"Failed to add items to archive for preset '{preset}'.", "message": str(e)}, + status=web.HTTPInternalServerError.status_code, + ) + + +@route("DELETE", "api/archiver/", "archiver_delete") +async def archiver_delete(request: Request) -> Response: + """ + Remove IDs from the download archive for a given preset. + + Body: { "preset": string, "items": [string, ...] } + """ + post = await request.json() + preset: str | None = post.get("preset") if isinstance(post, dict) else None + if not preset: + return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code) + + archive_file: str | None = _get_archive_file_from_preset(preset) + if not archive_file: + return web.json_response( + data={"error": f"Preset '{preset}' does not provide a download_archive."}, + status=web.HTTPBadRequest.status_code, + ) + + items, invalid = _normalize_ids((post or {}).get("items", [])) if isinstance(post, dict) else ([], []) + if invalid: + return web.json_response( + data={"error": "invalid ids provided.", "invalid_items": invalid}, + status=web.HTTPBadRequest.status_code, + ) + if len(items) < 1: + return web.json_response( + data={"error": "items is required and must be a non-empty list."}, status=web.HTTPBadRequest.status_code + ) + + try: + status: bool = Archiver.get_instance().delete(archive_file, items) + return web.json_response( + data={"file": archive_file, "status": status, "items": items}, + status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code, + ) + except Exception as e: + LOG.exception(e) + return web.json_response( + data={"error": f"Failed to delete items from archive for preset '{preset}'.", "message": str(e)}, + status=web.HTTPInternalServerError.status_code, + )