diff --git a/container/Containerfile b/container/Containerfile index 6dbe5b1..9349438 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -18,7 +18,7 @@ RUN mkdir -p /app/backend /app/html /app/conf /Volumes/8TB /Volumes/8TB_RAID1 # Installeer een lichtgewicht Python API framework (FastAPI) # We gebruiken --break-system-packages omdat we in een container zitten -RUN pip3 install fastapi uvicorn python-multipart --break-system-packages +RUN pip3 install fastapi uvicorn python-multipart httpx --break-system-packages # Exposeer de poort voor de webinterface EXPOSE 8030 diff --git a/finder_commander/app/main.py b/finder_commander/app/main.py index e7ed3a8..43c0a28 100644 --- a/finder_commander/app/main.py +++ b/finder_commander/app/main.py @@ -2,6 +2,7 @@ from __future__ import annotations import fnmatch import html +import json import mimetypes import os import secrets @@ -9,6 +10,7 @@ import shutil import stat import time from datetime import datetime +from functools import lru_cache from pathlib import Path from typing import Literal, Optional @@ -94,6 +96,97 @@ def _now_iso() -> str: return datetime.utcnow().isoformat(timespec="seconds") + "Z" +@lru_cache(maxsize=1) +def remote_agent_config() -> dict: + config_path = os.getenv("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", "").strip() + if not config_path: + return {} + try: + return json.loads(Path(config_path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def remote_agent_access_token() -> str: + return os.getenv("FINDER_COMMANDER_AGENT_ACCESS_TOKEN", "").strip() or str( + remote_agent_config().get("agent_access_token", "") + ).strip() + + +def remote_agent_shares() -> dict[str, str]: + raw = remote_agent_config().get("shares", {}) + if not isinstance(raw, dict): + return {} + normalized: dict[str, str] = {} + for key, value in raw.items(): + share_key = str(key).strip() + share_root = str(value).strip() + if share_key and share_root: + normalized[share_key] = share_root + return normalized + + +def require_remote_agent_auth(request: Request) -> None: + expected_token = remote_agent_access_token() + if not expected_token: + return + authorization = request.headers.get("authorization", "").strip() + if authorization != f"Bearer {expected_token}": + raise HTTPException(status_code=403, detail="Invalid agent token") + + +def share_root(share: str) -> Path: + shares = remote_agent_shares() + normalized_share = (share or "").strip() + if normalized_share not in shares: + raise HTTPException(status_code=404, detail="Share not found") + return Path(shares[normalized_share]).expanduser().resolve(strict=False) + + +def ensure_within_share(root: Path, candidate: Path) -> Path: + try: + candidate.relative_to(root) + except ValueError as exc: + raise HTTPException(status_code=403, detail="Path escapes share root") from exc + return candidate + + +def resolve_share_path(share: str, raw_path: str, *, must_exist: bool = True) -> Path: + root = share_root(share) + normalized_raw_path = (raw_path or "").strip().replace("\\", "/") + if normalized_raw_path.startswith("/") or any(part == ".." for part in normalized_raw_path.split("/")): + raise HTTPException(status_code=400, detail="Invalid share-relative path") + candidate = (root / normalized_raw_path).resolve(strict=False) + candidate = ensure_within_share(root, candidate) + if must_exist and not candidate.exists(): + raise HTTPException(status_code=404, detail="Path not found") + return candidate + + +def remote_entry_payload(path: Path) -> dict: + st = path.lstat() + return { + "name": path.name, + "kind": "directory" if path.is_dir() else "file", + "size": st.st_size, + "modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"), + } + + +def sorted_share_entries(path: Path, show_hidden: bool = False) -> list[dict]: + try: + children = list(path.iterdir()) + except PermissionError as exc: + raise HTTPException(status_code=403, detail="Permission denied by operating system") from exc + filtered = [] + for child in children: + if not show_hidden and child.name.startswith("."): + continue + filtered.append(child) + filtered.sort(key=lambda p: (not p.is_dir(), p.name.lower())) + return [remote_entry_payload(child) for child in filtered] + + def rel_from_home(path: Path) -> str: return "" if path == HOME_ROOT else str(path.relative_to(HOME_ROOT)) @@ -314,7 +407,8 @@ async def harden_headers(request: Request, call_next): @app.get("/health") -def health() -> dict: +def health(request: Request) -> dict: + require_remote_agent_auth(request) return {"ok": True, "app": APP_NAME, "time": _now_iso(), "home": str(HOME_ROOT)} @@ -332,7 +426,17 @@ def index(request: Request): @app.get("/api/list") -def api_list(path: str = "", show_hidden: bool = False) -> dict: +def api_list(request: Request, path: str = "", share: str = "", show_hidden: bool = False) -> dict: + if share.strip(): + require_remote_agent_auth(request) + target = resolve_share_path(share, path) + if not target.is_dir(): + raise HTTPException(status_code=400, detail="Path is not a directory") + return { + "share": share.strip(), + "path": path.strip().replace("\\", "/").strip("/"), + "entries": sorted_share_entries(target, show_hidden=show_hidden), + } target = resolve_user_path(path) if not target.is_dir(): raise HTTPException(status_code=400, detail="Path is not a directory") diff --git a/webui/backend/app/__pycache__/config.cpython-313.pyc b/webui/backend/app/__pycache__/config.cpython-313.pyc index 61a6cae..c452a9b 100644 Binary files a/webui/backend/app/__pycache__/config.cpython-313.pyc and b/webui/backend/app/__pycache__/config.cpython-313.pyc differ diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index e016732..9ef1b3c 100644 Binary files a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc and b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc differ diff --git a/webui/backend/app/config.py b/webui/backend/app/config.py index a3c8f8e..192ed0f 100644 --- a/webui/backend/app/config.py +++ b/webui/backend/app/config.py @@ -13,6 +13,7 @@ class Settings: remote_client_offline_timeout_seconds: int remote_client_agent_auth_header: str remote_client_agent_auth_scheme: str + remote_client_agent_auth_token: str DEFAULT_ROOT_ALIASES = { @@ -57,4 +58,5 @@ def get_settings() -> Settings: remote_client_agent_auth_header=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_HEADER", "Authorization").strip() or "Authorization", remote_client_agent_auth_scheme=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_SCHEME", "Bearer").strip() or "Bearer", + remote_client_agent_auth_token=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_TOKEN", "").strip(), ) diff --git a/webui/backend/app/db/remote_client_repository.py b/webui/backend/app/db/remote_client_repository.py index b2bad1d..4b14585 100644 --- a/webui/backend/app/db/remote_client_repository.py +++ b/webui/backend/app/db/remote_client_repository.py @@ -97,6 +97,20 @@ class RemoteClientRepository: ).fetchall() return [self._to_dict(row) for row in rows] + def get_client(self, client_id: str) -> dict | None: + with self._connection() as conn: + row = conn.execute( + """ + SELECT * + FROM remote_clients + WHERE client_id = ? + """, + (client_id,), + ).fetchone() + if row is None: + return None + return self._to_dict(row) + def _ensure_schema(self) -> None: db_path = Path(self._db_path) if db_path.parent and str(db_path.parent) not in {"", "."}: diff --git a/webui/backend/app/dependencies.py b/webui/backend/app/dependencies.py index 41aafd7..a670ebc 100644 --- a/webui/backend/app/dependencies.py +++ b/webui/backend/app/dependencies.py @@ -20,6 +20,7 @@ from backend.app.services.duplicate_task_service import DuplicateTaskService from backend.app.services.file_ops_service import FileOpsService from backend.app.services.history_service import HistoryService from backend.app.services.move_task_service import MoveTaskService +from backend.app.services.remote_browse_service import RemoteBrowseService from backend.app.services.remote_client_service import RemoteClientService from backend.app.services.search_service import SearchService from backend.app.services.settings_service import SettingsService @@ -83,7 +84,11 @@ def get_archive_artifact_root() -> str: async def get_browse_service() -> BrowseService: - return BrowseService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter()) + return BrowseService( + path_guard=get_path_guard(), + filesystem=get_filesystem_adapter(), + remote_browse_service=await get_remote_browse_service(), + ) async def get_file_ops_service() -> FileOpsService: @@ -172,3 +177,13 @@ async def get_remote_client_service() -> RemoteClientService: registration_token=settings.remote_client_registration_token, offline_timeout_seconds=settings.remote_client_offline_timeout_seconds, ) + + +async def get_remote_browse_service() -> RemoteBrowseService: + settings: Settings = get_settings() + return RemoteBrowseService( + remote_client_service=await get_remote_client_service(), + agent_auth_header=settings.remote_client_agent_auth_header, + agent_auth_scheme=settings.remote_client_agent_auth_scheme, + agent_auth_token=settings.remote_client_agent_auth_token, + ) diff --git a/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc index 69bc2e8..59904a1 100644 Binary files a/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/browse_service.py b/webui/backend/app/services/browse_service.py index e516208..23c24e0 100644 --- a/webui/backend/app/services/browse_service.py +++ b/webui/backend/app/services/browse_service.py @@ -3,14 +3,24 @@ from __future__ import annotations from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.security.path_guard import PathGuard +from backend.app.services.remote_browse_service import RemoteBrowseService class BrowseService: - def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter): + def __init__( + self, + path_guard: PathGuard, + filesystem: FilesystemAdapter, + remote_browse_service: RemoteBrowseService | None = None, + ): self._path_guard = path_guard self._filesystem = filesystem + self._remote_browse_service = remote_browse_service def browse(self, path: str, show_hidden: bool) -> BrowseResponse: + if self._remote_browse_service and self._remote_browse_service.handles_path(path): + return self._remote_browse_service.browse(path=path, show_hidden=show_hidden) + if self._path_guard.is_virtual_volumes_path(path): directories = [ DirectoryEntry(name=item["name"], path=item["path"], modified="") diff --git a/webui/backend/app/services/remote_browse_service.py b/webui/backend/app/services/remote_browse_service.py new file mode 100644 index 0000000..ac6f3e8 --- /dev/null +++ b/webui/backend/app/services/remote_browse_service.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from urllib.parse import urlencode + +import httpx + +from backend.app.api.errors import AppError +from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry, RemoteClientItem +from backend.app.services.remote_client_service import RemoteClientService + + +class RemoteBrowseService: + ROOT_PATH = "/Clients" + + def __init__( + self, + remote_client_service: RemoteClientService, + agent_auth_header: str, + agent_auth_scheme: str, + agent_auth_token: str, + agent_timeout_seconds: float = 2.0, + ): + self._remote_client_service = remote_client_service + self._agent_auth_header = (agent_auth_header or "Authorization").strip() or "Authorization" + self._agent_auth_scheme = (agent_auth_scheme or "Bearer").strip() or "Bearer" + self._agent_auth_token = (agent_auth_token or "").strip() + self._agent_timeout_seconds = max(0.1, float(agent_timeout_seconds)) + + @classmethod + def handles_path(cls, path: str) -> bool: + normalized = (path or "").strip() + return normalized == cls.ROOT_PATH or normalized.startswith(f"{cls.ROOT_PATH}/") + + def browse(self, path: str, show_hidden: bool) -> BrowseResponse: + parts = self._path_parts(path) + if not parts: + return self._browse_clients_root() + if len(parts) == 1: + return self._browse_client(parts[0]) + return self._browse_remote_share(parts[0], parts[1], parts[2:], show_hidden) + + @classmethod + def _path_parts(cls, path: str) -> list[str]: + normalized = (path or "").strip().rstrip("/") + if normalized == cls.ROOT_PATH: + return [] + return normalized[len(cls.ROOT_PATH) + 1 :].split("/") + + def _browse_clients_root(self) -> BrowseResponse: + clients = self._remote_client_service.list_clients().items + directories = [ + DirectoryEntry( + name=client.display_name, + path=f"{self.ROOT_PATH}/{client.client_id}", + modified=client.last_seen or client.updated_at, + ) + for client in clients + ] + return BrowseResponse(path=self.ROOT_PATH, directories=directories, files=[]) + + def _browse_client(self, client_id: str) -> BrowseResponse: + client = self._remote_client_service.get_client(client_id) + directories = [ + DirectoryEntry( + name=share.label, + path=f"{self.ROOT_PATH}/{client.client_id}/{share.key}", + modified=client.last_seen or client.updated_at, + ) + for share in client.shares + ] + return BrowseResponse(path=f"{self.ROOT_PATH}/{client.client_id}", directories=directories, files=[]) + + def _browse_remote_share( + self, + client_id: str, + share_key: str, + relative_parts: list[str], + show_hidden: bool, + ) -> BrowseResponse: + client = self._remote_client_service.get_client(client_id) + if client.status != "online": + raise AppError( + code="remote_client_unavailable", + message=f"Remote client '{client.display_name}' is offline", + status_code=503, + details={"client_id": client.client_id, "status": client.status}, + ) + share = next((item for item in client.shares if item.key == share_key), None) + if share is None: + raise AppError( + code="path_not_found", + message="Remote share was not found", + status_code=404, + details={"client_id": client.client_id, "share_key": share_key}, + ) + if not self._agent_auth_token: + raise AppError( + code="remote_client_agent_auth_not_configured", + message="Remote client agent auth token is not configured", + status_code=503, + details={"client_id": client.client_id}, + ) + + base_path = f"{self.ROOT_PATH}/{client.client_id}/{share.key}" + relative_path = "/".join(relative_parts) + agent_payload = self._fetch_remote_listing(client=client, share_key=share.key, relative_path=relative_path, show_hidden=show_hidden) + + directories: list[DirectoryEntry] = [] + files: list[FileEntry] = [] + for entry in agent_payload.get("entries", []): + if not isinstance(entry, dict): + continue + name = str(entry.get("name", "")).strip() + kind = str(entry.get("kind", "")).strip() + if not name or kind not in {"directory", "file"}: + continue + child_path = f"{base_path}/{name}" + modified = str(entry.get("modified", "") or "") + if kind == "directory": + directories.append(DirectoryEntry(name=name, path=child_path, modified=modified)) + continue + size = entry.get("size", 0) + try: + normalized_size = max(0, int(size)) + except (TypeError, ValueError): + normalized_size = 0 + files.append(FileEntry(name=name, path=child_path, size=normalized_size, modified=modified)) + + response_path = base_path if not relative_path else f"{base_path}/{relative_path}" + return BrowseResponse(path=response_path, directories=directories, files=files) + + def _fetch_remote_listing( + self, + *, + client: RemoteClientItem, + share_key: str, + relative_path: str, + show_hidden: bool, + ) -> dict: + normalized_endpoint = client.endpoint.rstrip("/") + query = urlencode({"share": share_key, "path": relative_path, "show_hidden": str(show_hidden).lower()}) + url = f"{normalized_endpoint}/api/list?{query}" + headers = {self._agent_auth_header: f"{self._agent_auth_scheme} {self._agent_auth_token}"} + timeout = httpx.Timeout(self._agent_timeout_seconds, connect=self._agent_timeout_seconds) + + try: + with httpx.Client(timeout=timeout, headers=headers) as client_http: + response = client_http.get(url) + except httpx.TimeoutException as exc: + raise AppError( + code="remote_client_timeout", + message=f"Remote client '{client.display_name}' timed out", + status_code=504, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) from exc + except httpx.HTTPError as exc: + raise AppError( + code="remote_client_unreachable", + message=f"Remote client '{client.display_name}' is unreachable", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) from exc + + if response.status_code == 404: + raise AppError( + code="path_not_found", + message="Remote path was not found", + status_code=404, + details={"client_id": client.client_id, "share_key": share_key}, + ) + if response.status_code in {401, 403}: + raise AppError( + code="remote_client_forbidden", + message=f"Remote client '{client.display_name}' rejected authentication", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) + if response.status_code >= 400: + raise AppError( + code="remote_client_error", + message=f"Remote client '{client.display_name}' browse failed", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint, "status_code": str(response.status_code)}, + ) + try: + payload = response.json() + except ValueError as exc: + raise AppError( + code="remote_client_error", + message=f"Remote client '{client.display_name}' returned invalid JSON", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) from exc + if not isinstance(payload, dict): + raise AppError( + code="remote_client_error", + message=f"Remote client '{client.display_name}' returned an invalid response", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) + return payload diff --git a/webui/backend/app/services/remote_client_service.py b/webui/backend/app/services/remote_client_service.py index 02237fc..8dc82f3 100644 --- a/webui/backend/app/services/remote_client_service.py +++ b/webui/backend/app/services/remote_client_service.py @@ -27,14 +27,30 @@ class RemoteClientService: self._now = now or (lambda: datetime.now(tz=timezone.utc)) def list_clients(self) -> RemoteClientListResponse: - now = self._now() - self._repository.mark_stale_clients_offline( - cutoff_iso=self._to_iso(now - timedelta(seconds=self._offline_timeout_seconds)), - now_iso=self._to_iso(now), - ) + self._refresh_stale_statuses() items = [RemoteClientItem(**row) for row in self._repository.list_clients()] return RemoteClientListResponse(items=items) + def get_client(self, client_id: str) -> RemoteClientItem: + normalized_client_id = (client_id or "").strip() + if not normalized_client_id: + raise AppError( + code="invalid_request", + message="client_id is required", + status_code=400, + details={"client_id": client_id}, + ) + self._refresh_stale_statuses() + item = self._repository.get_client(normalized_client_id) + if item is None: + raise AppError( + code="path_not_found", + message="Remote client was not found", + status_code=404, + details={"client_id": normalized_client_id}, + ) + return RemoteClientItem(**item) + def register_client(self, authorization: str | None, request: RemoteClientRegisterRequest) -> RemoteClientItem: self._require_registration_auth(authorization) payload = self._normalize_register_request(request) @@ -123,6 +139,13 @@ class RemoteClientService: "shares": shares, } + def _refresh_stale_statuses(self) -> None: + now = self._now() + self._repository.mark_stale_clients_offline( + cutoff_iso=self._to_iso(now - timedelta(seconds=self._offline_timeout_seconds)), + now_iso=self._to_iso(now), + ) + @staticmethod def _to_iso(value: datetime) -> str: return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 62299f7..8533834 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_browse_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_browse_golden.cpython-313.pyc index 78bdec6..f5a0f61 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_browse_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_browse_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_api_browse_golden.py b/webui/backend/tests/golden/test_api_browse_golden.py index 03d2fbb..66944cc 100644 --- a/webui/backend/tests/golden/test_api_browse_golden.py +++ b/webui/backend/tests/golden/test_api_browse_golden.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import os import sys import tempfile import unittest @@ -11,11 +12,43 @@ import httpx sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from backend.app.api.errors import AppError from backend.app.dependencies import get_browse_service +from backend.app.db.remote_client_repository import RemoteClientRepository from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.main import app from backend.app.security.path_guard import PathGuard from backend.app.services.browse_service import BrowseService +from backend.app.services.remote_browse_service import RemoteBrowseService +from backend.app.services.remote_client_service import RemoteClientService + + +class _StubRemoteBrowseService(RemoteBrowseService): + def __init__( + self, + remote_client_service: RemoteClientService, + listings: dict[tuple[str, str, str], dict], + failing_client_ids: set[str], + ): + super().__init__( + remote_client_service=remote_client_service, + agent_auth_header="Authorization", + agent_auth_scheme="Bearer", + agent_auth_token="agent-secret", + agent_timeout_seconds=0.25, + ) + self._listings = listings + self._failing_client_ids = failing_client_ids + + def _fetch_remote_listing(self, *, client, share_key: str, relative_path: str, show_hidden: bool) -> dict: + if client.client_id in self._failing_client_ids: + raise AppError( + code="remote_client_unreachable", + message=f"Remote client '{client.display_name}' is unreachable", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) + return self._listings[(client.client_id, share_key, relative_path)] class BrowseApiGoldenTest(unittest.TestCase): @@ -36,6 +69,12 @@ class BrowseApiGoldenTest(unittest.TestCase): file_path.write_bytes(b"abc") second_file = self.second_root / "archive.txt" second_file.write_text("z", encoding="utf-8") + remote_root = Path(self.temp_dir.name) / "remote-downloads" + remote_root.mkdir(parents=True, exist_ok=True) + remote_dir = remote_root / "Series" + remote_dir.mkdir() + remote_file = remote_root / "episode.mkv" + remote_file.write_bytes(b"remote") hidden_dir = self.root / ".hidden_dir" hidden_dir.mkdir() @@ -43,15 +82,70 @@ class BrowseApiGoldenTest(unittest.TestCase): hidden_file.write_bytes(b"x") mtime = 1710000000 - for path in [folder, file_path, hidden_dir, hidden_file, second_file]: + for path in [folder, file_path, hidden_dir, hidden_file, second_file, remote_dir, remote_file]: Path(path).touch() Path(path).chmod(0o755) - import os os.utime(path, (mtime, mtime)) + repository = RemoteClientRepository(str(Path(self.temp_dir.name) / "remote-clients.db")) + now_iso = "2026-03-26T12:00:00Z" + repository.upsert_client( + client_id="client-123", + display_name="Jan MacBook", + platform="macos", + agent_version="1.1.0", + endpoint="http://agent.test", + shares=[{"key": "downloads", "label": "Downloads"}], + now_iso=now_iso, + ) + repository.upsert_client( + client_id="broken-client", + display_name="Offline iMac", + platform="macos", + agent_version="1.1.0", + endpoint="http://127.0.0.1:1", + shares=[{"key": "downloads", "label": "Downloads"}], + now_iso=now_iso, + ) + service = BrowseService( path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}), filesystem=FilesystemAdapter(), + remote_browse_service=_StubRemoteBrowseService( + remote_client_service=RemoteClientService( + repository=repository, + registration_token="secret-token", + offline_timeout_seconds=60, + now=lambda: datetime(2026, 3, 26, 12, 0, 0, tzinfo=timezone.utc), + ), + listings={ + ( + "client-123", + "downloads", + "", + ): { + "entries": [ + { + "name": "Series", + "kind": "directory", + "size": remote_dir.stat().st_size, + "modified": datetime.fromtimestamp(remote_dir.stat().st_mtime, tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + }, + { + "name": "episode.mkv", + "kind": "file", + "size": remote_file.stat().st_size, + "modified": datetime.fromtimestamp(remote_file.stat().st_mtime, tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + }, + ] + } + }, + failing_client_ids={"broken-client"}, + ), ) async def _override_browse_service() -> BrowseService: return service @@ -151,6 +245,80 @@ class BrowseApiGoldenTest(unittest.TestCase): }, ) + def test_browse_virtual_clients_and_remote_share(self) -> None: + clients_response = self._get("/Clients") + self.assertEqual(clients_response.status_code, 200) + self.assertEqual( + clients_response.json(), + { + "path": "/Clients", + "directories": [ + { + "name": "Jan MacBook", + "path": "/Clients/client-123", + "modified": "2026-03-26T12:00:00Z", + }, + { + "name": "Offline iMac", + "path": "/Clients/broken-client", + "modified": "2026-03-26T12:00:00Z", + }, + ], + "files": [], + }, + ) + + shares_response = self._get("/Clients/client-123") + self.assertEqual(shares_response.status_code, 200) + self.assertEqual( + shares_response.json(), + { + "path": "/Clients/client-123", + "directories": [ + { + "name": "Downloads", + "path": "/Clients/client-123/downloads", + "modified": "2026-03-26T12:00:00Z", + } + ], + "files": [], + }, + ) + + browse_response = self._get("/Clients/client-123/downloads") + self.assertEqual(browse_response.status_code, 200) + modified = datetime.fromtimestamp(1710000000, tz=timezone.utc).isoformat().replace("+00:00", "Z") + self.assertEqual( + browse_response.json(), + { + "path": "/Clients/client-123/downloads", + "directories": [ + { + "name": "Series", + "path": "/Clients/client-123/downloads/Series", + "modified": modified, + } + ], + "files": [ + { + "name": "episode.mkv", + "path": "/Clients/client-123/downloads/episode.mkv", + "size": 6, + "modified": modified, + } + ], + }, + ) + + def test_remote_client_failure_stays_local_to_remote_subtree(self) -> None: + broken_response = self._get("/Clients/broken-client/downloads") + self.assertEqual(broken_response.status_code, 502) + self.assertEqual(broken_response.json()["error"]["code"], "remote_client_unreachable") + + volumes_response = self._get("/Volumes") + self.assertEqual(volumes_response.status_code, 200) + self.assertEqual(volumes_response.json()["path"], "/Volumes") + if __name__ == "__main__": unittest.main() diff --git a/webui/html/app.js b/webui/html/app.js index 35e8e29..e24fa8e 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -141,6 +141,10 @@ const VALID_THEME_FAMILIES = [ "fluent-neon", ]; const VALID_COLOR_MODES = ["dark", "light"]; +const VIRTUAL_SOURCES = [ + { path: "/Volumes", label: "Volumes" }, + { path: "/Clients", label: "Clients" }, +]; let searchState = { pane: "left", path: "/Volumes", @@ -200,6 +204,56 @@ function activePaneState() { return paneState(state.activePane); } +function sourceRootForPath(path) { + const normalized = (path || "").trim(); + if (normalized === "/Clients" || normalized.startsWith("/Clients/")) { + return "/Clients"; + } + return "/Volumes"; +} + +function isRemoteBrowsePath(path) { + return sourceRootForPath(path) === "/Clients"; +} + +function syncSourceSwitchers() { + ["left", "right"].forEach((pane) => { + const container = document.getElementById(`${pane}-source-switcher`); + if (!container) { + return; + } + const activeSource = sourceRootForPath(paneState(pane).currentPath); + [...container.querySelectorAll("button[data-source-path]")].forEach((button) => { + const isActive = button.dataset.sourcePath === activeSource; + button.disabled = isActive; + button.setAttribute("aria-pressed", isActive ? "true" : "false"); + }); + }); +} + +function ensureSourceSwitchers() { + ["left", "right"].forEach((pane) => { + const toolbar = document.querySelector(`#${pane}-pane .pane-topbar`); + if (!toolbar || document.getElementById(`${pane}-source-switcher`)) { + return; + } + const container = document.createElement("div"); + container.id = `${pane}-source-switcher`; + container.className = "pane-source-switcher"; + VIRTUAL_SOURCES.forEach((source) => { + const button = createButton(source.label, () => { + setActivePane(pane); + navigateTo(pane, source.path); + }); + button.type = "button"; + button.dataset.sourcePath = source.path; + container.append(button); + }); + toolbar.prepend(container); + }); + syncSourceSwitchers(); +} + function setStatus(msg) { document.getElementById("status").textContent = msg; } @@ -716,6 +770,7 @@ function openContextMenu(pane, entry, event) { const items = selectedPathsSet.has(entry.path) ? selectedItems.map((item) => ({ ...item })) : [selectedEntryFromItem(entry)]; + const remoteSelection = items.some((item) => isRemoteBrowsePath(item.path)); contextMenuState.open = true; contextMenuState.pane = pane; @@ -723,26 +778,28 @@ function openContextMenu(pane, entry, event) { contextMenuState.anchorPath = entry.path; const isMulti = items.length > 1; - const openableSingle = items.length === 1 && isOpenableSelection(items[0]); - const editableSingle = items.length === 1 && isEditableSelection(items[0]); - const downloadableSelection = items.length > 0; + const openableSingle = items.length === 1 && (!remoteSelection || items[0].kind === "directory") && isOpenableSelection(items[0]); + const editableSingle = items.length === 1 && !remoteSelection && isEditableSelection(items[0]); + const downloadableSelection = items.length > 0 && !remoteSelection; elements.scope.textContent = isMulti ? "Multi-selection" : "Single item"; elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name; elements.openButton.classList.toggle("hidden", isMulti); elements.openButton.disabled = !openableSingle; - elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file"); + elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection); elements.editButton.disabled = !editableSingle; - elements.downloadButton.classList.remove("hidden"); + elements.downloadButton.classList.toggle("hidden", remoteSelection); elements.downloadButton.disabled = !downloadableSelection; - elements.renameButton.classList.toggle("hidden", isMulti); + elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection); elements.duplicateButton.classList.remove("hidden"); - elements.duplicateButton.disabled = items.length === 0; + elements.duplicateButton.disabled = remoteSelection || items.length === 0; elements.copyButton.classList.remove("hidden"); - elements.copyButton.disabled = items.length === 0; + elements.copyButton.disabled = remoteSelection || items.length === 0; elements.moveButton.classList.remove("hidden"); + elements.moveButton.disabled = remoteSelection || items.length === 0; elements.deleteButton.classList.remove("hidden"); - elements.propertiesButton.classList.remove("hidden"); - elements.propertiesButton.disabled = items.length === 0; + elements.deleteButton.disabled = remoteSelection || items.length === 0; + elements.propertiesButton.classList.toggle("hidden", remoteSelection); + elements.propertiesButton.disabled = remoteSelection || items.length === 0; const menuWidth = 220; const menuHeight = 120; @@ -2050,12 +2107,17 @@ function updateActionButtons() { const hasSelection = count > 0; const exactlyOne = count === 1; const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); - document.getElementById("view-btn").disabled = !exactlyOne || !allFiles; - document.getElementById("edit-btn").disabled = !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null); - document.getElementById("rename-btn").disabled = !exactlyOne; - document.getElementById("delete-btn").disabled = !hasSelection; - document.getElementById("copy-btn").disabled = !hasSelection; - document.getElementById("move-btn").disabled = !hasSelection; + const remoteBrowse = isRemoteBrowsePath(activePaneState().currentPath); + document.getElementById("view-btn").disabled = remoteBrowse || !exactlyOne || !allFiles; + document.getElementById("edit-btn").disabled = remoteBrowse || !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null); + document.getElementById("rename-btn").disabled = remoteBrowse || !exactlyOne; + document.getElementById("delete-btn").disabled = remoteBrowse || !hasSelection; + document.getElementById("copy-btn").disabled = remoteBrowse || !hasSelection; + document.getElementById("move-btn").disabled = remoteBrowse || !hasSelection; + document.getElementById("mkdir-btn").disabled = remoteBrowse; + document.getElementById("upload-btn").disabled = remoteBrowse; + document.getElementById("upload-menu-toggle").disabled = remoteBrowse; + document.getElementById("upload-folder-btn").disabled = remoteBrowse; } function isEditableSelection(item) { @@ -2208,7 +2270,7 @@ function currentParentPath(path) { if (!normalized) { return null; } - if (normalized === "/Volumes") { + if (normalized === "/Volumes" || normalized === "/Clients") { return null; } if (normalized.startsWith("/")) { @@ -2287,16 +2349,17 @@ function renderBreadcrumbs(pane, path) { const isHostPath = normalized.startsWith("/"); const parts = normalized.split("/").filter(Boolean); if (isHostPath) { + const rootTarget = parts.length > 0 ? `/${parts[0]}` : "/Volumes"; const rootCrumb = createButton("/", () => { setActivePane(pane); - navigateTo(pane, "/Volumes"); + navigateTo(pane, rootTarget); }); rootCrumb.type = "button"; rootCrumb.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); setActivePane(pane); - navigateTo(pane, "/Volumes"); + navigateTo(pane, rootTarget); }; nav.append(rootCrumb); if (parts.length > 0) { @@ -2619,6 +2682,7 @@ async function loadBrowsePane(pane) { }); const data = await apiRequest("GET", `/api/browse?${query.toString()}`); model.currentPath = data.path; + syncSourceSwitchers(); renderBreadcrumbs(pane, data.path); const visibleItems = []; @@ -2682,6 +2746,8 @@ function navigateTo(pane, path) { model.currentRowIndex = 0; clearSelectionAnchor(pane); setSelectedItem(pane, null); + syncSourceSwitchers(); + updateActionButtons(); loadBrowsePane(pane); } @@ -5305,6 +5371,7 @@ async function init() { setError("actions-error", ""); applyTheme("default", "dark"); setActivePane("left"); + ensureSourceSwitchers(); setupEvents(); await loadSettings(); applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);