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 [ return [
sys.executable, sys.executable,
"-m", "-m",
@@ -107,14 +107,16 @@ def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
str(final_db_path), str(final_db_path),
"--staging-path", "--staging-path",
str(staging_db_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 = _staging_db_path(index)
staging_path.unlink(missing_ok=True) staging_path.unlink(missing_ok=True)
return subprocess.Popen( return subprocess.Popen(
_worker_command(index.db_path, staging_path), _worker_command(index.db_path, staging_path, service_id),
start_new_session=True, start_new_session=True,
env=os.environ.copy(), env=os.environ.copy(),
) )
@@ -133,6 +135,7 @@ def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str,
def post_build_index( def post_build_index(
client: JellyfinClient = Depends(get_jellyfin_client), client: JellyfinClient = Depends(get_jellyfin_client),
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
jellyfin_service_id: str | None = None,
index: MediaIndex = Depends(get_media_index), index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Start a media index build in a subprocess worker.""" """Start a media index build in a subprocess worker."""
@@ -144,7 +147,7 @@ def post_build_index(
libraries = client.libraries(user_id) libraries = client.libraries(user_id)
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries)) 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( _set_build_metadata(
index, index,
{ {
@@ -14,7 +14,6 @@ from pathlib import Path
from typing import Any from typing import Any
from media_library_viewer_api.config import get_settings 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.logging_utils import configure_logging, describe_settings
from media_library_viewer_api.services.media_index import ( from media_library_viewer_api.services.media_index import (
MediaIndex, 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.""" """Run the media index build in a subprocess."""
settings = get_settings() settings = get_settings()
configure_logging(settings.log_level) configure_logging(settings.log_level)
logger.info("Media index worker starting: %s", describe_settings(settings)) logger.info("Media index worker starting: %s", describe_settings(settings))
client = get_jellyfin_client() client, user_id = _resolve_jellyfin(service_id)
user_id = get_user_id()
libraries = client.libraries(user_id) libraries = client.libraries(user_id)
final_index = MediaIndex(final_index_path) 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 = argparse.ArgumentParser(description="Build the media index in a worker process")
parser.add_argument("--index-path", required=True) parser.add_argument("--index-path", required=True)
parser.add_argument("--staging-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() 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 if __name__ == "__main__": # pragma: no cover
+23 -22
View File
@@ -232,7 +232,6 @@ export function WidgetConfigDialog({
startEdit(target); startEdit(target);
} }
} }
}, [open, editWidgetId, instances]); }, [open, editWidgetId, instances]);
function startAddBuiltIn(kind: string) { function startAddBuiltIn(kind: string) {
@@ -307,27 +306,26 @@ export function WidgetConfigDialog({
async function moveInstance(index: number, direction: -1 | 1) { async function moveInstance(index: number, direction: -1 | 1) {
const targetIndex = index + direction; const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= combinedWidgets.length) return; if (targetIndex < 0 || targetIndex >= combinedWidgets.length) return;
const a = combinedWidgets[index]; // Swap the two items in a copy, then renumber ALL items by their new
const b = combinedWidgets[targetIndex]; // index position (index * 10). This guarantees the sort_order values
const aRefId = (a as { _ref_id?: string })._ref_id; // change even when both items previously shared the same value (e.g. 0).
const bRefId = (b as { _ref_id?: string })._ref_id; const reordered = [...combinedWidgets];
// References use their own sort_order on the widget_references row; const tmp = reordered[index];
// owned widgets use the widget instance's sort_order. reordered[index] = reordered[targetIndex];
if (aRefId) { reordered[targetIndex] = tmp;
await updateRef.mutateAsync({ // Sequential (not Promise.all) to avoid cache-invalidation race.
referenceId: aRefId, for (let i = 0; i < reordered.length; i++) {
sortOrder: b.sort_order, const item = reordered[i];
}); const newSortOrder = i * 10;
} else { const refId = (item as { _ref_id?: string })._ref_id;
await saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }); if (refId) {
} await updateRef.mutateAsync({
if (bRefId) { referenceId: refId,
await updateRef.mutateAsync({ sortOrder: newSortOrder,
referenceId: bRefId, });
sortOrder: a.sort_order, } else {
}); await saveWidget.mutateAsync({ ...item, sort_order: newSortOrder });
} else { }
await saveWidget.mutateAsync({ ...b, sort_order: a.sort_order });
} }
} }
@@ -655,6 +653,9 @@ export function WidgetConfigDialog({
))} ))}
{services {services
.filter((s) => s.enabled) .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) => .flatMap((s) =>
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => ( (SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
<Button <Button
+168 -124
View File
@@ -1338,46 +1338,90 @@ export function Settings() {
function ServicesAdminCard() { function ServicesAdminCard() {
const { data: services = [] } = useServiceInstances(); const { data: services = [] } = useServiceInstances();
const { data: types = [] } = useServiceTypes(); const { data: types = [] } = useServiceTypes();
const [selectedServiceId, setSelectedServiceId] = useState("");
// Group by service_type, alphabetical. const sortedServices = useMemo(
const grouped = useMemo(() => { () =>
const map = new Map<string, ServiceInstance[]>(); [...services].sort((a, b) =>
for (const svc of services) { `${a.service_type}:${a.name}`.localeCompare(
const list = map.get(svc.service_type) ?? []; `${b.service_type}:${b.name}`,
list.push(svc); ),
map.set(svc.service_type, list); ),
} [services],
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0])); );
}, [services]);
const selectedService = useMemo(
() =>
sortedServices.find((s) => s.id === selectedServiceId) ??
sortedServices[0] ??
null,
[sortedServices, selectedServiceId],
);
const selectedTypeInfo = selectedService
? types.find((t) => t.service_type === selectedService.service_type)
: undefined;
if (sortedServices.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No service instances configured. Create one from the Services page.
</p>
);
}
return ( return (
<div className="flex flex-col gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
{grouped.length === 0 ? ( <SelectionRailCard
<p className="text-sm text-muted-foreground"> title="Services"
No service instances configured. Create one from the Services page. description="Select a service to edit its configuration."
</p> minHeight={420}
) : ( >
grouped.map(([serviceType, instances]) => { {sortedServices.map((svc) => {
const typeInfo = types.find((t) => t.service_type === serviceType); const active = svc.id === (selectedService?.id ?? "");
const typeName =
types.find((t) => t.service_type === svc.service_type)?.name ??
svc.service_type;
return ( return (
<SectionCard <div
key={serviceType} key={svc.id}
title={typeInfo?.name ?? serviceType} onClick={() => setSelectedServiceId(svc.id)}
description={typeInfo?.description ?? ""} className={cn(
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5",
active ? "bg-muted" : "bg-card hover:bg-muted/50",
)}
> >
<div className="flex flex-col gap-4"> <div className="min-w-0">
{instances.map((svc) => ( <p className="truncate font-semibold">{svc.name}</p>
<ServiceConfigEditor <p className="text-xs text-muted-foreground">
key={svc.id} {typeName} · {svc.enabled ? "Enabled" : "Disabled"}
instance={svc} </p>
typeInfo={typeInfo}
/>
))}
</div> </div>
</SectionCard> <Badge variant={svc.enabled ? "default" : "secondary"}>
{svc.enabled ? "on" : "off"}
</Badge>
</div>
); );
}) })}
)} </SelectionRailCard>
<SectionCard
title={selectedService?.name ?? "No service selected"}
description={
selectedTypeInfo?.description ??
"Select a service on the left to edit its configuration."
}
>
{selectedService ? (
<ServiceConfigEditor
instance={selectedService}
typeInfo={selectedTypeInfo}
/>
) : (
<p className="text-sm text-muted-foreground">
Select a service on the left.
</p>
)}
</SectionCard>
</div> </div>
); );
} }
@@ -1439,113 +1483,113 @@ function ServiceConfigEditor({
return ( return (
<> <>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<span className="font-medium">{instance.name}</span> <span className="font-medium">{instance.name}</span>
<Badge variant={instance.enabled ? "default" : "secondary"}> <Badge variant={instance.enabled ? "default" : "secondary"}>
{instance.enabled ? "enabled" : "disabled"} {instance.enabled ? "enabled" : "disabled"}
</Badge> </Badge>
</div>
<div className="flex flex-col gap-3">
<FormField label="Name" htmlFor={`svc-name-${instance.id}`}>
<Input
id={`svc-name-${instance.id}`}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
<div className="flex items-center gap-2">
<Switch
id={`svc-enabled-${instance.id}`}
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor={`svc-enabled-${instance.id}`}>Enabled</Label>
</div> </div>
<div className="flex flex-col gap-3">
<FormField label="Name" htmlFor={`svc-name-${instance.id}`}>
<Input
id={`svc-name-${instance.id}`}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
<div className="flex items-center gap-2">
<Switch
id={`svc-enabled-${instance.id}`}
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor={`svc-enabled-${instance.id}`}>Enabled</Label>
</div>
{configEntries.map(([key, schema]) => { {configEntries.map(([key, schema]) => {
const isNumber = const isNumber =
schema.type === "integer" || schema.type === "number"; schema.type === "integer" || schema.type === "number";
return ( return (
<FormField
key={key}
label={key}
htmlFor={`svc-cfg-${instance.id}-${key}`}
helperText={schema.description}
>
<Input
id={`svc-cfg-${instance.id}-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
setDraftConfig({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</FormField>
);
})}
{Object.keys(instance.secrets_set).length === 0
? null
: Object.entries(instance.secrets_set).map(([key, isSet]) => (
<FormField <FormField
key={key} key={key}
label={key} label={key}
htmlFor={`svc-secret-${instance.id}-${key}`} htmlFor={`svc-cfg-${instance.id}-${key}`}
helperText="Leave blank to keep the current value." helperText={schema.description}
> >
<Input <Input
id={`svc-secret-${instance.id}-${key}`} id={`svc-cfg-${instance.id}-${key}`}
type="password" type={isNumber ? "number" : "text"}
placeholder={isSet ? "•••••• (set)" : "Not set"} value={String(draftConfig[key] ?? "")}
value={draftSecrets[key] ?? ""}
onChange={(e) => onChange={(e) =>
setDraftSecrets({ setDraftConfig({
...draftSecrets, ...draftConfig,
[key]: e.target.value, [key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
}) })
} }
/> />
</FormField> </FormField>
))} );
})}
<div className="flex justify-between"> {Object.keys(instance.secrets_set).length === 0
<Button ? null
onClick={handleSave} : Object.entries(instance.secrets_set).map(([key, isSet]) => (
disabled={saveService.isPending} <FormField
className="mobile-touch-target" key={key}
> label={key}
Save htmlFor={`svc-secret-${instance.id}-${key}`}
</Button> helperText="Leave blank to keep the current value."
<Button >
variant="destructive" <Input
onClick={() => setDeleteOpen(true)} id={`svc-secret-${instance.id}-${key}`}
className="mobile-touch-target" type="password"
> placeholder={isSet ? "•••••• (set)" : "Not set"}
Delete value={draftSecrets[key] ?? ""}
</Button> onChange={(e) =>
setDraftSecrets({
...draftSecrets,
[key]: e.target.value,
})
}
/>
</FormField>
))}
<div className="flex justify-between">
<Button
onClick={handleSave}
disabled={saveService.isPending}
className="mobile-touch-target"
>
Save
</Button>
<Button
variant="destructive"
onClick={() => setDeleteOpen(true)}
className="mobile-touch-target"
>
Delete
</Button>
</div>
</div> </div>
</div> </div>
</div>
<ConfirmDialog <ConfirmDialog
open={deleteOpen} open={deleteOpen}
title="Delete service?" title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone." message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete" confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)} onCancel={() => setDeleteOpen(false)}
onConfirm={() => { onConfirm={() => {
deleteService.mutate(instance.id); deleteService.mutate(instance.id);
setDeleteOpen(false); setDeleteOpen(false);
}} }}
/> />
</> </>
); );
} }