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 @@
-
+