From 5277f21577d03f375f6f6200e80830a3327fbf54 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 6 May 2026 16:33:02 +0200 Subject: [PATCH] fixes and improvements --- .env.example | 36 ++-- README.md | 50 +++--- backend/README.md | 52 +++--- .../media_library_viewer_api/clients/ssh.py | 11 +- .../src/media_library_viewer_api/config.py | 15 +- .../media_library_viewer_api/dependencies.py | 159 ++++++++++++++---- backend/src/media_library_viewer_api/main.py | 9 +- backend/tests/test_api.py | 64 +++++++ docker-compose.dev.yml | 21 +-- docker-compose.yml | 21 +-- docs/REQUIREMENTS.md | 17 +- frontend/src/App.tsx | 12 +- frontend/src/api/client.ts | 103 +++++++++--- frontend/src/hooks/useDashboard.ts | 18 +- frontend/src/hooks/useFiles.ts | 22 +-- frontend/src/hooks/useMedia.ts | 20 +-- frontend/src/hooks/useUsers.ts | 9 +- frontend/src/pages/FileBrowser.impl.tsx | 57 ++++++- frontend/src/pages/Media.tsx | 76 ++++++++- frontend/src/types/index.ts | 27 +++ 20 files changed, 577 insertions(+), 222 deletions(-) diff --git a/.env.example b/.env.example index 269b4c4..8faa29d 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,4 @@ -JELLYFIN_URL=https://jellyfin.example.com -JELLYFIN_API_KEY=your-api-key -# Optional if /Users works with your API key. Otherwise set the id of the Jellyfin user whose library views should be shown. -JELLYFIN_USER_ID= - -# Optional Jellyseerr enrichment for the Users tab. -JELLYSEERR_URL=https://requests.example.com -JELLYSEERR_API_KEY=your-jellyseerr-api-key - -# Optional logging level for backend diagnostics. +# Optional backend logging level. LOG_LEVEL=INFO # Optional SMTP settings for the Users -> message popup. @@ -21,20 +12,17 @@ SMTP_USE_TLS=true SMTP_USE_SSL=false SMTP_TIMEOUT=30 -SSH_HOST=media-server.example.com -SSH_USERNAME=username -SSH_PORT=22 -# Host-side directory mounted into the backend container at /root/.ssh. -SSH_KEY_HOST_DIR=/absolute/path/to/your/ssh-dir -# Container-side path assembled by the app: -SSH_KEY_DIRECTORY=/root/.ssh -SSH_KEY_NAME=id_ed25519 -# SSH_PASSWORD=optional-password-or-key-passphrase -REMOTE_MEDIA_ROOT=/mnt/media -# Optional fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough. -# Example: Jellyfin gives /media/... but SSH host requires /srv/media/... -REMOTE_PATH_PREFIX= - +# Machine/service configuration now lives in the app's Settings tab. +# The built-in local machine is seeded automatically. +# +# For a remote SSH machine, the backend still needs access to a private key +# file. Compose mounts a single host key file as a Docker secret instead of the +# whole ~/.ssh directory. +# +# Host-side path used by Docker Compose secret definitions: +SSH_KEY_HOST_PATH=/absolute/path/to/id_ed25519 +# The container-side path is fixed by the app and Compose at: +# /run/secrets/ssh_private_key # For deployment with traefik FRONTEND_APP_NAME=manage diff --git a/README.md b/README.md index f9dd9e8..9aed078 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,6 @@ The Compose files use environment-variable interpolation. Export the required va Production-style example with shell exports: ```bash -export JELLYFIN_URL=https://jellyfin.example.com -export JELLYFIN_API_KEY=your-api-key -export SSH_HOST=media-server.example.com -export SSH_USERNAME=username -export SSH_KEY_HOST_DIR=$HOME/.ssh -export SSH_KEY_NAME=id_ed25519 export BACKEND_APP_HOST=manage.example.com export FRONTEND_APP_HOST=manage.example.com export CERT_RESOLVER=letsencrypt @@ -90,41 +84,45 @@ export VITE_OIDC_CLIENT_ID=manage export VITE_OIDC_REDIRECT_URI=https://manage.example.com/ export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ +# Optional: provide a single SSH private key file for remote machines configured later in Settings. +# Compose mounts it as a Docker secret, so you do not need to mount your whole ~/.ssh directory. +# If you prefer SSH agent forwarding, Paramiko will use SSH_AUTH_SOCK when you provide it. +export SSH_KEY_HOST_PATH=$HOME/.ssh/id_ed25519 +# The container-side secret path is fixed at /run/secrets/ssh_private_key. +# In the app's SSH machine settings, use /run/secrets as the key directory and ssh_private_key as the key name. + docker compose up --build ``` Inline one-liner example: ```bash -JELLYFIN_URL=https://jellyfin.example.com JELLYFIN_API_KEY=your-api-key SSH_HOST=media-server.example.com SSH_USERNAME=username SSH_KEY_HOST_DIR=$HOME/.ssh SSH_KEY_NAME=id_ed25519 BACKEND_APP_HOST=manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build +BACKEND_APP_HOST=manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ SSH_KEY_HOST_PATH=$HOME/.ssh/id_ed25519 docker compose up --build ``` Example environment variables: ```bash -JELLYFIN_URL=https://jellyfin.example.com -JELLYFIN_API_KEY=your-api-key -JELLYFIN_USER_ID= - -# Optional Jellyseerr enrichment for the Users tab -JELLYSEERR_URL=https://requests.example.com -JELLYSEERR_API_KEY=your-jellyseerr-api-key - # Optional backend logging level LOG_LEVEL=INFO -SSH_HOST=media-server.example.com -SSH_USERNAME=username -SSH_PORT=22 -# Host-side directory mounted into the backend container at /root/.ssh. -SSH_KEY_HOST_DIR=/absolute/path/to/your/ssh-dir -# Container-side path assembled by the app: -SSH_KEY_DIRECTORY=/root/.ssh -SSH_KEY_NAME=id_ed25519 -SSH_PASSWORD= +# Optional SMTP settings for the Users -> message popup +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=your-smtp-username +SMTP_PASSWORD=your-smtp-password +SMTP_FROM_ADDRESS=no-reply@example.com +SMTP_FROM_NAME=Manage +SMTP_USE_TLS=true +SMTP_USE_SSL=false +SMTP_TIMEOUT=30 -REMOTE_MEDIA_ROOT=/srv/media -REMOTE_PATH_PREFIX= +# Jellyfin, Jellyseerr, and SSH targets are now configured per machine in the app's Settings tab. +# The backend seeds a local machine automatically, so no global Jellyfin or SSH env vars are required. +# +# If you still want to keep a fallback SSH key available for legacy/manual use, provide a single key file path: +SSH_KEY_HOST_PATH=/absolute/path/to/id_ed25519 +# The container-side secret path is fixed at /run/secrets/ssh_private_key. # Authentik / OIDC AUTH_ENABLED=true diff --git a/backend/README.md b/backend/README.md index 352d3bc..0f93c44 100644 --- a/backend/README.md +++ b/backend/README.md @@ -55,36 +55,33 @@ Set environment variables directly in your shell or a wrapper script before runn For local `.env` development, you can still create one if you prefer, but it is optional. ```bash -JELLYFIN_URL=https://jellyfin.example.com -JELLYFIN_API_KEY=your-api-key -JELLYFIN_USER_ID= - -# Optional Jellyseerr enrichment for the Users tab -JELLYSEERR_URL=https://requests.example.com -JELLYSEERR_API_KEY=your-jellyseerr-api-key - # Optional backend logging level LOG_LEVEL=INFO +# Optional SMTP settings for the Users -> message popup +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=your-smtp-username +SMTP_PASSWORD=your-smtp-password +SMTP_FROM_ADDRESS=no-reply@example.com +SMTP_FROM_NAME=Manage +SMTP_USE_TLS=true +SMTP_USE_SSL=false +SMTP_TIMEOUT=30 + +# Jellyfin, Jellyseerr, and SSH targets are now configured per machine in the app's Settings tab. +# The backend seeds a local machine automatically, so no global Jellyfin or SSH env vars are required. +# +# If you still want to keep a fallback SSH key available for legacy/manual use, provide a single key file path: +SSH_KEY_HOST_PATH=/absolute/path/to/id_ed25519 +# The container-side secret path is fixed at /run/secrets/ssh_private_key. + # Authentik / OIDC AUTH_ENABLED=true OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/ OIDC_AUDIENCE=media-library-viewer OIDC_JWKS_URL= OIDC_CLOCK_SKEW_SECONDS=30 - -SSH_HOST=media-server.example.com -SSH_USERNAME=username -SSH_PORT=22 -# Host-side directory mounted into the backend container at /root/.ssh. -SSH_KEY_HOST_DIR=/absolute/path/to/your/ssh-dir -# Container-side path assembled by the app: -SSH_KEY_DIRECTORY=/root/.ssh -SSH_KEY_NAME=id_ed25519 -SSH_PASSWORD= - -REMOTE_MEDIA_ROOT=/srv/media -REMOTE_PATH_PREFIX= ``` ## Running @@ -113,12 +110,6 @@ The production compose file expects required environment variables to be supplie 1. Export your runtime variables before launching Compose: ```bash -export JELLYFIN_URL=https://jellyfin.example.com -export JELLYFIN_API_KEY=your-api-key -export SSH_HOST=media-server.example.com -export SSH_USERNAME=username -export SSH_KEY_HOST_DIR=$HOME/.ssh -export SSH_KEY_NAME=id_ed25519 export BACKEND_APP_HOST=manage.example.com export FRONTEND_APP_HOST=manage.example.com export CERT_RESOLVER=letsencrypt @@ -127,12 +118,17 @@ export VITE_OIDC_CLIENT_ID=manage export VITE_OIDC_REDIRECT_URI=https://manage.example.com/ export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ +# Optional: provide a single SSH private key file for remote machines configured later in Settings. +# Compose mounts it as a Docker secret, so you do not need to mount your whole ~/.ssh directory. +# If you prefer SSH agent forwarding, Paramiko will use SSH_AUTH_SOCK when you provide it. +export SSH_KEY_HOST_PATH=$HOME/.ssh/id_ed25519 + docker compose up --build ``` 2. After the API is running, open the app, go to **Settings**, and add machine entries: - **Local**: monitors the API host itself without SSH. - - **SSH**: monitors another machine using a host, username, key directory, and key name. + - **SSH**: monitors another machine using a host, username, and a key file path stored in the container. 3. Open **Monitoring** to see one section per configured machine. Each section uses its own collector state, disk path, metrics queries, and recent action history, which are populated automatically by the backend poller. diff --git a/backend/src/media_library_viewer_api/clients/ssh.py b/backend/src/media_library_viewer_api/clients/ssh.py index d71458e..0d808b5 100644 --- a/backend/src/media_library_viewer_api/clients/ssh.py +++ b/backend/src/media_library_viewer_api/clients/ssh.py @@ -14,6 +14,7 @@ import logging import posixpath import shlex from dataclasses import dataclass +from pathlib import Path from typing import Any import paramiko @@ -41,6 +42,7 @@ class RemoteSSHClient: port: int = 22, key_filename: str | None = None, password: str | None = None, + known_hosts_path: str | None = None, timeout: int = 20, ): if not host or not username: @@ -50,19 +52,23 @@ class RemoteSSHClient: self.port = port self.key_filename = key_filename or None self.password = password or None + self.known_hosts_path = known_hosts_path or None self.timeout = timeout self._client: paramiko.SSHClient | None = None def connect(self) -> paramiko.SSHClient: """Create or reuse the Paramiko connection. - Unknown host keys are rejected. Users should connect once manually with - ssh so the server is present in known_hosts. + Unknown host keys are rejected. The application can synthesize a managed + known_hosts file under its cache directory so users do not need to mount + their local SSH directory into the container. """ if self._client: return self._client client = paramiko.SSHClient() client.load_system_host_keys() + if self.known_hosts_path and Path(self.known_hosts_path).is_file(): + client.load_host_keys(self.known_hosts_path) client.set_missing_host_key_policy(paramiko.RejectPolicy()) client.connect( self.host, @@ -119,7 +125,6 @@ class RemoteSSHClient: was a source of file-browser confusion. Output is NUL-delimited before Python serializes it, making spaces in filenames safe. """ - # JSON-ish output: type, size, mtime epoch, filename. Handles spaces/newlines reasonably via NUL boundaries. quoted = shlex.quote(path) not_dir_message = shlex.quote(f"Not a directory: {path}") command = ( diff --git a/backend/src/media_library_viewer_api/config.py b/backend/src/media_library_viewer_api/config.py index c47671e..42bede9 100644 --- a/backend/src/media_library_viewer_api/config.py +++ b/backend/src/media_library_viewer_api/config.py @@ -21,12 +21,12 @@ logger = logging.getLogger(__name__) class Settings(BaseSettings): """Flat application settings read from env vars / .env file.""" - # Jellyfin + # Jellyfin (legacy fallback only; machine settings are preferred) jellyfin_url: str = "" jellyfin_api_key: str = "" jellyfin_user_id: str = "" - # Jellyseerr (optional) + # Jellyseerr (legacy fallback only; machine settings are preferred) jellyseerr_url: str = "" jellyseerr_api_key: str = "" @@ -51,13 +51,15 @@ class Settings(BaseSettings): smtp_use_ssl: bool = False smtp_timeout: int = 30 - # SSH + # Legacy SSH fallback (new preferred path is machine-specific settings) ssh_host: str = "" ssh_username: str = "" ssh_port: int = 22 ssh_key_directory: str = "" ssh_key_name: str = "" + ssh_key_file: str = "/run/secrets/ssh_private_key" ssh_password: str = "" + ssh_known_hosts_path: str = "" # Monitoring poller monitoring_poll_interval_seconds: int = 300 @@ -79,10 +81,16 @@ class Settings(BaseSettings): @property def ssh_key_path(self) -> str: + if self.ssh_key_file: + return self.ssh_key_file if not self.ssh_key_directory or not self.ssh_key_name: return "" return str(Path(self.ssh_key_directory) / self.ssh_key_name) + @property + def ssh_known_hosts_file(self) -> Path: + return Path(self.ssh_known_hosts_path or ".cache/media_library_viewer/known_hosts") + model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} @@ -93,7 +101,6 @@ def _find_env_file() -> str | None: candidate = directory / ".env" if candidate.is_file(): return str(candidate) - # Stop at repo root (has .git) if (directory / ".git").exists(): break return None diff --git a/backend/src/media_library_viewer_api/dependencies.py b/backend/src/media_library_viewer_api/dependencies.py index ffa9d4e..20e04cf 100644 --- a/backend/src/media_library_viewer_api/dependencies.py +++ b/backend/src/media_library_viewer_api/dependencies.py @@ -1,17 +1,23 @@ """Dependency injection for FastAPI. -Provides singleton-like access to SSH and Jellyfin clients via FastAPI's -dependency system. Uses lru_cache so connections are reused across requests. +Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request +context. The selected machine can be chosen with a ``machine_id`` query +parameter; otherwise the backend falls back to the first enabled machine that +matches the requested service. """ from __future__ import annotations import logging from functools import lru_cache +from typing import Any + +from fastapi import Request from media_library_viewer_api.clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyseerr import JellyseerrClient from media_library_viewer_api.clients.ssh import RemoteSSHClient +from media_library_viewer_api.services.known_hosts import ensure_known_host from media_library_viewer_api.config import get_settings from media_library_viewer_api.services.mail_queue import MailQueue, get_mail_queue as _get_mail_queue from media_library_viewer_api.services.monitoring_poller import ( @@ -23,17 +29,100 @@ from media_library_viewer_api.services.settings_store import SettingsStore, get_ logger = logging.getLogger(__name__) -@lru_cache -def get_jellyfin_client() -> JellyfinClient: - """Return a cached Jellyfin client.""" +def _request_machine_id(request: Request | None) -> str | None: + if request is None: + return None + machine_id = request.query_params.get("machine_id") + return machine_id or None + + +@lru_cache(maxsize=32) +def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient: + machine_id, url, api_key = cache_key + logger.info("Creating Jellyfin client machine_id=%s url=%s", machine_id or "", url.rstrip("/") or "") + return JellyfinClient(url, api_key) + + +@lru_cache(maxsize=32) +def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | None: + machine_id, url = cache_key + if not url: + return None + settings = get_settings_store().get_machine_config(machine_id) if machine_id else None + api_key = (settings or {}).get("jellyseerr_api_key") if settings else "" + if not api_key: + return None + logger.info("Creating Jellyseerr client machine_id=%s url=%s", machine_id or "", url.rstrip("/") or "") + return JellyseerrClient(url, api_key) + + +@lru_cache(maxsize=32) +def _ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None]) -> RemoteSSHClient: + machine_id, host, username, port, key_filename, password, known_hosts_path = cache_key + logger.info( + "Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s", + machine_id or "", + host or "", + username or "", + port, + key_filename or "", + "set" if password else "missing", + ) + client = RemoteSSHClient( + host=host, + username=username, + port=port, + key_filename=key_filename or None, + password=password or None, + known_hosts_path=known_hosts_path or None, + ) + try: + client.connect() + except Exception: + logger.exception("Failed to establish SSH connection to %s", host or "") + raise + return client + + +def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None: + store = get_settings_store() + machine_id = _request_machine_id(request) + if machine_id: + machine = store.get_machine(machine_id) + if machine and (service in machine.get("services", []) or service == "ssh"): + return machine + return machine + if service == "jellyfin": + machines = store.list_machines_for_service("jellyfin") + elif service == "jellyseerr": + machines = [m for m in store.list_machines_for_service("jellyfin") if m.get("jellyseerr_url")] + elif service == "ssh": + machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring") + else: + machines = store.list_machines_for_service(service) + return machines[0] if machines else None + + +def get_jellyfin_client(request: Request = None) -> JellyfinClient: + """Return a Jellyfin client for the selected machine or legacy env fallback.""" + machine = get_settings_store().get_machine_config(_request_machine_id(request)) or _resolve_machine("jellyfin", request) + if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"): + cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "") + return _jellyfin_client_for(cache_key) + settings = get_settings() - logger.info("Creating Jellyfin client for %s", settings.jellyfin_url.rstrip("/") or "") - return JellyfinClient(settings.jellyfin_url, settings.jellyfin_api_key) + if not settings.jellyfin_url or not settings.jellyfin_api_key: + raise RuntimeError("No Jellyfin machine is configured and JELLYFIN_URL/API_KEY are not set") + logger.info("Falling back to legacy Jellyfin env settings") + return _jellyfin_client_for(("legacy", settings.jellyfin_url, settings.jellyfin_api_key)) -@lru_cache -def get_jellyseerr_client() -> JellyseerrClient | None: +def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None: """Return a cached Jellyseerr client when configured, otherwise None.""" + machine = get_settings_store().get_machine_config(_request_machine_id(request)) or _resolve_machine("jellyseerr", request) + if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"): + return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "") + settings = get_settings() if not settings.jellyseerr_url or not settings.jellyseerr_api_key: logger.info( @@ -42,16 +131,30 @@ def get_jellyseerr_client() -> JellyseerrClient | None: "set" if settings.jellyseerr_api_key else "missing", ) return None - logger.info("Creating Jellyseerr client for %s", settings.jellyseerr_url.rstrip("/") or "") + logger.info("Falling back to legacy Jellyseerr env settings") return JellyseerrClient(settings.jellyseerr_url, settings.jellyseerr_api_key) -@lru_cache -def get_ssh_client() -> RemoteSSHClient: - """Return a cached SSH client (connects on first use).""" +def get_ssh_client(request: Request = None) -> RemoteSSHClient: + """Return a cached SSH client for the selected machine or legacy env fallback.""" + machine = get_settings_store().get_machine_config(_request_machine_id(request)) or _resolve_machine("ssh", request) + if machine and machine.get("host") and machine.get("username") and machine.get("key_directory") and machine.get("key_name"): + known_hosts_path = get_settings().ssh_known_hosts_file + ensure_known_host(str(machine.get("host")), int(machine.get("port") or 22), known_hosts_path) + cache_key = ( + machine["id"], + machine["host"], + machine["username"], + int(machine.get("port") or 22), + f"{machine.get('key_directory')}/{machine.get('key_name')}", + machine.get("password") or None, + str(known_hosts_path), + ) + return _ssh_client_for(cache_key) + settings = get_settings() logger.info( - "Creating SSH client host=%s user=%s port=%s key_dir=%s key_name=%s password=%s", + "Creating SSH client from legacy env host=%s user=%s port=%s key_dir=%s key_name=%s password=%s", settings.ssh_host or "", settings.ssh_username or "", settings.ssh_port, @@ -60,20 +163,9 @@ def get_ssh_client() -> RemoteSSHClient: "set" if settings.ssh_password else "missing", ) if not settings.ssh_key_path: - raise RuntimeError("SSH_KEY_DIRECTORY and SSH_KEY_NAME must be configured") - client = RemoteSSHClient( - host=settings.ssh_host, - username=settings.ssh_username, - port=settings.ssh_port, - key_filename=settings.ssh_key_path, - password=settings.ssh_password or None, - ) - try: - client.connect() - except Exception: - logger.exception("Failed to establish SSH connection to %s", settings.ssh_host or "") - raise - return client + raise RuntimeError("No SSH machine is configured and SSH key settings must be configured") + ensure_known_host(settings.ssh_host, settings.ssh_port, settings.ssh_known_hosts_file) + return _ssh_client_for(("legacy", settings.ssh_host, settings.ssh_username, settings.ssh_port, settings.ssh_key_path, settings.ssh_password or None, str(settings.ssh_known_hosts_file))) def get_mail_queue() -> MailQueue: @@ -91,13 +183,16 @@ def get_settings_store() -> SettingsStore: return _get_settings_store() -def get_user_id() -> str: - """Return the configured Jellyfin user ID, or discover the first available user.""" +def get_user_id(request: Request = None) -> str: + """Return the configured Jellyfin user ID or discover the first available one.""" + machine = get_settings_store().get_machine_config(_request_machine_id(request)) or _resolve_machine("jellyfin", request) + if machine and machine.get("jellyfin_user_id"): + return str(machine["jellyfin_user_id"]) settings = get_settings() if settings.jellyfin_user_id: return settings.jellyfin_user_id - client = get_jellyfin_client() + client = get_jellyfin_client(request) users = client.users() if not users: - raise RuntimeError("No Jellyfin users found and JELLYFIN_USER_ID not set") + raise RuntimeError("No Jellyfin users found and no machine/user id configured") return users[0]["Id"] diff --git a/backend/src/media_library_viewer_api/main.py b/backend/src/media_library_viewer_api/main.py index 17f1fbb..a625072 100644 --- a/backend/src/media_library_viewer_api/main.py +++ b/backend/src/media_library_viewer_api/main.py @@ -15,7 +15,8 @@ from media_library_viewer_api.config import get_settings from media_library_viewer_api.logging_utils import configure_logging, describe_settings from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs, users from media_library_viewer_api.routers.settings import router as settings_router -from media_library_viewer_api.dependencies import get_mail_queue, get_monitoring_poller +from media_library_viewer_api.dependencies import get_mail_queue, get_monitoring_poller, get_settings_store +from media_library_viewer_api.services.known_hosts import ensure_known_hosts_for_machines logger = logging.getLogger(__name__) @@ -27,6 +28,12 @@ async def lifespan(app: FastAPI): configure_logging(settings.log_level) validate_auth_settings(settings) logger.info("Backend startup complete: %s", describe_settings(settings)) + store = get_settings_store() + try: + changed = ensure_known_hosts_for_machines(store.list_machines(), settings.ssh_known_hosts_file) + logger.info("Managed known_hosts updated entries=%s file=%s", changed, settings.ssh_known_hosts_file) + except Exception: + logger.exception("Failed to synthesize managed known_hosts file") mail_queue = get_mail_queue() monitoring_poller = get_monitoring_poller() mail_queue.start() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e8a2e97..ff5e713 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -23,6 +23,7 @@ from media_library_viewer_api.dependencies import ( from media_library_viewer_api.clients.ssh import CommandResult from media_library_viewer_api.routers.media import get_media_index from media_library_viewer_api.services.media_index import MediaIndex +from media_library_viewer_api.services.settings_store import SettingsStore # --- Fixtures --- @@ -263,6 +264,69 @@ class TestDashboard: assert len(data) == 2 +# --- Settings reset --- + +class TestSettingsReset: + def test_reset_local_database_requires_full_confirmation(self, test_client, tmp_path): + store = SettingsStore(tmp_path / "settings.sqlite") + store.ensure_defaults() + media_db = tmp_path / "media.sqlite" + media_db.write_text("placeholder", encoding="utf-8") + media_wal = tmp_path / "media.sqlite-wal" + media_wal.write_text("wal", encoding="utf-8") + + app.dependency_overrides[get_settings_store] = lambda: store + settings_module = __import__("media_library_viewer_api.routers.settings", fromlist=["MediaIndex"]) + original_media_index = settings_module.MediaIndex + settings_module.MediaIndex = lambda: SimpleNamespace(db_path=media_db) + try: + response = test_client.post( + "/api/settings/reset-local-database", + json={ + "confirm_phrase": "RESET LOCAL DATABASE", + "acknowledge_settings_loss": True, + "acknowledge_media_index_loss": False, + "acknowledge_irreversible": True, + }, + ) + finally: + app.dependency_overrides.pop(get_settings_store, None) + settings_module.MediaIndex = original_media_index + assert response.status_code == 400 + + def test_reset_local_database_wipes_state_and_reseeds_local_machine(self, test_client, tmp_path): + store = SettingsStore(tmp_path / "settings.sqlite") + store.ensure_defaults() + media_db = tmp_path / "media.sqlite" + media_db.write_text("placeholder", encoding="utf-8") + media_wal = tmp_path / "media.sqlite-wal" + media_wal.write_text("wal", encoding="utf-8") + + app.dependency_overrides[get_settings_store] = lambda: store + settings_module = __import__("media_library_viewer_api.routers.settings", fromlist=["MediaIndex"]) + original_media_index = settings_module.MediaIndex + settings_module.MediaIndex = lambda: SimpleNamespace(db_path=media_db) + try: + response = test_client.post( + "/api/settings/reset-local-database", + json={ + "confirm_phrase": "RESET LOCAL DATABASE", + "acknowledge_settings_loss": True, + "acknowledge_media_index_loss": True, + "acknowledge_irreversible": True, + }, + ) + finally: + app.dependency_overrides.pop(get_settings_store, None) + settings_module.MediaIndex = original_media_index + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "reset" + assert not media_db.exists() + assert not media_wal.exists() + assert store.get_machine("local") is not None + assert len(store.list_machines()) == 1 + # --- Users --- class TestUsers: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7852112..2fbe244 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,25 +10,14 @@ services: OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} OIDC_AUDIENCE: ${OIDC_AUDIENCE:-} OIDC_JWKS_URL: ${OIDC_JWKS_URL:-} - JELLYFIN_URL: ${JELLYFIN_URL:-http://host.docker.internal:8096} - JELLYFIN_API_KEY: ${JELLYFIN_API_KEY:-} - JELLYFIN_USER_ID: ${JELLYFIN_USER_ID:-} - JELLYSEERR_URL: ${JELLYSEERR_URL:-} - JELLYSEERR_API_KEY: ${JELLYSEERR_API_KEY:-} LOG_LEVEL: ${LOG_LEVEL:-INFO} - SSH_HOST: ${SSH_HOST:-host.docker.internal} - SSH_USERNAME: ${SSH_USERNAME:-} - SSH_PORT: ${SSH_PORT:-22} - SSH_KEY_DIRECTORY: /root/.ssh - SSH_KEY_NAME: ${SSH_KEY_NAME:-id_ed25519} - SSH_PASSWORD: ${SSH_PASSWORD:-} - REMOTE_MEDIA_ROOT: ${REMOTE_MEDIA_ROOT:-} - REMOTE_PATH_PREFIX: ${REMOTE_PATH_PREFIX:-} + SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts + secrets: + - ssh_private_key ports: - "8000:8000" volumes: - ./backend:/app/backend - - ${SSH_KEY_HOST_DIR:-./secrets/ssh}:/root/.ssh:ro - backend_cache:/app/backend/.cache restart: unless-stopped @@ -54,3 +43,7 @@ services: volumes: frontend_node_modules: backend_cache: + +secrets: + ssh_private_key: + file: ${SSH_KEY_HOST_PATH:-./secrets/ssh/id_ed25519} diff --git a/docker-compose.yml b/docker-compose.yml index cd10d43..081e162 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,11 +9,6 @@ services: OIDC_AUDIENCE: ${OIDC_AUDIENCE:?set OIDC_AUDIENCE} OIDC_JWKS_URL: ${OIDC_JWKS_URL:-} OIDC_CLOCK_SKEW_SECONDS: ${OIDC_CLOCK_SKEW_SECONDS:-30} - JELLYFIN_URL: ${JELLYFIN_URL:?set JELLYFIN_URL} - JELLYFIN_API_KEY: ${JELLYFIN_API_KEY:?set JELLYFIN_API_KEY} - JELLYFIN_USER_ID: ${JELLYFIN_USER_ID:-} - JELLYSEERR_URL: ${JELLYSEERR_URL:-} - JELLYSEERR_API_KEY: ${JELLYSEERR_API_KEY:-} LOG_LEVEL: ${LOG_LEVEL:-INFO} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} @@ -24,16 +19,10 @@ services: SMTP_USE_TLS: ${SMTP_USE_TLS:-true} SMTP_USE_SSL: ${SMTP_USE_SSL:-false} SMTP_TIMEOUT: ${SMTP_TIMEOUT:-30} - SSH_HOST: ${SSH_HOST:?set SSH_HOST} - SSH_USERNAME: ${SSH_USERNAME:?set SSH_USERNAME} - SSH_PORT: ${SSH_PORT:-22} - SSH_KEY_DIRECTORY: /root/.ssh - SSH_KEY_NAME: ${SSH_KEY_NAME:?set SSH_KEY_NAME} - SSH_PASSWORD: ${SSH_PASSWORD:-} - REMOTE_MEDIA_ROOT: ${REMOTE_MEDIA_ROOT:-} - REMOTE_PATH_PREFIX: ${REMOTE_PATH_PREFIX:-} + SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts + secrets: + - ssh_private_key volumes: - - ${SSH_KEY_HOST_DIR:?set SSH_KEY_HOST_DIR}:/root/.ssh:ro - backend_cache:/app/backend/.cache restart: unless-stopped networks: @@ -91,6 +80,10 @@ services: volumes: backend_cache: +secrets: + ssh_private_key: + file: ${SSH_KEY_HOST_PATH:?set SSH_KEY_HOST_PATH} + networks: web: external: true diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index e84d0c4..00a848f 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -1,6 +1,6 @@ # Manage - Requirements and Decision Log -This is a living document for the project. Update it whenever requirements, UX expectations, architecture decisions, constraints, or implementation plans change. +This is a living document for the project. Update it whenever requirements, UX expectations, architecture, constraints, or implementation plans change. ## Product Goal @@ -71,7 +71,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo ### Remote Filesystem over SSH - Connect to a remote media server via SSH. -- Use strict SSH host key behavior; users should connect manually once to populate `known_hosts`. +- Use strict SSH host key behavior, but synthesize and persist the managed `known_hosts` file from configured SSH machines instead of requiring users to mount their own `known_hosts` file. - Browse remote directories and files rooted at a configurable default media path. - File browser handoff should map Jellyfin paths to `REMOTE_MEDIA_ROOT` when possible (for example `/media/...` -> `/srv/media/...` when root is `/srv/media`). - Media index paths should be stored in the SSH-visible form by default, using the same Jellyfin-to-SSH mapping so the Media tab and file browser agree on paths. @@ -132,11 +132,12 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - Support OIDC login in the frontend using an OIDC client library, with backend JWT validation for protected API requests. - Persist frontend OIDC auth state across tab reloads by storing the OIDC user and request state in browser localStorage. - Provide Docker Compose deployment files at the repository root for production and local development. -- Backend Docker deployment should mount a private SSH key and a known_hosts file into the container rather than baking them into the image. +- Backend Docker deployment should mount a private SSH key file into the container while the app synthesizes its own managed `known_hosts` file in the backend cache volume. +- The Settings tab should include a destructive local-database reset action protected by multiple acknowledgements and a typed confirmation phrase. - Production compose should also pass the root `.env` into the backend container so runtime auth settings like `OIDC_ISSUER_URL` are available there, not just at compose interpolation time. - The backend media index should persist in a Docker volume so a container restart or image rebuild does not force a new full index build. - Compose deployment should not require `env_file`; required values should be supplied through environment interpolation or inline shell exports. -- The SSH key configuration should support separate directory/name inputs so Docker Compose can mount an arbitrary host SSH directory into `/root/.ssh` while the app assembles the full key path. +- The SSH key configuration should still support separate directory/name inputs for legacy/manual setups, but Compose should prefer a single mounted key file path instead of a whole SSH directory. - Show Jellyfin media counts for movies, series, and series episodes on the dashboard. - Show dashboard session activity from Jellyfin, including both currently playing sessions and logged-in idle sessions. - Activity rows should include user, media title (or `(idle)`), playback state (`playing`/`paused`/`idle`), and whether transcoding is active. @@ -212,3 +213,11 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - 2026-05-06: The dashboard monitoring table now shows 10-minute averages with min/max subtext and can be sorted by machine, status, and metric columns. - 2026-05-06: The Monitoring page now includes a poller-health badge in the header so users can quickly see whether backend collection is active. - 2026-05-06: The dashboard monitoring table now renders each metric summary with compact stacked low/high lines to keep the table narrower, and the activity/session table no longer hides columns on mobile so all details remain available. +- 2026-05-06: The dashboard monitoring table now renders the 10-minute value as the visual focus and keeps the low/high lines smaller as supporting detail. +- 2026-05-06: The file browser and media routes now support machine-specific SSH/Jellyfin selection via the request machine id, and the backend resolves clients from configured machines before falling back to legacy env-based defaults. +- 2026-05-06: Application settings now include per-machine Jellyfin/Jellyseerr configuration and multi-select service roles so the UI can manage app hosts from the same machine registry. +- 2026-05-06: The app shell now uses an Applications top-level tab with a Jellyfin subtab for media/library work and a placeholder Nextcloud subtab for future expansion. +- 2026-05-06: The settings model now treats Jellyfin/Jellyseerr as machine-level configuration instead of global env-only values, so app hosts can be edited alongside other machine services. +- 2026-05-06: The backend now synthesizes a managed `known_hosts` file from configured SSH machines at startup, avoiding a mounted SSH directory while keeping strict host-key verification enabled. +- 2026-05-06: Docker Compose was simplified to mount a single SSH private key file when needed instead of a whole `~/.ssh` directory. +- 2026-05-06: The Settings page now exposes a protected local-database reset flow that requires several explicit acknowledgements and a typed confirmation phrase before it can delete the cached app databases. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c18a41d..e473d84 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,7 +27,7 @@ import { useEffect, useMemo } from "react"; import { AuthProvider, useAuth } from "react-oidc-context"; import { Dashboard } from "./pages/Dashboard"; import { Monitoring } from "./pages/Monitoring"; -import { Media } from "./pages/Media"; +import { Applications } from "./pages/Applications"; import { Settings } from "./pages/Settings"; import { UsersPage } from "./pages/Users"; import { FileBrowser } from "./pages/FileBrowser"; @@ -173,7 +173,12 @@ function Shell({ component={NavLink} to="/monitoring" /> - + } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index df31b1a..7517d37 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -25,6 +25,8 @@ import type { JobTemplate, JobResult, ResolvedPath, + ResetLocalDatabaseInput, + ResetLocalDatabaseResponse, } from "../types"; const BASE_URL = import.meta.env.VITE_API_URL || "/api"; @@ -126,12 +128,26 @@ async function del(path: string): Promise { } // Dashboard -export const fetchCounts = () => get("/api/dashboard/counts"); -export const fetchLibraries = () => - get("/api/dashboard/libraries"); -export const fetchActivity = () => - get("/api/dashboard/activity"); -export const fetchUsers = () => get("/api/users"); +export const fetchCounts = (machineId?: string) => + get( + "/api/dashboard/counts", + machineId ? { machine_id: machineId } : undefined, + ); +export const fetchLibraries = (machineId?: string) => + get( + "/api/dashboard/libraries", + machineId ? { machine_id: machineId } : undefined, + ); +export const fetchActivity = (machineId?: string) => + get( + "/api/dashboard/activity", + machineId ? { machine_id: machineId } : undefined, + ); +export const fetchUsers = (machineId?: string) => + get( + "/api/users", + machineId ? { machine_id: machineId } : undefined, + ); // Backward-compatible alias used by older hooks/components. export const fetchNowPlaying = fetchActivity; @@ -211,16 +227,36 @@ export const deleteMonitoringMachine = (machineId: string) => del<{ status: string }>( `/api/settings/machines/${encodeURIComponent(machineId)}`, ); +export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) => + post( + "/api/settings/reset-local-database", + payload, + ); // Media -export const fetchMediaStatus = () => - get("/api/media/status"); -export const buildMediaIndex = () => - post("/api/media/build"); -export const stopMediaIndexBuild = () => - post("/api/media/stop"); -export const forceStopMediaIndexBuild = () => - post("/api/media/force-stop"); +export const fetchMediaStatus = (machineId?: string) => + get( + "/api/media/status", + machineId ? { machine_id: machineId } : undefined, + ); +export const buildMediaIndex = (machineId?: string) => + post( + machineId + ? `/api/media/build?machine_id=${encodeURIComponent(machineId)}` + : "/api/media/build", + ); +export const stopMediaIndexBuild = (machineId?: string) => + post( + machineId + ? `/api/media/stop?machine_id=${encodeURIComponent(machineId)}` + : "/api/media/stop", + ); +export const forceStopMediaIndexBuild = (machineId?: string) => + post( + machineId + ? `/api/media/force-stop?machine_id=${encodeURIComponent(machineId)}` + : "/api/media/force-stop", + ); export const queryMedia = (params: { libraries?: string; types?: string; @@ -230,6 +266,7 @@ export const queryMedia = (params: { sort_order?: string; limit?: number; offset?: number; + machineId?: string; }) => get("/api/media/query", { libraries: params.libraries || "", @@ -240,23 +277,41 @@ export const queryMedia = (params: { sort_order: params.sort_order || "Ascending", limit: String(params.limit || 100), offset: String(params.offset || 0), + ...(params.machineId ? { machine_id: params.machineId } : {}), }); // Files -export const fetchDirectoryListing = (path: string) => - get("/api/files/list", { path }); -export const fetchFfprobe = (path: string) => - get>("/api/files/ffprobe", { path }); -export const fetchStat = (path: string) => - get<{ path: string; output: string }>("/api/files/stat", { path }); -export const resolvePath = (path: string) => - get("/api/files/resolve-path", { path }); +export const fetchDirectoryListing = (path: string, machineId?: string) => + get("/api/files/list", { + path, + ...(machineId ? { machine_id: machineId } : {}), + }); +export const fetchFfprobe = (path: string, machineId?: string) => + get>("/api/files/ffprobe", { + path, + ...(machineId ? { machine_id: machineId } : {}), + }); +export const fetchStat = (path: string, machineId?: string) => + get<{ path: string; output: string }>("/api/files/stat", { + path, + ...(machineId ? { machine_id: machineId } : {}), + }); +export const resolvePath = (path: string, machineId?: string) => + get("/api/files/resolve-path", { + path, + ...(machineId ? { machine_id: machineId } : {}), + }); // Jobs export const fetchJobTemplates = () => get("/api/jobs/templates"); -export const runJob = (jobKey: string, path: string) => - post("/api/jobs/run", { job_key: jobKey, path }); +export const runJob = (jobKey: string, path: string, machineId?: string) => + post( + machineId + ? `/api/jobs/run?machine_id=${encodeURIComponent(machineId)}` + : "/api/jobs/run", + { job_key: jobKey, path }, + ); export const fetchUserMessageQueueStatus = () => get("/api/users/message/status"); diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts index c679e1f..83a04ab 100644 --- a/frontend/src/hooks/useDashboard.ts +++ b/frontend/src/hooks/useDashboard.ts @@ -6,26 +6,26 @@ import { fetchMonitoringOverview, } from "../api/client"; -export function useCounts() { +export function useCounts(machineId?: string) { return useQuery({ - queryKey: ["dashboard", "counts"], - queryFn: fetchCounts, + queryKey: ["dashboard", "counts", machineId ?? "default"], + queryFn: () => fetchCounts(machineId), staleTime: 5 * 60 * 1000, }); } -export function useLibraries() { +export function useLibraries(machineId?: string) { return useQuery({ - queryKey: ["dashboard", "libraries"], - queryFn: fetchLibraries, + queryKey: ["dashboard", "libraries", machineId ?? "default"], + queryFn: () => fetchLibraries(machineId), staleTime: 5 * 60 * 1000, }); } -export function useActivity() { +export function useActivity(machineId?: string) { return useQuery({ - queryKey: ["dashboard", "activity"], - queryFn: fetchActivity, + queryKey: ["dashboard", "activity", machineId ?? "default"], + queryFn: () => fetchActivity(machineId), refetchInterval: 15_000, }); } diff --git a/frontend/src/hooks/useFiles.ts b/frontend/src/hooks/useFiles.ts index 8f7764e..c4ff22d 100644 --- a/frontend/src/hooks/useFiles.ts +++ b/frontend/src/hooks/useFiles.ts @@ -7,28 +7,28 @@ import { runJob, } from "../api/client"; -export function useDirectoryListing(path: string) { +export function useDirectoryListing(path: string, machineId?: string) { return useQuery({ - queryKey: ["files", "list", path], - queryFn: () => fetchDirectoryListing(path), + queryKey: ["files", "list", path, machineId ?? "default"], + queryFn: () => fetchDirectoryListing(path, machineId), enabled: !!path, staleTime: 30_000, }); } -export function useFfprobe(path: string, enabled = false) { +export function useFfprobe(path: string, enabled = false, machineId?: string) { return useQuery({ - queryKey: ["files", "ffprobe", path], - queryFn: () => fetchFfprobe(path), + queryKey: ["files", "ffprobe", path, machineId ?? "default"], + queryFn: () => fetchFfprobe(path, machineId), enabled: enabled && !!path, staleTime: 5 * 60_000, }); } -export function useStat(path: string, enabled = false) { +export function useStat(path: string, enabled = false, machineId?: string) { return useQuery({ - queryKey: ["files", "stat", path], - queryFn: () => fetchStat(path), + queryKey: ["files", "stat", path, machineId ?? "default"], + queryFn: () => fetchStat(path, machineId), enabled: enabled && !!path, }); } @@ -41,9 +41,9 @@ export function useJobTemplates() { }); } -export function useRunJob() { +export function useRunJob(machineId?: string) { return useMutation({ mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) => - runJob(jobKey, path), + runJob(jobKey, path, machineId), }); } diff --git a/frontend/src/hooks/useMedia.ts b/frontend/src/hooks/useMedia.ts index e957a4d..a0f8eb8 100644 --- a/frontend/src/hooks/useMedia.ts +++ b/frontend/src/hooks/useMedia.ts @@ -7,10 +7,10 @@ import { forceStopMediaIndexBuild, } from "../api/client"; -export function useMediaStatus() { +export function useMediaStatus(machineId?: string) { return useQuery({ - queryKey: ["media", "status"], - queryFn: fetchMediaStatus, + queryKey: ["media", "status", machineId ?? "default"], + queryFn: () => fetchMediaStatus(machineId), staleTime: 5_000, refetchInterval: (query) => query.state.data?.build_running ? 1000 : false, @@ -27,11 +27,11 @@ export function useMediaQuery(params: { sort_order?: string; limit?: number; offset?: number; + machineId?: string; enabled?: boolean; }) { const { enabled = true, ...queryParams } = params; - // Feature: Sync file browser with selected media path return useQuery({ queryKey: ["media", "query", queryParams], queryFn: () => queryMedia(queryParams), @@ -44,30 +44,30 @@ function invalidateMedia(queryClient: ReturnType) { queryClient.invalidateQueries({ queryKey: ["media"] }); } -export function useBuildIndex() { +export function useBuildIndex(machineId?: string) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: buildMediaIndex, + mutationFn: () => buildMediaIndex(machineId), onSuccess: () => { invalidateMedia(queryClient); }, }); } -export function useStopBuildIndex() { +export function useStopBuildIndex(machineId?: string) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: stopMediaIndexBuild, + mutationFn: () => stopMediaIndexBuild(machineId), onSuccess: () => { invalidateMedia(queryClient); }, }); } -export function useForceStopBuildIndex() { +export function useForceStopBuildIndex(machineId?: string) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: forceStopMediaIndexBuild, + mutationFn: () => forceStopMediaIndexBuild(machineId), onSuccess: () => { invalidateMedia(queryClient); }, diff --git a/frontend/src/hooks/useUsers.ts b/frontend/src/hooks/useUsers.ts index 3770c9f..d8bbd07 100644 --- a/frontend/src/hooks/useUsers.ts +++ b/frontend/src/hooks/useUsers.ts @@ -1,10 +1,11 @@ import { useQuery } from "@tanstack/react-query"; import { fetchUsers } from "../api/client"; +import type { UserDirectoryResponse } from "../types"; -export function useUsers() { - return useQuery({ - queryKey: ["users"], - queryFn: fetchUsers, +export function useUsers(machineId?: string) { + return useQuery({ + queryKey: ["users", machineId ?? "default"], + queryFn: () => fetchUsers(machineId), staleTime: 30_000, }); } diff --git a/frontend/src/pages/FileBrowser.impl.tsx b/frontend/src/pages/FileBrowser.impl.tsx index f8800e1..8a7253a 100644 --- a/frontend/src/pages/FileBrowser.impl.tsx +++ b/frontend/src/pages/FileBrowser.impl.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useSearchParams } from "react-router-dom"; import { DataGrid } from "@mui/x-data-grid"; import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid"; @@ -25,6 +26,7 @@ import { useRunJob, } from "../hooks/useFiles"; import { usePersistentState } from "../hooks/usePersistentState"; +import { useMonitoringSettings } from "../hooks/useSettings"; interface DisplayRow { id: string; @@ -556,9 +558,22 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) { } export function FileBrowser() { - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const isMobile = useMediaQuery("(max-width: 900px)"); + const { data: machines } = useMonitoringSettings(); + const fileMachines = useMemo( + () => + (machines ?? []).filter( + (machine) => + machine.enabled && + (machine.services.includes("files") || + machine.services.includes("monitoring")), + ), + [machines], + ); const initialRequestedPath = searchParams.get("path"); + const initialMachineId = + searchParams.get("machine_id") || fileMachines[0]?.id || ""; const [browserState, setBrowserState] = usePersistentState( FILE_BROWSER_STATE_KEY, () => { @@ -580,6 +595,7 @@ export function FileBrowser() { }, ); const { currentDir, pathInput, selectedPath, selectedJob } = browserState; + const selectedMachineId = searchParams.get("machine_id") || initialMachineId; const updateBrowserState = (patch: Partial) => setBrowserState((current) => ({ ...current, ...patch })); @@ -588,7 +604,7 @@ export function FileBrowser() { isLoading, error, refetch, - } = useDirectoryListing(currentDir); + } = useDirectoryListing(currentDir, selectedMachineId || undefined); const { data: ffprobeData, isLoading: ffprobeLoading, @@ -596,9 +612,10 @@ export function FileBrowser() { } = useFfprobe( selectedPath ?? "", !!selectedPath && isVideoFile(selectedPath), + selectedMachineId || undefined, ); const { data: templates } = useJobTemplates(); - const runJob = useRunJob(); + const runJob = useRunJob(selectedMachineId || undefined); const navigate = (path: string) => { updateBrowserState({ @@ -608,6 +625,18 @@ export function FileBrowser() { }); }; + const setMachine = (machineId: string) => { + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + if (machineId) next.set("machine_id", machineId); + else next.delete("machine_id"); + return next; + }, + { replace: true }, + ); + }; + const handlePathSubmit = (e: React.KeyboardEvent) => { if (e.key === "Enter") navigate(pathInput || "/"); }; @@ -657,7 +686,27 @@ export function FileBrowser() { return ( - File Browser + + File Browser + + Machine + + + + (machines ?? []).filter( + (machine) => machine.enabled && machine.services.includes("jellyfin"), + ), + [machines], + ); + const selectedMachineId = + searchParams.get("machine_id") || jellyfinMachines[0]?.id || ""; + const { data: counts } = useCounts(selectedMachineId || undefined); + const { data: libraries } = useLibraries(selectedMachineId || undefined); + const { data: status } = useMediaStatus(selectedMachineId || undefined); + const buildIndex = useBuildIndex(selectedMachineId || undefined); + const stopBuildIndex = useStopBuildIndex(selectedMachineId || undefined); + const forceStopBuildIndex = useForceStopBuildIndex( + selectedMachineId || undefined, + ); const [mediaState, setMediaState] = usePersistentState( MEDIA_TAB_STATE_KEY, @@ -79,6 +96,19 @@ export function Media() { setMediaState((current) => ({ ...current, ...patch })); const limit = 100; + useEffect(() => { + if (!searchParams.get("machine_id") && selectedMachineId) { + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + next.set("machine_id", selectedMachineId); + return next; + }, + { replace: true }, + ); + } + }, [searchParams, selectedMachineId, setSearchParams]); + const { data: queryResult, isLoading } = useMediaDataQuery({ types, search, @@ -87,6 +117,7 @@ export function Media() { sort_order: sortOrder, limit, offset, + machineId: selectedMachineId || undefined, enabled: status?.exists ?? false, }); @@ -155,7 +186,30 @@ export function Media() { spacing={1.5} sx={{ alignItems: "center", flexWrap: "wrap" }} > - Media + Jellyfin + + Machine + + {status?.exists ? ( Index: {status.item_count.toLocaleString()} items @@ -168,6 +222,14 @@ export function Media() { No index built yet. )} + {counts && ( + + Library stats: {counts.movies.toLocaleString()} movies ·{" "} + {counts.series.toLocaleString()} series ·{" "} + {counts.episodes.toLocaleString()} episodes ·{" "} + {(libraries?.length ?? 0).toLocaleString()} libraries + + )}