Compare commits

...

5 Commits

Author SHA1 Message Date
Developer cfb9977532 chore: remove dead machine-level Jellyfin/Jellyseerr fields
Follow-up #1 to the service-registry change. Jellyfin/Jellyseerr now resolve
from the service registry, so the machine-level app fields are dead config.

- dependencies.py: drop dead _jellyseerr_client_for; simplify _resolve_machine
  to SSH-only.
- settings_store.py + routers/settings.py: remove jellyfin_*/jellyseerr_* from
  machine default config, get_machine_config, normalization, row mappers, and
  MachineInput.
- frontend types + Settings.tsx: drop the fields and the Jellyfin/Jellyseerr
  form sections + service options.
- Update frontend test fixtures.

Existing DB rows may still carry these keys in config_json; they are inert and
drop on the next machine save. Verification: backend ruff clean, pytest 222;
frontend lint 0 errors, build success, 70 tests.
2026-06-23 12:54:27 +00:00
Developer 802a9202e9 Merge pull request 'feat(services): select Jellyfin via jellyfin_service_id on the frontend' (#12) from feat/service-registry-jellyfin-services-frontend into main 2026-06-23 12:28:53 +00:00
Developer 7ab9b1ac59 style(tests): apply formatter to Applications and Media tests 2026-06-23 12:28:53 +00:00
Developer cbb703341e feat(services): select Jellyfin via jellyfin_service_id on the frontend
Slice 4b frontend half. Jellyfin-touching pages now select a Jellyfin service
instance instead of a machine.

- api/client.ts: Jellyfin-backed calls (counts/libraries/activity/users, media
  status/build/stop/force-stop, queryMedia) send jellyfin_service_id.
- hooks/useDashboard, useUsers, useMedia: selector param renamed to
  jellyfinServiceId.
- pages/Media + Applications: list jellyfin service instances and persist
  jellyfin_service_id in the URL.
- Dashboard (widgets) and Users (default instance) need no selector change.
- Update Applications + Media tests for the new hook/param.

Files/SSH transport keeps machine_id. Verification: frontend lint 0 errors,
build success, 70 tests; backend ruff clean, 222 tests.
2026-06-23 12:10:27 +00:00
Developer a13f560df2 Merge pull request 'feat(services): resolve Jellyfin/Jellyseerr from the service registry (backend)' (#11) from feat/service-registry-jellyfin-services-backend into main 2026-06-23 11:48:25 +00:00
18 changed files with 137 additions and 307 deletions
+10
View File
@@ -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
pages. Migrating those onto the service registry is a separate follow-up change
(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.
@@ -81,21 +81,6 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
return JellyfinClient(url, api_key)
@lru_cache(maxsize=32)
def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | None:
machine_id, url = cache_key
if not url:
return None
settings = get_settings_store().get_machine_config(machine_id) if machine_id else None
api_key = (settings or {}).get("jellyseerr_api_key") if settings else ""
if not api_key:
return None
logger.info(
"Creating Jellyseerr client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>"
)
return JellyseerrClient(url, api_key)
@lru_cache(maxsize=32)
def _ssh_client_for(
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
@@ -154,6 +139,10 @@ def _ssh_client_for(
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()
machine_id = _request_machine_id(request)
if machine_id:
@@ -161,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"):
return machine
return machine
if service == "jellyfin":
machines = store.list_machines_for_service("jellyfin")
elif service == "jellyseerr":
machines = [m for m in store.list_machines_for_service("jellyfin") if m.get("jellyseerr_url")]
elif service == "ssh":
if service == "ssh":
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
else:
machines = store.list_machines_for_service(service)
@@ -43,11 +43,6 @@ class MonitoringMachineInput(BaseModel):
password: str = ""
media_root: str = ""
path_prefix: str = ""
jellyfin_url: str = ""
jellyfin_user_id: str = ""
jellyfin_api_key: str = ""
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
notes: str = ""
@@ -44,11 +44,6 @@ def _default_local_machine() -> dict[str, Any]:
"password": "",
"media_root": settings.media_root,
"path_prefix": settings.path_prefix,
"jellyfin_url": "",
"jellyfin_user_id": "",
"jellyfin_api_key": "",
"jellyseerr_url": "",
"jellyseerr_api_key": "",
"node_exporter_enabled": False,
"node_exporter_port": 9100,
"node_exporter_scrape_host": "",
@@ -306,11 +301,6 @@ class SettingsStore:
"password_set": bool(data.get("password")),
"media_root": data.get("media_root", ""),
"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_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
@@ -360,17 +350,6 @@ class SettingsStore:
password = str(password or "")
media_root = _current_str("media_root")
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(
payload.get("node_exporter_enabled")
if payload.get("node_exporter_enabled") is not None
@@ -402,11 +381,6 @@ class SettingsStore:
"password": password,
"media_root": media_root,
"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_port": node_exporter_port,
"node_exporter_scrape_host": node_exporter_scrape_host,
@@ -430,11 +404,6 @@ class SettingsStore:
"password": "",
"media_root": machine["media_root"],
"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_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
@@ -520,11 +489,6 @@ class SettingsStore:
"password": data.get("password", ""),
"media_root": data.get("media_root", ""),
"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_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
@@ -564,11 +528,6 @@ class SettingsStore:
"password": machine["password"],
"media_root": machine["media_root"],
"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_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
+24 -22
View File
@@ -134,26 +134,26 @@ async function del<T>(path: string): Promise<T> {
return response.json();
}
// Dashboard
export const fetchCounts = (machineId?: string) =>
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
export const fetchCounts = (jellyfinServiceId?: string) =>
get<MediaCounts>(
"/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[]>(
"/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[]>(
"/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>(
"/api/users",
machineId ? { machine_id: machineId } : undefined,
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
// Backward-compatible alias used by older hooks/components.
@@ -293,27 +293,27 @@ export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
);
// Media
export const fetchMediaStatus = (machineId?: string) =>
export const fetchMediaStatus = (jellyfinServiceId?: string) =>
get<MediaIndexStatus>(
"/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>(
machineId
? `/api/media/build?machine_id=${encodeURIComponent(machineId)}`
jellyfinServiceId
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/build",
);
export const stopMediaIndexBuild = (machineId?: string) =>
export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>(
machineId
? `/api/media/stop?machine_id=${encodeURIComponent(machineId)}`
jellyfinServiceId
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/stop",
);
export const forceStopMediaIndexBuild = (machineId?: string) =>
export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>(
machineId
? `/api/media/force-stop?machine_id=${encodeURIComponent(machineId)}`
jellyfinServiceId
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/force-stop",
);
export const queryMedia = (params: {
@@ -325,7 +325,7 @@ export const queryMedia = (params: {
sort_order?: string;
limit?: number;
offset?: number;
machineId?: string;
jellyfinServiceId?: string;
}) =>
get<MediaQueryResponse>("/api/media/query", {
libraries: params.libraries || "",
@@ -336,7 +336,9 @@ export const queryMedia = (params: {
sort_order: params.sort_order || "Ascending",
limit: String(params.limit || 100),
offset: String(params.offset || 0),
...(params.machineId ? { machine_id: params.machineId } : {}),
...(params.jellyfinServiceId
? { jellyfin_service_id: params.jellyfinServiceId }
: {}),
});
// Files
+9 -9
View File
@@ -9,26 +9,26 @@ import {
} from "../api/client";
import type { DashboardShortcutInput } from "../types";
export function useCounts(machineId?: string) {
export function useCounts(jellyfinServiceId?: string) {
return useQuery({
queryKey: ["dashboard", "counts", machineId ?? "default"],
queryFn: () => fetchCounts(machineId),
queryKey: ["dashboard", "counts", jellyfinServiceId ?? "default"],
queryFn: () => fetchCounts(jellyfinServiceId),
staleTime: 5 * 60 * 1000,
});
}
export function useLibraries(machineId?: string) {
export function useLibraries(jellyfinServiceId?: string) {
return useQuery({
queryKey: ["dashboard", "libraries", machineId ?? "default"],
queryFn: () => fetchLibraries(machineId),
queryKey: ["dashboard", "libraries", jellyfinServiceId ?? "default"],
queryFn: () => fetchLibraries(jellyfinServiceId),
staleTime: 5 * 60 * 1000,
});
}
export function useActivity(machineId?: string) {
export function useActivity(jellyfinServiceId?: string) {
return useQuery({
queryKey: ["dashboard", "activity", machineId ?? "default"],
queryFn: () => fetchActivity(machineId),
queryKey: ["dashboard", "activity", jellyfinServiceId ?? "default"],
queryFn: () => fetchActivity(jellyfinServiceId),
refetchInterval: 15_000,
});
}
+10 -10
View File
@@ -7,10 +7,10 @@ import {
forceStopMediaIndexBuild,
} from "../api/client";
export function useMediaStatus(machineId?: string) {
export function useMediaStatus(jellyfinServiceId?: string) {
return useQuery({
queryKey: ["media", "status", machineId ?? "default"],
queryFn: () => fetchMediaStatus(machineId),
queryKey: ["media", "status", jellyfinServiceId ?? "default"],
queryFn: () => fetchMediaStatus(jellyfinServiceId),
staleTime: 5_000,
refetchInterval: (query) =>
query.state.data?.build_running ? 1000 : false,
@@ -27,7 +27,7 @@ export function useMediaQuery(params: {
sort_order?: string;
limit?: number;
offset?: number;
machineId?: string;
jellyfinServiceId?: string;
enabled?: boolean;
}) {
const { enabled = true, ...queryParams } = params;
@@ -44,30 +44,30 @@ function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ["media"] });
}
export function useBuildIndex(machineId?: string) {
export function useBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => buildMediaIndex(machineId),
mutationFn: () => buildMediaIndex(jellyfinServiceId),
onSuccess: () => {
invalidateMedia(queryClient);
},
});
}
export function useStopBuildIndex(machineId?: string) {
export function useStopBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => stopMediaIndexBuild(machineId),
mutationFn: () => stopMediaIndexBuild(jellyfinServiceId),
onSuccess: () => {
invalidateMedia(queryClient);
},
});
}
export function useForceStopBuildIndex(machineId?: string) {
export function useForceStopBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => forceStopMediaIndexBuild(machineId),
mutationFn: () => forceStopMediaIndexBuild(jellyfinServiceId),
onSuccess: () => {
invalidateMedia(queryClient);
},
+3 -3
View File
@@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/client";
import type { UserDirectoryResponse } from "../types";
export function useUsers(machineId?: string) {
export function useUsers(jellyfinServiceId?: string) {
return useQuery<UserDirectoryResponse>({
queryKey: ["users", machineId ?? "default"],
queryFn: () => fetchUsers(machineId),
queryKey: ["users", jellyfinServiceId ?? "default"],
queryFn: () => fetchUsers(jellyfinServiceId),
staleTime: 30_000,
});
}
+10 -15
View File
@@ -1,28 +1,23 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { TabsTrigger } from "@/components/ui/tabs";
import { Media } from "./Media";
import { useCounts, useLibraries } from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings";
import { useServiceInstances } from "../hooks/useServices";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
function JellyfinLibraryStats() {
const [searchParams] = useSearchParams();
const { data: machines = [] } = useMonitoringSettings();
const jellyfinMachines = useMemo(
() =>
machines.filter(
(machine) => machine.enabled && machine.services.includes("jellyfin"),
),
[machines],
);
const selectedMachineId =
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
const { data: counts } = useCounts(selectedMachineId || undefined);
const { data: libraries } = useLibraries(selectedMachineId || undefined);
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const selectedServiceId =
searchParams.get("jellyfin_service_id") ||
jellyfinServices.find((s) => s.enabled)?.id ||
"";
const { data: counts } = useCounts(selectedServiceId || undefined);
const { data: libraries } = useLibraries(selectedServiceId || undefined);
return (
<SectionCard
@@ -30,7 +25,7 @@ function JellyfinLibraryStats() {
description="Compact Jellyfin summary for the selected machine."
action={
<Badge variant="outline">
{selectedMachineId ? "Selected machine" : "Default machine"}
{selectedServiceId ? "Selected service" : "Default service"}
</Badge>
}
>
+24 -29
View File
@@ -32,7 +32,7 @@ import {
} from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState";
import type { MediaItem } from "../types";
import { useMonitoringSettings } from "../hooks/useSettings";
import { useServiceInstances } from "../hooks/useServices";
import { useCounts, useLibraries } from "../hooks/useDashboard";
function formatDuration(seconds: number | null | undefined): string {
@@ -178,23 +178,18 @@ export function Media() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isSmall = usePrefersSmallScreen();
const { data: machines } = useMonitoringSettings();
const jellyfinMachines = useMemo(
() =>
(machines ?? []).filter(
(machine) => machine.enabled && machine.services.includes("jellyfin"),
),
[machines],
);
const selectedMachineId =
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
const { data: counts } = useCounts(selectedMachineId || undefined);
const { data: libraries } = useLibraries(selectedMachineId || undefined);
const { data: status } = useMediaStatus(selectedMachineId || undefined);
const buildIndex = useBuildIndex(selectedMachineId || undefined);
const stopBuildIndex = useStopBuildIndex(selectedMachineId || undefined);
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const selectedServiceId =
searchParams.get("jellyfin_service_id") ||
jellyfinServices.find((s) => s.enabled)?.id ||
"";
const { data: counts } = useCounts(selectedServiceId || undefined);
const { data: libraries } = useLibraries(selectedServiceId || undefined);
const { data: status } = useMediaStatus(selectedServiceId || undefined);
const buildIndex = useBuildIndex(selectedServiceId || undefined);
const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
const forceStopBuildIndex = useForceStopBuildIndex(
selectedMachineId || undefined,
selectedServiceId || undefined,
);
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
@@ -215,17 +210,17 @@ export function Media() {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
useEffect(() => {
if (!searchParams.get("machine_id") && selectedMachineId) {
if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("machine_id", selectedMachineId);
next.set("jellyfin_service_id", selectedServiceId);
return next;
},
{ replace: true },
);
}
}, [searchParams, selectedMachineId, setSearchParams]);
}, [searchParams, selectedServiceId, setSearchParams]);
const { data: queryResult, isLoading } = useMediaDataQuery({
types,
@@ -235,7 +230,7 @@ export function Media() {
sort_order: sortOrder,
limit: pageSize,
offset,
machineId: selectedMachineId || undefined,
jellyfinServiceId: selectedServiceId || undefined,
enabled: status?.exists ?? false,
});
@@ -323,27 +318,27 @@ export function Media() {
<div className="flex flex-row flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">Jellyfin</h2>
<div className="flex flex-col gap-1.5">
<Label htmlFor="media-machine">Machine</Label>
<Label htmlFor="media-service">Service</Label>
<Select
value={selectedMachineId}
value={selectedServiceId}
onValueChange={(value) =>
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("machine_id", value);
next.set("jellyfin_service_id", value);
return next;
},
{ replace: true },
)
}
>
<SelectTrigger id="media-machine" className="w-full md:w-[220px]">
<SelectValue placeholder="Select a machine" />
<SelectTrigger id="media-service" className="w-full md:w-[220px]">
<SelectValue placeholder="Select a service" />
</SelectTrigger>
<SelectContent>
{jellyfinMachines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
{jellyfinServices.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.name}
</SelectItem>
))}
</SelectContent>
+2 -122
View File
@@ -52,8 +52,6 @@ import { Textarea } from "@/components/ui/textarea";
const SERVICE_OPTIONS = [
{ value: "monitoring", label: "Monitoring" },
{ value: "files", label: "Files" },
{ value: "jellyfin", label: "Jellyfin" },
{ value: "jellyseerr", label: "Jellyseerr" },
{ value: "nextcloud", label: "Nextcloud" },
];
@@ -114,7 +112,7 @@ function emptyMachine(
name: mode === "local" ? "This machine" : "",
mode,
enabled: true,
services: mode === "local" ? ["monitoring", "files", "jellyfin"] : [],
services: mode === "local" ? ["monitoring", "files"] : [],
host: "",
port: 22,
username: "",
@@ -126,11 +124,6 @@ function emptyMachine(
password: "",
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key: "",
jellyseerr_url: "",
jellyseerr_api_key: "",
notes: "",
};
}
@@ -169,8 +162,6 @@ function MachineEditor({
const isLocal = draft.mode === "local";
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
const enabledServices = draft.services.length;
const hasJellyfin = draft.services.includes("jellyfin");
const hasJellyseerr = draft.services.includes("jellyseerr");
const placeholderIfSet = (isSet: boolean | undefined) =>
isSet ? "Set, not shown" : undefined;
return (
@@ -398,99 +389,6 @@ function MachineEditor({
/>
</FormField>
</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 && (
<div className="col-span-12 md:col-span-6">
<FormField label="Local hint">
@@ -538,7 +436,7 @@ function MachineEditor({
</AlertDescription>
</Alert>
) : null}
{!isLocal && !hasJellyfin && (
{!isLocal && enabledServices === 0 && (
<Alert>
<AlertDescription>
SSH machines usually need monitoring or files enabled.
@@ -584,13 +482,6 @@ function MachineEditor({
)}
</div>
)}
{hasJellyseerr && !draft.jellyseerr_url && (
<Alert>
<AlertDescription>
Jellyseerr is enabled, but no URL is configured yet.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
@@ -1146,11 +1037,6 @@ export function Settings() {
ssh_private_key_passphrase: "",
password: "",
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,
},
machine,
@@ -1237,12 +1123,6 @@ export function Settings() {
ssh_private_key_passphrase: "",
password: "",
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,
},
selectedMachine,
@@ -48,11 +48,6 @@ function machine(
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
} 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", () => ({
useCounts: () => ({
data: { movies: 10, series: 5, episodes: 100 },
@@ -30,11 +30,6 @@ function machineFixture(
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
};
+12 -6
View File
@@ -34,11 +34,6 @@ function machineFixture(
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
};
@@ -103,7 +98,10 @@ let queryResult: MediaQueryResponse;
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
useSearchParams: () => [new URLSearchParams("machine_id=local"), vi.fn()],
useSearchParams: () => [
new URLSearchParams("jellyfin_service_id=jfs1"),
vi.fn(),
],
}));
vi.mock("../../hooks/useMedia", () => ({
@@ -118,6 +116,14 @@ vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [machineFixture()] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: [
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({ data: undefined }),
useLibraries: () => ({ data: undefined }),
@@ -53,11 +53,6 @@ function localMachine(
password_set: false,
media_root: "/mnt/media",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "Primary node",
...overrides,
} as MonitoringMachine;
-10
View File
@@ -175,11 +175,6 @@ export interface MonitoringMachine {
password_set: boolean;
media_root: 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;
}
@@ -200,11 +195,6 @@ export interface MonitoringMachineInput {
password: string;
media_root: string;
path_prefix: string;
jellyfin_url: string;
jellyfin_user_id: string;
jellyfin_api_key: string;
jellyseerr_url: string;
jellyseerr_api_key: string;
notes: string;
}
@@ -88,6 +88,26 @@ both.
- 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