Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c69911252 | |||
| 7b3e2ebace | |||
| cfb9977532 | |||
| 802a9202e9 | |||
| 7ab9b1ac59 | |||
| cbb703341e | |||
| a13f560df2 | |||
| 5eb49be697 | |||
| 8ff735d644 |
@@ -54,3 +54,13 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
|
|||||||
- Machine-level Jellyfin/Jellyseerr app config still powers the Media/Users/Files
|
- Machine-level Jellyfin/Jellyseerr app config still powers the Media/Users/Files
|
||||||
pages. Migrating those onto the service registry is a separate follow-up change
|
pages. Migrating those onto the service registry is a separate follow-up change
|
||||||
(see `openspec/changes/service-registry/design.md` §12.5).
|
(see `openspec/changes/service-registry/design.md` §12.5).
|
||||||
|
|
||||||
|
## Follow-up #1 — remove dead machine Jellyfin/Jellyseerr fields
|
||||||
|
|
||||||
|
With Jellyfin/Jellyseerr now resolved from the service registry, the machine-level
|
||||||
|
Jellyfin/Jellyseerr fields are dead config. Removed from `dependencies.py` (dead
|
||||||
|
`_jellyseerr_client_for`; `_resolve_machine` simplified to SSH-only),
|
||||||
|
`services/settings_store.py`, `routers/settings.py` (`MachineInput`), frontend
|
||||||
|
types, the `Settings.tsx` form, and frontend test fixtures. Existing DB rows may
|
||||||
|
still carry these keys in `config_json`; they are inert and get dropped on the
|
||||||
|
next machine save. No data migration required.
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Dependency injection for FastAPI.
|
"""Dependency injection for FastAPI.
|
||||||
|
|
||||||
Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request
|
Provides access to service-specific Jellyfin/Jellyseerr clients and
|
||||||
context. The selected machine can be chosen with a ``machine_id`` query
|
machine-specific SSH clients via FastAPI's request context.
|
||||||
parameter; otherwise the backend falls back to the first enabled machine that
|
|
||||||
matches the requested service.
|
- Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query
|
||||||
|
parameter (resolved against the service registry); the backend falls back to
|
||||||
|
the first enabled ``jellyfin``/``jellyseerr`` service instance.
|
||||||
|
- SSH/Files transport is selected with ``machine_id`` as before.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -34,6 +37,41 @@ def _request_machine_id(request: Request | None) -> str | None:
|
|||||||
return machine_id or None
|
return machine_id or None
|
||||||
|
|
||||||
|
|
||||||
|
def _request_jellyfin_service_id(request: Request | None) -> str | None:
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
service_id = request.query_params.get("jellyfin_service_id")
|
||||||
|
return service_id or None
|
||||||
|
|
||||||
|
|
||||||
|
def _service_record(store: SettingsStore, service_type: str, service_id: str | None) -> dict[str, Any] | None:
|
||||||
|
"""Return a service row for a type, preferring the requested id.
|
||||||
|
|
||||||
|
The row carries an in-memory decrypted ``secrets`` dict. Returns None if no
|
||||||
|
enabled instance of the type exists.
|
||||||
|
"""
|
||||||
|
from media_library_viewer_api.services.secrets import decrypt_secrets
|
||||||
|
|
||||||
|
row = None
|
||||||
|
if service_id:
|
||||||
|
candidate = store.get_service(service_id)
|
||||||
|
if candidate and candidate.get("service_type") == service_type and candidate.get("enabled", True):
|
||||||
|
row = candidate
|
||||||
|
if row is None:
|
||||||
|
instances = [s for s in store.list_services(service_type) if s.get("enabled", True)]
|
||||||
|
row = instances[0] if instances else None
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
decrypted = {}
|
||||||
|
blob = row.get("secrets") or {}
|
||||||
|
if blob:
|
||||||
|
try:
|
||||||
|
decrypted = decrypt_secrets(blob)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to decrypt service secrets service_id=%s", row.get("id"))
|
||||||
|
return {**row, "secrets": decrypted}
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
@lru_cache(maxsize=32)
|
||||||
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
||||||
machine_id, url, api_key = cache_key
|
machine_id, url, api_key = cache_key
|
||||||
@@ -43,21 +81,6 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
|||||||
return JellyfinClient(url, api_key)
|
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)
|
@lru_cache(maxsize=32)
|
||||||
def _ssh_client_for(
|
def _ssh_client_for(
|
||||||
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
|
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
|
||||||
@@ -116,6 +139,10 @@ def _ssh_client_for(
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
|
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
|
||||||
|
"""Resolve an SSH/Files machine for the given transport service.
|
||||||
|
|
||||||
|
Jellyfin/Jellyseerr are resolved against the service registry, not here.
|
||||||
|
"""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
machine_id = _request_machine_id(request)
|
||||||
if machine_id:
|
if machine_id:
|
||||||
@@ -123,11 +150,7 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
|
|||||||
if machine and (service in machine.get("services", []) or service == "ssh"):
|
if machine and (service in machine.get("services", []) or service == "ssh"):
|
||||||
return machine
|
return machine
|
||||||
return machine
|
return machine
|
||||||
if service == "jellyfin":
|
if service == "ssh":
|
||||||
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")
|
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
|
||||||
else:
|
else:
|
||||||
machines = store.list_machines_for_service(service)
|
machines = store.list_machines_for_service(service)
|
||||||
@@ -135,37 +158,34 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
|
|||||||
|
|
||||||
|
|
||||||
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
||||||
"""Return a Jellyfin client for the selected machine."""
|
"""Return a Jellyfin client for the selected Jellyfin service instance."""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service_id = _request_jellyfin_service_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
service = _service_record(store, "jellyfin", service_id)
|
||||||
if machine is None:
|
if service is None:
|
||||||
resolved = _resolve_machine("jellyfin", request)
|
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
|
||||||
if resolved:
|
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||||
machine = store.get_machine_config(resolved["id"])
|
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||||
if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"):
|
if not base_url or not api_key:
|
||||||
cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "")
|
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
|
||||||
return _jellyfin_client_for(cache_key)
|
cache_key = (service["id"], base_url, api_key)
|
||||||
|
return _jellyfin_client_for(cache_key)
|
||||||
raise RuntimeError(
|
|
||||||
"No Jellyfin machine is configured. Add a machine with jellyfin_url and jellyfin_api_key in Settings."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
||||||
"""Return a cached Jellyseerr client when configured, otherwise None."""
|
"""Return a cached Jellyseerr client when configured, otherwise None."""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service_id = _request_jellyfin_service_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
service = _service_record(store, "jellyseerr", service_id)
|
||||||
if machine is None:
|
if service is None:
|
||||||
resolved = _resolve_machine("jellyseerr", request)
|
logger.info("Jellyseerr client not configured (no jellyseerr service)")
|
||||||
if resolved:
|
return None
|
||||||
machine = store.get_machine_config(resolved["id"])
|
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||||
if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"):
|
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||||
return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "")
|
if not base_url or not api_key:
|
||||||
|
logger.info("Jellyseerr service is missing base_url or api_key")
|
||||||
logger.info("Jellyseerr client not configured (no machine with jellyseerr_url and jellyseerr_api_key)")
|
return None
|
||||||
return None
|
return JellyseerrClient(base_url, api_key)
|
||||||
|
|
||||||
|
|
||||||
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
||||||
@@ -253,16 +273,12 @@ def get_settings_store() -> SettingsStore:
|
|||||||
def get_user_id(request: Request = None) -> str:
|
def get_user_id(request: Request = None) -> str:
|
||||||
"""Return the configured Jellyfin user ID or discover the first available one."""
|
"""Return the configured Jellyfin user ID or discover the first available one."""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service_id = _request_jellyfin_service_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
service = _service_record(store, "jellyfin", service_id)
|
||||||
if machine is None:
|
if service and service.get("config", {}).get("user_id"):
|
||||||
resolved = _resolve_machine("jellyfin", request)
|
return str(service["config"]["user_id"])
|
||||||
if resolved:
|
|
||||||
machine = store.get_machine_config(resolved["id"])
|
|
||||||
if machine and machine.get("jellyfin_user_id"):
|
|
||||||
return str(machine["jellyfin_user_id"])
|
|
||||||
client = get_jellyfin_client(request)
|
client = get_jellyfin_client(request)
|
||||||
users = client.users()
|
users = client.users()
|
||||||
if not users:
|
if not users:
|
||||||
raise RuntimeError("No Jellyfin users found and no machine/user id configured")
|
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
|
||||||
return users[0]["Id"]
|
return users[0]["Id"]
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Jellyseerr service definition.
|
||||||
|
|
||||||
|
Jellyseerr is a companion to Jellyfin (request management). It is modeled as its
|
||||||
|
own service type so multiple Jellyseerr instances are supported independently of
|
||||||
|
Jellyfin. It provides no dashboard widgets today.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyseerrConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Jellyseerr connection config."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="jellyseerr",
|
||||||
|
name="Jellyseerr",
|
||||||
|
description="Request management companion to Jellyfin.",
|
||||||
|
config_model=JellyseerrConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="api_key", label="API key", required=True),
|
||||||
|
],
|
||||||
|
widget_kinds=[],
|
||||||
|
)
|
||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||||
|
from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR
|
||||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||||
@@ -17,6 +18,7 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
|||||||
GRAFANA.service_type: GRAFANA,
|
GRAFANA.service_type: GRAFANA,
|
||||||
PROMETHEUS.service_type: PROMETHEUS,
|
PROMETHEUS.service_type: PROMETHEUS,
|
||||||
JELLYFIN.service_type: JELLYFIN,
|
JELLYFIN.service_type: JELLYFIN,
|
||||||
|
JELLYSEERR.service_type: JELLYSEERR,
|
||||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||||
SSH_TASKS.service_type: SSH_TASKS,
|
SSH_TASKS.service_type: SSH_TASKS,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,6 @@ class MonitoringMachineInput(BaseModel):
|
|||||||
password: str = ""
|
password: str = ""
|
||||||
media_root: str = ""
|
media_root: str = ""
|
||||||
path_prefix: str = ""
|
path_prefix: str = ""
|
||||||
jellyfin_url: str = ""
|
|
||||||
jellyfin_user_id: str = ""
|
|
||||||
jellyfin_api_key: str = ""
|
|
||||||
jellyseerr_url: str = ""
|
|
||||||
jellyseerr_api_key: str = ""
|
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,6 @@ def _default_local_machine() -> dict[str, Any]:
|
|||||||
"password": "",
|
"password": "",
|
||||||
"media_root": settings.media_root,
|
"media_root": settings.media_root,
|
||||||
"path_prefix": settings.path_prefix,
|
"path_prefix": settings.path_prefix,
|
||||||
"jellyfin_url": "",
|
|
||||||
"jellyfin_user_id": "",
|
|
||||||
"jellyfin_api_key": "",
|
|
||||||
"jellyseerr_url": "",
|
|
||||||
"jellyseerr_api_key": "",
|
|
||||||
"node_exporter_enabled": False,
|
"node_exporter_enabled": False,
|
||||||
"node_exporter_port": 9100,
|
"node_exporter_port": 9100,
|
||||||
"node_exporter_scrape_host": "",
|
"node_exporter_scrape_host": "",
|
||||||
@@ -306,11 +301,6 @@ class SettingsStore:
|
|||||||
"password_set": bool(data.get("password")),
|
"password_set": bool(data.get("password")),
|
||||||
"media_root": data.get("media_root", ""),
|
"media_root": data.get("media_root", ""),
|
||||||
"path_prefix": data.get("path_prefix", ""),
|
"path_prefix": data.get("path_prefix", ""),
|
||||||
"jellyfin_url": data.get("jellyfin_url", ""),
|
|
||||||
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
|
|
||||||
"jellyfin_api_key_set": bool(data.get("jellyfin_api_key")),
|
|
||||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
|
||||||
"jellyseerr_api_key_set": bool(data.get("jellyseerr_api_key")),
|
|
||||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||||
@@ -360,17 +350,6 @@ class SettingsStore:
|
|||||||
password = str(password or "")
|
password = str(password or "")
|
||||||
media_root = _current_str("media_root")
|
media_root = _current_str("media_root")
|
||||||
path_prefix = _current_str("path_prefix")
|
path_prefix = _current_str("path_prefix")
|
||||||
jellyfin_url = _current_str("jellyfin_url")
|
|
||||||
jellyfin_user_id = _current_str("jellyfin_user_id")
|
|
||||||
jellyfin_api_key = payload.get("jellyfin_api_key")
|
|
||||||
if jellyfin_api_key in (None, ""):
|
|
||||||
jellyfin_api_key = (current or {}).get("jellyfin_api_key", "")
|
|
||||||
jellyfin_api_key = str(jellyfin_api_key or "")
|
|
||||||
jellyseerr_url = _current_str("jellyseerr_url")
|
|
||||||
jellyseerr_api_key = payload.get("jellyseerr_api_key")
|
|
||||||
if jellyseerr_api_key in (None, ""):
|
|
||||||
jellyseerr_api_key = (current or {}).get("jellyseerr_api_key", "")
|
|
||||||
jellyseerr_api_key = str(jellyseerr_api_key or "")
|
|
||||||
node_exporter_enabled = bool(
|
node_exporter_enabled = bool(
|
||||||
payload.get("node_exporter_enabled")
|
payload.get("node_exporter_enabled")
|
||||||
if payload.get("node_exporter_enabled") is not None
|
if payload.get("node_exporter_enabled") is not None
|
||||||
@@ -402,11 +381,6 @@ class SettingsStore:
|
|||||||
"password": password,
|
"password": password,
|
||||||
"media_root": media_root,
|
"media_root": media_root,
|
||||||
"path_prefix": path_prefix,
|
"path_prefix": path_prefix,
|
||||||
"jellyfin_url": jellyfin_url,
|
|
||||||
"jellyfin_user_id": jellyfin_user_id,
|
|
||||||
"jellyfin_api_key": jellyfin_api_key,
|
|
||||||
"jellyseerr_url": jellyseerr_url,
|
|
||||||
"jellyseerr_api_key": jellyseerr_api_key,
|
|
||||||
"node_exporter_enabled": node_exporter_enabled,
|
"node_exporter_enabled": node_exporter_enabled,
|
||||||
"node_exporter_port": node_exporter_port,
|
"node_exporter_port": node_exporter_port,
|
||||||
"node_exporter_scrape_host": node_exporter_scrape_host,
|
"node_exporter_scrape_host": node_exporter_scrape_host,
|
||||||
@@ -430,11 +404,6 @@ class SettingsStore:
|
|||||||
"password": "",
|
"password": "",
|
||||||
"media_root": machine["media_root"],
|
"media_root": machine["media_root"],
|
||||||
"path_prefix": machine["path_prefix"],
|
"path_prefix": machine["path_prefix"],
|
||||||
"jellyfin_url": machine["jellyfin_url"],
|
|
||||||
"jellyfin_user_id": machine["jellyfin_user_id"],
|
|
||||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
|
||||||
"jellyseerr_url": machine["jellyseerr_url"],
|
|
||||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
|
||||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||||
"node_exporter_port": machine["node_exporter_port"],
|
"node_exporter_port": machine["node_exporter_port"],
|
||||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||||
@@ -520,11 +489,6 @@ class SettingsStore:
|
|||||||
"password": data.get("password", ""),
|
"password": data.get("password", ""),
|
||||||
"media_root": data.get("media_root", ""),
|
"media_root": data.get("media_root", ""),
|
||||||
"path_prefix": data.get("path_prefix", ""),
|
"path_prefix": data.get("path_prefix", ""),
|
||||||
"jellyfin_url": data.get("jellyfin_url", ""),
|
|
||||||
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
|
|
||||||
"jellyfin_api_key": data.get("jellyfin_api_key", ""),
|
|
||||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
|
||||||
"jellyseerr_api_key": data.get("jellyseerr_api_key", ""),
|
|
||||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||||
@@ -564,11 +528,6 @@ class SettingsStore:
|
|||||||
"password": machine["password"],
|
"password": machine["password"],
|
||||||
"media_root": machine["media_root"],
|
"media_root": machine["media_root"],
|
||||||
"path_prefix": machine["path_prefix"],
|
"path_prefix": machine["path_prefix"],
|
||||||
"jellyfin_url": machine["jellyfin_url"],
|
|
||||||
"jellyfin_user_id": machine["jellyfin_user_id"],
|
|
||||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
|
||||||
"jellyseerr_url": machine["jellyseerr_url"],
|
|
||||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
|
||||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||||
"node_exporter_port": machine["node_exporter_port"],
|
"node_exporter_port": machine["node_exporter_port"],
|
||||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ def test_registry_contains_five_service_types():
|
|||||||
"grafana",
|
"grafana",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
|
"jellyseerr",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
"ssh_tasks",
|
"ssh_tasks",
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,14 @@ def test_list_service_types(client):
|
|||||||
response = client.get("/api/services/types")
|
response = client.get("/api/services/types")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
types = {item["service_type"] for item in response.json()}
|
types = {item["service_type"] for item in response.json()}
|
||||||
assert types == {"grafana", "prometheus", "jellyfin", "nextcloud", "ssh_tasks"}
|
assert types == {
|
||||||
|
"grafana",
|
||||||
|
"jellyfin",
|
||||||
|
"jellyseerr",
|
||||||
|
"nextcloud",
|
||||||
|
"prometheus",
|
||||||
|
"ssh_tasks",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_service_type_includes_secret_and_widget_metadata(client):
|
def test_service_type_includes_secret_and_widget_metadata(client):
|
||||||
|
|||||||
+24
-22
@@ -134,26 +134,26 @@ async function del<T>(path: string): Promise<T> {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
|
||||||
export const fetchCounts = (machineId?: string) =>
|
export const fetchCounts = (jellyfinServiceId?: string) =>
|
||||||
get<MediaCounts>(
|
get<MediaCounts>(
|
||||||
"/api/dashboard/counts",
|
"/api/dashboard/counts",
|
||||||
machineId ? { machine_id: machineId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const fetchLibraries = (machineId?: string) =>
|
export const fetchLibraries = (jellyfinServiceId?: string) =>
|
||||||
get<LibraryCount[]>(
|
get<LibraryCount[]>(
|
||||||
"/api/dashboard/libraries",
|
"/api/dashboard/libraries",
|
||||||
machineId ? { machine_id: machineId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const fetchActivity = (machineId?: string) =>
|
export const fetchActivity = (jellyfinServiceId?: string) =>
|
||||||
get<NowPlayingSession[]>(
|
get<NowPlayingSession[]>(
|
||||||
"/api/dashboard/activity",
|
"/api/dashboard/activity",
|
||||||
machineId ? { machine_id: machineId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const fetchUsers = (machineId?: string) =>
|
export const fetchUsers = (jellyfinServiceId?: string) =>
|
||||||
get<UserDirectoryResponse>(
|
get<UserDirectoryResponse>(
|
||||||
"/api/users",
|
"/api/users",
|
||||||
machineId ? { machine_id: machineId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Backward-compatible alias used by older hooks/components.
|
// Backward-compatible alias used by older hooks/components.
|
||||||
@@ -293,27 +293,27 @@ export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
export const fetchMediaStatus = (machineId?: string) =>
|
export const fetchMediaStatus = (jellyfinServiceId?: string) =>
|
||||||
get<MediaIndexStatus>(
|
get<MediaIndexStatus>(
|
||||||
"/api/media/status",
|
"/api/media/status",
|
||||||
machineId ? { machine_id: machineId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const buildMediaIndex = (machineId?: string) =>
|
export const buildMediaIndex = (jellyfinServiceId?: string) =>
|
||||||
post<MediaIndexActionResponse>(
|
post<MediaIndexActionResponse>(
|
||||||
machineId
|
jellyfinServiceId
|
||||||
? `/api/media/build?machine_id=${encodeURIComponent(machineId)}`
|
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||||
: "/api/media/build",
|
: "/api/media/build",
|
||||||
);
|
);
|
||||||
export const stopMediaIndexBuild = (machineId?: string) =>
|
export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
||||||
post<MediaIndexActionResponse>(
|
post<MediaIndexActionResponse>(
|
||||||
machineId
|
jellyfinServiceId
|
||||||
? `/api/media/stop?machine_id=${encodeURIComponent(machineId)}`
|
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||||
: "/api/media/stop",
|
: "/api/media/stop",
|
||||||
);
|
);
|
||||||
export const forceStopMediaIndexBuild = (machineId?: string) =>
|
export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
||||||
post<MediaIndexActionResponse>(
|
post<MediaIndexActionResponse>(
|
||||||
machineId
|
jellyfinServiceId
|
||||||
? `/api/media/force-stop?machine_id=${encodeURIComponent(machineId)}`
|
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||||
: "/api/media/force-stop",
|
: "/api/media/force-stop",
|
||||||
);
|
);
|
||||||
export const queryMedia = (params: {
|
export const queryMedia = (params: {
|
||||||
@@ -325,7 +325,7 @@ export const queryMedia = (params: {
|
|||||||
sort_order?: string;
|
sort_order?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
machineId?: string;
|
jellyfinServiceId?: string;
|
||||||
}) =>
|
}) =>
|
||||||
get<MediaQueryResponse>("/api/media/query", {
|
get<MediaQueryResponse>("/api/media/query", {
|
||||||
libraries: params.libraries || "",
|
libraries: params.libraries || "",
|
||||||
@@ -336,7 +336,9 @@ 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 } : {}),
|
...(params.jellyfinServiceId
|
||||||
|
? { jellyfin_service_id: params.jellyfinServiceId }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
|
|||||||
@@ -9,26 +9,26 @@ import {
|
|||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import type { DashboardShortcutInput } from "../types";
|
import type { DashboardShortcutInput } from "../types";
|
||||||
|
|
||||||
export function useCounts(machineId?: string) {
|
export function useCounts(jellyfinServiceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "counts", machineId ?? "default"],
|
queryKey: ["dashboard", "counts", jellyfinServiceId ?? "default"],
|
||||||
queryFn: () => fetchCounts(machineId),
|
queryFn: () => fetchCounts(jellyfinServiceId),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLibraries(machineId?: string) {
|
export function useLibraries(jellyfinServiceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "libraries", machineId ?? "default"],
|
queryKey: ["dashboard", "libraries", jellyfinServiceId ?? "default"],
|
||||||
queryFn: () => fetchLibraries(machineId),
|
queryFn: () => fetchLibraries(jellyfinServiceId),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useActivity(machineId?: string) {
|
export function useActivity(jellyfinServiceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "activity", machineId ?? "default"],
|
queryKey: ["dashboard", "activity", jellyfinServiceId ?? "default"],
|
||||||
queryFn: () => fetchActivity(machineId),
|
queryFn: () => fetchActivity(jellyfinServiceId),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
forceStopMediaIndexBuild,
|
forceStopMediaIndexBuild,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
|
||||||
export function useMediaStatus(machineId?: string) {
|
export function useMediaStatus(jellyfinServiceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["media", "status", machineId ?? "default"],
|
queryKey: ["media", "status", jellyfinServiceId ?? "default"],
|
||||||
queryFn: () => fetchMediaStatus(machineId),
|
queryFn: () => fetchMediaStatus(jellyfinServiceId),
|
||||||
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,7 +27,7 @@ export function useMediaQuery(params: {
|
|||||||
sort_order?: string;
|
sort_order?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
machineId?: string;
|
jellyfinServiceId?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { enabled = true, ...queryParams } = params;
|
const { enabled = true, ...queryParams } = params;
|
||||||
@@ -44,30 +44,30 @@ function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["media"] });
|
queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBuildIndex(machineId?: string) {
|
export function useBuildIndex(jellyfinServiceId?: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => buildMediaIndex(machineId),
|
mutationFn: () => buildMediaIndex(jellyfinServiceId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateMedia(queryClient);
|
invalidateMedia(queryClient);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStopBuildIndex(machineId?: string) {
|
export function useStopBuildIndex(jellyfinServiceId?: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => stopMediaIndexBuild(machineId),
|
mutationFn: () => stopMediaIndexBuild(jellyfinServiceId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateMedia(queryClient);
|
invalidateMedia(queryClient);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useForceStopBuildIndex(machineId?: string) {
|
export function useForceStopBuildIndex(jellyfinServiceId?: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => forceStopMediaIndexBuild(machineId),
|
mutationFn: () => forceStopMediaIndexBuild(jellyfinServiceId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateMedia(queryClient);
|
invalidateMedia(queryClient);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { fetchUsers } from "../api/client";
|
import { fetchUsers } from "../api/client";
|
||||||
import type { UserDirectoryResponse } from "../types";
|
import type { UserDirectoryResponse } from "../types";
|
||||||
|
|
||||||
export function useUsers(machineId?: string) {
|
export function useUsers(jellyfinServiceId?: string) {
|
||||||
return useQuery<UserDirectoryResponse>({
|
return useQuery<UserDirectoryResponse>({
|
||||||
queryKey: ["users", machineId ?? "default"],
|
queryKey: ["users", jellyfinServiceId ?? "default"],
|
||||||
queryFn: () => fetchUsers(machineId),
|
queryFn: () => fetchUsers(jellyfinServiceId),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,23 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { TabsTrigger } from "@/components/ui/tabs";
|
import { TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Media } from "./Media";
|
import { Media } from "./Media";
|
||||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { TabbedCard } from "../components/TabbedCard";
|
import { TabbedCard } from "../components/TabbedCard";
|
||||||
|
|
||||||
function JellyfinLibraryStats() {
|
function JellyfinLibraryStats() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { data: machines = [] } = useMonitoringSettings();
|
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||||
const jellyfinMachines = useMemo(
|
const selectedServiceId =
|
||||||
() =>
|
searchParams.get("jellyfin_service_id") ||
|
||||||
machines.filter(
|
jellyfinServices.find((s) => s.enabled)?.id ||
|
||||||
(machine) => machine.enabled && machine.services.includes("jellyfin"),
|
"";
|
||||||
),
|
const { data: counts } = useCounts(selectedServiceId || undefined);
|
||||||
[machines],
|
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
||||||
);
|
|
||||||
const selectedMachineId =
|
|
||||||
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
|
|
||||||
const { data: counts } = useCounts(selectedMachineId || undefined);
|
|
||||||
const { data: libraries } = useLibraries(selectedMachineId || undefined);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
@@ -30,7 +25,7 @@ function JellyfinLibraryStats() {
|
|||||||
description="Compact Jellyfin summary for the selected machine."
|
description="Compact Jellyfin summary for the selected machine."
|
||||||
action={
|
action={
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{selectedMachineId ? "Selected machine" : "Default machine"}
|
{selectedServiceId ? "Selected service" : "Default service"}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ 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 { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||||
|
|
||||||
function formatDuration(seconds: number | null | undefined): string {
|
function formatDuration(seconds: number | null | undefined): string {
|
||||||
@@ -178,23 +178,18 @@ export function Media() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const isSmall = usePrefersSmallScreen();
|
const isSmall = usePrefersSmallScreen();
|
||||||
const { data: machines } = useMonitoringSettings();
|
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||||
const jellyfinMachines = useMemo(
|
const selectedServiceId =
|
||||||
() =>
|
searchParams.get("jellyfin_service_id") ||
|
||||||
(machines ?? []).filter(
|
jellyfinServices.find((s) => s.enabled)?.id ||
|
||||||
(machine) => machine.enabled && machine.services.includes("jellyfin"),
|
"";
|
||||||
),
|
const { data: counts } = useCounts(selectedServiceId || undefined);
|
||||||
[machines],
|
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
||||||
);
|
const { data: status } = useMediaStatus(selectedServiceId || undefined);
|
||||||
const selectedMachineId =
|
const buildIndex = useBuildIndex(selectedServiceId || undefined);
|
||||||
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
|
const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
|
||||||
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(
|
const forceStopBuildIndex = useForceStopBuildIndex(
|
||||||
selectedMachineId || undefined,
|
selectedServiceId || undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
||||||
@@ -215,17 +210,17 @@ export function Media() {
|
|||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchParams.get("machine_id") && selectedMachineId) {
|
if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(current) => {
|
(current) => {
|
||||||
const next = new URLSearchParams(current);
|
const next = new URLSearchParams(current);
|
||||||
next.set("machine_id", selectedMachineId);
|
next.set("jellyfin_service_id", selectedServiceId);
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [searchParams, selectedMachineId, setSearchParams]);
|
}, [searchParams, selectedServiceId, setSearchParams]);
|
||||||
|
|
||||||
const { data: queryResult, isLoading } = useMediaDataQuery({
|
const { data: queryResult, isLoading } = useMediaDataQuery({
|
||||||
types,
|
types,
|
||||||
@@ -235,7 +230,7 @@ export function Media() {
|
|||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset,
|
offset,
|
||||||
machineId: selectedMachineId || undefined,
|
jellyfinServiceId: selectedServiceId || undefined,
|
||||||
enabled: status?.exists ?? false,
|
enabled: status?.exists ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -323,27 +318,27 @@ export function Media() {
|
|||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold">Jellyfin</h2>
|
<h2 className="text-lg font-semibold">Jellyfin</h2>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="media-machine">Machine</Label>
|
<Label htmlFor="media-service">Service</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedMachineId}
|
value={selectedServiceId}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(current) => {
|
(current) => {
|
||||||
const next = new URLSearchParams(current);
|
const next = new URLSearchParams(current);
|
||||||
next.set("machine_id", value);
|
next.set("jellyfin_service_id", value);
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="media-machine" className="w-full md:w-[220px]">
|
<SelectTrigger id="media-service" className="w-full md:w-[220px]">
|
||||||
<SelectValue placeholder="Select a machine" />
|
<SelectValue placeholder="Select a service" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{jellyfinMachines.map((machine) => (
|
{jellyfinServices.map((service) => (
|
||||||
<SelectItem key={machine.id} value={machine.id}>
|
<SelectItem key={service.id} value={service.id}>
|
||||||
{machine.name}
|
{service.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
const SERVICE_OPTIONS = [
|
const SERVICE_OPTIONS = [
|
||||||
{ value: "monitoring", label: "Monitoring" },
|
{ value: "monitoring", label: "Monitoring" },
|
||||||
{ value: "files", label: "Files" },
|
{ value: "files", label: "Files" },
|
||||||
{ value: "jellyfin", label: "Jellyfin" },
|
|
||||||
{ value: "jellyseerr", label: "Jellyseerr" },
|
|
||||||
{ value: "nextcloud", label: "Nextcloud" },
|
{ value: "nextcloud", label: "Nextcloud" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -114,7 +112,7 @@ function emptyMachine(
|
|||||||
name: mode === "local" ? "This machine" : "",
|
name: mode === "local" ? "This machine" : "",
|
||||||
mode,
|
mode,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
services: mode === "local" ? ["monitoring", "files", "jellyfin"] : [],
|
services: mode === "local" ? ["monitoring", "files"] : [],
|
||||||
host: "",
|
host: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
username: "",
|
username: "",
|
||||||
@@ -126,11 +124,6 @@ function emptyMachine(
|
|||||||
password: "",
|
password: "",
|
||||||
media_root: "",
|
media_root: "",
|
||||||
path_prefix: "",
|
path_prefix: "",
|
||||||
jellyfin_url: "",
|
|
||||||
jellyfin_user_id: "",
|
|
||||||
jellyfin_api_key: "",
|
|
||||||
jellyseerr_url: "",
|
|
||||||
jellyseerr_api_key: "",
|
|
||||||
notes: "",
|
notes: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -169,8 +162,6 @@ function MachineEditor({
|
|||||||
const isLocal = draft.mode === "local";
|
const isLocal = draft.mode === "local";
|
||||||
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
|
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
|
||||||
const enabledServices = draft.services.length;
|
const enabledServices = draft.services.length;
|
||||||
const hasJellyfin = draft.services.includes("jellyfin");
|
|
||||||
const hasJellyseerr = draft.services.includes("jellyseerr");
|
|
||||||
const placeholderIfSet = (isSet: boolean | undefined) =>
|
const placeholderIfSet = (isSet: boolean | undefined) =>
|
||||||
isSet ? "Set, not shown" : undefined;
|
isSet ? "Set, not shown" : undefined;
|
||||||
return (
|
return (
|
||||||
@@ -398,99 +389,6 @@ function MachineEditor({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
{hasJellyfin && (
|
|
||||||
<>
|
|
||||||
<div className="col-span-12">
|
|
||||||
<SectionLabel
|
|
||||||
title="Jellyfin"
|
|
||||||
description="Library host and user selection for media browsing."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Jellyfin URL">
|
|
||||||
<Input
|
|
||||||
value={draft.jellyfin_url}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
jellyfin_url: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Jellyfin user ID">
|
|
||||||
<Input
|
|
||||||
value={draft.jellyfin_user_id}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
jellyfin_user_id: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Jellyfin API key">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder={placeholderIfSet(
|
|
||||||
editingMachine?.jellyfin_api_key_set,
|
|
||||||
)}
|
|
||||||
value={draft.jellyfin_api_key}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
jellyfin_api_key: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{hasJellyseerr && (
|
|
||||||
<>
|
|
||||||
<div className="col-span-12">
|
|
||||||
<SectionLabel
|
|
||||||
title="Jellyseerr"
|
|
||||||
description="Optional request-manager enrichment for users and requests."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Jellyseerr URL">
|
|
||||||
<Input
|
|
||||||
value={draft.jellyseerr_url}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
jellyseerr_url: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Jellyseerr API key">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder={placeholderIfSet(
|
|
||||||
editingMachine?.jellyseerr_api_key_set,
|
|
||||||
)}
|
|
||||||
value={draft.jellyseerr_api_key}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
jellyseerr_api_key: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isLocal && (
|
{isLocal && (
|
||||||
<div className="col-span-12 md:col-span-6">
|
<div className="col-span-12 md:col-span-6">
|
||||||
<FormField label="Local hint">
|
<FormField label="Local hint">
|
||||||
@@ -538,7 +436,7 @@ function MachineEditor({
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{!isLocal && !hasJellyfin && (
|
{!isLocal && enabledServices === 0 && (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
SSH machines usually need monitoring or files enabled.
|
SSH machines usually need monitoring or files enabled.
|
||||||
@@ -584,13 +482,6 @@ function MachineEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasJellyseerr && !draft.jellyseerr_url && (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
Jellyseerr is enabled, but no URL is configured yet.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -1146,11 +1037,6 @@ export function Settings() {
|
|||||||
ssh_private_key_passphrase: "",
|
ssh_private_key_passphrase: "",
|
||||||
password: "",
|
password: "",
|
||||||
media_root: machine.media_root,
|
media_root: machine.media_root,
|
||||||
jellyfin_url: machine.jellyfin_url,
|
|
||||||
jellyfin_user_id: machine.jellyfin_user_id,
|
|
||||||
jellyfin_api_key: "",
|
|
||||||
jellyseerr_url: machine.jellyseerr_url,
|
|
||||||
jellyseerr_api_key: "",
|
|
||||||
notes: machine.notes,
|
notes: machine.notes,
|
||||||
},
|
},
|
||||||
machine,
|
machine,
|
||||||
@@ -1237,12 +1123,6 @@ export function Settings() {
|
|||||||
ssh_private_key_passphrase: "",
|
ssh_private_key_passphrase: "",
|
||||||
password: "",
|
password: "",
|
||||||
media_root: selectedMachine.media_root,
|
media_root: selectedMachine.media_root,
|
||||||
jellyfin_url: selectedMachine.jellyfin_url,
|
|
||||||
jellyfin_user_id:
|
|
||||||
selectedMachine.jellyfin_user_id,
|
|
||||||
jellyfin_api_key: "",
|
|
||||||
jellyseerr_url: selectedMachine.jellyseerr_url,
|
|
||||||
jellyseerr_api_key: "",
|
|
||||||
notes: selectedMachine.notes,
|
notes: selectedMachine.notes,
|
||||||
},
|
},
|
||||||
selectedMachine,
|
selectedMachine,
|
||||||
|
|||||||
@@ -48,11 +48,6 @@ function machine(
|
|||||||
password_set: false,
|
password_set: false,
|
||||||
media_root: "",
|
media_root: "",
|
||||||
path_prefix: "",
|
path_prefix: "",
|
||||||
jellyfin_url: "",
|
|
||||||
jellyfin_user_id: "",
|
|
||||||
jellyfin_api_key_set: false,
|
|
||||||
jellyseerr_url: "",
|
|
||||||
jellyseerr_api_key_set: false,
|
|
||||||
notes: "",
|
notes: "",
|
||||||
...overrides,
|
...overrides,
|
||||||
} as MonitoringMachine;
|
} as MonitoringMachine;
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ vi.mock("../../hooks/useSettings", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({
|
||||||
|
data: [
|
||||||
|
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useDashboard", () => ({
|
vi.mock("../../hooks/useDashboard", () => ({
|
||||||
useCounts: () => ({
|
useCounts: () => ({
|
||||||
data: { movies: 10, series: 5, episodes: 100 },
|
data: { movies: 10, series: 5, episodes: 100 },
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ function machineFixture(
|
|||||||
password_set: false,
|
password_set: false,
|
||||||
media_root: "",
|
media_root: "",
|
||||||
path_prefix: "",
|
path_prefix: "",
|
||||||
jellyfin_url: "",
|
|
||||||
jellyfin_user_id: "",
|
|
||||||
jellyfin_api_key_set: false,
|
|
||||||
jellyseerr_url: "",
|
|
||||||
jellyseerr_api_key_set: false,
|
|
||||||
notes: "",
|
notes: "",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,11 +34,6 @@ function machineFixture(
|
|||||||
password_set: false,
|
password_set: false,
|
||||||
media_root: "",
|
media_root: "",
|
||||||
path_prefix: "",
|
path_prefix: "",
|
||||||
jellyfin_url: "",
|
|
||||||
jellyfin_user_id: "",
|
|
||||||
jellyfin_api_key_set: false,
|
|
||||||
jellyseerr_url: "",
|
|
||||||
jellyseerr_api_key_set: false,
|
|
||||||
notes: "",
|
notes: "",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -103,7 +98,10 @@ let queryResult: MediaQueryResponse;
|
|||||||
|
|
||||||
vi.mock("react-router-dom", () => ({
|
vi.mock("react-router-dom", () => ({
|
||||||
useNavigate: () => navigate,
|
useNavigate: () => navigate,
|
||||||
useSearchParams: () => [new URLSearchParams("machine_id=local"), vi.fn()],
|
useSearchParams: () => [
|
||||||
|
new URLSearchParams("jellyfin_service_id=jfs1"),
|
||||||
|
vi.fn(),
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useMedia", () => ({
|
vi.mock("../../hooks/useMedia", () => ({
|
||||||
@@ -118,6 +116,14 @@ vi.mock("../../hooks/useSettings", () => ({
|
|||||||
useMonitoringSettings: () => ({ data: [machineFixture()] }),
|
useMonitoringSettings: () => ({ data: [machineFixture()] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({
|
||||||
|
data: [
|
||||||
|
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useDashboard", () => ({
|
vi.mock("../../hooks/useDashboard", () => ({
|
||||||
useCounts: () => ({ data: undefined }),
|
useCounts: () => ({ data: undefined }),
|
||||||
useLibraries: () => ({ data: undefined }),
|
useLibraries: () => ({ data: undefined }),
|
||||||
|
|||||||
@@ -53,11 +53,6 @@ function localMachine(
|
|||||||
password_set: false,
|
password_set: false,
|
||||||
media_root: "/mnt/media",
|
media_root: "/mnt/media",
|
||||||
path_prefix: "",
|
path_prefix: "",
|
||||||
jellyfin_url: "",
|
|
||||||
jellyfin_user_id: "",
|
|
||||||
jellyfin_api_key_set: false,
|
|
||||||
jellyseerr_url: "",
|
|
||||||
jellyseerr_api_key_set: false,
|
|
||||||
notes: "Primary node",
|
notes: "Primary node",
|
||||||
...overrides,
|
...overrides,
|
||||||
} as MonitoringMachine;
|
} as MonitoringMachine;
|
||||||
|
|||||||
@@ -175,11 +175,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +195,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,63 @@ npm run build # success
|
|||||||
npm run test # 70 passed
|
npm run test # 70 passed
|
||||||
```
|
```
|
||||||
|
|
||||||
## Remaining work
|
## Slice 4b — Jellyfin/Jellyseerr → services migration (in progress)
|
||||||
|
|
||||||
- Slice 4b: add `jellyseerr` service definition; rewire `dependencies.py`
|
### Completed (backend, this PR)
|
||||||
Jellyfin/Jellyseerr resolution to the service registry; migrate the
|
|
||||||
Media/Users/Files/Dashboard selector from machine_id to service instance;
|
- [x] Added `jellyseerr` service definition (`integrations/jellyseerr.py`) and
|
||||||
remove machine-level Jellyfin/Jellyseerr fields from `settings_store.py`,
|
registered it (6 service types total).
|
||||||
`routers/settings.py`, and the Settings UI.
|
- [x] Added `user_id` to the Jellyfin service config.
|
||||||
|
- [x] `dependencies.py`: new `_request_jellyfin_service_id` + `_service_record`
|
||||||
|
(decrypt-on-read). Rewrote `get_jellyfin_client`, `get_jellyseerr_client`,
|
||||||
|
and `get_user_id` to resolve against the service registry via the
|
||||||
|
`jellyfin_service_id` query param (first enabled instance as fallback).
|
||||||
|
- [x] SSH/Files transport (`get_ssh_client`) unchanged — still uses
|
||||||
|
`machine_id`.
|
||||||
|
- [x] Updated service-registry tests for 6 types.
|
||||||
|
|
||||||
|
### Selection model (decided)
|
||||||
|
|
||||||
|
Split query params: `?jellyfin_service_id=` selects the Jellyfin/Jellyseerr
|
||||||
|
instance; `?machine_id=` selects SSH/Files transport. Pages that need both pass
|
||||||
|
both.
|
||||||
|
|
||||||
|
### Remaining (frontend, next PR)
|
||||||
|
|
||||||
|
- Thread `jellyfinServiceId` through Media / Applications / Dashboard / Users:
|
||||||
|
list `jellyfin` service instances instead of `useMonitoringSettings()`
|
||||||
|
Jellyfin machines; pass `jellyfin_service_id` to Jellyfin API calls.
|
||||||
|
- Files page keeps `machine_id`.
|
||||||
|
- Settings UI: remove machine-level Jellyfin/Jellyseerr fields.
|
||||||
|
- Remove machine app fields from `settings_store.py` + `routers/settings.py`
|
||||||
|
once the UI no longer writes them.
|
||||||
|
|
||||||
|
### Frontend half (this PR)
|
||||||
|
|
||||||
|
- [x] `api/client.ts`: Jellyfin-backed calls (`fetchCounts`, `fetchLibraries`,
|
||||||
|
`fetchActivity`, `fetchUsers`, Media status/build/stop/force-stop, and
|
||||||
|
`queryMedia`) now send `jellyfin_service_id` instead of `machine_id`.
|
||||||
|
- [x] `hooks/useDashboard.ts`, `hooks/useUsers.ts`, `hooks/useMedia.ts`: renamed
|
||||||
|
the selector param to `jellyfinServiceId`.
|
||||||
|
- [x] `pages/Media.tsx` + `pages/Applications.tsx`: select a `jellyfin` service
|
||||||
|
instance via `useServiceInstances("jellyfin")` and persist
|
||||||
|
`jellyfin_service_id` in the URL.
|
||||||
|
- [x] Dashboard (widget-based) and Users (default-instance) need no selector
|
||||||
|
change.
|
||||||
|
- [x] Updated Applications + Media tests for the new hook/param.
|
||||||
|
|
||||||
|
### Deferred (explicit follow-up)
|
||||||
|
|
||||||
|
- Remove machine-level Jellyfin/Jellyseerr fields from `settings_store.py`,
|
||||||
|
`routers/settings.py`, and the Settings UI. Low urgency now that the runtime
|
||||||
|
reads from services; the machine fields are simply unused for Jellyfin.
|
||||||
|
|
||||||
|
### Verification (backend half)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
.venv/bin/ruff check . # clean
|
||||||
|
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
|
||||||
|
cd ../frontend
|
||||||
|
npm run lint && npm run build && npm run test # green (unchanged)
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Design: Unify Saved Tasks on SSH Services
|
||||||
|
|
||||||
|
**Change:** `unify-tasks-on-services`
|
||||||
|
**Phase:** design
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
|
||||||
|
## 1. Architecture overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ saved_tasks (global, reusable) │
|
||||||
|
│ default_service_id → ssh_tasks │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
Actions page │ │ SSH task widget
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ run_saved_task(store, task, svc) │ ← shared helper
|
||||||
|
│ build client → run → log │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ service_task_runs (one history) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Both the Actions runner and the SSH task widget call one shared helper, so there
|
||||||
|
is a single execution path and a single history table.
|
||||||
|
|
||||||
|
## 2. Shared execution helper
|
||||||
|
|
||||||
|
New: `backend/src/media_library_viewer_api/services/task_runner.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord, _build_ssh_client
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskRunResult:
|
||||||
|
exit_status: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
duration_ms: int
|
||||||
|
status: str # "success" | "failure" | "timeout" | "error"
|
||||||
|
error: str
|
||||||
|
|
||||||
|
def run_saved_task(
|
||||||
|
store: SettingsStore,
|
||||||
|
task: dict,
|
||||||
|
service: ServiceRecord,
|
||||||
|
*,
|
||||||
|
request_id: str = "",
|
||||||
|
) -> TaskRunResult:
|
||||||
|
"""Run a saved task on an ssh_tasks service instance and log it.
|
||||||
|
|
||||||
|
Builds the SSH client from the service record, renders the command (shell or
|
||||||
|
python3 -c), runs it with the service's timeout, appends a service_task_runs
|
||||||
|
row, and returns the result.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- The widget adapter (`SshTaskWidgetSource.fetch`) is refactored to call
|
||||||
|
`run_saved_task`, removing its inline copy.
|
||||||
|
- `routers/tasks.py` `run_task` calls `run_saved_task` instead of
|
||||||
|
`_client_for_machine` + `record_task_run`.
|
||||||
|
- `_build_ssh_client` (currently private in `widgets/sources.py`) is promoted to
|
||||||
|
the helper module or a shared location so both callers use it.
|
||||||
|
|
||||||
|
## 3. Data model changes
|
||||||
|
|
||||||
|
### 3.1 `saved_tasks`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- default_machine_id replaced by default_service_id
|
||||||
|
ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
In SQLite (3.25+) `RENAME COLUMN` is supported. The column still stores an id,
|
||||||
|
now pointing at `services.id` (an `ssh_tasks` instance) instead of a machine.
|
||||||
|
|
||||||
|
### 3.2 `saved_task_runs` dropped
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP TABLE IF EXISTS saved_task_runs;
|
||||||
|
```
|
||||||
|
|
||||||
|
All history lives in `service_task_runs` (added in the service-registry change).
|
||||||
|
The `record_task_run` / `list_task_runs` methods on `SettingsStore` are removed.
|
||||||
|
|
||||||
|
## 4. Backend API
|
||||||
|
|
||||||
|
### `routers/tasks.py`
|
||||||
|
|
||||||
|
| Method | Path | Change |
|
||||||
|
|--------|------|--------|
|
||||||
|
| GET | `/api/tasks` | Unchanged (task now carries `default_service_id`). |
|
||||||
|
| POST | `/api/tasks` | `TaskInput.default_service_id` replaces `default_machine_id`. |
|
||||||
|
| PUT | `/api/tasks/{id}` | Same field rename. |
|
||||||
|
| DELETE | `/api/tasks/{id}` | Unchanged. |
|
||||||
|
| GET | `/api/tasks/{id}/runs` | Reads `service_task_runs` (filtered by `task_id`). |
|
||||||
|
| POST | `/api/tasks/run?service_id=...` | `service_id` replaces `machine_id`; resolves an `ssh_tasks` service (override) or the task's `default_service_id`; calls `run_saved_task`. |
|
||||||
|
|
||||||
|
`_resolve_machine_for_task` and `_client_for_machine` are removed (replaced by
|
||||||
|
service resolution + the shared helper).
|
||||||
|
|
||||||
|
### Resolution + validation
|
||||||
|
|
||||||
|
- `run_task`: load the task; if `service_id` query param is given, use it
|
||||||
|
(override), else use `task.default_service_id`; load the `ssh_tasks` service
|
||||||
|
record; build a `ServiceRecord` (decrypt secrets); call `run_saved_task`.
|
||||||
|
- 400 if the task is disabled; 400 if no service resolves; 404 if the task or
|
||||||
|
service is missing.
|
||||||
|
|
||||||
|
## 5. Frontend
|
||||||
|
|
||||||
|
### 5.1 Types
|
||||||
|
|
||||||
|
`SavedTask` / `SavedTaskInput` / `SavedTaskRun` (`frontend/src/types/index.ts`):
|
||||||
|
|
||||||
|
- `default_machine_id` → `default_service_id`.
|
||||||
|
- `SavedTaskRun` fields align with `service_task_runs` (`service_id`,
|
||||||
|
`exit_status`, `stdout_tail`, …).
|
||||||
|
|
||||||
|
### 5.2 API client + hooks
|
||||||
|
|
||||||
|
- `runTask(taskId, serviceId?)` sends `service_id`.
|
||||||
|
- `fetchSavedTaskRuns(taskId)` reads `/api/tasks/{id}/runs` (now
|
||||||
|
`service_task_runs`-backed).
|
||||||
|
|
||||||
|
### 5.3 Actions page
|
||||||
|
|
||||||
|
- Task editor: "Default service" `<Select>` lists `ssh_tasks` service instances
|
||||||
|
(via `useServiceInstances("ssh_tasks")`), not machines.
|
||||||
|
- Run dialog: "Run on" `<Select>` lists `ssh_tasks` instances (override).
|
||||||
|
- Run history: reads the task's `service_task_runs`.
|
||||||
|
- `useMonitoringSettings` removed from the Actions page (no longer needed).
|
||||||
|
|
||||||
|
## 6. Migration and breaking changes
|
||||||
|
|
||||||
|
- **DB:** `saved_tasks.default_machine_id` renamed to `default_service_id`
|
||||||
|
(existing values become stale references to machine ids; inert — the user
|
||||||
|
re-points). `saved_task_runs` dropped.
|
||||||
|
- **Local execution removed.** Deployments relying on local tasks must use an
|
||||||
|
`ssh_tasks` service (e.g. pointing at localhost with a key).
|
||||||
|
- **Changelog + README** note the breaking change.
|
||||||
|
|
||||||
|
## 7. File-level plan
|
||||||
|
|
||||||
|
### Create (backend)
|
||||||
|
|
||||||
|
- `services/task_runner.py` — `run_saved_task` shared helper.
|
||||||
|
|
||||||
|
### Modify (backend)
|
||||||
|
|
||||||
|
- `services/settings_store.py` — rename column; drop `saved_task_runs` +
|
||||||
|
`record_task_run` / `list_task_runs` (task-run flavor).
|
||||||
|
- `routers/tasks.py` — service resolution; call `run_saved_task`; `service_id`
|
||||||
|
param; read `service_task_runs`.
|
||||||
|
- `widgets/sources.py` — `SshTaskWidgetSource.fetch` delegates to
|
||||||
|
`run_saved_task`.
|
||||||
|
|
||||||
|
### Modify (frontend)
|
||||||
|
|
||||||
|
- `types/index.ts` — field rename + `SavedTaskRun` alignment.
|
||||||
|
- `api/client.ts` — `runTask` sends `service_id`.
|
||||||
|
- `pages/Actions.tsx` — service selectors + history source.
|
||||||
|
|
||||||
|
## 8. Slice boundaries
|
||||||
|
|
||||||
|
1. **Backend** — `run_saved_task` helper; saved_tasks column rename; tasks router
|
||||||
|
rewired; widget delegates; `saved_task_runs` dropped; tests.
|
||||||
|
2. **Frontend** — types + API + Actions page rewire; tests.
|
||||||
|
|
||||||
|
Estimated ~600–800 changed lines across two PRs.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Proposal: Unify Saved Tasks on SSH Services
|
||||||
|
|
||||||
|
**Change:** `unify-tasks-on-services`
|
||||||
|
**Phase:** proposal
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
**Status:** awaiting review (design only — no implementation yet)
|
||||||
|
|
||||||
|
## Context and problem
|
||||||
|
|
||||||
|
Saved tasks (the Actions page) currently have **two execution paths**:
|
||||||
|
|
||||||
|
1. **Actions page** → resolves a *machine* (`default_machine_id`) → runs via
|
||||||
|
`_client_for_machine` → logs to `saved_task_runs`.
|
||||||
|
2. **SSH task widget** → resolves an `ssh_tasks` *service instance* → runs via
|
||||||
|
`_build_ssh_client` → logs to `service_task_runs`.
|
||||||
|
|
||||||
|
Same saved-task records, two runners, two history tables, two target models. This
|
||||||
|
is the leftover inconsistency from the service-registry change (design §12): the
|
||||||
|
widget was migrated to services but the Actions page was not.
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
Migrate the Actions page onto the same `ssh_tasks` service model the widget
|
||||||
|
already uses, so there is **one execution path** and **one history table**.
|
||||||
|
|
||||||
|
- Saved tasks gain `default_service_id` (replaces `default_machine_id`), pointing
|
||||||
|
at an `ssh_tasks` service instance.
|
||||||
|
- The Actions runner resolves an `ssh_tasks` service (the task's default, or an
|
||||||
|
explicit run-time override), builds the SSH client from the service record, runs
|
||||||
|
the task, and logs to `service_task_runs`.
|
||||||
|
- `saved_task_runs` is dropped; both the Actions page and the widget read
|
||||||
|
`service_task_runs`.
|
||||||
|
- Local (API-host) task execution is dropped — all tasks run over SSH against
|
||||||
|
`ssh_tasks` services.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- One execution path for saved tasks (Actions page + widget share it).
|
||||||
|
- One run-history table (`service_task_runs`).
|
||||||
|
- Tasks target `ssh_tasks` service instances, consistent with the rest of the
|
||||||
|
service registry.
|
||||||
|
- Run-time override preserved: a task can be run against any `ssh_tasks` instance.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **No change to the jobs router** (`/api/jobs/run`, the `disk_usage` template,
|
||||||
|
etc.). That stays machine-based for the File Browser's on-demand SSH checks.
|
||||||
|
- **No machine/service unification** (follow-up #3). Machines still own File
|
||||||
|
Browser + node_exporter transport.
|
||||||
|
- **No local execution mode.** Dropped per decision; tasks are SSH-only.
|
||||||
|
- **No automatic data migration** of `default_machine_id` → `default_service_id`.
|
||||||
|
Break backwards compatibility (consistent with the service-registry change):
|
||||||
|
existing tasks lose their default target and the user re-points them.
|
||||||
|
|
||||||
|
## Decisions (from grilling)
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|-------|----------|
|
||||||
|
| Local execution | **SSH-only.** Drop local mode; `ssh_tasks` services handle all task execution. |
|
||||||
|
| Run history | **`service_task_runs` only.** Drop `saved_task_runs`. |
|
||||||
|
| Run-time override | **Keep.** A task can run against any `ssh_tasks` instance at run time. |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Breaking upgrade.** Existing tasks lose `default_machine_id`; users re-point
|
||||||
|
to an `ssh_tasks` service. Document in changelog.
|
||||||
|
- **Local-mode loss.** Any deployment relying on local task execution must set up
|
||||||
|
an SSH loopback (or an ssh_tasks service pointing at localhost with a key) to
|
||||||
|
keep running local tasks.
|
||||||
|
- **Shared execution code.** The Actions runner and the widget must share one
|
||||||
|
`run_saved_task` helper to avoid divergence; extracting it is the core refactor.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Machine/service unification (follow-up #3).
|
||||||
|
- Migrating the jobs router (`/api/jobs`) off machines.
|
||||||
|
- A UI for browsing `service_task_runs` across all services (the service page
|
||||||
|
already shows per-instance history; the Actions page shows per-task history).
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Tasks: Unify Saved Tasks on SSH Services
|
||||||
|
|
||||||
|
**Change:** `unify-tasks-on-services`
|
||||||
|
**Phase:** tasks
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
|
||||||
|
## Review workload forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | ~600–800 |
|
||||||
|
| Chained PRs recommended | Yes (2 PRs) |
|
||||||
|
| Chain strategy | stacked-to-main |
|
||||||
|
|
||||||
|
## Slice 1: Backend — shared runner + service-based tasks
|
||||||
|
|
||||||
|
**Goal:** One execution path; tasks target ssh_tasks services; one history table.
|
||||||
|
|
||||||
|
- [ ] **1.1 Add shared `run_saved_task` helper**
|
||||||
|
- Files: `backend/src/media_library_viewer_api/services/task_runner.py` (new)
|
||||||
|
- Lines: ~90
|
||||||
|
- Details: `run_saved_task(store, task, service, *, request_id)` builds the SSH
|
||||||
|
client from the service record (promote `_build_ssh_client`), renders the
|
||||||
|
command, runs with the service timeout, appends a `service_task_runs` row,
|
||||||
|
returns a `TaskRunResult`.
|
||||||
|
- [ ] **1.2 Rename saved_tasks column**
|
||||||
|
- Files: `services/settings_store.py` (modify)
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: `default_machine_id` → `default_service_id` (ALTER TABLE RENAME
|
||||||
|
COLUMN on startup; update `_row_to_task`, `_normalize_task_payload`,
|
||||||
|
`upsert_task`).
|
||||||
|
- [ ] **1.3 Drop saved_task_runs**
|
||||||
|
- Files: `services/settings_store.py` (modify)
|
||||||
|
- Lines: ~-60
|
||||||
|
- Details: `DROP TABLE IF EXISTS saved_task_runs`; remove `record_task_run`
|
||||||
|
and `list_task_runs` (task flavor).
|
||||||
|
- [ ] **1.4 Rewire tasks router**
|
||||||
|
- Files: `routers/tasks.py` (modify)
|
||||||
|
- Lines: ~70
|
||||||
|
- Details: `TaskInput.default_service_id`; `run_task` takes `service_id`
|
||||||
|
(override), resolves an ssh_tasks service, calls `run_saved_task`;
|
||||||
|
`/api/tasks/{id}/runs` reads `service_task_runs`. Remove
|
||||||
|
`_resolve_machine_for_task` and `_client_for_machine`.
|
||||||
|
- [ ] **1.5 Widget delegates to shared helper**
|
||||||
|
- Files: `widgets/sources.py` (modify)
|
||||||
|
- Lines: ~-40
|
||||||
|
- Details: `SshTaskWidgetSource.fetch` calls `run_saved_task` instead of its
|
||||||
|
inline run+log block.
|
||||||
|
- [ ] **1.6 Add `list_service_task_runs` by task (if not present)**
|
||||||
|
- Files: `services/settings_store.py` (modify)
|
||||||
|
- Lines: ~10
|
||||||
|
- Details: Confirm `list_service_task_runs(task_id=...)` covers the tasks
|
||||||
|
router needs.
|
||||||
|
- [ ] **1.7 Update backend tests**
|
||||||
|
- Files: `backend/tests/test_jobs.py`, `test_api.py` (modify)
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: Update task-run tests to the service model; cover override +
|
||||||
|
default + disabled-service paths.
|
||||||
|
- [ ] **1.8 Verify**
|
||||||
|
- Run: `cd backend && .venv/bin/ruff check . && PYTHONPATH=src .venv/bin/python -m pytest`
|
||||||
|
|
||||||
|
**Slice 1 total:** ~250 changed lines.
|
||||||
|
|
||||||
|
## Slice 2: Frontend — Actions page on services
|
||||||
|
|
||||||
|
**Goal:** Actions page targets ssh_tasks services; reads service_task_runs.
|
||||||
|
|
||||||
|
- [ ] **2.1 Update types**
|
||||||
|
- Files: `frontend/src/types/index.ts` (modify)
|
||||||
|
- Lines: ~15
|
||||||
|
- Details: `SavedTask` / `SavedTaskInput` `default_service_id`;
|
||||||
|
`SavedTaskRun` aligned to `service_task_runs`.
|
||||||
|
- [ ] **2.2 Update API client**
|
||||||
|
- Files: `frontend/src/api/client.ts` (modify)
|
||||||
|
- Lines: ~10
|
||||||
|
- Details: `runTask(taskId, serviceId?)` sends `service_id`.
|
||||||
|
- [ ] **2.3 Rewire Actions page**
|
||||||
|
- Files: `frontend/src/pages/Actions.tsx` (modify)
|
||||||
|
- Lines: ~120
|
||||||
|
- Details: Task editor "Default service" select lists ssh_tasks services via
|
||||||
|
`useServiceInstances("ssh_tasks")`; run dialog "Run on" selects an instance;
|
||||||
|
run history reads `service_task_runs`. Remove `useMonitoringSettings`.
|
||||||
|
- [ ] **2.4 Update Actions tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Actions.test.tsx` (modify)
|
||||||
|
- Lines: ~30
|
||||||
|
- Details: Mock `useServiceInstances`; update fixtures.
|
||||||
|
- [ ] **2.5 Docs + changelog**
|
||||||
|
- Files: `docs/REQUIREMENTS.md`, `CHANGELOG.md` (modify)
|
||||||
|
- Lines: ~30
|
||||||
|
- Details: Saved-actions section: tasks target ssh_tasks services; local mode
|
||||||
|
dropped; breaking-upgrade note.
|
||||||
|
- [ ] **2.6 Verify**
|
||||||
|
- Run: `cd frontend && npm run lint && npm run build && npm run test`
|
||||||
|
|
||||||
|
**Slice 2 total:** ~200 changed lines.
|
||||||
|
|
||||||
|
## Integration and acceptance
|
||||||
|
|
||||||
|
- [ ] **3.1 Backend full test run** — `PYTHONPATH=src pytest`, all green.
|
||||||
|
- [ ] **3.2 Frontend full build/lint/test**.
|
||||||
|
- [ ] **3.3 Manual dev-stack check**:
|
||||||
|
- Create an ssh_tasks service; create a task with that default; run from
|
||||||
|
Actions; see the run in both the Actions history and the service page.
|
||||||
|
- Override the target at run time.
|
||||||
|
- SSH task widget uses the same history.
|
||||||
Reference in New Issue
Block a user