From 26d9e1fbcfbed59f3364655c90ddbb557088996a Mon Sep 17 00:00:00 2001 From: lashman Date: Sun, 29 Mar 2026 20:19:19 +0300 Subject: [PATCH] request modal for radarr/sonarr --- src/components/request/RequestButton.tsx | 91 ++++ src/components/request/RequestModal.tsx | 505 ++++++++++++++++++ .../settings/OverrideRulesEditor.tsx | 377 +++++++++++++ 3 files changed, 973 insertions(+) create mode 100644 src/components/request/RequestButton.tsx create mode 100644 src/components/request/RequestModal.tsx create mode 100644 src/components/settings/OverrideRulesEditor.tsx diff --git a/src/components/request/RequestButton.tsx b/src/components/request/RequestButton.tsx new file mode 100644 index 0000000..63be31a --- /dev/null +++ b/src/components/request/RequestButton.tsx @@ -0,0 +1,91 @@ +import { Database, Check, RefreshCw, Clock } from '../../lib/icons' +import { useRequestModal } from '../../stores/request-modal-store' +import { useItemAvailability } from '../../hooks/use-availability' +import type { TmdbMovie, TmdbTvShow } from '../../api/tmdb' + +interface Props { + tmdbId: number | null | undefined + kind: 'movie' | 'tv' + tmdbData: TmdbMovie | TmdbTvShow | null + /** Visual style. 'pill' for hero rows, 'inline' for compact contexts. */ + variant?: 'pill' | 'inline' +} + +/** + * Renders a Request CTA when the item isn't available locally and an + * appropriate *arr instance exists. When *arr already tracks the item, + * morphs into a status pill (Downloading / Pending / etc.) so the user + * sees the request state without a confusing duplicate Request button. + * + * Hides itself entirely when no *arr instance is configured for the + * relevant kind, since there's nothing to request against. + */ +export default function RequestButton({ tmdbId, kind, tmdbData, variant = 'pill' }: Props) { + const show = useRequestModal(s => s.show) + const availability = useItemAvailability(tmdbId) + + if (!tmdbId) return null + + // Available locally - nothing to request. Hide. + if (availability?.status === 'available') return null + + // Note: we intentionally render the button even when the matching + // *arr (Radarr for movies, Sonarr for shows) isn't configured. The + // modal explains how to set it up. Hiding silently was confusing - + // users assumed the app didn't support requests at all. + + // Tracked by *arr already - render a status chip instead of "Request" + // so the user can see what stage their existing request is in. + if (availability && availability.status !== 'missing') { + const Icon = + availability.status === 'processing' ? RefreshCw + : availability.status === 'partial' ? Check + : Clock + const label = + availability.status === 'processing' ? 'Downloading' + : availability.status === 'partial' ? 'Partially downloaded' + : availability.status === 'pending' ? 'Pending release' + : 'Requested' + if (variant === 'inline') { + return ( + + + {label} + + ) + } + return ( + + + {label} + + ) + } + + function open() { + if (!tmdbId) return + show({ tmdbId, kind, tmdbData }) + } + + if (variant === 'inline') { + return ( + + ) + } + + return ( + + ) +} diff --git a/src/components/request/RequestModal.tsx b/src/components/request/RequestModal.tsx new file mode 100644 index 0000000..0b4d652 --- /dev/null +++ b/src/components/request/RequestModal.tsx @@ -0,0 +1,505 @@ +import { useEffect, useMemo, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { X, Database, Check, AlertCircle, Settings } from '../../lib/icons' +import { useArrInstances, type ArrInstance } from '../../stores/arr-instances-store' +import { useArrProfiles, useArrRootFolders, useSonarrLanguageProfiles } from '../../hooks/use-arr' +import { useOverrideRules, applyOverrideRules } from '../../stores/override-rules-store' +import { radarrClient } from '../../api/radarr' +import { sonarrClient, type SonarrSeason } from '../../api/sonarr' +import Select, { type SelectOption } from '../ui/Select' +import type { TmdbMovie, TmdbTvShow } from '../../api/tmdb' + +/** Merged shape that lets call sites access both movie and TV fields without + * rediscriminating the union at every read. */ +type TmdbDetail = Partial & Partial + +interface Props { + open: boolean + onClose: () => void + /** TMDB id of the item to request. */ + tmdbId: number + kind: 'movie' | 'tv' + /** TMDB metadata so we can pre-fill title / year / images for *arr. */ + tmdbData: TmdbMovie | TmdbTvShow | null +} + +/** + * One-shot request flow. Opens with a default tier (regular) and lets + * the user choose between regular/4K, quality profile, root folder, + * monitored, search-on-add, and per-season selection for shows. On + * submit we call Radarr's POST /movie or Sonarr's POST /series. + * + * Override rules (Wave 31) hook in here later: when the user opens + * the modal, we evaluate active rules against the TMDB metadata and + * pre-select tier + profile + root folder accordingly. + */ +export default function RequestModal({ open, onClose, tmdbId, kind, tmdbData }: Props) { + const qc = useQueryClient() + const navigate = useNavigate() + const radarr = useArrInstances(s => s.pick('radarr', 'default')) + const radarr4k = useArrInstances(s => s.pick('radarr', '4k')) + const sonarr = useArrInstances(s => s.pick('sonarr', 'default')) + const sonarr4k = useArrInstances(s => s.pick('sonarr', '4k')) + + const tierOptions: Array<{ tier: 'default' | '4k'; instance: ArrInstance | null }> = kind === 'movie' + ? [ + { tier: 'default', instance: radarr }, + { tier: '4k', instance: radarr4k }, + ] + : [ + { tier: 'default', instance: sonarr }, + { tier: '4k', instance: sonarr4k }, + ] + + // Evaluate override rules once per modal open so the tier and other + // selections start from the rule-driven values when one matches. + const rules = useOverrideRules(s => s.rules) + const matched = useMemo(() => { + if (!tmdbData) return null + const d = tmdbData as TmdbDetail + const ctx = { + kind, + genres: (d.genres || []).map(g => g.name).filter(Boolean) as string[], + originalLanguage: d.original_language || null, + year: parseInt( + String(d.release_date || d.first_air_date || '').slice(0, 4), + ) || null, + keywordIds: (d.keywords?.keywords || d.keywords?.results || []) + .map(k => k?.id) + .filter((x): x is number => typeof x === 'number'), + } + return applyOverrideRules(rules, ctx) + }, [rules, tmdbData, kind]) + + const [tier, setTier] = useState<'default' | '4k'>(matched?.tier || 'default') + const active = tierOptions.find(t => t.tier === tier)?.instance || null + + const profiles = useArrProfiles(active) + const rootFolders = useArrRootFolders(active) + const langProfiles = useSonarrLanguageProfiles(active) + + const [profileId, setProfileId] = useState(null) + const [rootFolder, setRootFolder] = useState(null) + const [langProfileId, setLangProfileId] = useState(null) + const [monitored, setMonitored] = useState(true) + const [searchOnAdd, setSearchOnAdd] = useState(true) + const [seasonsRequested, setSeasonsRequested] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null) + + // Initialise selections from the active instance's defaults whenever + // the user picks a different tier (or opens the modal). When a rule + // matched, its overrides take precedence over the instance defaults. + useEffect(() => { + if (!active) return + setProfileId(matched?.qualityProfileId ?? active.defaultQualityProfileId ?? null) + setRootFolder(matched?.rootFolderPath ?? active.defaultRootFolder ?? null) + setLangProfileId(matched?.languageProfileId ?? active.defaultLanguageProfileId ?? null) + setMonitored(active.defaultMonitored ?? true) + }, [active, matched]) + + // For shows, pre-populate the seasons-to-request map from the TMDB + // payload. All seasons selected by default; user can opt out per-season. + const tvSeasons = useMemo(() => { + if (kind !== 'tv' || !tmdbData) return [] + const seasons = (tmdbData as TmdbTvShow).seasons || [] + return seasons.filter(s => (s.season_number ?? 0) > 0 || s.episode_count > 0) + }, [kind, tmdbData]) + + useEffect(() => { + if (kind !== 'tv') return + const init: Record = {} + for (const s of tvSeasons) init[s.season_number ?? 0] = true + setSeasonsRequested(init) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tmdbData?.id]) + + // Esc closes. + useEffect(() => { + if (!open) return + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, onClose]) + + // Reset transient state when the modal closes so re-opening it for a + // different item starts fresh. + useEffect(() => { + if (!open) { + setResult(null) + setSubmitting(false) + } + }, [open]) + + // Auto-close after a successful request - the user gets a brief flash + // of confirmation, then we get out of their way. + useEffect(() => { + if (result?.ok) { + const id = setTimeout(() => onClose(), 1800) + return () => clearTimeout(id) + } + }, [result?.ok, onClose]) + + async function submit() { + if (!active || !profileId || !rootFolder) return + if (!tmdbData) return + setSubmitting(true) + setResult(null) + try { + if (kind === 'movie' && active.kind === 'radarr') { + const lookup = await radarrClient(active).lookupByTmdbId(tmdbId) + const match = lookup?.[0] + if (!match) { + setResult({ ok: false, message: "Radarr couldn't find this movie on TMDB - check the id." }) + return + } + const added = await radarrClient(active).addMovie({ + tmdbId, + qualityProfileId: profileId, + rootFolderPath: rootFolder, + monitored, + searchOnAdd, + title: match.title, + titleSlug: (match as any).titleSlug || '', + year: match.year || 0, + images: match.images, + minimumAvailability: 'released', + }) + if (added) { + setResult({ ok: true, message: `Requested "${match.title}". Radarr will start searching now.` }) + qc.invalidateQueries({ queryKey: ['radarr', 'library'] }) + qc.invalidateQueries({ queryKey: ['radarr', 'queue'] }) + } else { + setResult({ ok: false, message: 'Radarr rejected the request. It may already exist there.' }) + } + } else if (kind === 'tv' && active.kind === 'sonarr') { + const lookup = await sonarrClient(active).lookupByTmdbId(tmdbId) + const match = lookup?.[0] + if (!match || !match.tvdbId) { + setResult({ ok: false, message: "Sonarr couldn't find a TVDB id for this show. Check that Sonarr's metadata is populated." }) + return + } + const seasons: SonarrSeason[] = (match.seasons || []).map(s => ({ + seasonNumber: s.seasonNumber, + monitored: !!seasonsRequested[s.seasonNumber], + })) + const added = await sonarrClient(active).addSeries({ + tvdbId: match.tvdbId, + qualityProfileId: profileId, + languageProfileId: langProfileId ?? undefined, + rootFolderPath: rootFolder, + monitored, + searchOnAdd, + title: match.title, + titleSlug: (match as any).titleSlug || '', + year: match.year || 0, + images: match.images, + seasons, + }) + if (added) { + setResult({ ok: true, message: `Requested "${match.title}". Sonarr is monitoring the seasons you picked.` }) + qc.invalidateQueries({ queryKey: ['sonarr', 'library'] }) + qc.invalidateQueries({ queryKey: ['sonarr', 'queue'] }) + } else { + setResult({ ok: false, message: 'Sonarr rejected the request. It may already exist there.' }) + } + } else { + setResult({ ok: false, message: `No ${kind === 'movie' ? 'Radarr' : 'Sonarr'} instance configured for this tier.` }) + } + } catch (e: any) { + setResult({ ok: false, message: String(e?.message || e) }) + } finally { + setSubmitting(false) + } + } + + const noInstance = !radarr && !radarr4k && !sonarr && !sonarr4k + const noTierForKind = kind === 'movie' + ? !radarr && !radarr4k + : !sonarr && !sonarr4k + + return ( + + {open && ( + + e.stopPropagation()} + className="relative w-full max-w-xl rounded-2xl bg-surface ring-1 ring-border-strong shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] max-h-[88vh] overflow-y-auto" + > +
+
+ +

+ Request {kind === 'movie' ? 'movie' : 'show'} +

+
+ +
+ +
+ {tmdbData && (() => { const d = tmdbData as TmdbDetail; return ( +

+ {d.title || d.name} + {d.release_date && ( + + {String(d.release_date).slice(0, 4)} + + )} + {d.first_air_date && ( + + {String(d.first_air_date).slice(0, 4)} + + )} +

+ )})()} + + {noInstance || noTierForKind ? ( +
+ +
+

+ {noInstance + ? 'No Sonarr or Radarr instance configured' + : `No ${kind === 'movie' ? 'Radarr' : 'Sonarr'} instance configured`} +

+

+ {noInstance + ? 'Connect a Sonarr or Radarr instance to start requesting new titles.' + : `Add a ${kind === 'movie' ? 'Radarr' : 'Sonarr'} instance to request ${kind === 'movie' ? 'movies' : 'shows'}.`} +

+ +
+
+ ) : ( + <> + {/* Tier picker */} + {tierOptions.filter(t => t.instance).length > 1 && ( + +
+ {tierOptions.map(t => { + if (!t.instance) return null + const on = tier === t.tier + return ( + + ) + })} +
+
+ )} + + + setRootFolder(v || null)} + placeholder="Pick a folder" + options={(rootFolders.data || []).map>((r: any) => ({ + value: r.path, + label: {r.path}, + }))} + /> + + + {kind === 'tv' && langProfiles.data && langProfiles.data.length > 0 && ( + + setMonitored(e.target.checked)} + className="accent-amber-500" + /> + Monitor for new episodes / releases + + +
+ + {result && ( +
+ + {result.ok ? : } + {result.message} + +
+ )} + + )} + + +
+ + {!result?.ok && ( + + )} +
+
+
+ )} +
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} diff --git a/src/components/settings/OverrideRulesEditor.tsx b/src/components/settings/OverrideRulesEditor.tsx new file mode 100644 index 0000000..19076d7 --- /dev/null +++ b/src/components/settings/OverrideRulesEditor.tsx @@ -0,0 +1,377 @@ +import { useMemo, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Plus, Trash2, ChevronUp, ChevronDown } from '../../lib/icons' +import { useOverrideRules, type OverrideRule } from '../../stores/override-rules-store' +import { useArrInstances } from '../../stores/arr-instances-store' +import { useArrProfiles, useArrRootFolders } from '../../hooks/use-arr' +import Select, { type SelectOption } from '../ui/Select' + +const COMMON_GENRES = [ + 'Action', 'Adventure', 'Animation', 'Anime', 'Comedy', 'Crime', + 'Documentary', 'Drama', 'Family', 'Fantasy', 'History', 'Horror', + 'Music', 'Mystery', 'Romance', 'Science Fiction', 'Thriller', 'War', 'Western', +] + +const COMMON_LANGS = ['en', 'ja', 'ko', 'fr', 'es', 'it', 'de', 'zh', 'ru', 'pt', 'tr', 'ar'] + +/** + * UI for the override-rules store. Lists existing rules with reorder / + * delete affordances; expanding a row shows the editor inline. The + * "Add rule" button creates a permissive default the user can refine. + */ +export default function OverrideRulesEditor() { + const rules = useOverrideRules(s => s.rules) + const add = useOverrideRules(s => s.add) + const update = useOverrideRules(s => s.update) + const remove = useOverrideRules(s => s.remove) + const reorder = useOverrideRules(s => s.reorder) + const [editing, setEditing] = useState(null) + + function newRule() { + const created = add({ + name: 'Untitled rule', + kind: 'any', + genres: [], + languages: [], + keywordIds: [], + enabled: true, + }) + setEditing(created.id) + } + + return ( +
+
+

+ Rules evaluate top-to-bottom; the first match wins. Use them to route Anime to a different Sonarr instance, or 4K-only requests to a different root folder. +

+ +
+ + {rules.length === 0 ? ( +

No rules yet - requests will use each instance's defaults.

+ ) : ( +
    + {rules.map((rule, i) => ( + setEditing(editing === rule.id ? null : rule.id)} + onChange={patch => update(rule.id, patch)} + onRemove={() => { + if (confirm(`Delete rule "${rule.name}"?`)) remove(rule.id) + }} + onMove={dir => reorder(rule.id, dir)} + /> + ))} +
+ )} +
+ ) +} + +function RuleCard({ + rule, + expanded, + isFirst, + isLast, + onToggle, + onChange, + onRemove, + onMove, +}: { + rule: OverrideRule + expanded: boolean + isFirst: boolean + isLast: boolean + onToggle: () => void + onChange: (patch: Partial) => void + onRemove: () => void + onMove: (dir: -1 | 1) => void +}) { + const instances = useArrInstances(s => s.instances) + // Pick the instance the rule's tier + kind would route to so we can + // populate profile / root folder dropdowns from a real source. + const targetInstance = useMemo(() => { + if (rule.kind === 'movie') { + return instances.find(i => i.kind === 'radarr' && i.tier === (rule.tier || 'default')) || null + } + if (rule.kind === 'tv') { + return instances.find(i => i.kind === 'sonarr' && i.tier === (rule.tier || 'default')) || null + } + // For 'any', prefer Radarr default (the override fields apply to + // whichever kind the request is - we just need an instance to list + // valid choices from). + return instances.find(i => i.tier === (rule.tier || 'default')) || instances[0] || null + }, [instances, rule.kind, rule.tier]) + const profiles = useArrProfiles(targetInstance) + const rootFolders = useArrRootFolders(targetInstance) + return ( +
  • +
    + onChange({ enabled: e.target.checked })} + className="accent-amber-500" + aria-label="Rule enabled" + /> + + + + +
    + + + {expanded && ( + +
    + + onChange({ name: e.target.value })} + className="w-full h-9 px-3 rounded-md bg-void/40 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight" + /> + + + + onChange({ kind: v })} + options={[ + { value: 'any', label: 'Any' }, + { value: 'movie', label: 'Movies' }, + { value: 'tv', label: 'Shows' }, + ]} + /> + + + +
    + {COMMON_GENRES.map(g => { + const on = rule.genres.includes(g) + return ( + + ) + })} +
    +
    + + +
    + {COMMON_LANGS.map(l => { + const on = rule.languages.includes(l) + return ( + + ) + })} +
    +
    + + +
    + onChange({ yearMin: e.target.value ? Number(e.target.value) : undefined })} + placeholder="From" + className="flex-1 h-9 px-3 rounded-md bg-void/40 ring-1 ring-border focus:ring-accent/50 outline-none tabular-nums text-[12.5px]" + /> + + onChange({ yearMax: e.target.value ? Number(e.target.value) : undefined })} + placeholder="To" + className="flex-1 h-9 px-3 rounded-md bg-void/40 ring-1 ring-border focus:ring-accent/50 outline-none tabular-nums text-[12.5px]" + /> +
    +
    + +
    +

    + When matched, override: +

    + + onChange({ tier: v === 'inherit' ? undefined : (v as 'default' | '4k') })} + options={[ + { value: 'inherit', label: 'Inherit' }, + { value: 'default', label: 'Regular' }, + { value: '4k', label: '4K' }, + ]} + /> + + {!targetInstance && ( +

    + Connect a {rule.kind === 'tv' ? 'Sonarr' : rule.kind === 'movie' ? 'Radarr' : 'Sonarr or Radarr'} instance to pick a profile or folder. Until then this rule will inherit the instance defaults. +

    + )} + + onChange({ rootFolderPath: v || undefined })} + placeholder="Inherit from instance" + disabled={!targetInstance || !rootFolders.data} + options={[ + { value: '', label: 'Inherit from instance', muted: true } as SelectOption, + ...((rootFolders.data || []).map>((r: any) => ({ + value: r.path, + label: {r.path}, + }))), + ]} + /> + +
    +
    +
    + )} +
    +
  • + ) +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
    + + {children} + {hint &&

    {hint}

    } +
    + ) +} + +function Segmented({ + value, + onChange, + options, +}: { + value: T + onChange: (v: T) => void + options: { value: T; label: string }[] +}) { + return ( +
    + {options.map(o => ( + + ))} +
    + ) +} + +function summarise(rule: OverrideRule): string { + const parts: string[] = [] + if (rule.kind !== 'any') parts.push(rule.kind === 'movie' ? 'Movies' : 'Shows') + if (rule.genres.length > 0) parts.push(rule.genres.slice(0, 3).join(', ')) + if (rule.languages.length > 0) parts.push(rule.languages.join(' / ').toUpperCase()) + if (rule.yearMin != null || rule.yearMax != null) parts.push(`${rule.yearMin ?? '...'}-${rule.yearMax ?? '...'}`) + if (rule.tier) parts.push(`→ ${rule.tier === '4k' ? '4K tier' : 'Regular tier'}`) + if (rule.qualityProfileId) parts.push(`profile ${rule.qualityProfileId}`) + return parts.length > 0 ? parts.join(' · ') : 'Matches everything' +}