ytptube/app/routes/api/browser.py

399 lines
15 KiB
Python

import asyncio
import logging
from pathlib import Path
from typing import Any
from urllib.parse import unquote_plus
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.cache import Cache
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.ffprobe import ffprobe
from app.library.router import route
from app.library.Utils import delete_dir, get_file, get_file_sidecar, get_files, get_mime_type
LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", "api/file/ffprobe/{file:.*}", "ffprobe")
async def get_ffprobe(request: Request, config: Config, encoder: Encoder, app: web.Application) -> Response:
"""
Get the ffprobe data.
Args:
request (Request): The request object.
config (Config): The configuration object.
encoder (Encoder): The encoder object.
app (web.Application): The aiohttp application object.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
if not file:
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
try:
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=web.HTTPFound.status_code,
headers={
"Location": str(
app.router["ffprobe"].url_for(
file=str(realFile.relative_to(config.download_path).as_posix()).strip("/")
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
return web.json_response(data=await ffprobe(realFile), status=web.HTTPOk.status_code, dumps=encoder.encode)
except Exception as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
@route("GET", "api/file/info/{file:.*}", "file_info")
async def get_file_info(request: Request, config: Config, encoder: Encoder, app: web.Application) -> Response:
"""
Get file info
Args:
request (Request): The request object.
config (Config): The configuration object.
encoder (Encoder): The encoder object.
app (web.Application): The aiohttp application object.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
if not file:
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
try:
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=web.HTTPFound.status_code,
headers={
"Location": str(
app.router["file_info"].url_for(
file=str(realFile.relative_to(config.download_path).as_posix()).strip("/")
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
ff_info = await ffprobe(realFile)
response = {
"title": realFile.stem,
"ffprobe": ff_info,
"mimetype": get_mime_type(ff_info.get("metadata", {}), realFile),
"sidecar": get_file_sidecar(realFile),
}
for key in response["sidecar"]:
for i, f in enumerate(response["sidecar"][key]):
response["sidecar"][key][i]["file"] = str(
realFile.with_name(f["file"].name).relative_to(config.download_path)
).strip("/")
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)
except Exception as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
@route("GET", "api/file/browser/{path:.*}", "file_browser")
async def file_browser(request: Request, config: Config, encoder: Encoder) -> Response:
"""
Get the file browser.
Args:
request (Request): The request object.
config (Config): The configuration object.
encoder (Encoder): The encoder object.
Returns:
Response: The response object.
"""
if not config.browser_enabled:
return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code)
req_path: str = request.match_info.get("path")
req_path: str = "/" if not req_path else unquote_plus(req_path)
test: Path = Path(config.download_path).joinpath(req_path)
if not test.exists():
return web.json_response(
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
)
if not test.is_dir() and not test.is_symlink():
return web.json_response(
data={"error": f"path '{req_path}' is not a directory."}, status=web.HTTPBadRequest.status_code
)
try:
return web.json_response(
data={
"path": req_path,
"contents": get_files(base_path=Path(config.download_path), dir=req_path),
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
except OSError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
@route("POST", "api/file/action/{path:.*}", "browser.actions")
async def path_action(request: Request, config: Config) -> Response:
"""
Browser actions.
Args:
request (Request): The request object.
config (Config): The configuration object.
Returns:
Response: The response object.
"""
if not config.browser_enabled:
return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code)
if not config.browser_control_enabled:
return web.json_response(
data={"error": "File browser actions is disabled."}, status=web.HTTPForbidden.status_code
)
rootPath: Path = Path(config.download_path)
try:
params = await request.json()
if not params or not isinstance(params, dict):
return web.json_response(data={"error": "Invalid parameters."}, status=web.HTTPBadRequest.status_code)
except Exception as e:
LOG.exception(e)
return web.json_response(data={"error": "Invalid JSON."}, status=web.HTTPBadRequest.status_code)
action = params.get("action").lower()
if not action:
return web.json_response(data={"error": "Action is required."}, status=web.HTTPBadRequest.status_code)
req_path: str = request.match_info.get("path")
req_path: str = "/" if not req_path else unquote_plus(req_path)
test: Path = Path(config.download_path)
if req_path and "/" != req_path:
test = test.joinpath(req_path)
if not test.exists():
return web.json_response(
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
)
try:
path, status = get_file(download_path=config.download_path, file=str(test.relative_to(config.download_path)))
if web.HTTPOk.status_code != status:
return web.json_response(
data={"error": f"File {status}: '{test}' does not exist."}, status=web.HTTPNotFound.status_code
)
if not path.is_relative_to(rootPath):
return web.json_response(
data={"error": "Cannot perform actions on files outside the download path."},
status=web.HTTPBadRequest.status_code,
)
except Exception as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
if "directory" != action:
if path == rootPath:
return web.json_response(
data={"error": "Cannot perform actions on the root directory."}, status=web.HTTPBadRequest.status_code
)
if not path.is_relative_to(rootPath):
return web.json_response(
data={"error": "Cannot perform actions on files outside the download path."},
status=web.HTTPBadRequest.status_code,
)
if "rename" == action:
new_name = params.get("new_name")
if not new_name:
return web.json_response(data={"error": "New name is required."}, status=web.HTTPBadRequest.status_code)
new_path = path.parent.joinpath(new_name)
if new_path.exists():
return web.json_response(
data={"error": f"File '{new_name}' already exists."}, status=web.HTTPConflict.status_code
)
try:
path.rename(new_path)
LOG.info(
f"Renamed '{path.relative_to(config.download_path)}' to '{test.relative_to(config.download_path)}'"
)
except OSError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
if "delete" == action:
try:
if not path.exists():
return web.json_response(
data={"error": f"Path '{path}' does not exist."}, status=web.HTTPNotFound.status_code
)
if path.is_dir():
delete_dir(path)
else:
path.unlink(missing_ok=True)
LOG.info(f"Deleted '{path.relative_to(config.download_path)}'")
except OSError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
if "move" == action:
new_path = params.get("new_path")
if not new_path:
return web.json_response(data={"error": "New path is required."}, status=web.HTTPBadRequest.status_code)
new_path = Path(config.download_path).joinpath(unquote_plus(new_path))
if not new_path.exists() or not new_path.is_dir():
return web.json_response(
data={"error": f"New path '{new_path}' does not exist or is not a directory."},
status=web.HTTPNotFound.status_code,
)
try:
path.rename(new_path.joinpath(path.name))
except OSError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
if "directory" == action:
new_dir = params.get("new_dir").lstrip("/").strip()
if not new_dir:
return web.json_response(
data={"error": "New directory name is required."}, status=web.HTTPBadRequest.status_code
)
new_path = path.joinpath(*new_dir.split("/"))
if new_path.exists():
return web.json_response(
data={"error": f"Directory '{new_dir}' already exists."}, status=web.HTTPConflict.status_code
)
try:
new_path.mkdir(parents=True, exist_ok=True)
LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'")
except OSError as e:
LOG.exception(e)
return web.json_response(
data={"error": str(e), "path": str(test)}, status=web.HTTPInternalServerError.status_code
)
return web.Response(status=web.HTTPOk.status_code)
@route("POST", "api/file/download", "browser.download.prepare")
async def prepare_zip_file(request: Request, config: Config, cache: Cache):
json = await request.json()
if not json or not isinstance(json, list):
return web.json_response({"error": "Invalid parameters."}, status=400)
files: list[str] = []
for f in json:
if not isinstance(f, str):
continue
ref, status = get_file(download_path=config.download_path, file=f)
if status == web.HTTPNotFound.status_code:
continue
files.append(ref)
sc: list[dict] = get_file_sidecar(ref)
if sc:
for side in sc:
for scf in sc[side]:
if isinstance(scf, dict) and "file" in scf:
files.append(scf["file"]) # noqa: PERF401
if not files:
return web.json_response({"error": "No valid files."}, status=400)
import uuid
token = str(uuid.uuid4())
cache.set(f"download:{token}", files, ttl=600)
return web.json_response(
data={"token": token, "files": [str(f.relative_to(config.download_path)) for f in files]},
status=web.HTTPOk.status_code,
)
@route("GET", "api/file/download/{token}", "browser.download.stream")
async def stream_zip_download(request: Request, config: Config, cache: Cache) -> Response | web.StreamResponse:
token: str | None = request.match_info.get("token")
if not token:
return web.json_response({"error": "Download token is required."}, status=web.HTTPBadRequest.status_code)
files: Any | None = cache.get(f"download:{token}")
if not files or not isinstance(files, list):
return web.json_response({"error": "Invalid or expired download token."}, status=web.HTTPBadRequest.status_code)
files: list[Path] = [p for p in files if p.is_file() and p.exists()]
if len(files) < 1:
return web.json_response({"error": "No valid files."}, status=web.HTTPBadRequest.status_code)
from zipstream import ZipStream
rootPath = Path(config.download_path).resolve()
zs = ZipStream()
for file in files:
zs.add_path(str(file), str(file.relative_to(rootPath)))
response = web.StreamResponse(
status=200,
headers={
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="{token}.zip"',
},
)
await response.prepare(request)
try:
LOG.info(f"Streaming zip download for token: '{token}', files: {len(files)}")
for chunk in zs:
if request.transport is None or request.transport.is_closing():
LOG.info("Client disconnected, aborting zip download.")
break
await response.write(chunk)
await response.write_eof()
except asyncio.CancelledError:
LOG.info("Download cancelled by client.")
except Exception as e:
LOG.error(f"Streaming zip download error. {type(e).__name__}: {e}")
finally:
return response # noqa: B012