diff --git a/app/library/Archiver.py b/app/library/Archiver.py
index 185eb9e2..977db85f 100644
--- a/app/library/Archiver.py
+++ b/app/library/Archiver.py
@@ -26,6 +26,7 @@ class _Entry:
self.size: int = -1
self.mtime: float = -1.0
self.loaded: bool = False
+ self.last_check: float = 0.0
class Archiver(metaclass=ThreadSafe):
@@ -45,7 +46,8 @@ class Archiver(metaclass=ThreadSafe):
self._cache: dict[str, _Entry] = {}
self._locks: dict[str, threading.RLock] = {}
self._global_lock = threading.RLock()
- self._stats_check: bool = False
+ self._stats_check: bool = True
+ self._stats_ttl: float = 0.2
self._initialized = True
@classmethod
@@ -130,7 +132,13 @@ class Archiver(metaclass=ThreadSafe):
if not self._stats_check:
return entry
+ if self._stats_ttl > 0:
+ now = time.monotonic()
+ if (now - (entry.last_check or 0.0)) < self._stats_ttl:
+ return entry
+
st: os.stat_result | None = self._stat(key)
+ entry.last_check = time.monotonic()
if not st:
self._cache[key] = _Entry()
@@ -148,6 +156,7 @@ class Archiver(metaclass=ThreadSafe):
entry.size = -1
entry.mtime = -1
entry.loaded = True
+ entry.last_check = time.monotonic()
self._cache[key] = entry
return entry
@@ -178,6 +187,7 @@ class Archiver(metaclass=ThreadSafe):
entry.size = st.st_size
entry.mtime = st.st_mtime
entry.loaded = True
+ entry.last_check = time.monotonic()
self._cache[key] = entry
return entry
@@ -284,6 +294,7 @@ class Archiver(metaclass=ThreadSafe):
st: os.stat_result | None = self._stat(key)
if st:
entry.size, entry.mtime = st.st_size, st.st_mtime
+ entry.last_check = time.monotonic()
return True
@@ -353,12 +364,12 @@ class Archiver(metaclass=ThreadSafe):
st: os.stat_result | None = self._stat(key)
if st:
entry.size, entry.mtime = st.st_size, st.st_mtime
+ entry.last_check = time.monotonic()
self._cache[key] = entry
return True
- # Global configuration
@classmethod
def set_skip_read_stat_checks(cls, skip: bool = True) -> None:
"""
@@ -375,3 +386,17 @@ class Archiver(metaclass=ThreadSafe):
inst = cls.get_instance()
with inst._global_lock:
inst._stats_check = not skip
+
+ @classmethod
+ def set_read_stat_ttl(cls, seconds: float = 0.0) -> None:
+ """
+ Set a short TTL to throttle os.stat checks on reads.
+
+ Args:
+ seconds (float): Minimum time between successive stat() calls per file
+ when checking for external changes. Use 0 to disable throttling.
+
+ """
+ inst = cls.get_instance()
+ with inst._global_lock:
+ inst._stats_ttl = max(0.0, float(seconds))
diff --git a/app/library/ItemDTO.py b/app/library/ItemDTO.py
index 02d9ae17..a35ccd85 100644
--- a/app/library/ItemDTO.py
+++ b/app/library/ItemDTO.py
@@ -151,11 +151,7 @@ class Item:
from .Utils import arg_converter
try:
- removed_options: list = []
- arg_converter(args=cli, level=True, removed_options=removed_options)
- if len(removed_options) > 0:
- LOG.warning("Removed the following options '%s' for '%s'.", ", ".join(removed_options), url)
-
+ arg_converter(args=cli, level=True)
data["cli"] = cli
except Exception as e:
msg = f"Failed to parse command options for yt-dlp. {e!s}"
diff --git a/app/library/YTDLPOpts.py b/app/library/YTDLPOpts.py
index cd33bfb1..ee421357 100644
--- a/app/library/YTDLPOpts.py
+++ b/app/library/YTDLPOpts.py
@@ -76,18 +76,12 @@ class YTDLPOpts:
if from_user:
bad_options: dict[str, str] = {k: v for d in REMOVE_KEYS for k, v in d.items()}
- removed_options: list = []
-
for key, value in config.items():
if from_user and key in bad_options:
- removed_options.append(bad_options[key])
continue
self._item_opts[key] = value
- if len(removed_options) > 0:
- LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options))
-
return self
def preset(self, name: str) -> "YTDLPOpts":
@@ -175,15 +169,7 @@ class YTDLPOpts:
if len(self._item_cli) > 0:
try:
- removed_options: list = []
- user_cli: dict = arg_converter(
- args="\n".join(self._item_cli),
- level=True,
- removed_options=removed_options,
- )
-
- if len(removed_options) > 0:
- LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options))
+ user_cli: dict = arg_converter(args="\n".join(self._item_cli), level=True)
except Exception as e:
msg = f"Invalid command options for yt-dlp were given. '{e!s}'."
raise ValueError(msg) from e
diff --git a/app/library/ytdlp.py b/app/library/ytdlp.py
index e263d057..c67ce607 100644
--- a/app/library/ytdlp.py
+++ b/app/library/ytdlp.py
@@ -1,12 +1,9 @@
-import logging
from typing import Any
import yt_dlp
import app.postprocessors # noqa: F401
-LOG: logging.Logger = logging.getLogger(__name__)
-
class _ArchiveProxy:
"""
@@ -26,7 +23,6 @@ class _ArchiveProxy:
from app.library.Archiver import Archiver
status: bool = item in Archiver.get_instance().read(self._file, [item])
- LOG.debug(f"ArchiveProxy: '{item}' in '{self._file}': {'yes' if status else 'no'}.")
return status
except Exception:
return False
@@ -39,7 +35,6 @@ class _ArchiveProxy:
from app.library.Archiver import Archiver
status: bool = Archiver.get_instance().add(self._file, [item])
- LOG.debug(f"ArchiveProxy: Added '{item}' to '{self._file}': {'yes' if status else 'no'}.")
return status
except Exception:
return False
diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py
index 1d09876a..42075f85 100644
--- a/app/routes/api/browser.py
+++ b/app/routes/api/browser.py
@@ -135,7 +135,24 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
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).joinpath(req_path)
+ # Normalize requested path to always be inside download root.
+ raw_req: str = (req_path or "").strip()
+ root_dir: Path = Path(config.download_path).resolve()
+ if raw_req in ("", "/"):
+ test: Path = root_dir
+ rel_for_listing = "/"
+ else:
+ # Strip leading slash so joinpath doesn't ignore the base path.
+ test = root_dir.joinpath(raw_req.lstrip("/")).resolve(strict=False)
+ rel_for_listing = raw_req.lstrip("/")
+
+ try:
+ test.relative_to(root_dir)
+ except Exception:
+ return web.json_response(
+ data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
+ )
+
if not test.exists():
return web.json_response(
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
@@ -149,8 +166,8 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
try:
return web.json_response(
data={
- "path": req_path,
- "contents": get_files(base_path=Path(config.download_path), dir=req_path),
+ "path": rel_for_listing,
+ "contents": get_files(base_path=root_dir, dir=rel_for_listing),
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
@@ -160,8 +177,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 +201,254 @@ 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)
+ if not rel or rel == ".":
+ rel = "/"
- 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)
+ entry: dict = {"path": rel, "status": ok, "error": error}
- 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)
+ if action:
+ entry["action"] = action
- 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,
- )
+ 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)
+
+ # 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
+
+ action: str = params.get("action", "").lower()
+ if not action:
+ record(req_path, ok=False, error="Action is required.", extra={"item": params})
+ continue
+
+ 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, extra={"item": params})
+ 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
+
+ if not path.is_relative_to(rootPath):
+ record(path, ok=False, error="Path outside download root.", action=action)
+ continue
+
+ 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.", action=action)
+ continue
+
+ new_path = path.joinpath(*new_dir.split("/"))
+ try:
+ new_path = new_path.resolve(strict=False)
+ except Exception:
+ record(path, ok=False, error="Invalid directory path.", action=action, extra={"new_dir": new_dir})
+ continue
+
+ root_real = Path(config.download_path).resolve()
+ if not new_path.is_relative_to(root_real):
+ record(path, ok=False, error="Destination outside root.", action=action, extra={"new_dir": new_path})
+ continue
+
+ if new_path.exists():
+ dst = new_path.relative_to(config.download_path)
+ record(path, ok=False, error="Directory already exists.", action=action, extra={"new_dir": dst})
+ continue
+
+ try:
+ new_path.mkdir(parents=True, exist_ok=True)
+ record(path, ok=True, action=action, extra={"new_dir": new_path.relative_to(config.download_path)})
+ LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'")
+ except OSError as e:
+ LOG.exception(e)
+ record(path, ok=False, error=str(e), action=action, extra={"item": params})
+ continue
+
+ 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
+
+ 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:
+ renamed = path.rename(new_path)
+ LOG.info(
+ f"Renamed '{path.relative_to(config.download_path)}' to '{renamed.relative_to(config.download_path)}'"
+ )
+ except OSError as e:
+ LOG.exception(e)
+ record(path, ok=False, error=str(e), action=action, extra={"item": params})
+ continue
+ else:
+ record(path, ok=True, action=action, extra={"new_path": renamed})
+
+ 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, extra={"item": params})
+ 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, extra={"item": params})
+ continue
+
+ raw_new: str = unquote_plus(str(new_path)).strip()
+ target_dir = (
+ Path(config.download_path)
+ if not raw_new or raw_new in ("/", ".")
+ else Path(config.download_path).joinpath(raw_new.lstrip("/"))
)
- 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:
+ target_dir = target_dir.resolve()
+ except Exception:
+ record(
+ path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir}
+ )
+ 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
- )
+ root_real = Path(config.download_path).resolve()
+ if not target_dir.exists() or not target_dir.is_dir():
+ record(
+ path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir}
+ )
+ continue
- return web.Response(status=web.HTTPOk.status_code)
+ if not target_dir.is_relative_to(root_real):
+ record(
+ path,
+ ok=False,
+ error="Destination outside download root.",
+ action=action,
+ extra={"new_path": target_dir},
+ )
+ continue
+
+ if path.parent == target_dir:
+ record(path, ok=False, error="Source and destination are the same.", action=action)
+ continue
+
+ try:
+ dest = target_dir.joinpath(path.name)
+ path.rename(dest)
+ except OSError as e:
+ LOG.exception(e)
+ record(path, ok=False, error=str(e), action=action, extra={"item": params})
+ continue
+ else:
+ record(path, ok=True, action=action, extra={"new_path": dest})
+
+ 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/ConditionForm.vue b/ui/app/components/ConditionForm.vue
index e7ff9fed..b29bc385 100644
--- a/ui/app/components/ConditionForm.vue
+++ b/ui/app/components/ConditionForm.vue
@@ -343,7 +343,7 @@ const importItem = async (): Promise