ytptube/app/routes/api/history.py

423 lines
14 KiB
Python

import asyncio
import logging
from typing import TYPE_CHECKING
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.config import Config
from app.library.Download import Download
from app.library.DownloadQueue import DownloadQueue
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item
from app.library.Presets import Preset, Presets
from app.library.router import route
if TYPE_CHECKING:
from library.Download import Download
LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", r"api/history/", "items_list")
async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, config: Config) -> Response:
"""
Get the history with optional pagination support.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
config (Config): The configuration instance.
Returns:
Response: The response object.
Query Parameters:
type (str): Type of items to return - "all", "queue", or "done". Default: "all"
page (int): Page number for pagination (1-indexed). Only used when type != "all"
per_page (int): Items per page. Default: 50, Max: 1000. Only used when type != "all"
order (str): Sort order - "ASC" or "DESC". Default: "DESC". Only used when type != "all"
"""
from app.library.DataStore import StoreType
store_type = request.query.get("type", "all").lower()
stores: list[str] = ["all", StoreType.QUEUE.value, StoreType.HISTORY.value]
if store_type not in stores:
return web.json_response(
data={"error": f"type must be one of {', '.join(stores)}."},
status=web.HTTPBadRequest.status_code,
)
# Legacy behavior: return all items without pagination, will be removed in future.
if "all" == store_type:
data: dict = {"queue": [], "history": []}
q = queue.get()
data["queue"].extend([q.get("queue", {}).get(k) for k in q.get("queue", {})])
data["history"].extend([q.get("done", {}).get(k) for k in q.get("done", {})])
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
ds = queue.queue if store_type == StoreType.QUEUE.value else queue.done
try:
page = int(request.query.get("page", 1))
per_page = int(request.query.get("per_page", config.default_pagination))
order = request.query.get("order", "DESC").upper()
except ValueError:
return web.json_response(
data={"error": "page and per_page must be valid integers."},
status=web.HTTPBadRequest.status_code,
)
if page < 1:
return web.json_response(data={"error": "page must be >= 1."}, status=web.HTTPBadRequest.status_code)
if per_page < 1 or per_page > 1000:
return web.json_response(
data={"error": "per_page must be between 1 and 1000."},
status=web.HTTPBadRequest.status_code,
)
if order not in ("ASC", "DESC"):
return web.json_response(
data={"error": "order must be ASC or DESC."},
status=web.HTTPBadRequest.status_code,
)
items, total, current_page, total_pages = ds.get_items_paginated(page=page, per_page=per_page, order=order)
data = {
"pagination": {
"page": current_page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"has_next": current_page < total_pages,
"has_prev": current_page > 1,
},
"items": [item for _, item in items],
}
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("DELETE", "api/history/", "item_delete")
async def item_delete(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
"""
Delete an item from the queue.
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()
ids = data.get("ids")
where = data.get("where")
if not ids or where not in ["queue", "done"]:
return web.json_response(data={"error": "ids and where are required."}, status=web.HTTPBadRequest.status_code)
remove_file: bool = bool(data.get("remove_file", True))
return web.json_response(
data=await (queue.cancel(ids) if where == "queue" else queue.clear(ids, remove_file=remove_file)),
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("GET", "api/history/{id}", "item_view")
async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
"""
Update an item in the history.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
"""
id: str = request.match_info.get("id")
if not id:
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
item: Download | None = queue.done.get_by_id(id) or queue.queue.get_by_id(id)
if not item:
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
if not item.info:
return web.json_response(data={"error": "item has no info."}, status=web.HTTPNotFound.status_code)
info: dict = {
**item.info.serialize(),
"ffprobe": {},
}
if "finished" == item.info.status and (filename := item.info.get_file()):
from app.library.ffprobe import ffprobe
try:
info["ffprobe"] = await ffprobe(filename)
except Exception:
pass
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/history/{id}", "item_update")
async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
"""
Update an item in the history.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
"""
id: str = request.match_info.get("id")
if not id:
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
post = await request.json()
if not post:
return web.json_response(data={"error": "no data provided."}, status=web.HTTPBadRequest.status_code)
updated = False
for k, v in post.items():
if not hasattr(item.info, k):
continue
if getattr(item.info, k) == v:
continue
updated = True
setattr(item.info, k, v)
LOG.debug(f"Updated '{k}' to '{v}' for '{item.info.id}'")
if updated:
queue.done.put(item)
notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response(
data=item.info,
status=web.HTTPOk.status_code if updated else web.HTTPNotModified.status_code,
dumps=encoder.encode,
)
@route("GET", "api/history/add/", "item_add")
async def item_add(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
"""
Add a URL to the download queue.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object
"""
url: str | None = request.query.get("url")
if not url:
return web.json_response(data={"error": "url param is required."}, status=web.HTTPBadRequest.status_code)
data: dict[str, str] = {"url": url}
preset: str | None = request.query.get("preset")
if preset:
exists: Preset | None = Presets.get_instance().get(preset)
if not exists:
return web.json_response(
data={"status": False, "message": f"Preset '{preset}' does not exist."},
status=web.HTTPBadRequest.status_code,
)
data["preset"] = preset
try:
status: dict[str, str] = await queue.add(item=Item.format(data))
except ValueError as e:
return web.json_response(data={"status": False, "message": str(e)}, status=web.HTTPBadRequest.status_code)
return web.json_response(
data={"status": status.get("status") == "ok", "message": status.get("msg", "URL added")},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("POST", "api/history/", "items_add")
async def items_add(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
"""
Add a URL to the download queue.
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 isinstance(data, dict):
data = [data]
items = []
for item in data:
try:
items.append(Item.format(item))
except ValueError as e:
return web.json_response(data={"error": str(e), "data": item}, status=web.HTTPBadRequest.status_code)
status: list = await asyncio.wait_for(
fut=asyncio.gather(*[queue.add(item=item) for item in items]),
timeout=None,
)
response: list = []
for i, item in enumerate(items):
response.append({"item": item, "status": "ok" == status[i].get("status"), "msg": status[i].get("msg")})
return web.json_response(data=response, 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:
"""
Manually mark an item as archived.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
"""
if not (id := request.match_info.get("id")):
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try:
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
except KeyError:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
if not item.info.is_archivable:
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive file."},
status=web.HTTPBadRequest.status_code,
)
if not item.info.archive_id:
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive ID."},
status=web.HTTPBadRequest.status_code,
)
if item.info.is_archived:
return web.json_response(
data={"error": f"item '{item.info.title}' already archived."},
status=web.HTTPConflict.status_code,
)
if not item.info.archive_add():
return web.json_response(
data={"error": f"item '{item.info.title}' could not be added to archive."},
status=web.HTTPInternalServerError.status_code,
)
item.info.archive_status(force=True)
queue.done.put(item, no_notify=True)
notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response(
data={"message": f"item '{item.info.title}' archived."},
status=web.HTTPOk.status_code,
)
@route("DELETE", r"api/history/{id}/archive", "history.item.archive.delete")
async def item_archive_delete(request: Request, queue: DownloadQueue, notify: EventBus) -> Response:
"""
Remove an item from the archive.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
"""
if not (id := request.match_info.get("id")):
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try:
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
except KeyError:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
if not item.info.is_archivable:
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive file."},
status=web.HTTPBadRequest.status_code,
)
if not item.info.archive_id:
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive ID."},
status=web.HTTPBadRequest.status_code,
)
if not item.info.is_archived:
return web.json_response(
data={"error": f"item '{item.info.title}' not archived."},
status=web.HTTPConflict.status_code,
)
if not item.info.archive_delete():
return web.json_response(
data={"error": f"item '{item.info.title}' not found in archive file."},
status=web.HTTPInternalServerError.status_code,
)
item.info.archive_status(force=True)
queue.done.put(item, no_notify=True)
notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response(
data={"message": f"item '{item.info.title}' removed from archive."},
status=web.HTTPOk.status_code,
)