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
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
+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:
```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
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.
```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 = (
+11 -4
View File
@@ -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"]
+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.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()
+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.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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+9 -3
View File
@@ -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
View File
@@ -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");
+9 -9
View File
@@ -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,
});
}
+11 -11
View File
@@ -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),
});
}
+10 -10
View File
@@ -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);
},
+5 -4
View File
@@ -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,
});
}
+53 -4
View File
@@ -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
+69 -7
View File
@@ -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()}
+27
View File
@@ -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;