Expose archiver as an API endpoint.

This commit is contained in:
arabcoders 2025-09-03 19:01:41 +03:00
parent 0abaf98b53
commit 063f62bebb
2 changed files with 288 additions and 0 deletions

78
API.md
View file

@ -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 '<name>' 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": "<preset-name>",
"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": "<preset-name>",
"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.

210
app/routes/api/archiver.py Normal file
View file

@ -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,
)