fixes and improvements
This commit is contained in:
+12
-24
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-28
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "<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()
|
||||
logger.info("Creating Jellyfin client for %s", settings.jellyfin_url.rstrip("/") or "<unset>")
|
||||
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 "<unset>")
|
||||
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 "<unset>",
|
||||
settings.ssh_username or "<unset>",
|
||||
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 "<unset>")
|
||||
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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
+7
-14
@@ -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}
|
||||
|
||||
+7
-14
@@ -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
|
||||
|
||||
+13
-4
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<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="/files" label="Files" component={NavLink} to="/files" />
|
||||
<Tab
|
||||
@@ -192,7 +197,8 @@ function Shell({
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<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="/files" element={<FileBrowser />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
+79
-24
@@ -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<T>(path: string): Promise<T> {
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
|
||||
export const fetchLibraries = () =>
|
||||
get<LibraryCount[]>("/api/dashboard/libraries");
|
||||
export const fetchActivity = () =>
|
||||
get<NowPlayingSession[]>("/api/dashboard/activity");
|
||||
export const fetchUsers = () => get<UserDirectoryResponse>("/api/users");
|
||||
export const fetchCounts = (machineId?: string) =>
|
||||
get<MediaCounts>(
|
||||
"/api/dashboard/counts",
|
||||
machineId ? { machine_id: machineId } : undefined,
|
||||
);
|
||||
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.
|
||||
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<ResetLocalDatabaseResponse>(
|
||||
"/api/settings/reset-local-database",
|
||||
payload,
|
||||
);
|
||||
|
||||
// Media
|
||||
export const fetchMediaStatus = () =>
|
||||
get<MediaIndexStatus>("/api/media/status");
|
||||
export const buildMediaIndex = () =>
|
||||
post<MediaIndexActionResponse>("/api/media/build");
|
||||
export const stopMediaIndexBuild = () =>
|
||||
post<MediaIndexActionResponse>("/api/media/stop");
|
||||
export const forceStopMediaIndexBuild = () =>
|
||||
post<MediaIndexActionResponse>("/api/media/force-stop");
|
||||
export const fetchMediaStatus = (machineId?: string) =>
|
||||
get<MediaIndexStatus>(
|
||||
"/api/media/status",
|
||||
machineId ? { machine_id: machineId } : undefined,
|
||||
);
|
||||
export const buildMediaIndex = (machineId?: string) =>
|
||||
post<MediaIndexActionResponse>(
|
||||
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: {
|
||||
libraries?: string;
|
||||
types?: string;
|
||||
@@ -230,6 +266,7 @@ export const queryMedia = (params: {
|
||||
sort_order?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
machineId?: string;
|
||||
}) =>
|
||||
get<MediaQueryResponse>("/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<DirectoryListing>("/api/files/list", { path });
|
||||
export const fetchFfprobe = (path: string) =>
|
||||
get<Record<string, unknown>>("/api/files/ffprobe", { path });
|
||||
export const fetchStat = (path: string) =>
|
||||
get<{ path: string; output: string }>("/api/files/stat", { path });
|
||||
export const resolvePath = (path: string) =>
|
||||
get<ResolvedPath>("/api/files/resolve-path", { path });
|
||||
export const fetchDirectoryListing = (path: string, machineId?: string) =>
|
||||
get<DirectoryListing>("/api/files/list", {
|
||||
path,
|
||||
...(machineId ? { machine_id: machineId } : {}),
|
||||
});
|
||||
export const fetchFfprobe = (path: string, machineId?: string) =>
|
||||
get<Record<string, unknown>>("/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<ResolvedPath>("/api/files/resolve-path", {
|
||||
path,
|
||||
...(machineId ? { machine_id: machineId } : {}),
|
||||
});
|
||||
|
||||
// Jobs
|
||||
export const fetchJobTemplates = () =>
|
||||
get<JobTemplate[]>("/api/jobs/templates");
|
||||
export const runJob = (jobKey: string, path: string) =>
|
||||
post<JobResult>("/api/jobs/run", { job_key: jobKey, path });
|
||||
export const runJob = (jobKey: string, path: string, machineId?: string) =>
|
||||
post<JobResult>(
|
||||
machineId
|
||||
? `/api/jobs/run?machine_id=${encodeURIComponent(machineId)}`
|
||||
: "/api/jobs/run",
|
||||
{ job_key: jobKey, path },
|
||||
);
|
||||
|
||||
export const fetchUserMessageQueueStatus = () =>
|
||||
get<UserMessageQueueStatus>("/api/users/message/status");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<typeof useQueryClient>) {
|
||||
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);
|
||||
},
|
||||
|
||||
@@ -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<UserDirectoryResponse>({
|
||||
queryKey: ["users", machineId ?? "default"],
|
||||
queryFn: () => fetchUsers(machineId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<FileBrowserState>(
|
||||
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<FileBrowserState>) =>
|
||||
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 (
|
||||
<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}>
|
||||
<TextField
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { DataGrid } from "@mui/x-data-grid";
|
||||
import type { GridColDef } from "@mui/x-data-grid";
|
||||
import {
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
} from "../hooks/useMedia";
|
||||
import { usePersistentState } from "../hooks/usePersistentState";
|
||||
import type { MediaItem } from "../types";
|
||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||
|
||||
function formatDuration(seconds: number | null | undefined): string {
|
||||
if (seconds == null || Number.isNaN(seconds)) return "-";
|
||||
@@ -64,11 +66,26 @@ function defaultMediaTabState(): MediaTabState {
|
||||
|
||||
export function Media() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isMobile = useMediaQuery("(max-width: 900px)");
|
||||
const { data: status } = useMediaStatus();
|
||||
const buildIndex = useBuildIndex();
|
||||
const stopBuildIndex = useStopBuildIndex();
|
||||
const forceStopBuildIndex = useForceStopBuildIndex();
|
||||
const { data: machines } = useMonitoringSettings();
|
||||
const jellyfinMachines = useMemo(
|
||||
() =>
|
||||
(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>(
|
||||
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" }}
|
||||
>
|
||||
<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 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Index: {status.item_count.toLocaleString()} items
|
||||
@@ -168,6 +222,14 @@ export function Media() {
|
||||
No index built yet.
|
||||
</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
|
||||
variant="outlined"
|
||||
onClick={() => buildIndex.mutate()}
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface MonitoringMachine {
|
||||
name: string;
|
||||
mode: "local" | "ssh";
|
||||
enabled: boolean;
|
||||
services: string[];
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -102,6 +103,11 @@ export interface MonitoringMachine {
|
||||
password_set: boolean;
|
||||
media_root: 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;
|
||||
}
|
||||
|
||||
@@ -110,6 +116,7 @@ export interface MonitoringMachineInput {
|
||||
name: string;
|
||||
mode: "local" | "ssh";
|
||||
enabled: boolean;
|
||||
services: string[];
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -118,6 +125,11 @@ export interface MonitoringMachineInput {
|
||||
password: string;
|
||||
media_root: string;
|
||||
path_prefix: string;
|
||||
jellyfin_url: string;
|
||||
jellyfin_user_id: string;
|
||||
jellyfin_api_key: string;
|
||||
jellyseerr_url: string;
|
||||
jellyseerr_api_key: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
@@ -143,6 +155,21 @@ export interface MetricSummary {
|
||||
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 {
|
||||
worker_running: boolean;
|
||||
stop_requested: boolean;
|
||||
|
||||
Reference in New Issue
Block a user