diff --git a/README.md b/README.md index f43e96aa..ac6b82f6 100644 --- a/README.md +++ b/README.md @@ -307,11 +307,12 @@ Certain configuration values can be set via environment variables, using the `-e | YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` | | YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` | | YTP_BASIC_MODE | Whether to run WebUI in basic mode | `false` | -| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use. | `empty string` | +| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `empty string` | | YTP_BROWSER_ENABLED | Whether to enable the file browser | `false` | +| YTP_BROWSER_CONTROL_ENABLED | Whether to enable the file browser actions | `false` | | YTP_YTDLP_AUTO_UPDATE | Whether to enable the auto update for yt-dlp | `true` | | YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` | | YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` | -| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer. | `15 */1 * * *` | -| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time. | `1` | +| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` | +| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `1` | diff --git a/app/library/config.py b/app/library/config.py index 92e8acbc..1c3d98a5 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -157,6 +157,9 @@ class Config: browser_enabled: bool = False "Enable file browser access." + browser_control_enabled: bool = False + "Enable file browser control access." + ytdlp_auto_update: bool = True """Enable in-place auto update of yt-dlp package.""" @@ -227,6 +230,7 @@ class Config: "file_logging", "console_enabled", "browser_enabled", + "browser_control_enabled", "ytdlp_auto_update", "prevent_premiere_live", ) @@ -246,6 +250,7 @@ class Config: "sentry_dsn", "console_enabled", "browser_enabled", + "browser_control_enabled", "ytdlp_cli", "file_logging", "base_path", diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py index 4a4d0ef0..4cd87ed5 100644 --- a/app/routes/api/browser.py +++ b/app/routes/api/browser.py @@ -9,7 +9,7 @@ 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 get_file, get_file_sidecar, get_files, get_mime_type +from app.library.Utils import delete_dir, get_file, get_file_sidecar, get_files, get_mime_type LOG: logging.Logger = logging.getLogger(__name__) @@ -155,3 +155,157 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re 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 controls 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) diff --git a/ui/@types/config.d.ts b/ui/@types/config.d.ts index 5f33c982..ae018cbb 100644 --- a/ui/@types/config.d.ts +++ b/ui/@types/config.d.ts @@ -26,6 +26,8 @@ type AppConfig = { console_enabled: boolean /** Indicates if the file browser is enabled */ browser_enabled: boolean + /** Indicates if the file browser control is enabled */ + browser_control_enabled: boolean /** Command options for yt-dlp */ ytdlp_cli: string /** Indicates if file logging is enabled */ diff --git a/ui/pages/browser/[...slug].vue b/ui/pages/browser/[...slug].vue index d76dd871..4f7477d9 100644 --- a/ui/pages/browser/[...slug].vue +++ b/ui/pages/browser/[...slug].vue @@ -32,6 +32,13 @@ +
+ +