ytptube/app/routes/api/system.py
2026-06-12 20:18:58 +03:00

557 lines
19 KiB
Python

import asyncio
import os
import time
from pathlib import Path
from aiohttp import web
from aiohttp.web import Request, Response
from aiohttp.web_runner import GracefulExit
from app.features.dl_fields.service import DLFields
from app.features.presets.service import Presets
from app.library.cache import Cache
from app.library.config import Config
from app.library.diagnostics import collect_diagnostics, diagnostics_error_report
from app.library.downloads import DownloadQueue
from app.library.downloads.core import Download
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.log import get_logger
from app.library.router import route
from app.library.TerminalSessionManager import TerminalSessionConflictError, TerminalSessionManager
from app.library.UpdateChecker import UpdateChecker
LOG = get_logger()
@route("GET", "api/system/configuration", "system.configuration")
async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response:
"""
Get the system configuration.
Args:
queue (DownloadQueue): The download queue instance.
config (Config): The config instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object.
"""
return web.json_response(
data={
"app": config.frontend(),
"presets": Presets.get_instance().get_all(),
"dl_fields": await DLFields.get_instance().get_all_serialized(),
"paused": queue.is_paused(),
"history_count": await queue.done.get_total_count(),
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("GET", "api/system/folders", "system.folders")
async def system_folders(request: Request, config: Config, encoder: Encoder, cache: Cache) -> Response:
"""
List child directories for a given relative path.
Query params:
path: Relative path within the download directory (default: root).
Returns:
Response: The response object.
"""
raw_path: str = request.query.get("path", "").strip().lstrip("/")
base: Path = Path(config.download_path).resolve()
target: Path = (base / raw_path).resolve()
if not target.is_relative_to(base):
return web.json_response(
data={"path": raw_path, "folders": []},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
cache_key = f"folders:{target!s}"
if (cached := cache.get(cache_key)) is not None:
return web.json_response(data=cached, status=web.HTTPOk.status_code, dumps=encoder.encode)
folders: list[str] = []
if target.is_dir():
try:
folders.extend(
entry.name
for entry in sorted(target.iterdir())
if entry.is_dir() and entry.resolve().is_relative_to(base)
)
except PermissionError:
pass
resolved_rel: str = str(target.relative_to(base)) if target != base else ""
if resolved_rel == ".":
resolved_rel = ""
data: dict[str, str | list[str]] = {"path": resolved_rel, "folders": folders}
cache.set(cache_key, data, ttl=30.0)
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/system/pause", "system.pause")
async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
"""
Pause non-active downloads.
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.
"""
if queue.is_paused():
return web.json_response(
{"message": "Non-active downloads are already paused."},
status=web.HTTPNotAcceptable.status_code,
dumps=encoder.encode,
)
queue.pause()
msg = "Non-active downloads have been paused."
notify.emit(
Events.PAUSED,
data={"paused": True, "at": time.time()},
title="Downloads Paused",
message=msg,
)
return web.json_response(data={"message": msg}, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/system/resume", "system.resume")
async def downloads_resume(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
"""
Resume non-active downloads.
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.
"""
if not queue.is_paused():
return web.json_response(
{"message": "Non-active downloads are not paused."},
status=web.HTTPNotAcceptable.status_code,
dumps=encoder.encode,
)
queue.resume()
msg = "Resumed all downloads."
notify.emit(
Events.RESUMED,
data={"paused": False, "at": time.time()},
title="Downloads Resumed",
message=msg,
)
return web.json_response(data={"message": msg}, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/system/shutdown", "system.shutdown")
async def shutdown_system(request: Request, config: Config, encoder: Encoder, notify: EventBus) -> Response:
"""
Initiate application shutdown.
Args:
request (Request): The request object.
config (Config): The config instance.
encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
"""
if config.is_native is not True:
return web.json_response(
{"error": "Shutdown is only available in the native mode."},
status=web.HTTPBadRequest.status_code,
)
app = request.app
async def do_shutdown():
notify.emit(
Events.SHUTDOWN,
data={"app": app},
title="Application Shutdown",
message="Shutdown initiated by user request.",
)
await asyncio.sleep(0.5)
await app.shutdown()
await app.cleanup()
raise GracefulExit
# Schedule shutdown after response
asyncio.create_task(do_shutdown())
LOG.info("Shutdown initiated by user request. Stopping the server...")
return web.json_response(
data={
"message": "The application shutting down.",
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("POST", "api/system/check-updates", "system.check_updates")
async def check_updates(config: Config, encoder: Encoder, update_checker: UpdateChecker) -> Response:
"""
Manually trigger update check.
Args:
config (Config): The config instance.
encoder (Encoder): The encoder instance.
update_checker (UpdateChecker): The update checker instance.
Returns:
Response: The response object.
"""
if config.new_version or config.yt_new_version:
return web.json_response(
data={
"app": {
"status": "update_available" if config.new_version else "up_to_date",
"current_version": config.app_version,
"new_version": config.new_version or None,
},
"ytdlp": {
"status": "update_available" if config.yt_new_version else "up_to_date",
"current_version": config._ytdlp_version(),
"new_version": config.yt_new_version or None,
},
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
(app_status, app_new_version), (ytdlp_status, ytdlp_new_version) = await update_checker.check_for_updates()
return web.json_response(
data={
"app": {
"status": app_status,
"current_version": config.app_version,
"new_version": app_new_version,
},
"ytdlp": {
"status": ytdlp_status,
"current_version": config._ytdlp_version(),
"new_version": ytdlp_new_version,
},
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("GET", "api/system/diagnostics", "system.diagnostics")
async def system_diagnostics(
config: Config, encoder: Encoder, request: Request | None = None, cache: Cache | None = None
) -> Response:
"""Return user-facing runtime diagnostics for standalone installs."""
cache_key = "system:diagnostics"
cache = cache or Cache.get_instance()
use_cache: bool = request is None or request.query.get("refresh") not in {"1", "true", "yes"}
if use_cache and (data := cache.get(cache_key)) is not None:
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
try:
data = await collect_diagnostics(config)
except Exception:
LOG.exception("Failed to collect system diagnostics.")
data = diagnostics_error_report(config)
else:
cache.set(cache_key, data, ttl=60.0)
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
async def _validate_terminal_command_request(request: Request) -> str | Response:
if not request.can_read_body:
return web.json_response(
{"error": "Request body is required."},
status=web.HTTPBadRequest.status_code,
)
payload = await request.json()
if not isinstance(payload, dict):
return web.json_response(
{"error": "Invalid request payload."},
status=web.HTTPBadRequest.status_code,
)
raw_command = payload.get("command")
if not isinstance(raw_command, str) or not raw_command.strip():
return web.json_response(
{"error": "Command is required."},
status=web.HTTPBadRequest.status_code,
)
return raw_command
@route("POST", "api/system/terminal", "system.terminal")
async def create_terminal_session(
request: Request, config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
) -> Response:
if not config.console_enabled:
return web.json_response(
{"error": "Console feature is disabled."},
status=web.HTTPForbidden.status_code,
)
raw_command = await _validate_terminal_command_request(request)
if isinstance(raw_command, Response):
return raw_command
try:
metadata = await terminal_manager.create_session(raw_command)
except TerminalSessionConflictError as exc:
return web.json_response(
{"error": str(exc)},
status=web.HTTPConflict.status_code,
)
return web.json_response(data=metadata, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("GET", "api/system/terminal", "system.terminal.list")
async def list_terminal_sessions(
config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
) -> Response:
if not config.console_enabled:
return web.json_response(
{"error": "Console feature is disabled."},
status=web.HTTPForbidden.status_code,
)
items = await terminal_manager.list_sessions()
return web.json_response(data={"items": items}, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("GET", "api/system/terminal/active", "system.terminal.active")
async def get_active_terminal_session(
config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
) -> Response:
if not config.console_enabled:
return web.json_response(
{"error": "Console feature is disabled."},
status=web.HTTPForbidden.status_code,
)
metadata = await terminal_manager.get_active_session()
return web.json_response(data=metadata, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("GET", "api/system/terminal/{session_id}", "system.terminal.session")
async def get_terminal_session(
request: Request, config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
) -> Response:
if not config.console_enabled:
return web.json_response(
{"error": "Console feature is disabled."},
status=web.HTTPForbidden.status_code,
)
session_id = request.match_info.get("session_id", "")
metadata = await terminal_manager.get_session(session_id)
if metadata is None:
return web.json_response(
{"error": "Terminal session not found."},
status=web.HTTPNotFound.status_code,
)
return web.json_response(data=metadata, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("DELETE", "api/system/terminal/{session_id}", "system.terminal.cancel")
async def cancel_terminal_session(
request: Request, config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager
) -> Response:
if not config.console_enabled:
return web.json_response(
{"error": "Console feature is disabled."},
status=web.HTTPForbidden.status_code,
)
session_id = request.match_info.get("session_id", "")
try:
await terminal_manager.cancel_session(session_id)
except FileNotFoundError:
return web.json_response(
{"error": "Terminal session not found."},
status=web.HTTPNotFound.status_code,
)
except RuntimeError as exc:
return web.json_response(
{"error": str(exc)},
status=web.HTTPConflict.status_code,
)
return web.json_response(
data={"message": "Terminal session cancellation requested.", "session_id": session_id},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("GET", "api/system/terminal/{session_id}/stream", "system.terminal.stream")
async def stream_terminal_session(
request: Request, config: Config, terminal_manager: TerminalSessionManager
) -> Response | web.StreamResponse:
if not config.console_enabled:
return web.json_response(
{"error": "Console feature is disabled."},
status=web.HTTPForbidden.status_code,
)
session_id = request.match_info.get("session_id", "")
metadata = await terminal_manager.get_session(session_id)
if metadata is None:
return web.json_response(
{"error": "Terminal session not found."},
status=web.HTTPNotFound.status_code,
)
try:
return await terminal_manager.stream_session(session_id=session_id, request=request)
except ValueError as exc:
return web.json_response(
{"error": str(exc)},
status=web.HTTPBadRequest.status_code,
)
@route("GET", "api/system/limits", "system.limits")
async def system_limits(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response:
"""Return user-facing operational limits for downloads and extraction."""
active_downloads: dict[str, Download] = queue.pool.get_active_downloads()
queue_items: list[tuple[str, Download]] = list(queue.queue.items())
active_non_live: list[Download] = [download for download in active_downloads.values() if not download.is_live]
active_live: list[Download] = [download for download in active_downloads.values() if download.is_live]
queued_non_live: list[Download] = [
download
for _, download in queue_items
if not download.started() and not download.is_live and getattr(download.info, "auto_start", True) is not False
]
def _get_extractor_override_limits(config: Config) -> dict[str, dict[str, int | str]]:
overrides: dict[str, dict[str, int | str]] = {}
prefix = "YTP_MAX_WORKERS_FOR_"
for env_key, raw_value in os.environ.items():
if not env_key.startswith(prefix):
continue
if not (extractor := env_key.removeprefix(prefix).strip().lower()):
continue
if not raw_value.isdigit() or int(raw_value) < 1:
continue
configured = int(raw_value)
overrides[extractor] = {
"configured": raw_value,
"effective": min(configured, config.max_workers),
"source": "env_override",
}
return overrides
overrides: dict[str, dict[str, int | str]] = _get_extractor_override_limits(config)
per_extractor: dict[str, dict[str, int | str]] = {}
for download in [*active_non_live, *queued_non_live]:
extractor = (download.info.get_extractor() or "unknown").lower()
entry = per_extractor.setdefault(
extractor,
{
"name": extractor,
"limit": config.max_workers_per_extractor,
"source": "default",
"active": 0,
"queued": 0,
"available": config.max_workers_per_extractor,
},
)
if extractor in overrides:
entry["limit"] = overrides[extractor]["effective"]
entry["source"] = overrides[extractor]["source"]
if download in active_non_live:
entry["active"] += 1
else:
entry["queued"] += 1
for extractor, override in overrides.items():
per_extractor.setdefault(
extractor,
{
"name": extractor,
"limit": override["effective"],
"source": override["source"],
"active": 0,
"queued": 0,
"available": override["effective"],
},
)
for entry in per_extractor.values():
entry["available"] = max(int(entry["limit"]) - int(entry["active"]), 0)
return web.json_response(
data={
"downloads": {
"paused": queue.is_paused(),
"global": {
"limit": config.max_workers,
"active": len(active_non_live),
"available": max(config.max_workers - len(active_non_live), 0),
"live_active": len(active_live),
"queued": len(queued_non_live),
},
"per_extractor": {
"default_limit": config.max_workers_per_extractor,
"items": sorted(per_extractor.values(), key=lambda item: str(item["name"])),
},
},
"extraction": {
"concurrency": config.extract_info_concurrency,
"timeout_seconds": config.extract_info_timeout,
"info_cache_ttl_seconds": config.download_info_expires,
},
"live": {
"prevent_premiere": config.prevent_live_premiere,
"premiere_buffer_minutes": config.live_premiere_buffer,
},
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)