import asyncio 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.features.core.utils import build_pagination, normalize_pagination from app.features.streaming.library.ffprobe import ffprobe from app.library.cache import Cache 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.log import get_logger from app.library.router import route from app.library.Utils import delete_dir, get_file, get_file_sidecar, get_files, get_mime_type, move_file, rename_file LOG = get_logger() @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 | None = 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 | None = 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( "Failed to load file info for '%s' because %s.", file, e, extra={"route": "file_info", "file_path": file}, ) 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. """ req_path: str | None = request.match_info.get("path") req_path: str = "/" if not req_path else unquote_plus(req_path) raw_req: str = (req_path or "").strip() root_dir: Path = Path(config.download_path).resolve() if raw_req in ("", "/"): test: Path = root_dir rel_for_listing: str = "/" else: # Strip leading slash so joinpath doesn't ignore the base path. test = root_dir.joinpath(raw_req.lstrip("/")).resolve(strict=False) rel_for_listing: str = raw_req.lstrip("/") try: test.relative_to(root_dir) except Exception: return web.json_response( data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code ) 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: page, per_page = normalize_pagination(request) sort_by: str = request.query.get("sort_by", "name") sort_order: str = request.query.get("sort_order", "asc") search: str | None = request.query.get("search") if sort_by not in ("name", "size", "date", "type"): sort_by = "name" if sort_order not in ("asc", "desc"): sort_order = "asc" contents, total = get_files( base_path=root_dir, dir=rel_for_listing, page=page, per_page=per_page, sort_by=sort_by, sort_order=sort_order, search=search, ) total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1 return web.json_response( data={ "path": rel_for_listing, "contents": contents, "pagination": build_pagination(total, page, per_page, total_pages), }, status=web.HTTPOk.status_code, dumps=encoder.encode, ) except OSError as e: LOG.exception( "Failed to browse file path '%s' because %s.", req_path, e, extra={"route": "file_browser", "request_path": req_path}, ) return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) @route("POST", "api/file/actions", "browser.file.actions") async def path_actions(request: Request, config: Config, queue: DownloadQueue, notify: EventBus) -> Response: """ Browser actions. Args: request (Request): The request object. config (Config): The configuration object. queue (DownloadQueue): The download queue instance. notify (EventBus): The event bus instance. Returns: Response: The response object. """ 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: actions = await request.json() if not actions or not isinstance(actions, list): return web.json_response( data={"error": "Invalid parameters expecting list of dicts."}, status=web.HTTPBadRequest.status_code ) except Exception as e: LOG.debug( "Ignoring invalid file browser actions JSON.", extra={"route": "browser.file.actions", "error": str(e)}, ) return web.json_response(data={"error": "Invalid JSON."}, status=web.HTTPBadRequest.status_code) # validate each action before performing any operations for params in actions: action = params.get("action").lower() if not action or action not in ["rename", "delete", "move", "directory"]: return web.json_response( data={"error": f"Invalid action '{action}'. Must be one of rename, delete, move, directory."}, status=web.HTTPBadRequest.status_code, ) if "rename" == action and not params.get("new_name"): return web.json_response( data={"error": "New name is required for rename action."}, status=web.HTTPBadRequest.status_code ) if "move" == action and not params.get("new_path"): return web.json_response( data={"error": "New path is required for move action."}, status=web.HTTPBadRequest.status_code ) if "directory" == action and not params.get("new_dir"): return web.json_response( data={"error": "New directory name is required for directory action."}, status=web.HTTPBadRequest.status_code, ) operations_status: list[dict[str, Any]] = [] def record( op_path: str | Path, *, ok: bool, error: str | None = None, action: str | None = None, extra: dict | None = None, ) -> None: try: if isinstance(op_path, Path): rel: str = str(op_path.relative_to(rootPath)).strip("/") else: rel: str = str(op_path).strip("/") except Exception: rel = str(op_path) if not rel or rel == ".": rel = "/" entry: dict = {"path": rel, "status": ok, "error": error} if action: entry["action"] = action if extra: norm_extra: dict = {} for k, v in extra.items(): if isinstance(v, Path): try: norm_extra[k] = str(v.relative_to(rootPath)).strip("/") or "/" except Exception: norm_extra[k] = str(v) else: norm_extra[k] = v entry.update(norm_extra) operations_status.append(entry) # perform each action for params in actions: req_path: str = params.get("path") if not req_path: record("no_path", ok=False, error="Path is required.", extra={"item": params}) continue action: str = params.get("action", "").lower() if not action: record(req_path, ok=False, error="Action is required.", extra={"item": params}) continue 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(): record(req_path, ok=False, error=f"path '{req_path}' does not exist.", action=action) continue try: path, status = get_file( download_path=config.download_path, file=str(test.relative_to(config.download_path)) ) if web.HTTPOk.status_code != status: record(req_path, ok=False, error="Invalid path.", action=action) continue if not path.is_relative_to(rootPath): record(req_path, ok=False, error="Path outside download root.", action=action) continue except Exception as e: LOG.exception( "Failed to resolve file browser action '%s' for path '%s' because %s.", action, req_path, e, extra={"route": "browser.file.actions", "action": action, "request_path": req_path}, ) record(req_path, ok=False, error=str(e), action=action, extra={"item": params}) continue if "directory" != action: if path == rootPath: record(path, ok=False, error="Cannot operate on root directory.", action=action) continue if not path.is_relative_to(rootPath): record(path, ok=False, error="Path outside download root.", action=action) continue if "directory" == action: new_dir = params.get("new_dir").lstrip("/").strip() if not new_dir: record(path, ok=False, error="New directory name is required.", action=action) continue new_path = path.joinpath(*new_dir.split("/")) try: new_path = new_path.resolve(strict=False) except Exception: record(path, ok=False, error="Invalid directory path.", action=action, extra={"new_dir": new_dir}) continue root_real = Path(config.download_path).resolve() if not new_path.is_relative_to(root_real): record(path, ok=False, error="Destination outside root.", action=action, extra={"new_dir": new_path}) continue if new_path.exists(): dst = new_path.relative_to(config.download_path) record(path, ok=False, error="Directory already exists.", action=action, extra={"new_dir": dst}) continue try: new_path.mkdir(parents=True, exist_ok=True) record(path, ok=True, action=action, extra={"new_dir": new_path.relative_to(config.download_path)}) LOG.info( "Created directory '%s'.", new_path.relative_to(config.download_path), extra={ "route": "browser.file.actions", "directory": str(new_path.relative_to(config.download_path)), }, ) except OSError as e: LOG.exception( "Failed to create directory '%s' because %s.", new_dir, e, extra={ "route": "browser.file.actions", "action": action, "request_path": req_path, "new_dir": new_dir, }, ) record(path, ok=False, error=str(e), action=action, extra={"item": params}) continue if "rename" == action: new_name: str = params.get("new_name") if not new_name: record(path, ok=False, error="New name is required for rename action.", action=action) continue new_path: Path = path.parent.joinpath(new_name) if new_path.exists(): record( new_path, ok=False, error="Destination already exists.", action=action, extra={"new_path": new_path} ) continue try: sidecar_count: int = 0 sidecar_renamed: list[tuple[Path, Path]] = [] if path.is_dir(): renamed: Path = path.rename(new_path) else: renamed, sidecar_renamed = rename_file(path, new_name) sidecar_count: int = len(sidecar_renamed) LOG.info( "Renamed '%s' to '%s'.", path.relative_to(config.download_path), renamed.relative_to(config.download_path), extra={ "route": "browser.file.actions", "old_path": str(path.relative_to(config.download_path)), "new_path": str(renamed.relative_to(config.download_path)), "sidecar_count": sidecar_count, }, ) except OSError as e: LOG.exception( "Failed to rename file browser path '%s' because %s.", path.relative_to(config.download_path), e, extra={ "route": "browser.file.actions", "action": action, "file_path": str(path.relative_to(config.download_path)), "new_name": new_name, }, ) record(path, ok=False, error=str(e), action=action, extra={"item": params}) continue except ValueError as e: record(path, ok=False, error=str(e), action=action, extra={"item": params}) continue else: extra_info: dict[str, Any] = {"new_path": renamed} if sidecar_renamed: extra_info["sidecar_count"] = len(sidecar_renamed) record(path, ok=True, action=action, extra=extra_info) for old_sidecar, new_sidecar in sidecar_renamed: record(old_sidecar, ok=True, action=action, extra={"new_path": new_sidecar}) if item := await queue.done.get_item(filename=str(path.relative_to(config.download_path))): item.info.filename = str(renamed.relative_to(config.download_path)) if sidecar_renamed: item.info.get_file_sidecar() await queue.done.put(item) notify.emit(Events.ITEM_UPDATED, data=item.info) if "delete" == action: try: if not path.exists(): record(path, ok=False, error="Path does not exist.", action=action) continue if path.is_dir(): delete_dir(path) else: path.unlink(missing_ok=True) LOG.info( "Deleted '%s'.", path.relative_to(config.download_path), extra={"route": "browser.file.actions", "file_path": str(path.relative_to(config.download_path))}, ) except OSError as e: LOG.exception( "Failed to delete file browser path '%s' because %s.", path.relative_to(config.download_path), e, extra={ "route": "browser.file.actions", "action": action, "file_path": str(path.relative_to(config.download_path)), }, ) record(path, ok=False, error=str(e), action=action, extra={"item": params}) continue else: record(path, ok=True, action=action, extra={"deleted": True}) if "move" == action: new_path = params.get("new_path") if not new_path: record(path, ok=False, error="New path is required for move.", action=action, extra={"item": params}) continue raw_new: str = unquote_plus(str(new_path)).strip() target_dir: Path = ( Path(config.download_path) if not raw_new or raw_new in ("/", ".") else Path(config.download_path).joinpath(raw_new.lstrip("/")) ) try: target_dir = target_dir.resolve() except Exception: record( path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir} ) continue root_real: Path = Path(config.download_path).resolve() if not target_dir.exists() or not target_dir.is_dir(): record( path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir} ) continue if not target_dir.is_relative_to(root_real): record( path, ok=False, error="Destination outside download root.", action=action, extra={"new_path": target_dir}, ) continue if path.parent == target_dir: record(path, ok=False, error="Source and destination are the same.", action=action) continue try: sidecar_count: int = 0 sidecar_moved: list[tuple[Path, Path]] = [] if path.is_dir(): dest: Path = target_dir.joinpath(path.name) moved: Path = path.rename(dest) else: moved, sidecar_moved = move_file(path, target_dir) sidecar_count: int = len(sidecar_moved) LOG.info( "Moved '%s' to '%s'.", path.relative_to(config.download_path), moved.relative_to(config.download_path), extra={ "route": "browser.file.actions", "old_path": str(path.relative_to(config.download_path)), "new_path": str(moved.relative_to(config.download_path)), "sidecar_count": sidecar_count, }, ) except OSError as e: LOG.exception( "Failed to move file browser path '%s' because %s.", path.relative_to(config.download_path), e, extra={ "route": "browser.file.actions", "action": action, "file_path": str(path.relative_to(config.download_path)), "new_path": raw_new, }, ) record(path, ok=False, error=str(e), action=action, extra={"item": params}) continue except ValueError as e: record(path, ok=False, error=str(e), action=action, extra={"item": params}) continue else: extra_info: dict[str, Any] = {"new_path": moved} if sidecar_moved: extra_info["sidecar_count"] = len(sidecar_moved) record(path, ok=True, action=action, extra=extra_info) for old_sidecar, new_sidecar in sidecar_moved: record(old_sidecar, ok=True, action=action, extra={"new_path": new_sidecar}) if item := await queue.done.get_item(filename=str(path.relative_to(config.download_path))): item.info.filename = str(moved.relative_to(config.download_path)) if sidecar_moved: item.info.get_file_sidecar() await queue.done.put(item) notify.emit(Events.ITEM_UPDATED, data=item.info) return web.json_response(data=operations_status, 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[Path] = [] 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: dict[str, list[dict[str, Any]]] = get_file_sidecar(ref) if sc: for value in sc.values(): files.extend(scf["file"] for scf in value if isinstance(scf, dict) and "file" in scf) 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( "Started streaming a ZIP download with %d file(s).", len(files), extra={"route": "browser.download.stream", "token": token, "file_count": len(files)}, ) for chunk in zs: if request.transport is None or request.transport.is_closing(): LOG.info( "Stopped streaming the ZIP download because the client disconnected.", extra={"route": "browser.download.stream", "token": token}, ) break await response.write(chunk) await response.write_eof() except asyncio.CancelledError: LOG.info( "Stopped streaming the ZIP download because the client cancelled it.", extra={"route": "browser.download.stream", "token": token}, ) except Exception as e: LOG.exception( "Failed to stream the ZIP download.", extra={ "route": "browser.download.stream", "token": token, "file_count": len(files), "exception_type": type(e).__name__, }, ) return response