Fix: settings master/detail, widget kind filter, reorder, media worker

Four fixes:

1. Settings > Services tab: master/detail layout. Replaced the vertical
   stack of ServiceConfigEditor cards with a SelectionRailCard (list on
   left) + SectionCard (details on right) — same pattern as Machines
   and SSH Keys tabs. Click a service in the rail to edit it.

2. Service Overview widget restriction. When adding widgets on a
   service's Overview, the dialog now only shows built-in widgets +
   widgets for THAT service type (not all services). Dashboard/named
   dashboards (no serviceId) still see all.

3. Reorder buttons fixed. The swap-sort_order approach was a no-op when
   both items had sort_order=0 (the default). Now moveInstance
   renumbers ALL items by their new index position (i * 10) after the
   swap, guaranteeing values change. References use updateRef, owned
   widgets use saveWidget, both sequential.

4. Media index worker resolution. The subprocess worker called
   get_jellyfin_client/get_user_id (FastAPI request dependencies) which
   don't work outside request context. Now accepts a service_id
   parameter (passed from post_build_index) and resolves the Jellyfin
   instance directly from the settings store via _resolve_jellyfin.
   Raises RuntimeError (not HTTPException) on failure.

283 backend tests pass; 128 frontend tests pass; ruff/eslint clean.
This commit is contained in:
Developer
2026-07-06 14:03:05 +00:00
parent eeb0cccbce
commit d8c0a37210
4 changed files with 231 additions and 155 deletions
@@ -98,7 +98,7 @@ def _serialize_status(status: Any) -> dict[str, Any]:
}
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
def _worker_command(final_db_path: Path, staging_db_path: Path, service_id: str = "") -> list[str]:
return [
sys.executable,
"-m",
@@ -107,14 +107,16 @@ def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
str(final_db_path),
"--staging-path",
str(staging_db_path),
"--service-id",
service_id,
]
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]:
def _start_worker(index: MediaIndex, service_id: str = "") -> subprocess.Popen[bytes]:
staging_path = _staging_db_path(index)
staging_path.unlink(missing_ok=True)
return subprocess.Popen(
_worker_command(index.db_path, staging_path),
_worker_command(index.db_path, staging_path, service_id),
start_new_session=True,
env=os.environ.copy(),
)
@@ -133,6 +135,7 @@ def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str,
def post_build_index(
client: JellyfinClient = Depends(get_jellyfin_client),
user_id: str = Depends(get_user_id),
jellyfin_service_id: str | None = None,
index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]:
"""Start a media index build in a subprocess worker."""
@@ -144,7 +147,7 @@ def post_build_index(
libraries = client.libraries(user_id)
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
process = _start_worker(index)
process = _start_worker(index, jellyfin_service_id or "")
_set_build_metadata(
index,
{
@@ -14,7 +14,6 @@ from pathlib import Path
from typing import Any
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
from media_library_viewer_api.services.media_index import (
MediaIndex,
@@ -88,13 +87,41 @@ def _progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) -> No
)
def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> int:
def _resolve_jellyfin(service_id: str) -> tuple[Any, str]:
"""Resolve the Jellyfin client + user_id from the settings store.
In a subprocess we cannot use the FastAPI dependency layer (no request),
so we query the settings store directly. When ``service_id`` is given,
resolve that specific instance; otherwise fall back to first-enabled.
"""
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.dependencies import _service_record, get_settings_store
store = get_settings_store()
service = _service_record(store, "jellyfin", service_id or None)
if service is None:
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
base_url = str(service.get("config", {}).get("base_url") or "")
api_key = str(service.get("secrets", {}).get("api_key") or "")
if not base_url or not api_key:
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
timeout = int(service.get("config", {}).get("timeout_seconds", 10))
client = JellyfinClient(base_url, api_key, timeout)
user_id = str(service.get("config", {}).get("user_id") or "")
if not user_id:
users = client.users()
if not users:
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
user_id = users[0]["Id"]
return client, user_id
def run_build(final_index_path: str | Path, staging_index_path: str | Path, service_id: str = "") -> int:
"""Run the media index build in a subprocess."""
settings = get_settings()
configure_logging(settings.log_level)
logger.info("Media index worker starting: %s", describe_settings(settings))
client = get_jellyfin_client()
user_id = get_user_id()
client, user_id = _resolve_jellyfin(service_id)
libraries = client.libraries(user_id)
final_index = MediaIndex(final_index_path)
@@ -192,8 +219,9 @@ def main() -> int:
parser = argparse.ArgumentParser(description="Build the media index in a worker process")
parser.add_argument("--index-path", required=True)
parser.add_argument("--staging-path", required=True)
parser.add_argument("--service-id", default="", help="Jellyfin service instance id")
args = parser.parse_args()
return run_build(args.index_path, args.staging_path)
return run_build(args.index_path, args.staging_path, args.service_id)
if __name__ == "__main__": # pragma: no cover