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[] = [ { 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; }; 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 (
); } // 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 (
); } return ; } 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( 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) => setMediaState((current) => ({ ...current, ...patch })); const [rowSelection, setRowSelection] = useState({}); 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 = (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 = ( 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 (

Jellyfin

{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 && (
)}
); }