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.
This commit is contained in:
Developer
2026-06-23 12:10:27 +00:00
parent a13f560df2
commit cbb703341e
9 changed files with 113 additions and 89 deletions
+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>
@@ -26,6 +26,12 @@ 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 },
+7 -1
View File
@@ -103,7 +103,7 @@ 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 +118,12 @@ 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 }),