diff --git a/frontend/src/pages/service-tabs/MediaTab.tsx b/frontend/src/pages/service-tabs/MediaTab.tsx new file mode 100644 index 0000000..ad0ae5d --- /dev/null +++ b/frontend/src/pages/service-tabs/MediaTab.tsx @@ -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[] = [ + { 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; +}; + +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 ( +
+ + +
+ ); +} + +function BuildProgress({ value }: { value: number | null }) { + if (value == null) { + return ( +
+ ); + } + return ; +} + +// --- 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( + MEDIA_TAB_STATE_KEY, + defaultMediaTabState, + ); + const mediaState: MediaTabState = { + ...defaultMediaTabState(), + ...rawMediaState, + }; + const { search, types, hdrFilter, sortKey, sortOrder, offset, pageSize } = + mediaState; + const updateMediaState = (patch: Partial) => + setMediaState((current) => ({ ...current, ...patch })); + + const [rowSelection, setRowSelection] = useState({}); + + 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 = (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 = ( + 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 ( +
+
+ {status?.exists ? ( +

+ Index: {status.item_count.toLocaleString()} items + {status.updated_at_label + ? ` | updated ${status.updated_at_label}` + : ""} +

+ ) : ( + + No index built yet. + + )} + + {counts && ( +

+ Library stats: {counts.movies.toLocaleString()} movies ·{" "} + {counts.series.toLocaleString()} series ·{" "} + {counts.episodes.toLocaleString()} episodes ·{" "} + {(libraries?.length ?? 0).toLocaleString()} libraries +

+ )} + + + {buildRunning && ( + <> + + + + )} +
+ + {(buildRunning || status?.build_error) && ( +
+

+ {buildLabel || + (buildRunning + ? "Building media index..." + : status?.build_error || "")} +

+ +
+

+ Overall:{" "} + {buildProgress != null + ? `${Math.round(buildProgress * 100)}%` + : "pending"} + {buildRunning + ? ` • elapsed ${elapsedLabel} • eta ${etaLabel}` + : ""} +

+ +

+ {status?.build_items_processed?.toLocaleString() ?? 0}/ + {status?.build_items_total?.toLocaleString() ?? 0} items +

+
+ +
+

+ Current: {libraryLabel} + {buildRunning + ? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}` + : ""} +

+ +

+ {status?.build_library_items_processed?.toLocaleString() ?? 0}/ + {status?.build_library_items_total?.toLocaleString() ?? 0} items +

+
+
+ )} + + + +
+ + + updateMediaState({ search: e.target.value, offset: 0 }) + } + /> +
+
+ + 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" }, + ]} + /> +
+
+ + updateMediaState({ hdrFilter: value, offset: 0 }) + } + options={[ + { value: "All", label: "All" }, + { value: "HDR only", label: "HDR only" }, + { value: "SDR/unknown only", label: "SDR/unknown only" }, + ]} + /> +
+
+ 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" }, + ]} + /> +
+
+ updateMediaState({ sortOrder: value })} + options={[ + { value: "Ascending", label: "Ascending" }, + { value: "Descending", label: "Descending" }, + ]} + /> +
+
+
+ + {queryResult && ( +

+ Showing {queryResult.items.length} of {total.toLocaleString()} items | + Page {pageIndex + 1} of {totalPages} +

+ )} + + {status?.exists && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/pages/service-tabs/RequestsTab.tsx b/frontend/src/pages/service-tabs/RequestsTab.tsx new file mode 100644 index 0000000..1228650 --- /dev/null +++ b/frontend/src/pages/service-tabs/RequestsTab.tsx @@ -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).jellyseerr_url ?? "", + ).trim(); + const jellyseerrApiKey = String( + (instance.config as Record).jellyseerr_api_key ?? "", + ).trim(); + + if (!jellyseerrUrl || !jellyseerrApiKey) { + return ( + + + Jellyseerr is not configured for this Jellyfin instance. Add + + jellyseerr_url + + and + + jellyseerr_api_key + + to the Jellyfin config (Config tab) to enable request management. + + + ); + } + + return ( +
+
+

Jellyseerr

+ + {jellyseerrUrl} + + +
+ + + Jellyseerr is configured. The requests view will show pending and + recently fulfilled media requests. (This surface is under + development.) + + +
+ ); +} diff --git a/frontend/src/pages/service-tabs/__tests__/MediaTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/MediaTab.test.tsx new file mode 100644 index 0000000..370cc34 --- /dev/null +++ b/frontend/src/pages/service-tabs/__tests__/MediaTab.test.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx new file mode 100644 index 0000000..6e253be --- /dev/null +++ b/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx @@ -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): 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.getByText(/not configured/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/service-tabs/index.ts b/frontend/src/pages/service-tabs/index.ts index 4f16998..ff38e63 100644 --- a/frontend/src/pages/service-tabs/index.ts +++ b/frontend/src/pages/service-tabs/index.ts @@ -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 }>; diff --git a/frontend/src/pages/service-tabs/stubs.tsx b/frontend/src/pages/service-tabs/stubs.tsx index c8b070e..ae509a1 100644 --- a/frontend/src/pages/service-tabs/stubs.tsx +++ b/frontend/src/pages/service-tabs/stubs.tsx @@ -28,14 +28,6 @@ export function OverviewTab({ instance }: { instance: ServiceInstance }) { return ; } -export function MediaTab({ instance }: { instance: ServiceInstance }) { - return ; -} - -export function RequestsTab({ instance }: { instance: ServiceInstance }) { - return ; -} - export function FilesTab({ instance }: { instance: ServiceInstance }) { return ; }