Expose archiver as an API endpoint.
This commit is contained in:
parent
0abaf98b53
commit
063f62bebb
2 changed files with 288 additions and 0 deletions
78
API.md
78
API.md
|
|
@ -27,6 +27,9 @@ This document describes the available endpoints and their usage. All endpoints r
|
||||||
- [GET /api/history](#get-apihistory)
|
- [GET /api/history](#get-apihistory)
|
||||||
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
|
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
|
||||||
- [POST /api/history/{id}/archive](#post-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)
|
- [GET /api/tasks](#get-apitasks)
|
||||||
- [PUT /api/tasks](#put-apitasks)
|
- [PUT /api/tasks](#put-apitasks)
|
||||||
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
|
- [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
|
### GET /api/tasks
|
||||||
**Purpose**: Retrieves the scheduled tasks from the internal `Tasks` manager.
|
**Purpose**: Retrieves the scheduled tasks from the internal `Tasks` manager.
|
||||||
|
|
||||||
|
|
|
||||||
210
app/routes/api/archiver.py
Normal file
210
app/routes/api/archiver.py
Normal 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,
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue