Files
manage/frontend/src/pages/Media.tsx
T
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

565 lines
17 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import type {
ColumnDef,
OnChangeFn,
PaginationState,
RowSelectionState,
VisibilityState,
} from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useMediaStatus,
useMediaQuery as useMediaDataQuery,
useBuildIndex,
useStopBuildIndex,
useForceStopBuildIndex,
} from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState";
import type { MediaItem } from "../types";
import { useServiceInstances } from "../hooks/useServices";
import { useCounts, useLibraries } from "../hooks/useDashboard";
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(seconds)) return "-";
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
// TanStack table instance stays stable — an unstable columns array drops the
// controlled selection/visibility state (7a discovery). Visibility-only parity
// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere.
const mediaColumns: ColumnDef<MediaItem>[] = [
{ accessorKey: "title", header: "Title" },
{ accessorKey: "series", header: "Series" },
{ accessorKey: "season", header: "Season" },
{ accessorKey: "episode", header: "Episode" },
{ accessorKey: "type", header: "Type" },
{ accessorKey: "year", header: "Year" },
{ accessorKey: "runtime_min", header: "Runtime" },
{ accessorKey: "size", header: "Size" },
{ accessorKey: "bitrate", header: "Bitrate" },
{ accessorKey: "hdr", header: "HDR" },
{ accessorKey: "video", header: "Video codec" },
{ accessorKey: "resolution", header: "Resolution" },
{ accessorKey: "date_added", header: "Date added" },
{ accessorKey: "library", header: "Library" },
{ accessorKey: "path", header: "Path" },
];
// Stable path-derived identity so row selection survives server-driven paging
// (design §3.4): the id is the item's filesystem path, which is stable across
// limit/offset page changes.
function getMediaRowId(row: MediaItem): string {
return row.path;
}
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
const SMALL_BREAKPOINT = "(max-width: 900px)";
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
const MOBILE_HIDDEN_COLUMNS = [
"series",
"season",
"episode",
"bitrate",
"video",
"resolution",
"date_added",
"library",
"path",
];
type MediaTabState = {
search: string;
types: string;
hdrFilter: string;
sortKey: string;
sortOrder: string;
offset: number;
pageSize: number;
columnVisibility: Record<string, boolean>;
};
function defaultMediaTabState(): MediaTabState {
return {
search: "",
types: "Movie,Episode",
hdrFilter: "All",
sortKey: "title",
sortOrder: "Ascending",
offset: 0,
pageSize: 100,
columnVisibility: {},
};
}
function usePrefersSmallScreen(): boolean {
const supportsMatchMedia =
typeof window !== "undefined" && typeof window.matchMedia === "function";
const [small, setSmall] = useState(() =>
supportsMatchMedia ? window.matchMedia(SMALL_BREAKPOINT).matches : false,
);
useEffect(() => {
if (!supportsMatchMedia) return;
const mql = window.matchMedia(SMALL_BREAKPOINT);
const onChange = () => setSmall(mql.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [supportsMatchMedia]);
return small;
}
function FilterSelect({
id,
label,
value,
onChange,
options,
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id}>{label}</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// LinearProgress → Progress: determinate value drives the shadcn Progress; the
// indeterminate (null) case renders a pulsing bar, preserving the pre-rework
// "indeterminate" affordance for unknown build progress.
function BuildProgress({ value }: { value: number | null }) {
if (value == null) {
return (
<div className="h-1 w-full animate-pulse rounded-full bg-muted-foreground/30" />
);
}
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
}
export function Media() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isSmall = usePrefersSmallScreen();
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(
selectedServiceId || undefined,
);
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
MEDIA_TAB_STATE_KEY,
defaultMediaTabState,
);
// Backward-compat: merge defaults so older persisted state (pre-7b shape,
// without pageSize/columnVisibility) never yields undefined fields.
const mediaState: MediaTabState = {
...defaultMediaTabState(),
...rawMediaState,
};
const { search, types, hdrFilter, sortKey, sortOrder, offset, pageSize } =
mediaState;
const updateMediaState = (patch: Partial<MediaTabState>) =>
setMediaState((current) => ({ ...current, ...patch }));
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
useEffect(() => {
if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("jellyfin_service_id", selectedServiceId);
return next;
},
{ replace: true },
);
}
}, [searchParams, selectedServiceId, setSearchParams]);
const { data: queryResult, isLoading } = useMediaDataQuery({
types,
search,
hdr_filter: hdrFilter,
sort_key: sortKey,
sort_order: sortOrder,
limit: pageSize,
offset,
jellyfinServiceId: selectedServiceId || undefined,
enabled: status?.exists ?? false,
});
// Server-driven pagination (design §3.4): pageIndex/pageSize lift into the
// persistent media state and drive useMediaQuery { limit, offset }.
const pageIndex = Math.floor(offset / pageSize);
const pagination: PaginationState = { pageIndex, pageSize };
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next =
typeof updater === "function"
? updater({ pageIndex, pageSize })
: updater;
const nextPageSize = next.pageSize || pageSize;
// Restart at page 0 whenever the page size changes (keeps offset sane
// under server-driven paging).
const nextOffset =
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
setMediaState((current) => ({
...current,
offset: nextOffset,
pageSize: nextPageSize,
}));
};
const handleColumnVisibilityChange: OnChangeFn<VisibilityState> = (
updater,
) => {
setMediaState((current) => {
const prev = current.columnVisibility ?? {};
const next = typeof updater === "function" ? updater(prev) : updater;
return { ...current, columnVisibility: next };
});
};
// On small screens force the same set of columns hidden as the pre-rework
// DataGrid `columnVisibilityModel` mobile override; on desktop the user
// toggles freely (the toggleable set still equals the locked 15).
const effectiveColumnVisibility = useMemo(() => {
const base = mediaState.columnVisibility ?? {};
if (!isSmall) return base;
const merged = { ...base };
for (const key of MOBILE_HIDDEN_COLUMNS) merged[key] = false;
return merged;
}, [mediaState.columnVisibility, isSmall]);
// Preserved exactly from the DataGrid onRowClick: opens the file browser at
// the item's path.
const handleRowClick = (row: MediaItem) => {
navigate(`/files?path=${encodeURIComponent(row.path)}`);
};
const total = queryResult?.total ?? 0;
const totalPages = queryResult ? Math.max(1, Math.ceil(total / pageSize)) : 1;
const buildRunning = status?.build_running ?? false;
const buildProgress = status?.build_progress ?? null;
const buildLibraryProgress = status?.build_library_progress ?? null;
const buildCancelRequested = status?.build_cancel_requested ?? false;
const buildLabel = buildRunning
? status?.build_message || "Building media index..."
: status?.build_error
? `Build failed: ${status.build_error}`
: "";
const elapsedLabel = formatDuration(status?.build_elapsed_seconds);
const etaLabel =
buildRunning && status?.build_eta_seconds != null
? formatDuration(status.build_eta_seconds)
: "-";
const libraryElapsedLabel = formatDuration(
status?.build_library_elapsed_seconds,
);
const libraryEtaLabel =
buildRunning && status?.build_library_eta_seconds != null
? formatDuration(status.build_library_eta_seconds)
: "-";
const libraryLabel =
status?.build_current_library ||
(status?.build_library_index && status?.build_libraries_total
? `Library ${status.build_library_index} / ${status.build_libraries_total}`
: "Current library");
return (
<div className="flex flex-col gap-4">
<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-service">Service</Label>
<Select
value={selectedServiceId}
onValueChange={(value) =>
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("jellyfin_service_id", value);
return next;
},
{ replace: true },
)
}
>
<SelectTrigger id="media-service" className="w-full md:w-[220px]">
<SelectValue placeholder="Select a service" />
</SelectTrigger>
<SelectContent>
{jellyfinServices.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{status?.exists ? (
<p className="text-sm text-muted-foreground">
Index: {status.item_count.toLocaleString()} items
{status.updated_at_label
? ` | updated ${status.updated_at_label}`
: ""}
</p>
) : (
<Alert variant="destructive" className="py-0">
<AlertDescription>No index built yet.</AlertDescription>
</Alert>
)}
{counts && (
<p className="text-sm text-muted-foreground">
Library stats: {counts.movies.toLocaleString()} movies ·{" "}
{counts.series.toLocaleString()} series ·{" "}
{counts.episodes.toLocaleString()} episodes ·{" "}
{(libraries?.length ?? 0).toLocaleString()} libraries
</p>
)}
<Button
variant="outline"
onClick={() => buildIndex.mutate()}
disabled={
buildIndex.isPending || buildRunning || buildCancelRequested
}
>
{buildIndex.isPending || buildRunning ? "Building..." : "Build index"}
</Button>
{buildRunning && (
<>
<Button
variant="destructive"
onClick={() => stopBuildIndex.mutate()}
disabled={stopBuildIndex.isPending || buildCancelRequested}
>
{buildCancelRequested || stopBuildIndex.isPending
? "Stopping..."
: "Stop build"}
</Button>
<Button
variant="outline"
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
onClick={() => forceStopBuildIndex.mutate()}
disabled={forceStopBuildIndex.isPending}
>
{forceStopBuildIndex.isPending
? "Force stopping..."
: "Force stop"}
</Button>
</>
)}
</div>
{(buildRunning || status?.build_error) && (
<div className="flex w-full min-w-[260px] flex-col gap-2">
<p
className={
status?.build_error
? "text-sm text-destructive"
: "text-sm text-muted-foreground"
}
>
{buildLabel ||
(buildRunning
? "Building media index..."
: status?.build_error || "")}
</p>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">
Overall:{" "}
{buildProgress != null
? `${Math.round(buildProgress * 100)}%`
: "pending"}
{buildRunning
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
: ""}
</p>
<BuildProgress value={buildProgress} />
<p className="text-xs text-muted-foreground">
{status?.build_items_processed?.toLocaleString() ?? 0}/
{status?.build_items_total?.toLocaleString() ?? 0} items
</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">
Current: {libraryLabel}
{buildRunning
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
: ""}
</p>
<BuildProgress value={buildLibraryProgress} />
<p className="text-xs text-muted-foreground">
{status?.build_library_items_processed?.toLocaleString() ?? 0}/
{status?.build_library_items_total?.toLocaleString() ?? 0} items
</p>
</div>
</div>
)}
<Card>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-12">
<div className="col-span-1 flex flex-col gap-1.5 md:col-span-4">
<Label htmlFor="media-search">Search</Label>
<Input
id="media-search"
value={search}
onChange={(e) =>
updateMediaState({ search: e.target.value, offset: 0 })
}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-types"
label="Types"
value={types}
onChange={(value) =>
updateMediaState({ types: value, offset: 0 })
}
options={[
{ value: "Movie,Episode", label: "Movies + Episodes" },
{ value: "Movie", label: "Movies only" },
{ value: "Episode", label: "Episodes only" },
{ value: "Movie,Episode,Video", label: "All video" },
]}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-hdr"
label="HDR"
value={hdrFilter}
onChange={(value) =>
updateMediaState({ hdrFilter: value, offset: 0 })
}
options={[
{ value: "All", label: "All" },
{ value: "HDR only", label: "HDR only" },
{ value: "SDR/unknown only", label: "SDR/unknown only" },
]}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-sort"
label="Sort"
value={sortKey}
onChange={(value) => updateMediaState({ sortKey: value })}
options={[
{ value: "title", label: "Title" },
{ value: "series", label: "Series" },
{ value: "size", label: "Size" },
{ value: "bitrate", label: "Bitrate" },
{ value: "runtime", label: "Runtime" },
{ value: "year", label: "Year" },
{ value: "date_added", label: "Date added" },
{ value: "resolution", label: "Resolution" },
]}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-order"
label="Order"
value={sortOrder}
onChange={(value) => updateMediaState({ sortOrder: value })}
options={[
{ value: "Ascending", label: "Ascending" },
{ value: "Descending", label: "Descending" },
]}
/>
</div>
</CardContent>
</Card>
{queryResult && (
<p className="text-xs text-muted-foreground">
Showing {queryResult.items.length} of {total.toLocaleString()} items |
Page {pageIndex + 1} of {totalPages}
</p>
)}
{status?.exists && (
<div className="rounded-lg border bg-card">
<DataTable
columns={mediaColumns}
data={queryResult?.items ?? []}
getRowId={getMediaRowId}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={effectiveColumnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange}
enablePagination
manualPagination
pagination={pagination}
onPaginationChange={handlePaginationChange}
pageSizeOptions={[50, 100, 200]}
rowCount={total}
emptyMessage={
isLoading
? "Loading media..."
: "No media items match these filters."
}
/>
</div>
)}
</div>
);
}