fixes and improvements

This commit is contained in:
2026-05-06 16:33:02 +02:00
parent d8d80867ce
commit 5277f21577
20 changed files with 577 additions and 222 deletions
+12 -24
View File
@@ -1,13 +1,4 @@
JELLYFIN_URL=https://jellyfin.example.com # Optional backend logging level.
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.
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Optional SMTP settings for the Users -> message popup. # Optional SMTP settings for the Users -> message popup.
@@ -21,20 +12,17 @@ SMTP_USE_TLS=true
SMTP_USE_SSL=false SMTP_USE_SSL=false
SMTP_TIMEOUT=30 SMTP_TIMEOUT=30
SSH_HOST=media-server.example.com # Machine/service configuration now lives in the app's Settings tab.
SSH_USERNAME=username # The built-in local machine is seeded automatically.
SSH_PORT=22 #
# Host-side directory mounted into the backend container at /root/.ssh. # For a remote SSH machine, the backend still needs access to a private key
SSH_KEY_HOST_DIR=/absolute/path/to/your/ssh-dir # file. Compose mounts a single host key file as a Docker secret instead of the
# Container-side path assembled by the app: # whole ~/.ssh directory.
SSH_KEY_DIRECTORY=/root/.ssh #
SSH_KEY_NAME=id_ed25519 # Host-side path used by Docker Compose secret definitions:
# SSH_PASSWORD=optional-password-or-key-passphrase SSH_KEY_HOST_PATH=/absolute/path/to/id_ed25519
REMOTE_MEDIA_ROOT=/mnt/media # The container-side path is fixed by the app and Compose at:
# Optional fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough. # /run/secrets/ssh_private_key
# Example: Jellyfin gives /media/... but SSH host requires /srv/media/...
REMOTE_PATH_PREFIX=
# For deployment with traefik # For deployment with traefik
FRONTEND_APP_NAME=manage FRONTEND_APP_NAME=manage
+24 -26
View File
@@ -76,12 +76,6 @@ The Compose files use environment-variable interpolation. Export the required va
Production-style example with shell exports: Production-style example with shell exports:
```bash ```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 BACKEND_APP_HOST=manage.example.com
export FRONTEND_APP_HOST=manage.example.com export FRONTEND_APP_HOST=manage.example.com
export CERT_RESOLVER=letsencrypt 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_REDIRECT_URI=https://manage.example.com/
export VITE_OIDC_POST_LOGOUT_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 docker compose up --build
``` ```
Inline one-liner example: Inline one-liner example:
```bash ```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: Example environment variables:
```bash ```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 # Optional backend logging level
LOG_LEVEL=INFO LOG_LEVEL=INFO
SSH_HOST=media-server.example.com # Optional SMTP settings for the Users -> message popup
SSH_USERNAME=username SMTP_HOST=smtp.example.com
SSH_PORT=22 SMTP_PORT=587
# Host-side directory mounted into the backend container at /root/.ssh. SMTP_USERNAME=your-smtp-username
SSH_KEY_HOST_DIR=/absolute/path/to/your/ssh-dir SMTP_PASSWORD=your-smtp-password
# Container-side path assembled by the app: SMTP_FROM_ADDRESS=no-reply@example.com
SSH_KEY_DIRECTORY=/root/.ssh SMTP_FROM_NAME=Manage
SSH_KEY_NAME=id_ed25519 SMTP_USE_TLS=true
SSH_PASSWORD= SMTP_USE_SSL=false
SMTP_TIMEOUT=30
REMOTE_MEDIA_ROOT=/srv/media # Jellyfin, Jellyseerr, and SSH targets are now configured per machine in the app's Settings tab.
REMOTE_PATH_PREFIX= # 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 # Authentik / OIDC
AUTH_ENABLED=true AUTH_ENABLED=true
+24 -28
View File
@@ -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. For local `.env` development, you can still create one if you prefer, but it is optional.
```bash ```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 # Optional backend logging level
LOG_LEVEL=INFO 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 # Authentik / OIDC
AUTH_ENABLED=true AUTH_ENABLED=true
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/ OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/
OIDC_AUDIENCE=media-library-viewer OIDC_AUDIENCE=media-library-viewer
OIDC_JWKS_URL= OIDC_JWKS_URL=
OIDC_CLOCK_SKEW_SECONDS=30 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 ## Running
@@ -113,12 +110,6 @@ The production compose file expects required environment variables to be supplie
1. Export your runtime variables before launching Compose: 1. Export your runtime variables before launching Compose:
```bash ```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 BACKEND_APP_HOST=manage.example.com
export FRONTEND_APP_HOST=manage.example.com export FRONTEND_APP_HOST=manage.example.com
export CERT_RESOLVER=letsencrypt 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_REDIRECT_URI=https://manage.example.com/
export VITE_OIDC_POST_LOGOUT_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 docker compose up --build
``` ```
2. After the API is running, open the app, go to **Settings**, and add machine entries: 2. After the API is running, open the app, go to **Settings**, and add machine entries:
- **Local**: monitors the API host itself without SSH. - **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. 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.
@@ -14,6 +14,7 @@ import logging
import posixpath import posixpath
import shlex import shlex
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any from typing import Any
import paramiko import paramiko
@@ -41,6 +42,7 @@ class RemoteSSHClient:
port: int = 22, port: int = 22,
key_filename: str | None = None, key_filename: str | None = None,
password: str | None = None, password: str | None = None,
known_hosts_path: str | None = None,
timeout: int = 20, timeout: int = 20,
): ):
if not host or not username: if not host or not username:
@@ -50,19 +52,23 @@ class RemoteSSHClient:
self.port = port self.port = port
self.key_filename = key_filename or None self.key_filename = key_filename or None
self.password = password or None self.password = password or None
self.known_hosts_path = known_hosts_path or None
self.timeout = timeout self.timeout = timeout
self._client: paramiko.SSHClient | None = None self._client: paramiko.SSHClient | None = None
def connect(self) -> paramiko.SSHClient: def connect(self) -> paramiko.SSHClient:
"""Create or reuse the Paramiko connection. """Create or reuse the Paramiko connection.
Unknown host keys are rejected. Users should connect once manually with Unknown host keys are rejected. The application can synthesize a managed
ssh so the server is present in known_hosts. known_hosts file under its cache directory so users do not need to mount
their local SSH directory into the container.
""" """
if self._client: if self._client:
return self._client return self._client
client = paramiko.SSHClient() client = paramiko.SSHClient()
client.load_system_host_keys() 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.set_missing_host_key_policy(paramiko.RejectPolicy())
client.connect( client.connect(
self.host, self.host,
@@ -119,7 +125,6 @@ class RemoteSSHClient:
was a source of file-browser confusion. Output is NUL-delimited before was a source of file-browser confusion. Output is NUL-delimited before
Python serializes it, making spaces in filenames safe. 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) quoted = shlex.quote(path)
not_dir_message = shlex.quote(f"Not a directory: {path}") not_dir_message = shlex.quote(f"Not a directory: {path}")
command = ( command = (
+11 -4
View File
@@ -21,12 +21,12 @@ logger = logging.getLogger(__name__)
class Settings(BaseSettings): class Settings(BaseSettings):
"""Flat application settings read from env vars / .env file.""" """Flat application settings read from env vars / .env file."""
# Jellyfin # Jellyfin (legacy fallback only; machine settings are preferred)
jellyfin_url: str = "" jellyfin_url: str = ""
jellyfin_api_key: str = "" jellyfin_api_key: str = ""
jellyfin_user_id: str = "" jellyfin_user_id: str = ""
# Jellyseerr (optional) # Jellyseerr (legacy fallback only; machine settings are preferred)
jellyseerr_url: str = "" jellyseerr_url: str = ""
jellyseerr_api_key: str = "" jellyseerr_api_key: str = ""
@@ -51,13 +51,15 @@ class Settings(BaseSettings):
smtp_use_ssl: bool = False smtp_use_ssl: bool = False
smtp_timeout: int = 30 smtp_timeout: int = 30
# SSH # Legacy SSH fallback (new preferred path is machine-specific settings)
ssh_host: str = "" ssh_host: str = ""
ssh_username: str = "" ssh_username: str = ""
ssh_port: int = 22 ssh_port: int = 22
ssh_key_directory: str = "" ssh_key_directory: str = ""
ssh_key_name: str = "" ssh_key_name: str = ""
ssh_key_file: str = "/run/secrets/ssh_private_key"
ssh_password: str = "" ssh_password: str = ""
ssh_known_hosts_path: str = ""
# Monitoring poller # Monitoring poller
monitoring_poll_interval_seconds: int = 300 monitoring_poll_interval_seconds: int = 300
@@ -79,10 +81,16 @@ class Settings(BaseSettings):
@property @property
def ssh_key_path(self) -> str: 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: if not self.ssh_key_directory or not self.ssh_key_name:
return "" return ""
return str(Path(self.ssh_key_directory) / self.ssh_key_name) 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"} 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" candidate = directory / ".env"
if candidate.is_file(): if candidate.is_file():
return str(candidate) return str(candidate)
# Stop at repo root (has .git)
if (directory / ".git").exists(): if (directory / ".git").exists():
break break
return None return None
@@ -1,17 +1,23 @@
"""Dependency injection for FastAPI. """Dependency injection for FastAPI.
Provides singleton-like access to SSH and Jellyfin clients via FastAPI's Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request
dependency system. Uses lru_cache so connections are reused across requests. 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 from __future__ import annotations
import logging import logging
from functools import lru_cache 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.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient 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.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.mail_queue import MailQueue, get_mail_queue as _get_mail_queue
from media_library_viewer_api.services.monitoring_poller import ( 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__) logger = logging.getLogger(__name__)
@lru_cache def _request_machine_id(request: Request | None) -> str | None:
def get_jellyfin_client() -> JellyfinClient: if request is None:
"""Return a cached Jellyfin client.""" 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 "<default>", url.rstrip("/") or "<unset>")
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 "<default>", url.rstrip("/") or "<unset>")
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 "<default>",
host or "<unset>",
username or "<unset>",
port,
key_filename or "<unset>",
"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 "<unset>")
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() settings = get_settings()
logger.info("Creating Jellyfin client for %s", settings.jellyfin_url.rstrip("/") or "<unset>") if not settings.jellyfin_url or not settings.jellyfin_api_key:
return JellyfinClient(settings.jellyfin_url, 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(request: Request = None) -> JellyseerrClient | None:
def get_jellyseerr_client() -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise 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() settings = get_settings()
if not settings.jellyseerr_url or not settings.jellyseerr_api_key: if not settings.jellyseerr_url or not settings.jellyseerr_api_key:
logger.info( logger.info(
@@ -42,16 +131,30 @@ def get_jellyseerr_client() -> JellyseerrClient | None:
"set" if settings.jellyseerr_api_key else "missing", "set" if settings.jellyseerr_api_key else "missing",
) )
return None return None
logger.info("Creating Jellyseerr client for %s", settings.jellyseerr_url.rstrip("/") or "<unset>") logger.info("Falling back to legacy Jellyseerr env settings")
return JellyseerrClient(settings.jellyseerr_url, settings.jellyseerr_api_key) return JellyseerrClient(settings.jellyseerr_url, settings.jellyseerr_api_key)
@lru_cache def get_ssh_client(request: Request = None) -> RemoteSSHClient:
def get_ssh_client() -> RemoteSSHClient: """Return a cached SSH client for the selected machine or legacy env fallback."""
"""Return a cached SSH client (connects on first use).""" 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() settings = get_settings()
logger.info( 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 "<unset>", settings.ssh_host or "<unset>",
settings.ssh_username or "<unset>", settings.ssh_username or "<unset>",
settings.ssh_port, settings.ssh_port,
@@ -60,20 +163,9 @@ def get_ssh_client() -> RemoteSSHClient:
"set" if settings.ssh_password else "missing", "set" if settings.ssh_password else "missing",
) )
if not settings.ssh_key_path: if not settings.ssh_key_path:
raise RuntimeError("SSH_KEY_DIRECTORY and SSH_KEY_NAME must be configured") raise RuntimeError("No SSH machine is configured and SSH key settings must be configured")
client = RemoteSSHClient( ensure_known_host(settings.ssh_host, settings.ssh_port, settings.ssh_known_hosts_file)
host=settings.ssh_host, 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)))
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 "<unset>")
raise
return client
def get_mail_queue() -> MailQueue: def get_mail_queue() -> MailQueue:
@@ -91,13 +183,16 @@ def get_settings_store() -> SettingsStore:
return _get_settings_store() return _get_settings_store()
def get_user_id() -> str: def get_user_id(request: Request = None) -> str:
"""Return the configured Jellyfin user ID, or discover the first available user.""" """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() settings = get_settings()
if settings.jellyfin_user_id: if settings.jellyfin_user_id:
return settings.jellyfin_user_id return settings.jellyfin_user_id
client = get_jellyfin_client() client = get_jellyfin_client(request)
users = client.users() users = client.users()
if not 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"] return users[0]["Id"]
+8 -1
View File
@@ -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.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 import dashboard, monitoring, media, files, jobs, users
from media_library_viewer_api.routers.settings import router as settings_router 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__) logger = logging.getLogger(__name__)
@@ -27,6 +28,12 @@ async def lifespan(app: FastAPI):
configure_logging(settings.log_level) configure_logging(settings.log_level)
validate_auth_settings(settings) validate_auth_settings(settings)
logger.info("Backend startup complete: %s", describe_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() mail_queue = get_mail_queue()
monitoring_poller = get_monitoring_poller() monitoring_poller = get_monitoring_poller()
mail_queue.start() mail_queue.start()
+64
View File
@@ -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.clients.ssh import CommandResult
from media_library_viewer_api.routers.media import get_media_index 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.media_index import MediaIndex
from media_library_viewer_api.services.settings_store import SettingsStore
# --- Fixtures --- # --- Fixtures ---
@@ -263,6 +264,69 @@ class TestDashboard:
assert len(data) == 2 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 --- # --- Users ---
class TestUsers: class TestUsers:
+7 -14
View File
@@ -10,25 +10,14 @@ services:
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:-} OIDC_AUDIENCE: ${OIDC_AUDIENCE:-}
OIDC_JWKS_URL: ${OIDC_JWKS_URL:-} 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} LOG_LEVEL: ${LOG_LEVEL:-INFO}
SSH_HOST: ${SSH_HOST:-host.docker.internal} SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
SSH_USERNAME: ${SSH_USERNAME:-} secrets:
SSH_PORT: ${SSH_PORT:-22} - ssh_private_key
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:-}
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./backend:/app/backend - ./backend:/app/backend
- ${SSH_KEY_HOST_DIR:-./secrets/ssh}:/root/.ssh:ro
- backend_cache:/app/backend/.cache - backend_cache:/app/backend/.cache
restart: unless-stopped restart: unless-stopped
@@ -54,3 +43,7 @@ services:
volumes: volumes:
frontend_node_modules: frontend_node_modules:
backend_cache: backend_cache:
secrets:
ssh_private_key:
file: ${SSH_KEY_HOST_PATH:-./secrets/ssh/id_ed25519}
+7 -14
View File
@@ -9,11 +9,6 @@ services:
OIDC_AUDIENCE: ${OIDC_AUDIENCE:?set OIDC_AUDIENCE} OIDC_AUDIENCE: ${OIDC_AUDIENCE:?set OIDC_AUDIENCE}
OIDC_JWKS_URL: ${OIDC_JWKS_URL:-} OIDC_JWKS_URL: ${OIDC_JWKS_URL:-}
OIDC_CLOCK_SKEW_SECONDS: ${OIDC_CLOCK_SKEW_SECONDS:-30} 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} LOG_LEVEL: ${LOG_LEVEL:-INFO}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
@@ -24,16 +19,10 @@ services:
SMTP_USE_TLS: ${SMTP_USE_TLS:-true} SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
SMTP_USE_SSL: ${SMTP_USE_SSL:-false} SMTP_USE_SSL: ${SMTP_USE_SSL:-false}
SMTP_TIMEOUT: ${SMTP_TIMEOUT:-30} SMTP_TIMEOUT: ${SMTP_TIMEOUT:-30}
SSH_HOST: ${SSH_HOST:?set SSH_HOST} SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
SSH_USERNAME: ${SSH_USERNAME:?set SSH_USERNAME} secrets:
SSH_PORT: ${SSH_PORT:-22} - ssh_private_key
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:-}
volumes: volumes:
- ${SSH_KEY_HOST_DIR:?set SSH_KEY_HOST_DIR}:/root/.ssh:ro
- backend_cache:/app/backend/.cache - backend_cache:/app/backend/.cache
restart: unless-stopped restart: unless-stopped
networks: networks:
@@ -91,6 +80,10 @@ services:
volumes: volumes:
backend_cache: backend_cache:
secrets:
ssh_private_key:
file: ${SSH_KEY_HOST_PATH:?set SSH_KEY_HOST_PATH}
networks: networks:
web: web:
external: true external: true
+13 -4
View File
@@ -1,6 +1,6 @@
# Manage - Requirements and Decision Log # 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 ## Product Goal
@@ -71,7 +71,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
### Remote Filesystem over SSH ### Remote Filesystem over SSH
- Connect to a remote media server via 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. - 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`). - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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 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. - 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. - 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 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 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 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.
+9 -3
View File
@@ -27,7 +27,7 @@ import { useEffect, useMemo } from "react";
import { AuthProvider, useAuth } from "react-oidc-context"; import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { Monitoring } from "./pages/Monitoring"; import { Monitoring } from "./pages/Monitoring";
import { Media } from "./pages/Media"; import { Applications } from "./pages/Applications";
import { Settings } from "./pages/Settings"; import { Settings } from "./pages/Settings";
import { UsersPage } from "./pages/Users"; import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser"; import { FileBrowser } from "./pages/FileBrowser";
@@ -173,7 +173,12 @@ function Shell({
component={NavLink} component={NavLink}
to="/monitoring" to="/monitoring"
/> />
<Tab value="/media" label="Media" component={NavLink} to="/media" /> <Tab
value="/applications"
label="Applications"
component={NavLink}
to="/applications"
/>
<Tab value="/users" label="Users" component={NavLink} to="/users" /> <Tab value="/users" label="Users" component={NavLink} to="/users" />
<Tab value="/files" label="Files" component={NavLink} to="/files" /> <Tab value="/files" label="Files" component={NavLink} to="/files" />
<Tab <Tab
@@ -192,7 +197,8 @@ function Shell({
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} /> <Route path="/monitoring" element={<Monitoring />} />
<Route path="/media" element={<Media />} /> <Route path="/applications" element={<Applications />} />
<Route path="/media" element={<Applications />} />
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/files" element={<FileBrowser />} /> <Route path="/files" element={<FileBrowser />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
+79 -24
View File
@@ -25,6 +25,8 @@ import type {
JobTemplate, JobTemplate,
JobResult, JobResult,
ResolvedPath, ResolvedPath,
ResetLocalDatabaseInput,
ResetLocalDatabaseResponse,
} from "../types"; } from "../types";
const BASE_URL = import.meta.env.VITE_API_URL || "/api"; const BASE_URL = import.meta.env.VITE_API_URL || "/api";
@@ -126,12 +128,26 @@ async function del<T>(path: string): Promise<T> {
} }
// Dashboard // Dashboard
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts"); export const fetchCounts = (machineId?: string) =>
export const fetchLibraries = () => get<MediaCounts>(
get<LibraryCount[]>("/api/dashboard/libraries"); "/api/dashboard/counts",
export const fetchActivity = () => machineId ? { machine_id: machineId } : undefined,
get<NowPlayingSession[]>("/api/dashboard/activity"); );
export const fetchUsers = () => get<UserDirectoryResponse>("/api/users"); export const fetchLibraries = (machineId?: string) =>
get<LibraryCount[]>(
"/api/dashboard/libraries",
machineId ? { machine_id: machineId } : undefined,
);
export const fetchActivity = (machineId?: string) =>
get<NowPlayingSession[]>(
"/api/dashboard/activity",
machineId ? { machine_id: machineId } : undefined,
);
export const fetchUsers = (machineId?: string) =>
get<UserDirectoryResponse>(
"/api/users",
machineId ? { machine_id: machineId } : undefined,
);
// Backward-compatible alias used by older hooks/components. // Backward-compatible alias used by older hooks/components.
export const fetchNowPlaying = fetchActivity; export const fetchNowPlaying = fetchActivity;
@@ -211,16 +227,36 @@ export const deleteMonitoringMachine = (machineId: string) =>
del<{ status: string }>( del<{ status: string }>(
`/api/settings/machines/${encodeURIComponent(machineId)}`, `/api/settings/machines/${encodeURIComponent(machineId)}`,
); );
export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
post<ResetLocalDatabaseResponse>(
"/api/settings/reset-local-database",
payload,
);
// Media // Media
export const fetchMediaStatus = () => export const fetchMediaStatus = (machineId?: string) =>
get<MediaIndexStatus>("/api/media/status"); get<MediaIndexStatus>(
export const buildMediaIndex = () => "/api/media/status",
post<MediaIndexActionResponse>("/api/media/build"); machineId ? { machine_id: machineId } : undefined,
export const stopMediaIndexBuild = () => );
post<MediaIndexActionResponse>("/api/media/stop"); export const buildMediaIndex = (machineId?: string) =>
export const forceStopMediaIndexBuild = () => post<MediaIndexActionResponse>(
post<MediaIndexActionResponse>("/api/media/force-stop"); machineId
? `/api/media/build?machine_id=${encodeURIComponent(machineId)}`
: "/api/media/build",
);
export const stopMediaIndexBuild = (machineId?: string) =>
post<MediaIndexActionResponse>(
machineId
? `/api/media/stop?machine_id=${encodeURIComponent(machineId)}`
: "/api/media/stop",
);
export const forceStopMediaIndexBuild = (machineId?: string) =>
post<MediaIndexActionResponse>(
machineId
? `/api/media/force-stop?machine_id=${encodeURIComponent(machineId)}`
: "/api/media/force-stop",
);
export const queryMedia = (params: { export const queryMedia = (params: {
libraries?: string; libraries?: string;
types?: string; types?: string;
@@ -230,6 +266,7 @@ export const queryMedia = (params: {
sort_order?: string; sort_order?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
machineId?: string;
}) => }) =>
get<MediaQueryResponse>("/api/media/query", { get<MediaQueryResponse>("/api/media/query", {
libraries: params.libraries || "", libraries: params.libraries || "",
@@ -240,23 +277,41 @@ export const queryMedia = (params: {
sort_order: params.sort_order || "Ascending", sort_order: params.sort_order || "Ascending",
limit: String(params.limit || 100), limit: String(params.limit || 100),
offset: String(params.offset || 0), offset: String(params.offset || 0),
...(params.machineId ? { machine_id: params.machineId } : {}),
}); });
// Files // Files
export const fetchDirectoryListing = (path: string) => export const fetchDirectoryListing = (path: string, machineId?: string) =>
get<DirectoryListing>("/api/files/list", { path }); get<DirectoryListing>("/api/files/list", {
export const fetchFfprobe = (path: string) => path,
get<Record<string, unknown>>("/api/files/ffprobe", { path }); ...(machineId ? { machine_id: machineId } : {}),
export const fetchStat = (path: string) => });
get<{ path: string; output: string }>("/api/files/stat", { path }); export const fetchFfprobe = (path: string, machineId?: string) =>
export const resolvePath = (path: string) => get<Record<string, unknown>>("/api/files/ffprobe", {
get<ResolvedPath>("/api/files/resolve-path", { path }); 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<ResolvedPath>("/api/files/resolve-path", {
path,
...(machineId ? { machine_id: machineId } : {}),
});
// Jobs // Jobs
export const fetchJobTemplates = () => export const fetchJobTemplates = () =>
get<JobTemplate[]>("/api/jobs/templates"); get<JobTemplate[]>("/api/jobs/templates");
export const runJob = (jobKey: string, path: string) => export const runJob = (jobKey: string, path: string, machineId?: string) =>
post<JobResult>("/api/jobs/run", { job_key: jobKey, path }); post<JobResult>(
machineId
? `/api/jobs/run?machine_id=${encodeURIComponent(machineId)}`
: "/api/jobs/run",
{ job_key: jobKey, path },
);
export const fetchUserMessageQueueStatus = () => export const fetchUserMessageQueueStatus = () =>
get<UserMessageQueueStatus>("/api/users/message/status"); get<UserMessageQueueStatus>("/api/users/message/status");
+9 -9
View File
@@ -6,26 +6,26 @@ import {
fetchMonitoringOverview, fetchMonitoringOverview,
} from "../api/client"; } from "../api/client";
export function useCounts() { export function useCounts(machineId?: string) {
return useQuery({ return useQuery({
queryKey: ["dashboard", "counts"], queryKey: ["dashboard", "counts", machineId ?? "default"],
queryFn: fetchCounts, queryFn: () => fetchCounts(machineId),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
export function useLibraries() { export function useLibraries(machineId?: string) {
return useQuery({ return useQuery({
queryKey: ["dashboard", "libraries"], queryKey: ["dashboard", "libraries", machineId ?? "default"],
queryFn: fetchLibraries, queryFn: () => fetchLibraries(machineId),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
export function useActivity() { export function useActivity(machineId?: string) {
return useQuery({ return useQuery({
queryKey: ["dashboard", "activity"], queryKey: ["dashboard", "activity", machineId ?? "default"],
queryFn: fetchActivity, queryFn: () => fetchActivity(machineId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
} }
+11 -11
View File
@@ -7,28 +7,28 @@ import {
runJob, runJob,
} from "../api/client"; } from "../api/client";
export function useDirectoryListing(path: string) { export function useDirectoryListing(path: string, machineId?: string) {
return useQuery({ return useQuery({
queryKey: ["files", "list", path], queryKey: ["files", "list", path, machineId ?? "default"],
queryFn: () => fetchDirectoryListing(path), queryFn: () => fetchDirectoryListing(path, machineId),
enabled: !!path, enabled: !!path,
staleTime: 30_000, staleTime: 30_000,
}); });
} }
export function useFfprobe(path: string, enabled = false) { export function useFfprobe(path: string, enabled = false, machineId?: string) {
return useQuery({ return useQuery({
queryKey: ["files", "ffprobe", path], queryKey: ["files", "ffprobe", path, machineId ?? "default"],
queryFn: () => fetchFfprobe(path), queryFn: () => fetchFfprobe(path, machineId),
enabled: enabled && !!path, enabled: enabled && !!path,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
} }
export function useStat(path: string, enabled = false) { export function useStat(path: string, enabled = false, machineId?: string) {
return useQuery({ return useQuery({
queryKey: ["files", "stat", path], queryKey: ["files", "stat", path, machineId ?? "default"],
queryFn: () => fetchStat(path), queryFn: () => fetchStat(path, machineId),
enabled: enabled && !!path, enabled: enabled && !!path,
}); });
} }
@@ -41,9 +41,9 @@ export function useJobTemplates() {
}); });
} }
export function useRunJob() { export function useRunJob(machineId?: string) {
return useMutation({ return useMutation({
mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) => mutationFn: ({ jobKey, path }: { jobKey: string; path: string }) =>
runJob(jobKey, path), runJob(jobKey, path, machineId),
}); });
} }
+10 -10
View File
@@ -7,10 +7,10 @@ import {
forceStopMediaIndexBuild, forceStopMediaIndexBuild,
} from "../api/client"; } from "../api/client";
export function useMediaStatus() { export function useMediaStatus(machineId?: string) {
return useQuery({ return useQuery({
queryKey: ["media", "status"], queryKey: ["media", "status", machineId ?? "default"],
queryFn: fetchMediaStatus, queryFn: () => fetchMediaStatus(machineId),
staleTime: 5_000, staleTime: 5_000,
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.build_running ? 1000 : false, query.state.data?.build_running ? 1000 : false,
@@ -27,11 +27,11 @@ export function useMediaQuery(params: {
sort_order?: string; sort_order?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
machineId?: string;
enabled?: boolean; enabled?: boolean;
}) { }) {
const { enabled = true, ...queryParams } = params; const { enabled = true, ...queryParams } = params;
// Feature: Sync file browser with selected media path
return useQuery({ return useQuery({
queryKey: ["media", "query", queryParams], queryKey: ["media", "query", queryParams],
queryFn: () => queryMedia(queryParams), queryFn: () => queryMedia(queryParams),
@@ -44,30 +44,30 @@ function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ["media"] }); queryClient.invalidateQueries({ queryKey: ["media"] });
} }
export function useBuildIndex() { export function useBuildIndex(machineId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: buildMediaIndex, mutationFn: () => buildMediaIndex(machineId),
onSuccess: () => { onSuccess: () => {
invalidateMedia(queryClient); invalidateMedia(queryClient);
}, },
}); });
} }
export function useStopBuildIndex() { export function useStopBuildIndex(machineId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: stopMediaIndexBuild, mutationFn: () => stopMediaIndexBuild(machineId),
onSuccess: () => { onSuccess: () => {
invalidateMedia(queryClient); invalidateMedia(queryClient);
}, },
}); });
} }
export function useForceStopBuildIndex() { export function useForceStopBuildIndex(machineId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: forceStopMediaIndexBuild, mutationFn: () => forceStopMediaIndexBuild(machineId),
onSuccess: () => { onSuccess: () => {
invalidateMedia(queryClient); invalidateMedia(queryClient);
}, },
+5 -4
View File
@@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/client"; import { fetchUsers } from "../api/client";
import type { UserDirectoryResponse } from "../types";
export function useUsers() { export function useUsers(machineId?: string) {
return useQuery({ return useQuery<UserDirectoryResponse>({
queryKey: ["users"], queryKey: ["users", machineId ?? "default"],
queryFn: fetchUsers, queryFn: () => fetchUsers(machineId),
staleTime: 30_000, staleTime: 30_000,
}); });
} }
+53 -4
View File
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid"; import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid"; import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
@@ -25,6 +26,7 @@ import {
useRunJob, useRunJob,
} from "../hooks/useFiles"; } from "../hooks/useFiles";
import { usePersistentState } from "../hooks/usePersistentState"; import { usePersistentState } from "../hooks/usePersistentState";
import { useMonitoringSettings } from "../hooks/useSettings";
interface DisplayRow { interface DisplayRow {
id: string; id: string;
@@ -556,9 +558,22 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
} }
export function FileBrowser() { export function FileBrowser() {
const [searchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useMediaQuery("(max-width: 900px)"); 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 initialRequestedPath = searchParams.get("path");
const initialMachineId =
searchParams.get("machine_id") || fileMachines[0]?.id || "";
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>( const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
FILE_BROWSER_STATE_KEY, FILE_BROWSER_STATE_KEY,
() => { () => {
@@ -580,6 +595,7 @@ export function FileBrowser() {
}, },
); );
const { currentDir, pathInput, selectedPath, selectedJob } = browserState; const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
const updateBrowserState = (patch: Partial<FileBrowserState>) => const updateBrowserState = (patch: Partial<FileBrowserState>) =>
setBrowserState((current) => ({ ...current, ...patch })); setBrowserState((current) => ({ ...current, ...patch }));
@@ -588,7 +604,7 @@ export function FileBrowser() {
isLoading, isLoading,
error, error,
refetch, refetch,
} = useDirectoryListing(currentDir); } = useDirectoryListing(currentDir, selectedMachineId || undefined);
const { const {
data: ffprobeData, data: ffprobeData,
isLoading: ffprobeLoading, isLoading: ffprobeLoading,
@@ -596,9 +612,10 @@ export function FileBrowser() {
} = useFfprobe( } = useFfprobe(
selectedPath ?? "", selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath), !!selectedPath && isVideoFile(selectedPath),
selectedMachineId || undefined,
); );
const { data: templates } = useJobTemplates(); const { data: templates } = useJobTemplates();
const runJob = useRunJob(); const runJob = useRunJob(selectedMachineId || undefined);
const navigate = (path: string) => { const navigate = (path: string) => {
updateBrowserState({ 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) => { const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") navigate(pathInput || "/"); if (e.key === "Enter") navigate(pathInput || "/");
}; };
@@ -657,7 +686,27 @@ export function FileBrowser() {
return ( return (
<Stack spacing={2}> <Stack spacing={2}>
<Typography variant="h5">File Browser</Typography> <Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">File Browser</Typography>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel>Machine</InputLabel>
<Select
label="Machine"
value={selectedMachineId}
onChange={(e) => setMachine(String(e.target.value))}
>
{fileMachines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name} · {machine.mode}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Stack direction={isMobile ? "column" : "row"} spacing={1}> <Stack direction={isMobile ? "column" : "row"} spacing={1}>
<TextField <TextField
+69 -7
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid"; import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef } from "@mui/x-data-grid"; import type { GridColDef } from "@mui/x-data-grid";
import { import {
@@ -28,6 +28,8 @@ import {
} from "../hooks/useMedia"; } from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState"; import { usePersistentState } from "../hooks/usePersistentState";
import type { MediaItem } from "../types"; import type { MediaItem } from "../types";
import { useMonitoringSettings } from "../hooks/useSettings";
import { useCounts, useLibraries } from "../hooks/useDashboard";
function formatDuration(seconds: number | null | undefined): string { function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(seconds)) return "-"; if (seconds == null || Number.isNaN(seconds)) return "-";
@@ -64,11 +66,26 @@ function defaultMediaTabState(): MediaTabState {
export function Media() { export function Media() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useMediaQuery("(max-width: 900px)"); const isMobile = useMediaQuery("(max-width: 900px)");
const { data: status } = useMediaStatus(); const { data: machines } = useMonitoringSettings();
const buildIndex = useBuildIndex(); const jellyfinMachines = useMemo(
const stopBuildIndex = useStopBuildIndex(); () =>
const forceStopBuildIndex = useForceStopBuildIndex(); (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<MediaTabState>( const [mediaState, setMediaState] = usePersistentState<MediaTabState>(
MEDIA_TAB_STATE_KEY, MEDIA_TAB_STATE_KEY,
@@ -79,6 +96,19 @@ export function Media() {
setMediaState((current) => ({ ...current, ...patch })); setMediaState((current) => ({ ...current, ...patch }));
const limit = 100; 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({ const { data: queryResult, isLoading } = useMediaDataQuery({
types, types,
search, search,
@@ -87,6 +117,7 @@ export function Media() {
sort_order: sortOrder, sort_order: sortOrder,
limit, limit,
offset, offset,
machineId: selectedMachineId || undefined,
enabled: status?.exists ?? false, enabled: status?.exists ?? false,
}); });
@@ -155,7 +186,30 @@ export function Media() {
spacing={1.5} spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }} sx={{ alignItems: "center", flexWrap: "wrap" }}
> >
<Typography variant="h5">Media</Typography> <Typography variant="h5">Jellyfin</Typography>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel>Machine</InputLabel>
<Select
label="Machine"
value={selectedMachineId}
onChange={(e) =>
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("machine_id", String(e.target.value));
return next;
},
{ replace: true },
)
}
>
{jellyfinMachines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
</Select>
</FormControl>
{status?.exists ? ( {status?.exists ? (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Index: {status.item_count.toLocaleString()} items Index: {status.item_count.toLocaleString()} items
@@ -168,6 +222,14 @@ export function Media() {
No index built yet. No index built yet.
</Alert> </Alert>
)} )}
{counts && (
<Typography variant="body2" color="text.secondary">
Library stats: {counts.movies.toLocaleString()} movies ·{" "}
{counts.series.toLocaleString()} series ·{" "}
{counts.episodes.toLocaleString()} episodes ·{" "}
{(libraries?.length ?? 0).toLocaleString()} libraries
</Typography>
)}
<Button <Button
variant="outlined" variant="outlined"
onClick={() => buildIndex.mutate()} onClick={() => buildIndex.mutate()}
+27
View File
@@ -94,6 +94,7 @@ export interface MonitoringMachine {
name: string; name: string;
mode: "local" | "ssh"; mode: "local" | "ssh";
enabled: boolean; enabled: boolean;
services: string[];
host: string; host: string;
port: number; port: number;
username: string; username: string;
@@ -102,6 +103,11 @@ export interface MonitoringMachine {
password_set: boolean; password_set: boolean;
media_root: string; media_root: string;
path_prefix: string; path_prefix: string;
jellyfin_url: string;
jellyfin_user_id: string;
jellyfin_api_key_set: boolean;
jellyseerr_url: string;
jellyseerr_api_key_set: boolean;
notes: string; notes: string;
} }
@@ -110,6 +116,7 @@ export interface MonitoringMachineInput {
name: string; name: string;
mode: "local" | "ssh"; mode: "local" | "ssh";
enabled: boolean; enabled: boolean;
services: string[];
host: string; host: string;
port: number; port: number;
username: string; username: string;
@@ -118,6 +125,11 @@ export interface MonitoringMachineInput {
password: string; password: string;
media_root: string; media_root: string;
path_prefix: string; path_prefix: string;
jellyfin_url: string;
jellyfin_user_id: string;
jellyfin_api_key: string;
jellyseerr_url: string;
jellyseerr_api_key: string;
notes: string; notes: string;
} }
@@ -143,6 +155,21 @@ export interface MetricSummary {
count: number; count: number;
} }
export interface ResetLocalDatabaseInput {
confirm_phrase: string;
acknowledge_settings_loss: boolean;
acknowledge_media_index_loss: boolean;
acknowledge_irreversible: boolean;
}
export interface ResetLocalDatabaseResponse {
status: string;
settings_db_removed: boolean;
media_index_removed: boolean;
settings_files: string[];
media_index_files: string[];
}
export interface MonitoringPollerStatus { export interface MonitoringPollerStatus {
worker_running: boolean; worker_running: boolean;
stop_requested: boolean; stop_requested: boolean;