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..557b764c 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 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)
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/package.json b/ui/package.json
index 3cedf51f..75fb9823 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -12,13 +12,13 @@
"web-types": "./web-types.json",
"dependencies": {
"@pinia/nuxt": "^0.11.1",
- "@sentry/nuxt": "^9.33.0",
+ "@sentry/nuxt": "^9.34.0",
"@vueuse/core": "^13.4.0",
"@vueuse/nuxt": "^13.4.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"cron-parser": "^5.3.0",
- "cronstrue": "^2.61.0",
+ "cronstrue": "^3.0.0",
"floating-vue": "^5.2.2",
"hls.js": "^1.6.5",
"moment": "^2.30.1",
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 @@
+
+
+