From e0227c177253880a3437a9aee330f0737c16abe5 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 31 Aug 2025 22:05:11 +0300 Subject: [PATCH 1/6] wip --- ui/app/components/Queue.vue | 13 ++++++++++++- ui/app/pages/browser/[...slug].vue | 4 ++++ ui/app/pages/conditions.vue | 3 +++ ui/app/pages/index.vue | 10 +++++----- ui/app/pages/notifications.vue | 13 ++++++++++--- ui/app/pages/presets.vue | 13 ++++++++++--- ui/app/pages/tasks.vue | 14 +++++++++++--- 7 files changed, 55 insertions(+), 15 deletions(-) diff --git a/ui/app/components/Queue.vue b/ui/app/components/Queue.vue index 63811d7a..7ab145b4 100644 --- a/ui/app/components/Queue.vue +++ b/ui/app/components/Queue.vue @@ -240,7 +240,7 @@ v-rtime="item.datetime" />
+ v-if="item.downloaded_bytes" v-tooltip="`Saving to: ${makePath(item)}`"> {{ formatBytes(item.downloaded_bytes) }}
@@ -598,4 +598,15 @@ watch(embed_url, v => { } document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`) }) + +const makePath = (item: StoreItem) => { + const parts = [ + eTrim(item.download_dir, '/').replace(config.app.download_path, ''), + ] + if (item?.filename) { + parts.push(eTrim(item.filename, '/')) + } + + return '/' + sTrim(parts.filter(p => !!p).map(p => p.replace(/\\/g, '/').replace(/\/+/g, '/')).join('/'), '/') +} diff --git a/ui/app/pages/browser/[...slug].vue b/ui/app/pages/browser/[...slug].vue index f1e06e18..9e204a6f 100644 --- a/ui/app/pages/browser/[...slug].vue +++ b/ui/app/pages/browser/[...slug].vue @@ -29,6 +29,7 @@
@@ -37,12 +38,14 @@ :class="{ 'is-loading': isLoading }" :disabled="!socket.isConnected || isLoading" v-tooltip.bottom="'Create new directory'" v-if="config.app.browser_control_enabled"> + New Folder

@@ -201,6 +204,7 @@ const route = useRoute() const toast = useNotification() const config = useConfigStore() const socket = useSocketStore() +const isMobile = useMediaQuery({ maxWidth: 1024 }) const bg_enable = useStorage('random_bg', true) const bg_opacity = useStorage('random_bg_opacity', 0.95) diff --git a/ui/app/pages/conditions.vue b/ui/app/pages/conditions.vue index 64cf6964..afcbfa82 100644 --- a/ui/app/pages/conditions.vue +++ b/ui/app/pages/conditions.vue @@ -13,12 +13,14 @@

@@ -121,6 +123,7 @@ const toast = useNotification() const config = useConfigStore() const socket = useSocketStore() const box = useConfirm() +const isMobile = useMediaQuery({ maxWidth: 1024 }) const items = ref([]) const item = ref>({}) diff --git a/ui/app/pages/index.vue b/ui/app/pages/index.vue index 27c065ba..dea27146 100644 --- a/ui/app/pages/index.vue +++ b/ui/app/pages/index.vue @@ -46,9 +46,9 @@

@@ -123,8 +123,9 @@ const stateStore = useStateStore() const socket = useSocketStore() const bg_enable = useStorage('random_bg', true) const bg_opacity = useStorage('random_bg_opacity', 0.95) -const display_style = useStorage('display_style', 'cards') +const display_style = useStorage('display_style', 'grid') const show_thumbnail = useStorage('show_thumbnail', true) +const isMobile = useMediaQuery({ maxWidth: 1024 }) const info_view = ref({ url: '', @@ -143,7 +144,6 @@ const dialog_confirm = ref({ options: [], }) -const isMobile = useMediaQuery({ maxWidth: 1024 }) watch(toggleFilter, () => { if (!toggleFilter.value) { @@ -217,7 +217,7 @@ watch(() => info_view.value.url, v => { document.querySelector('body')?.setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`) }) -const changeDisplay = () => display_style.value = display_style.value === 'cards' ? 'list' : 'cards' +const changeDisplay = () => display_style.value = display_style.value === 'grid' ? 'list' : 'grid' const toNewDownload = async (item: item_request | Partial) => { if (!item) { diff --git a/ui/app/pages/notifications.vue b/ui/app/pages/notifications.vue index a7325da6..bc234741 100644 --- a/ui/app/pages/notifications.vue +++ b/ui/app/pages/notifications.vue @@ -14,19 +14,25 @@

@@ -203,6 +209,7 @@ const config = useConfigStore() const socket = useSocketStore() const box = useConfirm() const display_style = useStorage("tasks_display_style", "cards") +const isMobile = useMediaQuery({ maxWidth: 1024 }) const allowedEvents = ref([]) const notifications = ref([]) diff --git a/ui/app/pages/presets.vue b/ui/app/pages/presets.vue index a66dec20..28698370 100644 --- a/ui/app/pages/presets.vue +++ b/ui/app/pages/presets.vue @@ -14,20 +14,26 @@

@@ -214,6 +220,7 @@ const socket = useSocketStore() const box = useConfirm() const display_style = useStorage('preset_display_style', 'cards') +const isMobile = useMediaQuery({ maxWidth: 1024 }) const presets = ref([]) const preset = ref>({}) diff --git a/ui/app/pages/tasks.vue b/ui/app/pages/tasks.vue index 4277af39..be648b2f 100644 --- a/ui/app/pages/tasks.vue +++ b/ui/app/pages/tasks.vue @@ -20,6 +20,7 @@

@@ -27,14 +28,19 @@

@@ -42,6 +48,7 @@

@@ -382,6 +389,7 @@ const config = useConfigStore() const socket = useSocketStore() const { confirmDialog: cDialog } = useDialog() const display_style = useStorage("tasks_display_style", "cards") +const isMobile = useMediaQuery({ maxWidth: 1024 }) const tasks = ref>([]) const task = ref({}) From bf1c79577ffbcc0e5e33f56750ad8497fa3ac17e Mon Sep 17 00:00:00 2001 From: arabcoders Date: Mon, 1 Sep 2025 01:01:50 +0300 Subject: [PATCH 2/6] wip --- app/routes/api/browser.py | 288 ++++++++++++++++++----------- ui/app/components/Dialog.vue | 6 +- ui/app/components/GetInfo.vue | 1 - ui/app/composables/useDialog.ts | 194 +++++++++---------- ui/app/pages/browser/[...slug].vue | 136 ++++++++++++-- 5 files changed, 408 insertions(+), 217 deletions(-) diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py index 1d09876a..f41d1126 100644 --- a/app/routes/api/browser.py +++ b/app/routes/api/browser.py @@ -160,8 +160,8 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re 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: +@route("POST", "api/file/actions", "browser.file.actions") +async def path_actions(request: Request, config: Config) -> Response: """ Browser actions. @@ -184,134 +184,212 @@ async def path_action(request: Request, config: Config) -> Response: 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) + 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.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: + # 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"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."}, + data={"error": f"Invalid action '{action}'. Must be one of rename, delete, move, directory."}, 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(): + if "rename" == action and not params.get("new_name"): return web.json_response( - data={"error": f"File '{new_name}' already exists."}, status=web.HTTPConflict.status_code + data={"error": "New name is required for rename action."}, status=web.HTTPBadRequest.status_code ) - try: - path.rename(new_path) - LOG.info( - f"Renamed '{path.relative_to(config.download_path)}' to '{test.relative_to(config.download_path)}'" + 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 ) - except OSError as e: - LOG.exception(e) - return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) - if "delete" == action: + 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 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) + if isinstance(op_path, Path): + rel: str = str(op_path.relative_to(rootPath)).strip("/") else: - path.unlink(missing_ok=True) + rel: str = str(op_path).strip("/") + except Exception: + rel = str(op_path) + entry: dict = { + "path": rel if rel else "/", + "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) - 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) + # 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 - 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) + action: str = params.get("action", "").lower() + if not action: + record(req_path, ok=False, error="Action is required.", extra={"item": params}) + continue - 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, - ) + 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.rename(new_path.joinpath(path.name)) - except OSError as e: + 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(e) - return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + record(req_path, ok=False, error=str(e), action=action) + continue - 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 - ) + if "directory" != action: + if path == rootPath: + record(path, ok=False, error="Cannot operate on root directory.", action=action) + continue - 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 - ) + if not path.is_relative_to(rootPath): + record(path, ok=False, error="Path outside download root.", action=action) + continue - 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 - ) + 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 - return web.Response(status=web.HTTPOk.status_code) + 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: + path.rename(new_path) + LOG.info( + f"Renamed '{path.relative_to(config.download_path)}' to '{new_path.relative_to(config.download_path)}'" + ) + except OSError as e: + LOG.exception(e) + record(path, ok=False, error=str(e), action=action, extra={"new_path": new_path}) + continue + else: + record(path, ok=True, action=action, extra={"new_path": new_path}) + + 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(f"Deleted '{path.relative_to(config.download_path)}'") + except OSError as e: + LOG.exception(e) + record(path, ok=False, error=str(e), action=action) + 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=action) + continue + + new_path = Path(config.download_path).joinpath(unquote_plus(new_path)) + if not new_path.exists() or not new_path.is_dir(): + record(path, ok=False, error="Destination path is invalid.", action=action, extra={"move_to": new_path}) + continue + + try: + dest = new_path.joinpath(path.name) + path.rename(dest) + except OSError as e: + LOG.exception(e) + record(path, ok=False, error=str(e), action=action, extra={"moved_to": dest}) + continue + else: + record(path, ok=True, action=action, extra={"moved_to": dest}) + + 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 for directory action.", action=action) + continue + + new_path = path.joinpath(*new_dir.split("/")) + if new_path.exists(): + record( + new_path, ok=False, error="Directory already exists.", action=action, extra={"created": new_path} + ) + continue + + 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) + record(new_path, ok=False, error=str(e), action=action, extra={"created": new_path}) + continue + else: + record(new_path, ok=True, action=action, extra={"created": new_path}) + + return web.json_response(data=operations_status, status=web.HTTPOk.status_code) @route("POST", "api/file/download", "browser.download.prepare") diff --git a/ui/app/components/Dialog.vue b/ui/app/components/Dialog.vue index 2c4809ae..330a5789 100644 --- a/ui/app/components/Dialog.vue +++ b/ui/app/components/Dialog.vue @@ -71,7 +71,9 @@