import asyncio from pathlib import Path from typing import Any from aiohttp import web from aiohttp.web import Request, Response from aiohttp.web_response import StreamResponse from app.features.presets.schemas import Preset from app.features.presets.service import Presets from app.features.streaming.library.thumbnail import ensure_thumb, pick_local_thumb from app.library.cache import Cache from app.library.config import Config from app.library.DataStore import StoreType from app.library.downloads import Download, DownloadQueue from app.library.downloads.utils import safe_relative_path from app.library.encoder import Encoder from app.library.Events import EventBus, Events from app.library.ItemDTO import Item from app.library.log import get_logger from app.library.router import route from app.library.Utils import calc_download_path, get_file_sidecar, rename_file LOG = get_logger() @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" status (str): Filter by status. Use "!status" to exclude a status. Only used when type != "all" Examples: "?status=finished" or "?status=!finished" """ from app.library.DataStore import StoreType store_type: str = request.query.get("type", "queue").lower() try: store_type: StoreType = StoreType.from_value(store_type) except ValueError: return web.json_response( data={"error": f"type must be one of {', '.join(StoreType.all())}."}, status=web.HTTPBadRequest.status_code, ) ds = queue.queue if store_type == StoreType.QUEUE else queue.done try: page = int(request.query.get("page", 1)) per_page = int(request.query.get("per_page", config.default_pagination)) order: str = 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, ) status_filter = request.query.get("status", None) items, total, current_page, total_pages = await ds.get_items_paginated( page=page, per_page=per_page, order=order, status_filter=status_filter ) if store_type == StoreType.HISTORY: for _, download in items: if not download.info: continue try: download.info.sidecar = download.info.get_file_sidecar() except Exception: download.info.sidecar = {} return web.json_response( data={ "type": store_type.value, "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": [download.info for _, download in items], }, status=web.HTTPOk.status_code, dumps=encoder.encode, ) @route("GET", "api/history/live", "items_live") async def items_live(request: Request, queue: DownloadQueue, encoder: Encoder, config: Config) -> Response: """ Get live queue data 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 with live queue items. """ try: limit = int(request.query.get("limit", config.queue_display_limit)) except ValueError: return web.json_response( data={"error": "limit must be a valid integer."}, status=web.HTTPBadRequest.status_code ) return web.json_response( data={**queue.live_queue(limit), "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: """ Delete items from the queue or history. Args: request (Request): The request object. queue (DownloadQueue): The download queue instance. encoder (Encoder): The encoder instance. Returns: Response: The response object. Request Body: type (str): "queue" or "done" status (str, optional): Filter by status (e.g., "finished" or "!finished") ids (list[str], optional): Specific IDs to delete (if provided, ignores status filter) remove_file (bool, optional): Whether to remove files. Default: True """ data = await request.json() ids = data.get("ids") remove_file: bool = bool(data.get("remove_file", True)) storeType = data.get("type", data.get("where")) if not storeType: return web.json_response(data={"error": "Type is required."}, status=web.HTTPBadRequest.status_code) try: storeType: StoreType = StoreType.from_value(storeType) except ValueError: return web.json_response( data={"error": f"type must be one of '{StoreType.all()}'."}, status=web.HTTPBadRequest.status_code, ) if ids: if storeType == StoreType.HISTORY: result = await queue.clear_bulk(ids, remove_file=remove_file) return web.json_response( data={"items": {}, "deleted": int(result.get("deleted", 0))}, status=web.HTTPOk.status_code, dumps=encoder.encode, ) return web.json_response( data={ "items": await ( queue.cancel(ids) if storeType == StoreType.QUEUE else queue.clear(ids, remove_file=remove_file) ), "deleted": len(ids), }, status=web.HTTPOk.status_code, dumps=encoder.encode, ) status_filter = data.get("status") if not status_filter: return web.json_response( data={"error": "either 'ids' or 'status' filter is required."}, status=web.HTTPBadRequest.status_code, ) if storeType == StoreType.HISTORY: result = await queue.clear_by_status(status_filter, remove_file=remove_file) deleted = int(result.get("deleted", 0)) if deleted < 1: return web.json_response( data={"items": {}, "deleted": 0}, status=web.HTTPOk.status_code, dumps=encoder.encode, ) return web.json_response( data={"items": {}, "deleted": deleted}, status=web.HTTPOk.status_code, dumps=encoder.encode, ) ds = queue.queue items_to_delete = [] page = 1 per_page = 1000 while True: items, _, current_page, total_pages = await ds.get_items_paginated( page=page, per_page=per_page, order="DESC", status_filter=status_filter ) items_to_delete.extend([item_id for item_id, _ in items]) if current_page >= total_pages: break page += 1 if not items_to_delete: return web.json_response( data={"items": {}, "deleted": 0}, status=web.HTTPOk.status_code, dumps=encoder.encode, ) return web.json_response( data={ "items": await queue.cancel(items_to_delete), "deleted": len(items_to_delete), }, 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 | None = 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 = await queue.done.get_by_id(id) or await 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": {}, "sidecar": {}, } if "finished" == item.info.status and (filename := item.info.get_file()): try: from app.features.streaming.library.ffprobe import ffprobe info["ffprobe"] = await ffprobe(filename) except Exception: pass try: info["sidecar"] = item.info.get_file_sidecar() except Exception: pass return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode) @route(["GET", "HEAD"], r"api/history/{id}/thumbnail", "history.item.thumbnail") async def item_thumbnail(request: Request, queue: DownloadQueue, config: Config) -> StreamResponse: if not (id := request.match_info.get("id")): return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) miss_key: str = f"thumb_missing:{id}" cache: Cache = Cache.get_instance() item: Download | None = await queue.done.get_by_id(id) if not item or not item.info: return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code) if cache.has(miss_key): return web.json_response(data={"error": "thumbnail not found."}, status=web.HTTPNotFound.status_code) filepath: Path | None = item.info.get_file(download_path=Path(config.download_path)) if not filepath or not filepath.exists() or not filepath.is_file(): cache.set(miss_key, value=True, ttl=30.0) return web.json_response(data={"error": "thumbnail not found."}, status=web.HTTPNotFound.status_code) cache.delete(miss_key) local_thumb: Path | None = pick_local_thumb(filepath) if local_thumb and local_thumb.exists() and local_thumb.is_file(): return web.FileResponse(path=str(local_thumb)) try: generated: Path | None = await ensure_thumb( filepath, Path(config.temp_path) / "thumbnails", item_id=item.info._id ) except OSError as e: LOG.warning( "Failed to generate thumbnail for file '%s'.", filepath, extra={"route": "history.item.thumbnail", "item_id": id, "file_path": str(filepath), "error": str(e)}, exc_info=True, ) generated = None except Exception: LOG.exception( "Failed to generate thumbnail for file '%s'.", filepath, extra={"route": "history.item.thumbnail", "item_id": id, "file_path": str(filepath)}, ) generated = None if generated and generated.exists() and generated.is_file(): return web.FileResponse(path=str(generated)) return web.json_response(data={"error": "thumbnail not found."}, status=web.HTTPNotFound.status_code) @route("POST", r"api/history/{id}/rename", "history.item.rename") async def item_rename( request: Request, queue: DownloadQueue, encoder: Encoder, notify: EventBus, config: Config, ) -> Response: if not (id := request.match_info.get("id")): return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) item: Download | None = await 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) try: post: dict = await request.json() if not post: return web.json_response(data={"error": "no data provided."}, status=web.HTTPBadRequest.status_code) except Exception: return web.json_response(data={"error": "invalid JSON body."}, status=web.HTTPBadRequest.status_code) new_name: str = str(post.get("new_name") or "").strip() if not new_name: return web.json_response(data={"error": "new_name is required."}, status=web.HTTPBadRequest.status_code) filepath: Path | None = item.info.get_file(download_path=Path(config.download_path)) if not filepath: return web.json_response(data={"error": "item has no downloaded file."}, status=web.HTTPBadRequest.status_code) try: renamed, sidecar_renamed = rename_file(filepath, new_name) except ValueError as e: return web.json_response(data={"error": str(e)}, status=web.HTTPConflict.status_code) except OSError as e: LOG.exception( "Failed to rename history item file '%s'.", filepath, extra={"route": "history.item.rename", "item_id": id, "file_path": str(filepath), "new_name": new_name}, ) return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) item_dir: Path = ( Path(item.info.download_dir) if item.info.download_dir else Path(calc_download_path(base_path=config.download_path, folder=item.info.folder, create_path=False)) ) item.info.filename = safe_relative_path(renamed, item_dir, Path(config.download_path)) try: item.info.sidecar = get_file_sidecar(renamed) except Exception: item.info.sidecar = {} await queue.done.put(item, no_notify=True) notify.emit(Events.ITEM_UPDATED, data=item.info) sidecar_count: int = len(sidecar_renamed) LOG.info( "Renamed file '%s' to '%s'.", filepath, renamed, extra={ "route": "history.item.rename", "item_id": id, "old_path": str(filepath), "new_path": str(renamed), "sidecar_count": sidecar_count, }, ) return web.json_response( data=item.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 | None = 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 = await 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( "Updated history item '%s' field '%s'.", item.info.id, k, extra={"route": "history.item_update", "item_id": item.info.id, "field": k, "value": v}, ) if updated: await 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. Query Parameters: sync (bool): If true, wait for all items to be processed synchronously. Default: false """ data = await request.json() if isinstance(data, dict): data = [data] items: list[Item] = [] 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) if "true" == request.query.get("sync", "false").lower(): status: list[dict] = await asyncio.wait_for( fut=asyncio.gather(*[queue.add(item=item) for item in items]), timeout=None, ) response: list[dict[str, Any]] = [] for i, item in enumerate(items): it = {"item": item, "status": "ok" == status[i].get("status"), "msg": status[i].get("msg")} if status[i].get("hidden"): it["hidden"] = True response.append(it) return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode) from app.library.downloads.utils import handle_task_exception batch_id: str = f"batch_{asyncio.get_running_loop().time():.0f}" for idx, item in enumerate(items): if not item.extras: item.extras = {} if len(items) > 1: item.extras["batch_id"] = batch_id item.extras["batch_index"] = idx item.extras["batch_total"] = len(items) task = asyncio.create_task(queue.add(item=item), name=f"bulk_add_{batch_id}_{idx}") task.add_done_callback(lambda t: handle_task_exception(t, LOG)) return web.json_response( data={ "status": "accepted", "message": f"Accepted {len(items)} item(s) for processing", "batch_id": batch_id, "count": len(items), }, status=web.HTTPAccepted.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: """ 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 = await 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) await 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 = await 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) await 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, ) @route("POST", r"api/history/{id}/nfo", "history.item.nfo.generate") async def item_nfo_generate(request: Request, queue: DownloadQueue) -> Response: """ Generate NFO file for an existing download. Args: request: The request object queue: DownloadQueue instance Request Body: type: 'tv' or 'movie' (default: 'tv') overwrite: bool (default: false) Returns: Response with NFO file path on success """ from app.features.ytdlp.extractor import fetch_info from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP 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 = await 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.filename: return web.json_response( data={"error": "item has no downloaded file."}, status=web.HTTPBadRequest.status_code, ) filepath = item.info.get_file() if not filepath or not filepath.exists(): return web.json_response( data={"error": f"file '{item.info.filename}' not found."}, status=web.HTTPNotFound.status_code, ) post = {} if request.body_exists: try: post = await request.json() or {} except Exception: post = {} mode: str = str(post.get("type", "tv")).lower() if mode not in ("tv", "movie"): return web.json_response( data={"error": "type must be 'tv' or 'movie'."}, status=web.HTTPBadRequest.status_code, ) try: (info_dict, _logs) = await fetch_info( config=item.info.get_ytdlp_opts().get_all(), url=item.info.url, no_archive=True, follow_redirect=True, budget_sleep=True, ) if not info_dict: return web.json_response( data={"error": "failed to extract metadata from URL."}, status=web.HTTPInternalServerError.status_code, ) result = NFOMakerPP.generate_nfo( info_dict=info_dict, filepath=filepath, mode=mode, overwrite=bool(post.get("overwrite", False)), logger=LOG, ) return web.json_response( data={"message": result["message"], "nfo_file": result.get("nfo_file")} if result["success"] else {"error": result["message"]}, status=web.HTTPOk.status_code if result["success"] else web.HTTPBadRequest.status_code, ) except Exception as e: LOG.exception( "Failed to generate NFO for item '%s'.", id, extra={"route": "history.item.nfo.generate", "item_id": id, "file_path": str(filepath), "mode": mode}, ) return web.json_response( data={"error": f"failed to generate NFO: {e!s}"}, status=web.HTTPInternalServerError.status_code, )