Files
silo-server/web/src/components/EditMetadataDialog.tsx
T
91e1164090 feat(metadata): local NFO metadata and sidecar artwork (builtin chain provider) (#390)
* feat(metadata): register builtin NFO provider and broaden parsing

Phases A and B of the #216 local-NFO work, implemented test-first.

Registration & hint-first identity (Phase A):
- Migration seeds a reserved kind='builtin' silo.builtin installation
  and an 'nfo' metadata capability (default_enabled=false, priority 1
  for movie/series) with a partial unique index and documented Down.
- In-process builtin provider registry (internal/metadata/builtin.go);
  buildProviders returns the registered provider for builtin rows.
- Guard rails keep the reserved row out of every plugin surface (user
  plugin-settings, installations list, image resolvers, preload,
  auto-update, store Delete, mutation handlers -> 409); silo.builtin is
  a reserved manifest id.
- Startup sync materializes legacy content_level='' chains per level,
  then appends builtin capabilities disabled via
  AppendProviderToAllChains (idempotent); resolveEnabledProvidersBy
  priority now respects default_enabled=false.
- NFO uniqueids seed the trusted-hint machinery via IdentityHintProvider
  with per-mode conflict policy (stored IDs win on scheduled refresh,
  NFO wins on manual refresh, Identify skips NFO); ID-less candidates
  are excluded from provider-priority tie-breaks and nfo never counts
  as corroboration.
- Web chain-editor empty-state gate is now server-derived so builtin
  providers are reachable on plugin-less servers.

Parser breadth & sidecar hardening (Phase B):
- Parser covers the practical Kodi/Jellyfin field set for <movie> and
  <tvshow>: original title, tagline, runtime, dates, content rating,
  genres/studios/countries/tags, multi-source ratings with scale
  normalization, cast with roles/order, director/credits. Empty
  collections stay nil so merge early-returns apply.
- findNFO parses candidates and falls through on read/parse failure or
  root-type mismatch, so a stray movie.nfo cannot shadow tvshow.nfo;
  GetMetadata gains the same ContentType guard Search has.
- New FieldReleaseDates lock gates Year/ReleaseDate/First+LastAirDate
  in merge (Go) and the edit-metadata dialog (web), closing the gap
  where a manual refresh re-applied NFO dates over admin corrections.
- Merge-contract tests pin NFO fill semantics, genres whole-list
  first-provider-wins, and NFO edits propagating on manual refresh only.
- Docs: new admin wiki page (supported fields, merge semantics,
  naming-supplies-structure contract), index bullet, sidecar wording
  revision, v1-scope feature-detection note.

Zero behavior change while the provider is disabled (default); pinned
by CI-mode and DB-gated test suites.

Part of #216

AI-use disclosure: implemented with Claude Code (Fable 5) via
spec-driven TDD and agent-assisted implementation.

* feat(metadata): ingest local sidecar artwork and read series-depth NFO

Phases C and D of the #216 local-NFO work, implemented test-first, plus
the mixed-library use-case pins. Together these deliver the headline
case: a series absent from every remote database (e.g. a fitness
library) scans into a fully presented show -> named seasons -> titled
episodes tree from NFO files and sidecar art alone.

Local sidecar artwork through the S3 image cache (Phase C):
- The NFO provider implements ImageProvider: poster/backdrop/logo
  sidecar discovery with a fixed precedence map, symlink/non-regular
  rejection, an 8 MiB cap, and file:// source URLs at rating 0. Generic
  filenames apply only via the sidecar search paths, so a shared
  folder.jpg in a flat multi-movie directory applies to none.
- file:// becomes a live local source scheme: routed into *_source_path
  (never *_path), accepted by every image enqueue gate, attributed as
  provider "local", excluded from cached-path detection.
- The image-cache processor caches local files with lexical-on-logical
  confinement to the library roots, open-handle reads with re-checks,
  the same variant widths as remote art, and stable (7-day) failure
  classification. Keys land under
  local/{contentType}/{contentID}/{hash8}/{imageType}; superseded
  prefixes are cleaned on re-cache and item deletion.
- applyIfBetter gains a local exemption so rating-0 local art can fill
  matched items without being stickily displaced; ImageRequest carries
  additive sidecar path context.

Series depth (Phase D):
- SeasonsRequest/EpisodesRequest carry additive local path context
  (series roots, per-season directories, per-episode file paths),
  derived from naming at match time and reconstructed on refresh.
- season.nfo supplies season name/plot; NFO season numbers are advisory
  (directory-derived number wins with a Warn - naming owns structure).
  <episodedetails> gains aired/runtime/ratings; <basename>.nfo titles
  episodes and <basename>-thumb.ext supplies thumbs; filename SxxEyy
  wins over NFO numbers.
- Episode NFOs work without a season.nfo (provider seasons unioned with
  on-disk seasons); SynthesizeFallbackEpisodes always runs after persist
  so NFO-less episodes keep synthesized rows. Season/episode file:// art
  rides the Phase C pipeline unchanged.
- Migration adds season:1/episode:1 to the builtin NFO capability's
  default_priority (still default_enabled=false).

Mixed sports-library use case (tests only, no product change):
- Pins the classification contract for one library holding movie-shaped
  and show-shaped content (WWE PPV events as movies next to a "WWE
  SmackDown" show, NASCAR/F1/FIFA with partial TVDB/TMDB data): naming
  decides movie-vs-series per file before any provider runs; the NFO
  supplies metadata/identity but never flips type (ContentType guard);
  the per-root Type override is the correction path.
- NFO-driven type classification at scan time is recorded as an explicit
  deferred open question.

Part of #216

AI-use disclosure: implemented with Claude Code (Fable 5) via
spec-driven TDD and agent-assisted implementation.

* docs(metadata): document local NFO metadata architecture

Add a single as-built architecture page
(docs/architecture/local-nfo-metadata.md) for the #216 local-NFO
feature: the builtin registration model, hint-first identity semantics,
the file:// -> S3 artwork pipeline and its deployment constraint, series
depth, the mixed-library classification contract, and known limitations.

This replaces the working implementation plan, the per-phase specs, and
the narrow sidecar-artwork note, which were planning drafts and are left
untracked; admin-facing behavior remains in the wiki.

Part of #216

AI-use disclosure: planned, drafted, and consolidated with Claude Code
(Fable 5) using multi-agent exploration and adversarial review.

* fix(metadata): address PR review findings on NFO builtin provider

Fold in the valid, low-risk fixes surfaced by automated review on #390:

- imagecache: extract validateCacheRequest so CacheBytes (the local
  sidecar season/episode path) enforces the same episode-requires-season
  guard as Cache, preventing distinct episodes' art from colliding under
  one S3 key.
- image_cache_processor: close the sidecar symlink-swap window by
  rejecting the opened handle unless os.SameFile matches the Lstat'd
  file, so a leaf swapped to a symlink can't pull an out-of-root target
  into the public cache.
- plugins: guard the reserved builtin installation row in the store's
  Update, matching Delete, so its version/enabled/capabilities can never
  be rewritten even if a mutation slips past the HTTP layer.
- cmd/silo: bound SyncBuiltinProviderChains with a 30s timeout so a stuck
  DB round-trip fails fast at startup instead of hanging.
- metadata: panic instead of silently no-op'ing on an invalid
  RegisterBuiltinProvider call (init-time programmer error).
- docs: correct the media-folder-and-naming NFO paragraph to state
  season/episode NFOs and sidecar artwork are actively read.

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-16 17:55:36 -04:00

731 lines
29 KiB
TypeScript

import { useState, useMemo, useCallback } from "react";
import { Lock, Unlock } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { MetadataTranslatePanel } from "@/components/MetadataTranslatePanel";
import { TagInput } from "@/components/TagInput";
import ImageSelectorTab from "@/components/ImageSelectorTab";
import type { ItemDetail } from "@/api/types";
import {
useUpdateItemMetadata,
useRefreshItemMetadata,
type UpdateItemMetadataRequest,
} from "@/hooks/queries/items";
import { useIsActingAdmin } from "@/hooks/useIsActingAdmin";
import { cn } from "@/lib/utils";
// MetadataField enum values matching internal/metadata/types.go
const FIELD_NAME = 0;
const FIELD_OVERVIEW = 1;
const FIELD_GENRES = 2;
const FIELD_STUDIOS = 3;
const FIELD_RATING = 6;
const FIELD_TAGS = 8;
const FIELD_RUNTIME = 7;
const FIELD_CONTENT_RATING = 9;
const FIELD_AIR_SCHEDULE = 11;
const FIELD_RELEASE_DATES = 13;
const AIR_TIMEZONES = [
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Europe/London",
"Europe/Paris",
"Asia/Tokyo",
"Asia/Seoul",
"Australia/Sydney",
];
type Section = "general" | "dates" | "tags" | "ids" | "images";
const SECTIONS: { key: Section; label: string; types: ItemDetail["type"][] }[] = [
{ key: "general", label: "General", types: ["movie", "series", "season", "episode"] },
{ key: "dates", label: "Dates & Ratings", types: ["movie", "series", "season", "episode"] },
{ key: "tags", label: "Tags & Genres", types: ["movie", "series"] },
{ key: "ids", label: "External IDs", types: ["movie", "series", "season", "episode"] },
// Episodes are excluded: the image search endpoint only returns series-level
// posters/backdrops/logos, and episodes store stills — there is nothing an
// episode apply could legitimately show or persist from this dialog.
{ key: "images", label: "Images", types: ["movie", "series", "season"] },
];
// Maps form field names to their MetadataField lock value.
const FIELD_LOCK_MAP: Record<string, number> = {
title: FIELD_NAME,
sort_title: FIELD_NAME,
original_title: FIELD_NAME,
tagline: FIELD_NAME,
overview: FIELD_OVERVIEW,
genres: FIELD_GENRES,
studios: FIELD_STUDIOS,
networks: FIELD_STUDIOS,
countries: FIELD_TAGS,
rating_imdb: FIELD_RATING,
rating_tmdb: FIELD_RATING,
rating_rt_critic: FIELD_RATING,
rating_rt_audience: FIELD_RATING,
runtime: FIELD_RUNTIME,
content_rating: FIELD_CONTENT_RATING,
air_time: FIELD_AIR_SCHEDULE,
air_timezone: FIELD_AIR_SCHEDULE,
year: FIELD_RELEASE_DATES,
release_date: FIELD_RELEASE_DATES,
first_air_date: FIELD_RELEASE_DATES,
last_air_date: FIELD_RELEASE_DATES,
};
interface EditMetadataDialogProps {
item: ItemDetail;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function initFormState(item: ItemDetail) {
return {
title: item.title ?? "",
sort_title: item.sort_title ?? "",
original_title: item.original_title ?? "",
overview: item.overview ?? "",
tagline: item.tagline ?? "",
content_rating: item.content_rating ?? "",
year: item.year ?? 0,
runtime: item.runtime ?? 0,
genres: item.genres ?? [],
studios: item.studios ?? [],
networks: item.networks ?? [],
countries: item.countries ?? [],
release_date: item.release_date ?? "",
first_air_date: item.first_air_date ?? "",
last_air_date: item.last_air_date ?? "",
air_time: item.air_time ?? "",
air_timezone: item.air_timezone ?? "",
air_date: item.air_date ?? "",
status: item.status ?? "",
rating_imdb: item.rating_imdb,
rating_tmdb: item.rating_tmdb,
rating_rt_critic: item.rating_rt_critic,
rating_rt_audience: item.rating_rt_audience,
imdb_id: item.imdb_id ?? "",
tmdb_id: item.tmdb_id ?? "",
tvdb_id: item.tvdb_id ?? "",
season_number: item.season_number ?? undefined,
episode_number: item.episode_number ?? undefined,
};
}
export default function EditMetadataDialog({ item, open, onOpenChange }: EditMetadataDialogProps) {
const [activeSection, setActiveSection] = useState<Section>("general");
const [form, setForm] = useState(() => initFormState(item));
const [lockedFields, setLockedFields] = useState<Set<number>>(
() => new Set(item.locked_fields ?? []),
);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const updateMutation = useUpdateItemMetadata(item.content_id);
const refreshMutation = useRefreshItemMetadata();
const isLockable = item.type === "movie" || item.type === "series";
const canEditImages = useIsActingAdmin();
const visibleSections = SECTIONS.filter(
(s) => s.types.includes(item.type) && (s.key !== "images" || canEditImages),
);
const effectiveActiveSection = visibleSections.some((section) => section.key === activeSection)
? activeSection
: "general";
const originalForm = useMemo(() => initFormState(item), [item]);
const setField = useCallback(
(field: string, value: unknown) => {
setForm((prev) => ({ ...prev, [field]: value }));
if (isLockable && field in FIELD_LOCK_MAP) {
const lockField = FIELD_LOCK_MAP[field] as number;
setLockedFields((prev) => new Set(prev).add(lockField));
}
},
[isLockable],
);
const toggleLock = useCallback((metadataField: number) => {
setLockedFields((prev) => {
const next = new Set(prev);
if (next.has(metadataField)) {
next.delete(metadataField);
} else {
next.add(metadataField);
}
return next;
});
}, []);
const lockedCount = lockedFields.size;
function handleSave() {
const data: UpdateItemMetadataRequest = {};
if (form.title !== originalForm.title) data.title = form.title;
if (form.sort_title !== originalForm.sort_title) data.sort_title = form.sort_title;
if (form.original_title !== originalForm.original_title)
data.original_title = form.original_title;
if (form.overview !== originalForm.overview) data.overview = form.overview;
if (form.tagline !== originalForm.tagline) data.tagline = form.tagline;
if (form.content_rating !== originalForm.content_rating)
data.content_rating = form.content_rating;
if (form.year !== originalForm.year) data.year = form.year;
if (form.runtime !== originalForm.runtime) data.runtime = form.runtime;
if (JSON.stringify(form.genres) !== JSON.stringify(originalForm.genres))
data.genres = form.genres;
if (JSON.stringify(form.studios) !== JSON.stringify(originalForm.studios))
data.studios = form.studios;
if (JSON.stringify(form.networks) !== JSON.stringify(originalForm.networks))
data.networks = form.networks;
if (JSON.stringify(form.countries) !== JSON.stringify(originalForm.countries))
data.countries = form.countries;
if (form.release_date !== originalForm.release_date)
data.release_date = form.release_date || null;
if (form.first_air_date !== originalForm.first_air_date)
data.first_air_date = form.first_air_date || null;
if (form.last_air_date !== originalForm.last_air_date)
data.last_air_date = form.last_air_date || null;
if (form.air_time !== originalForm.air_time) data.air_time = form.air_time || null;
if (form.air_timezone !== originalForm.air_timezone)
// Send "" (not null) when cleared: the server treats null as "skip", so
// clearing a previously-set timezone would not persist. "" is accepted by
// validation and normalized to NULL server-side.
data.air_timezone = form.air_timezone;
if (form.air_date !== originalForm.air_date) data.air_date = form.air_date || null;
if (form.status !== originalForm.status) data.status = form.status;
if (form.rating_imdb !== originalForm.rating_imdb) data.rating_imdb = form.rating_imdb;
if (form.rating_tmdb !== originalForm.rating_tmdb) data.rating_tmdb = form.rating_tmdb;
if (form.rating_rt_critic !== originalForm.rating_rt_critic)
data.rating_rt_critic = form.rating_rt_critic;
if (form.rating_rt_audience !== originalForm.rating_rt_audience)
data.rating_rt_audience = form.rating_rt_audience;
if (form.imdb_id !== originalForm.imdb_id) data.imdb_id = form.imdb_id;
if (form.tmdb_id !== originalForm.tmdb_id) data.tmdb_id = form.tmdb_id;
if (form.tvdb_id !== originalForm.tvdb_id) data.tvdb_id = form.tvdb_id;
if (form.season_number !== originalForm.season_number) data.season_number = form.season_number;
if (form.episode_number !== originalForm.episode_number)
data.episode_number = form.episode_number;
if (isLockable) {
const originalLocked = new Set(item.locked_fields ?? []);
const currentLocked = Array.from(lockedFields).sort();
const originalSorted = Array.from(originalLocked).sort();
if (JSON.stringify(currentLocked) !== JSON.stringify(originalSorted)) {
data.locked_fields = currentLocked;
}
}
if (Object.keys(data).length === 0) {
onOpenChange(false);
return;
}
updateMutation.mutate(data, {
onSuccess: () => onOpenChange(false),
});
}
function handleReset() {
refreshMutation.mutate({ item, mode: "quick" });
setShowResetConfirm(false);
onOpenChange(false);
}
function renderLockIcon(fieldName: string) {
if (!isLockable || !(fieldName in FIELD_LOCK_MAP)) return null;
const metadataField = FIELD_LOCK_MAP[fieldName] as number;
const isLocked = lockedFields.has(metadataField);
return (
<button
type="button"
onClick={() => toggleLock(metadataField)}
className={cn(
"inline-flex items-center gap-1 text-[10px] transition-colors",
isLocked
? "text-amber-500/80 hover:text-amber-500"
: "text-muted-foreground/30 hover:text-muted-foreground/60",
)}
title={isLocked ? "Locked — click to unlock" : "Unlocked — edits will auto-lock"}
>
{isLocked ? <Lock className="size-3" /> : <Unlock className="size-3" />}
{isLocked ? "locked" : ""}
</button>
);
}
const typeLabel =
item.type === "movie"
? "Movie"
: item.type === "series"
? "Series"
: item.type === "season"
? "Season"
: "Episode";
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl gap-0 overflow-hidden p-0 sm:max-w-5xl" showCloseButton>
{/* Header */}
<DialogHeader className="border-border/10 flex-row items-center justify-between border-b px-5 py-4">
<div className="flex items-center gap-2.5">
<DialogTitle className="text-[15px] font-semibold">Edit Metadata</DialogTitle>
<span className="bg-muted/50 text-muted-foreground rounded px-2 py-0.5 text-[11px]">
{typeLabel}
</span>
</div>
{isLockable && lockedCount > 0 && (
<span className="text-muted-foreground flex items-center gap-1 text-[11px]">
<Lock className="size-3" />
{lockedCount} locked
</span>
)}
</DialogHeader>
<div className="flex flex-col sm:flex-row" style={{ height: "min(70vh, 580px)" }}>
{/* Sidebar — horizontal tabs on mobile, vertical on sm+ */}
<nav className="border-border/10 flex flex-shrink-0 overflow-x-auto border-b bg-black/10 sm:w-[160px] sm:flex-col sm:overflow-x-visible sm:border-r sm:border-b-0 sm:py-2">
{visibleSections.map((section) => (
<button
key={section.key}
type="button"
onClick={() => setActiveSection(section.key)}
className={cn(
"px-4 py-2 text-left text-[13px] font-medium whitespace-nowrap transition-colors",
effectiveActiveSection === section.key
? "border-primary bg-primary/8 text-primary max-sm:border-b-2 sm:border-r-2"
: "text-muted-foreground hover:text-foreground",
)}
>
{section.label}
</button>
))}
</nav>
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 py-4 sm:px-6 sm:py-5">
{effectiveActiveSection === "general" && (
<div className="space-y-4">
<FieldRow label="Title" lockIcon={renderLockIcon("title")}>
<Input value={form.title} onChange={(e) => setField("title", e.target.value)} />
</FieldRow>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldRow label="Sort Title" lockIcon={renderLockIcon("sort_title")}>
<Input
value={form.sort_title}
onChange={(e) => setField("sort_title", e.target.value)}
/>
</FieldRow>
<FieldRow label="Original Title" lockIcon={renderLockIcon("original_title")}>
<Input
value={form.original_title}
onChange={(e) => setField("original_title", e.target.value)}
/>
</FieldRow>
</div>
<FieldRow label="Overview" lockIcon={renderLockIcon("overview")}>
<textarea
value={form.overview}
onChange={(e) => setField("overview", e.target.value)}
rows={4}
className="border-border bg-background text-foreground focus:border-ring focus:ring-ring/50 w-full resize-y rounded-md px-3 py-2.5 text-sm shadow-xs transition-[color,box-shadow] outline-none focus:ring-[3px]"
/>
</FieldRow>
<FieldRow label="Tagline" lockIcon={renderLockIcon("tagline")}>
<Input
value={form.tagline}
onChange={(e) => setField("tagline", e.target.value)}
placeholder="No tagline"
/>
</FieldRow>
{["movie", "series", "season", "episode"].includes(item.type) && (
<MetadataTranslatePanel item={item} />
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldRow label="Content Rating" lockIcon={renderLockIcon("content_rating")}>
<Input
value={form.content_rating}
onChange={(e) => setField("content_rating", e.target.value)}
/>
</FieldRow>
{item.type === "movie" && (
<FieldRow label="Runtime (min)" lockIcon={renderLockIcon("runtime")}>
<Input
type="number"
value={form.runtime || ""}
onChange={(e) => setField("runtime", parseInt(e.target.value) || 0)}
/>
</FieldRow>
)}
{item.type === "series" && (
<FieldRow label="Status">
<select
value={form.status}
onChange={(e) => setField("status", e.target.value)}
className="border-border bg-background text-foreground focus:border-ring focus:ring-ring/50 h-9 w-full rounded-md px-3 text-sm shadow-xs transition-[color,box-shadow] outline-none focus:ring-[3px]"
>
<option value="Continuing">Continuing</option>
<option value="Ended">Ended</option>
</select>
</FieldRow>
)}
{item.type === "season" && (
<FieldRow label="Season Number">
<Input
type="number"
value={form.season_number ?? ""}
onChange={(e) =>
setField("season_number", parseInt(e.target.value) || undefined)
}
/>
</FieldRow>
)}
{item.type === "episode" && (
<FieldRow label="Season Number">
<Input
type="number"
value={form.season_number ?? ""}
disabled
className="opacity-50"
/>
</FieldRow>
)}
</div>
{item.type === "episode" && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldRow label="Episode Number">
<Input
type="number"
value={form.episode_number ?? ""}
onChange={(e) =>
setField("episode_number", parseInt(e.target.value) || undefined)
}
/>
</FieldRow>
<FieldRow label="Runtime (min)" lockIcon={renderLockIcon("runtime")}>
<Input
type="number"
value={form.runtime || ""}
onChange={(e) => setField("runtime", parseInt(e.target.value) || 0)}
/>
</FieldRow>
</div>
)}
</div>
)}
{effectiveActiveSection === "dates" && (
<div className="space-y-4">
{(item.type === "movie" || item.type === "series") && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldRow label="Year" lockIcon={renderLockIcon("year")}>
<Input
type="number"
value={form.year || ""}
onChange={(e) => setField("year", parseInt(e.target.value) || 0)}
/>
</FieldRow>
{item.type === "movie" && (
<FieldRow label="Release Date" lockIcon={renderLockIcon("release_date")}>
<Input
type="date"
value={form.release_date}
onChange={(e) => setField("release_date", e.target.value)}
/>
</FieldRow>
)}
{item.type === "series" && (
<FieldRow
label="First Air Date"
lockIcon={renderLockIcon("first_air_date")}
>
<Input
type="date"
value={form.first_air_date}
onChange={(e) => setField("first_air_date", e.target.value)}
/>
</FieldRow>
)}
</div>
)}
{item.type === "series" && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FieldRow label="Last Air Date" lockIcon={renderLockIcon("last_air_date")}>
<Input
type="date"
value={form.last_air_date}
onChange={(e) => setField("last_air_date", e.target.value)}
/>
</FieldRow>
<FieldRow label="Air Time" lockIcon={renderLockIcon("air_time")}>
<Input
type="time"
value={form.air_time}
onChange={(e) => setField("air_time", e.target.value)}
/>
</FieldRow>
<FieldRow label="Air Timezone" lockIcon={renderLockIcon("air_timezone")}>
<Input
list="air-timezone-options"
value={form.air_timezone}
placeholder="America/New_York"
onChange={(e) => setField("air_timezone", e.target.value)}
/>
<datalist id="air-timezone-options">
{AIR_TIMEZONES.map((timezone) => (
<option key={timezone} value={timezone} />
))}
</datalist>
</FieldRow>
</div>
)}
{(item.type === "season" || item.type === "episode") && (
<FieldRow label="Air Date">
<Input
type="date"
value={form.air_date}
onChange={(e) => setField("air_date", e.target.value)}
/>
</FieldRow>
)}
{(item.type === "movie" || item.type === "series") && (
<>
<div className="text-muted-foreground pt-2 text-[11px] font-semibold tracking-wider uppercase">
Ratings
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldRow label="IMDb Rating" lockIcon={renderLockIcon("rating_imdb")}>
<Input
type="number"
step="0.1"
min="0"
max="10"
value={form.rating_imdb ?? ""}
onChange={(e) =>
setField(
"rating_imdb",
e.target.value ? parseFloat(e.target.value) : null,
)
}
/>
</FieldRow>
<FieldRow label="TMDB Rating" lockIcon={renderLockIcon("rating_tmdb")}>
<Input
type="number"
step="0.1"
min="0"
max="10"
value={form.rating_tmdb ?? ""}
onChange={(e) =>
setField(
"rating_tmdb",
e.target.value ? parseFloat(e.target.value) : null,
)
}
/>
</FieldRow>
<FieldRow
label="RT Critic Score"
lockIcon={renderLockIcon("rating_rt_critic")}
>
<Input
type="number"
min="0"
max="100"
value={form.rating_rt_critic ?? ""}
onChange={(e) =>
setField(
"rating_rt_critic",
e.target.value ? parseInt(e.target.value) : null,
)
}
/>
</FieldRow>
<FieldRow
label="RT Audience Score"
lockIcon={renderLockIcon("rating_rt_audience")}
>
<Input
type="number"
min="0"
max="100"
value={form.rating_rt_audience ?? ""}
onChange={(e) =>
setField(
"rating_rt_audience",
e.target.value ? parseInt(e.target.value) : null,
)
}
/>
</FieldRow>
</div>
</>
)}
</div>
)}
{effectiveActiveSection === "tags" && (
<div className="space-y-4">
<FieldRow label="Genres" lockIcon={renderLockIcon("genres")}>
<TagInput
value={form.genres}
onChange={(v) => setField("genres", v)}
placeholder="Add genre..."
/>
</FieldRow>
<FieldRow label="Studios" lockIcon={renderLockIcon("studios")}>
<TagInput
value={form.studios}
onChange={(v) => setField("studios", v)}
placeholder="Add studio..."
/>
</FieldRow>
{item.type === "series" && (
<FieldRow label="Networks" lockIcon={renderLockIcon("networks")}>
<TagInput
value={form.networks}
onChange={(v) => setField("networks", v)}
placeholder="Add network..."
/>
</FieldRow>
)}
<FieldRow label="Countries" lockIcon={renderLockIcon("countries")}>
<TagInput
value={form.countries}
onChange={(v) => setField("countries", v)}
placeholder="Add country..."
/>
</FieldRow>
</div>
)}
{effectiveActiveSection === "ids" && (
<div className="space-y-4">
<FieldRow label="IMDb ID">
<Input
value={form.imdb_id}
onChange={(e) => setField("imdb_id", e.target.value)}
placeholder="tt..."
/>
</FieldRow>
<FieldRow label="TMDB ID">
<Input
value={form.tmdb_id}
onChange={(e) => setField("tmdb_id", e.target.value)}
/>
</FieldRow>
<FieldRow label="TVDB ID">
<Input
value={form.tvdb_id}
onChange={(e) => setField("tvdb_id", e.target.value)}
/>
</FieldRow>
</div>
)}
{canEditImages && (
<div
className={
effectiveActiveSection === "images" ? "flex h-full flex-col" : "hidden"
}
>
<ImageSelectorTab item={item} enabled={effectiveActiveSection === "images"} />
</div>
)}
</div>
</div>
{/* Footer */}
<div className="border-border/10 flex flex-col-reverse gap-2 border-t px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3.5">
<div>
{isLockable && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowResetConfirm(true)}
className="text-muted-foreground hover:text-foreground"
>
Reset to Provider
</Button>
)}
</div>
<div className="flex gap-2 max-sm:w-full">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
className="max-sm:flex-1"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={updateMutation.isPending}
className="max-sm:flex-1"
>
{updateMutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<ConfirmDialog
open={showResetConfirm}
onOpenChange={setShowResetConfirm}
title="Reset to Provider"
description="This will unlock all fields and refresh metadata from your providers. Any manual edits will be overwritten on the next refresh."
confirmLabel="Reset & Refresh"
variant="destructive"
onConfirm={handleReset}
/>
</>
);
}
function FieldRow({
label,
lockIcon,
children,
}: {
label: string;
lockIcon?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div>
<div className="mb-1.5 flex items-center justify-between">
<Label className="text-muted-foreground text-[12px] font-medium">{label}</Label>
{lockIcon}
</div>
{children}
</div>
);
}