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:
+24
-22
@@ -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,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user