ytptube/app/routes/api/history.py
2026-06-14 18:35:03 +03:00

903 lines
31 KiB
Python

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