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