diff --git a/backend/src/media_library_viewer_api/routers/media.py b/backend/src/media_library_viewer_api/routers/media.py index 8744e23..eda060e 100644 --- a/backend/src/media_library_viewer_api/routers/media.py +++ b/backend/src/media_library_viewer_api/routers/media.py @@ -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, { diff --git a/backend/src/media_library_viewer_api/workers/media_index_worker.py b/backend/src/media_library_viewer_api/workers/media_index_worker.py index 6f6d28b..fff35c4 100644 --- a/backend/src/media_library_viewer_api/workers/media_index_worker.py +++ b/backend/src/media_library_viewer_api/workers/media_index_worker.py @@ -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 diff --git a/frontend/src/components/WidgetConfigDialog.tsx b/frontend/src/components/WidgetConfigDialog.tsx index 801829f..574c1a3 100644 --- a/frontend/src/components/WidgetConfigDialog.tsx +++ b/frontend/src/components/WidgetConfigDialog.tsx @@ -232,7 +232,6 @@ export function WidgetConfigDialog({ startEdit(target); } } - }, [open, editWidgetId, instances]); function startAddBuiltIn(kind: string) { @@ -307,27 +306,26 @@ export function WidgetConfigDialog({ async function moveInstance(index: number, direction: -1 | 1) { const targetIndex = index + direction; if (targetIndex < 0 || targetIndex >= combinedWidgets.length) return; - const a = combinedWidgets[index]; - const b = combinedWidgets[targetIndex]; - const aRefId = (a as { _ref_id?: string })._ref_id; - const bRefId = (b as { _ref_id?: string })._ref_id; - // References use their own sort_order on the widget_references row; - // owned widgets use the widget instance's sort_order. - if (aRefId) { - await updateRef.mutateAsync({ - referenceId: aRefId, - sortOrder: b.sort_order, - }); - } else { - await saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }); - } - if (bRefId) { - await updateRef.mutateAsync({ - referenceId: bRefId, - sortOrder: a.sort_order, - }); - } else { - await saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }); + // Swap the two items in a copy, then renumber ALL items by their new + // index position (index * 10). This guarantees the sort_order values + // change even when both items previously shared the same value (e.g. 0). + const reordered = [...combinedWidgets]; + const tmp = reordered[index]; + reordered[index] = reordered[targetIndex]; + reordered[targetIndex] = tmp; + // Sequential (not Promise.all) to avoid cache-invalidation race. + for (let i = 0; i < reordered.length; i++) { + const item = reordered[i]; + const newSortOrder = i * 10; + const refId = (item as { _ref_id?: string })._ref_id; + if (refId) { + await updateRef.mutateAsync({ + referenceId: refId, + sortOrder: newSortOrder, + }); + } else { + await saveWidget.mutateAsync({ ...item, sort_order: newSortOrder }); + } } } @@ -655,6 +653,9 @@ export function WidgetConfigDialog({ ))} {services .filter((s) => s.enabled) + // When scoped to a service Overview, only show widgets for THAT + // service instance's type (not all services' widgets). + .filter((s) => !serviceId || s.id === serviceId) .flatMap((s) => (SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => ( - + {Object.keys(instance.secrets_set).length === 0 + ? null + : Object.entries(instance.secrets_set).map(([key, isSet]) => ( + + + setDraftSecrets({ + ...draftSecrets, + [key]: e.target.value, + }) + } + /> + + ))} + +
+ + +
- - setDeleteOpen(false)} - onConfirm={() => { - deleteService.mutate(instance.id); - setDeleteOpen(false); - }} - /> + setDeleteOpen(false)} + onConfirm={() => { + deleteService.mutate(instance.id); + setDeleteOpen(false); + }} + /> ); }