diff --git a/README.md b/README.md index 62cf6792..c59b6db7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s * Send notification to targets based on selected events. * Support per link `yt-dlp JSON config or cli options`, `cookies` & `output format`. * Queue multiple URLs separated by comma. +* Simple file browser. `Disabled by default` * A built in video player that can play any video file regardless of the format. **With support for sidecar external subtitles**. * New `POST /api/history` endpoint that allow one or multiple links to be sent at the same time. * New `GET /api/history/add?url=http://..` endpoint that allow to add single item via GET request. @@ -112,6 +113,7 @@ Certain configuration values can be set via environment variables, using the `-e | 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_BROWSER_ENABLED | Whether to enable the file browser | `false` | # Browser extensions & bookmarklets diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index eeaeac71..7b921dc2 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -11,7 +11,7 @@ import uuid from collections.abc import Awaitable from datetime import UTC, datetime, timedelta from pathlib import Path -from urllib.parse import quote, urlparse +from urllib.parse import quote, unquote_plus, urlparse import anyio import httpx @@ -42,6 +42,7 @@ from .Utils import ( encrypt_data, extract_info, get_file, + get_files, get_mime_type, get_sidecar_subtitles, validate_url, @@ -1704,3 +1705,40 @@ class HttpAPI(Common): await self._notify.emit(Events.TEST, data=data) return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + + @route("GET", "api/file/browser/{path:.*}") + async def file_browser(self, request: Request) -> Response: + """ + Get the file browser. + + Args: + request (Request): The request object. + + Returns: + Response: The response object. + + """ + if not self.config.browser_enabled: + return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code) + + path = request.match_info.get("path") + path = "/" if not path else unquote_plus(path) + + test = os.path.realpath(os.path.join(self.config.download_path, path)) + if not os.path.exists(test): + return web.json_response( + data={"error": f"path '{path}' does not exist."}, status=web.HTTPNotFound.status_code + ) + + try: + return web.json_response( + data={ + "path": path, + "contents": get_files(base_path=self.config.download_path, dir=path), + }, + status=web.HTTPOk.status_code, + dumps=self.encoder.encode, + ) + except OSError as e: + LOG.exception(e) + return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) diff --git a/app/library/Utils.py b/app/library/Utils.py index 3f843f2e..d6a1a3d5 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -1,5 +1,6 @@ import base64 import copy +import datetime import glob import ipaddress import json @@ -702,3 +703,53 @@ def get( else: return default() if callable(default) else default return data + + +def get_files(base_path: str, dir: str | None = None): + """ + Get directory contents. + + Args: + base_path (str): Base download path. + dir (str): Directory to check. + + Returns: + list: List of files and directories. + + Raises: + OSError: If the directory is invalid or not a directory. + + """ + if dir and dir != "/": + path = os.path.normpath(os.path.join(base_path, str(dir))) + if not path.startswith(base_path): + msg = f"Invalid path: '{dir}' - '{path}' - must be inside '{base_path}'." + raise OSError(msg) + dir_path = os.path.realpath(path) + else: + dir_path = base_path + + if not os.path.isdir(dir_path): + msg = f"Invalid path: '{dir_path}' - must be a directory." + raise OSError(msg) + + contents: list = [] + for file in pathlib.Path(dir_path).iterdir(): + if file.name.startswith(".") or file.name.startswith("_"): + continue + + stat = file.stat() + contents.append( + { + "type": "file" if file.is_file() else "dir", + "name": file.name, + "path": str(file).replace(base_path, "").strip("/"), + "size": stat.st_size, + "mtime": datetime.datetime.fromtimestamp(stat.st_mtime, tz=datetime.UTC).isoformat(), + "ctime": datetime.datetime.fromtimestamp(stat.st_ctime, tz=datetime.UTC).isoformat(), + "is_dir": file.is_dir(), + "is_file": file.is_file(), + } + ) + + return contents diff --git a/app/library/config.py b/app/library/config.py index 3a8ebf2d..00e013af 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -146,6 +146,9 @@ class Config: console_enabled: bool = False "Enable direct access to yt-dlp console." + browser_enabled: bool = False + "Enable file browser access." + pictures_backends: list[str] = [ "https://unsplash.it/1920/1080?random", "https://picsum.photos/1920/1080", @@ -196,6 +199,7 @@ class Config: "basic_mode", "file_logging", "console_enabled", + "browser_enabled", ) "The variables that are booleans." @@ -214,6 +218,7 @@ class Config: "instance_title", "sentry_dsn", "console_enabled", + "browser_enabled" ) "The variables that are relevant to the frontend." diff --git a/ui/layouts/default.vue b/ui/layouts/default.vue index 03d84f77..068887f6 100644 --- a/ui/layouts/default.vue +++ b/ui/layouts/default.vue @@ -23,6 +23,15 @@ +
+