ytptube/app/routes/api/system.py
2026-01-22 19:36:20 +03:00

389 lines
12 KiB
Python

import asyncio
import errno
import logging
import os
import shlex
import time
from pathlib import Path
from typing import TYPE_CHECKING
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.library.config import Config
from app.library.downloads import DownloadQueue
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.Presets import Presets
from app.library.router import route
from app.library.UpdateChecker import UpdateChecker
from app.library.Utils import list_folders
if TYPE_CHECKING:
from asyncio import Task
from asyncio.events import AbstractEventLoop
from asyncio.subprocess import Process
LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", "api/system/configuration", "system.configuration")
async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response:
"""
Pause non-active downloads.
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(),
"folders": list_folders(
path=Path(config.download_path),
base=Path(config.download_path),
depth_limit=config.download_path_depth - 1,
),
"history_count": await queue.done.get_total_count(),
"queue": (await queue.get("queue"))["queue"],
},
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 not config.check_for_updates:
return web.json_response(
{"error": "Update checking is disabled in configuration."},
status=web.HTTPBadRequest.status_code,
dumps=encoder.encode,
)
# If update already found, return cached result
if config.new_version:
return web.json_response(
data={
"status": "update_available",
"current_version": config.app_version,
"new_version": config.new_version,
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
# Run check and await result
status, new_version = await update_checker.check_for_updates()
return web.json_response(
data={
"status": status,
"current_version": config.app_version,
"new_version": new_version,
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("POST", "api/system/terminal", "system.terminal")
async def stream_terminal(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse:
if not config.console_enabled:
return web.json_response(
{"error": "Console feature is disabled."},
status=web.HTTPForbidden.status_code,
)
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,
)
response = web.StreamResponse(
status=web.HTTPOk.status_code,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
await response.prepare(request)
async def emit_event(event: str, data: dict) -> None:
if request.transport is None or request.transport.is_closing():
return
payload: str = f"event: {event}\ndata: {encoder.encode(data)}\n\n"
await response.write(payload.encode("utf-8"))
returncode: int = -1
try:
LOG.info("Cli command from client. '%s'", raw_command)
args: list[str] = ["yt-dlp", *shlex.split(raw_command, posix=os.name != "nt")]
env_vars: dict[str, str] = os.environ.copy()
env_vars.update(
{
"PWD": config.download_path,
"FORCE_COLOR": "1",
"PYTHONUNBUFFERED": "1",
}
)
if "nt" != os.name:
env_vars.update(
{
"TERM": "xterm-256color",
"LANG": "en_US.UTF-8",
"LC_ALL": "en_US.UTF-8",
"SHELL": "/bin/bash",
}
)
try:
import pty
master_fd, slave_fd = pty.openpty()
stdin_arg = asyncio.subprocess.DEVNULL
stdout_arg = stderr_arg = slave_fd
use_pty = True
except ImportError:
use_pty = False
master_fd = slave_fd = None
stdin_arg = asyncio.subprocess.DEVNULL
stdout_arg = asyncio.subprocess.PIPE
stderr_arg = asyncio.subprocess.STDOUT
creationflags = 0
if os.name == "nt":
import subprocess
creationflags = subprocess.CREATE_NO_WINDOW
proc: Process = await asyncio.create_subprocess_exec(
*args,
cwd=config.download_path,
stdin=stdin_arg,
stdout=stdout_arg,
stderr=stderr_arg,
env=env_vars,
creationflags=creationflags,
)
if use_pty:
assert slave_fd is not None
try:
os.close(slave_fd)
except Exception as e:
LOG.error("Error closing PTY. '%s'.", str(e))
async def reader() -> None:
if use_pty is False:
assert proc.stdout is not None
async for raw_line in proc.stdout:
line = raw_line.rstrip(b"\n")
await emit_event("output", {"type": "stdout", "line": line.decode("utf-8", errors="replace")})
return
assert master_fd is not None
read_fd = master_fd
loop: AbstractEventLoop = asyncio.get_running_loop()
buffer: bytes = b""
while True:
try:
chunk: bytes = await loop.run_in_executor(None, lambda: os.read(read_fd, 1024))
except OSError as e:
if e.errno == errno.EIO:
break
raise
if not chunk:
if buffer:
await emit_event(
"output",
{"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
)
break
buffer += chunk
*lines, buffer = buffer.split(b"\n")
for line in lines:
await emit_event(
"output",
{"type": "stdout", "line": line.decode("utf-8", errors="replace")},
)
if master_fd is None:
return
try:
os.close(master_fd)
except Exception as e:
LOG.error("Error closing PTY. '%s'.", str(e))
read_task: Task = asyncio.create_task(reader(), name="cli_reader")
returncode = await proc.wait()
await read_task
except Exception as e:
LOG.error("CLI execute exception was thrown.")
LOG.exception(e)
await emit_event("output", {"type": "stderr", "line": str(e)})
finally:
await emit_event("close", {"exitcode": returncode})
await response.write_eof()
return response