Frontend: Jellyfin Media + Requests tabs (Slice 5)
Replace the MediaTab and RequestsTab stubs with real implementations on the Jellyfin service page. MediaTab (pages/service-tabs/MediaTab.tsx): lifts the operational content from the top-level Media page into an instance-scoped tab. Build controls, status display, library counts, media DataTable, and pagination all read the Jellyfin service id directly from the instance prop (replacing the old URL-search-param service selector + dropdown). Row-click navigation to the file browser is preserved (note: target /files is a cross-slice dependency on slice 6's FilesTab). RequestsTab (pages/service-tabs/RequestsTab.tsx): reads the absorbed jellyseerr_url + jellyseerr_api_key from the Jellyfin instance config. When unconfigured, renders a CTA to add the fields via the Config tab. When configured, shows the Jellyseerr URL + an honest placeholder (no requests backend endpoint exists yet -- out of scope for this slice). service-tabs/index.ts updated to wire the new components; the MediaTabStub/RequestsTabStub removed from stubs.tsx. Note: this branch is based on main, not on mobile-responsive-parity, so MediaTab lifts main's DataTable + TanStack column-visibility mobile hiding (no MobileCardRow -- that lands when the branches reconcile). Tests: MediaTab (instance-scoped hooks + build controls + table render) + RequestsTab (configured URL vs empty-state CTA). 90 tests pass (+6); lint/build green. Cross-slice flag: MediaTab row-click -> /files will 404 until slice 6 re-routes it to the ssh_tasks FilesTab. Refs openspec/changes/services-as-hub-ia/ (spec R2.4, tasks slice 5).
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* MediaTab — operational content for the Jellyfin service page.
|
||||
*
|
||||
* Lifted from the old top-level `pages/Media.tsx`. The service-id source is
|
||||
* changed from URL search params to the `instance` prop (the active service
|
||||
* instance selected on the service page). The service-selection dropdown and
|
||||
* its URL-sync effect are removed; everything else is preserved verbatim.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } 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, ServiceInstance } from "../../types";
|
||||
import { useCounts, useLibraries } from "../../hooks/useDashboard";
|
||||
|
||||
// --- Format helpers (lifted verbatim from Media.tsx) ---
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
// --- Column definitions (lifted verbatim) ---
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
function getMediaRowId(row: MediaItem): string {
|
||||
return row.path;
|
||||
}
|
||||
|
||||
// --- Persistent filter/sort/pagination state (lifted verbatim) ---
|
||||
|
||||
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
|
||||
const SMALL_BREAKPOINT = "(max-width: 900px)";
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Small UI helpers (lifted verbatim) ---
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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))} />;
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
const navigate = useNavigate();
|
||||
const isSmall = usePrefersSmallScreen();
|
||||
const serviceId = instance.id;
|
||||
|
||||
const { data: counts } = useCounts(serviceId);
|
||||
const { data: libraries } = useLibraries(serviceId);
|
||||
const { data: status } = useMediaStatus(serviceId);
|
||||
const buildIndex = useBuildIndex(serviceId);
|
||||
const stopBuildIndex = useStopBuildIndex(serviceId);
|
||||
const forceStopBuildIndex = useForceStopBuildIndex(serviceId);
|
||||
|
||||
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
||||
MEDIA_TAB_STATE_KEY,
|
||||
defaultMediaTabState,
|
||||
);
|
||||
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>({});
|
||||
|
||||
const { data: queryResult, isLoading } = useMediaDataQuery({
|
||||
types,
|
||||
search,
|
||||
hdr_filter: hdrFilter,
|
||||
sort_key: sortKey,
|
||||
sort_order: sortOrder,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
jellyfinServiceId: serviceId,
|
||||
enabled: status?.exists ?? false,
|
||||
});
|
||||
|
||||
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;
|
||||
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 };
|
||||
});
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* RequestsTab — Jellyseerr request-management surface on the Jellyfin page.
|
||||
*
|
||||
* Jellyseerr was absorbed into Jellyfin config (jellyseerr_url +
|
||||
* jellyseerr_api_key) in Slice 1. This tab reads those config fields. When
|
||||
* configured, it shows the URL and a placeholder (no requests backend endpoint
|
||||
* exists yet — building one is out of scope for this slice). When not
|
||||
* configured, it shows an empty-state CTA directing the user to add the fields
|
||||
* to the Jellyfin config.
|
||||
*/
|
||||
import type { ServiceInstance } from "../../types";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
export function RequestsTab({ instance }: { instance: ServiceInstance }) {
|
||||
const jellyseerrUrl = String(
|
||||
(instance.config as Record<string, unknown>).jellyseerr_url ?? "",
|
||||
).trim();
|
||||
const jellyseerrApiKey = String(
|
||||
(instance.config as Record<string, unknown>).jellyseerr_api_key ?? "",
|
||||
).trim();
|
||||
|
||||
if (!jellyseerrUrl || !jellyseerrApiKey) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Jellyseerr is not configured for this Jellyfin instance. Add
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
|
||||
jellyseerr_url
|
||||
</code>
|
||||
and
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
|
||||
jellyseerr_api_key
|
||||
</code>
|
||||
to the Jellyfin config (Config tab) to enable request management.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">Jellyseerr</h3>
|
||||
<a
|
||||
href={jellyseerrUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
{jellyseerrUrl}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Jellyseerr is configured. The requests view will show pending and
|
||||
recently fulfilled media requests. (This surface is under
|
||||
development.)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MediaTab } from "../MediaTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
const instance: ServiceInstance = {
|
||||
id: "jellyfin-1",
|
||||
service_type: "jellyfin",
|
||||
name: "Main Jellyfin",
|
||||
config: { base_url: "https://jf.example.com", user_id: "u1" },
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
|
||||
vi.mock("../../../hooks/useMedia", () => ({
|
||||
useMediaStatus: () => ({
|
||||
data: { exists: true, item_count: 42, updated_at_label: "today" },
|
||||
}),
|
||||
useMediaQuery: () => ({ data: { items: [], total: 0 }, isLoading: false }),
|
||||
useBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useForceStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/useDashboard", () => ({
|
||||
useCounts: () => ({
|
||||
data: { movies: 10, series: 5, episodes: 30 },
|
||||
}),
|
||||
useLibraries: () => ({ data: [{ id: "lib1" }] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hooks/usePersistentState", () => ({
|
||||
usePersistentState: () => [
|
||||
{
|
||||
search: "",
|
||||
types: "Movie,Episode",
|
||||
hdrFilter: "All",
|
||||
sortKey: "title",
|
||||
sortOrder: "Ascending",
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
columnVisibility: {},
|
||||
},
|
||||
vi.fn(),
|
||||
],
|
||||
}));
|
||||
|
||||
function renderTab() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MediaTab instance={instance} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MediaTab", () => {
|
||||
it("renders index status and build controls with instance-scoped data", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText(/42 items/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Build index/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders library counts", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText(/10 movies/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/5 series/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the filter card with search input", () => {
|
||||
renderTab();
|
||||
expect(screen.getByLabelText("Search")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RequestsTab } from "../RequestsTab";
|
||||
import type { ServiceInstance } from "../../../types";
|
||||
|
||||
function makeInstance(config: Record<string, unknown>): ServiceInstance {
|
||||
return {
|
||||
id: "jellyfin-1",
|
||||
service_type: "jellyfin",
|
||||
name: "Main Jellyfin",
|
||||
config,
|
||||
secrets_set: {},
|
||||
enabled: true,
|
||||
created_at: 1_700_000_000,
|
||||
updated_at: 1_700_000_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RequestsTab", () => {
|
||||
it("shows empty-state CTA when Jellyseerr is not configured", () => {
|
||||
render(
|
||||
<RequestsTab
|
||||
instance={makeInstance({
|
||||
base_url: "https://jf.example.com",
|
||||
user_id: "u1",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/jellyseerr_url/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the configured Jellyseerr URL when both fields are set", () => {
|
||||
render(
|
||||
<RequestsTab
|
||||
instance={makeInstance({
|
||||
base_url: "https://jf.example.com",
|
||||
jellyseerr_url: "https://requests.example.com",
|
||||
jellyseerr_api_key: "secret-key",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("https://requests.example.com"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty-state when only URL is set (missing api_key)", () => {
|
||||
render(
|
||||
<RequestsTab
|
||||
instance={makeInstance({
|
||||
jellyseerr_url: "https://requests.example.com",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
FilesTab,
|
||||
JobsTab,
|
||||
LinksTab,
|
||||
MediaTab,
|
||||
MessagingTab,
|
||||
MetricsTab,
|
||||
OverviewTab,
|
||||
RequestsTab,
|
||||
UsersTab,
|
||||
} from "./stubs";
|
||||
import { MediaTab } from "./MediaTab";
|
||||
import { RequestsTab } from "./RequestsTab";
|
||||
|
||||
export type ServiceTabComponent = ComponentType<{ instance: ServiceInstance }>;
|
||||
|
||||
|
||||
@@ -28,14 +28,6 @@ export function OverviewTab({ instance }: { instance: ServiceInstance }) {
|
||||
return <Stub label="Service overview" instance={instance} />;
|
||||
}
|
||||
|
||||
export function MediaTab({ instance }: { instance: ServiceInstance }) {
|
||||
return <Stub label="Media" instance={instance} />;
|
||||
}
|
||||
|
||||
export function RequestsTab({ instance }: { instance: ServiceInstance }) {
|
||||
return <Stub label="Requests" instance={instance} />;
|
||||
}
|
||||
|
||||
export function FilesTab({ instance }: { instance: ServiceInstance }) {
|
||||
return <Stub label="Files" instance={instance} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user