"use client"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth"; import { useEffectiveRole } from "../lib/viewMode"; import PageHeading from "../ui/PageHeading"; import ResolutionChoice from "../ui/ResolutionChoice"; import IssueFlowStep from "./IssueFlowStep"; type PortalPermissions = { can_edit?: boolean; can_comment?: boolean; can_moderate?: boolean; can_delete?: boolean; can_confirm_resolution?: boolean; }; type PortalItem = { id: number; kind: "request" | "issue" | "feature"; title: string; description: string; media_type?: "movie" | "tv" | null; year?: number | null; external_ref?: string | null; source_system?: string | null; source_request_id?: number | null; status: string; priority: string; created_by_username: string; assignee_username?: string | null; created_at: string; updated_at: string; last_activity_at: string; permissions?: PortalPermissions; workflow?: { request_status?: string; media_status?: string; stage_label?: string; is_terminal?: boolean; }; issue?: { issue_type?: string; related_item_id?: number | null; is_resolved?: boolean; resolved_at?: string | null; workflow?: { current_step?: number; total_steps?: number; stage?: string; stage_label?: string; headline?: string; message?: string; state?: "active" | "attention" | "complete"; steps?: Array<{ key: string; label: string; state: "waiting" | "active" | "attention" | "complete"; }>; }; confirmation?: { status?: string | null; attempts_sent?: number; maximum_attempts?: number; last_contact_at?: string | null; next_contact_at?: string | null; interval_value?: number | null; interval_unit?: string | null; last_delivery_succeeded?: boolean | null; }; }; }; type PortalComment = { id: number; item_id: number; author_username: string; author_role: string; message: string; is_internal: boolean; created_at: string; }; type PortalActivity = { id: number | string; item_id: number; event_type: string; actor_username: string; actor_role: string; message: string; created_at: string; }; type PortalOverview = { overview?: { total_items?: number; total_comments?: number; by_kind?: Record; by_status?: Record; }; my_items?: number; }; type UserProfile = { username: string; role: string; }; type DiscoveryResult = { title: string; year?: number | null; type?: "movie" | "tv" | null; tmdbId?: number | null; requestId?: number | null; statusLabel?: string | null; status?: number | null; accessible?: boolean; posterPath?: string | null; backdropPath?: string | null; }; type IssueCategoryId = | "broken_media" | "wrong_content" | "missing_content" | "audio" | "subtitle" | "playback" | "service_unavailable"; type MediaServerStatus = { checked_at?: string; status: "up" | "degraded" | "down" | "not_configured"; headline: string; message: string; latency_ms?: number | null; server?: { version?: string | null; restart_pending?: boolean | null; }; activity?: { active_streams?: number | null; transcoding_streams?: number | null; available?: boolean; }; }; type IssueEpisodeOption = { id: number; season_number: number; episode_number: number; code: string; title: string; released: boolean; monitored: boolean; has_file: boolean; missing: boolean; best_fit: boolean; file_id?: number | null; }; type IssueSeasonOption = { season_number: number; label: string; episode_count: number; available_count: number; missing_count: number; best_fit: boolean; }; type IssueTargetOptions = { request_id: string; request_type: "movie" | "tv"; title: string; collector_id?: number | null; movie?: { selected_label: string; has_file: boolean; missing: boolean; best_fit: boolean; file_id?: number | null; } | null; seasons: IssueSeasonOption[]; episodes: IssueEpisodeOption[]; can_act: boolean; message?: string; }; const ISSUE_CATEGORIES: Array<{ id: IssueCategoryId; marker: string; label: string; description: string; outcome: string; issueType: string; titlePrefix: string; }> = [ { id: "broken_media", marker: "REPLACE", label: "Picture or file is broken", description: "Corruption, visual artefacts, freezing, or playback stopping at the same point.", outcome: "The affected file will be replaced automatically.", issueType: "broken_media", titlePrefix: "Replace media", }, { id: "wrong_content", marker: "WRONG FILE", label: "Wrong thing downloaded", description: "The movie, episode, cut, or edition does not match what it should be.", outcome: "The incorrectly matched file will be replaced automatically.", issueType: "wrong_content", titlePrefix: "Wrong download", }, { id: "missing_content", marker: "MISSING", label: "Movie or episode is missing", description: "A title, season, episode, or expected part is not available in Jellyfin.", outcome: "The selected missing content will be sent back to Sonarr or Radarr.", issueType: "missing_content", titlePrefix: "Missing content", }, { id: "audio", marker: "AUDIO", label: "Audio is wrong", description: "No sound, wrong language, commentary only, distorted audio, or audio out of sync.", outcome: "The affected file will be replaced automatically.", issueType: "audio", titlePrefix: "Audio problem", }, { id: "subtitle", marker: "SUBS", label: "Subtitles are wrong", description: "Missing, incorrect, forced, unreadable, or out-of-sync subtitles.", outcome: "Bazarr will find a fresh subtitle without replacing the video.", issueType: "subtitle", titlePrefix: "Subtitle problem", }, { id: "playback", marker: "PLAYBACK", label: "Playback or transcoding problem", description: "The title will not start, constantly buffers, stops, or reports a transcode error.", outcome: "Magent will check Jellyfin and replace only the selected file when appropriate.", issueType: "playback", titlePrefix: "Playback problem", }, { id: "service_unavailable", marker: "SERVER", label: "Nothing will play", description: "Jellyfin will not open or every title fails across the device or household.", outcome: "Magent will check Jellyfin and attach the result to the issue.", issueType: "service_unavailable", titlePrefix: "Media server unavailable", }, ]; const ISSUE_SYMPTOMS: Record = { broken_media: [ "Visual artefacts or corruption", "Freezes at the same point", "Stops before the end", "File will not play", ], wrong_content: [ "Different movie or show", "Wrong episode", "Episodes are labelled incorrectly", "Wrong cut or edition", ], missing_content: ["Entire title is missing", "Season is missing", "Episode is missing", "Part or edition is missing"], audio: ["No audio", "Wrong language", "Commentary track only", "Audio is out of sync", "Audio is distorted"], subtitle: ["Subtitles are missing", "Wrong subtitles", "Subtitles are out of sync", "Forced subtitles are missing"], playback: [ "Will not start", "Constant buffering", "Transcode error", "Stops during playback", "Only fails on one device", ], service_unavailable: [ "Jellyfin will not open", "Every title fails", "Login works but playback does not", "Server error is shown", ], }; const DEVICE_OPTIONS = ["TV app", "Web browser", "Phone or tablet", "Multiple devices"] as const; type IssueStep = "problem" | "media" | "symptoms" | "targets" | "devices" | "review"; const ISSUE_TYPE_LABELS: Record = { general: "General", playback: "Playback", transcode: "Transcoding", service_unavailable: "Server unavailable", broken_media: "Broken media", wrong_content: "Wrong download", missing_content: "Missing content", audio: "Audio", subtitle: "Subtitles", quality: "Quality", metadata: "Metadata", other: "Other", }; const STATUS_OPTIONS = [ { value: "new", label: "New" }, { value: "triaging", label: "Triaging" }, { value: "planned", label: "Planned" }, { value: "in_progress", label: "In progress" }, { value: "blocked", label: "Blocked" }, { value: "awaiting_confirmation", label: "Fixed - ask reporter to confirm" }, { value: "done", label: "Resolved (legacy)" }, { value: "pending", label: "Pending approval" }, { value: "approved", label: "Approved" }, { value: "processing", label: "Processing" }, { value: "partially_available", label: "Partially available" }, { value: "available", label: "Available" }, { value: "failed", label: "Failed" }, { value: "declined", label: "Declined" }, { value: "closed", label: "Closed" }, ] as const; const REQUEST_STATUS_OPTIONS = [ { value: "pending", label: "Pending approval" }, { value: "approved", label: "Approved" }, { value: "declined", label: "Declined" }, ] as const; const MEDIA_STATUS_OPTIONS = [ { value: "pending", label: "Pending" }, { value: "processing", label: "Processing" }, { value: "partially_available", label: "Partially available" }, { value: "available", label: "Available" }, { value: "failed", label: "Failed" }, { value: "unknown", label: "Unknown" }, ] as const; const PRIORITY_OPTIONS = [ { value: "low", label: "Low" }, { value: "normal", label: "Normal" }, { value: "high", label: "High" }, { value: "urgent", label: "Urgent" }, ] as const; const MEDIA_TYPE_OPTIONS = [ { value: "", label: "None" }, { value: "movie", label: "Movie" }, { value: "tv", label: "TV" }, ] as const; const REQUEST_FILTER_STATUS_OPTIONS = [ { value: "pending", label: "Pending approval" }, { value: "approved", label: "Approved" }, { value: "processing", label: "Processing" }, { value: "partially_available", label: "Partially available" }, { value: "available", label: "Available" }, { value: "failed", label: "Failed" }, { value: "declined", label: "Declined" }, ] as const; const ISSUE_FILTER_STATUS_OPTIONS = [ { value: "new", label: "New" }, { value: "triaging", label: "Triaging" }, { value: "planned", label: "Planned" }, { value: "in_progress", label: "In progress" }, { value: "blocked", label: "Blocked" }, { value: "awaiting_confirmation", label: "Waiting for confirmation" }, { value: "done", label: "Previously resolved" }, { value: "closed", label: "Closed" }, ] as const; const formatDate = (value?: string | null) => { if (!value) return "Never"; const parsed = new Date(value); if (Number.isNaN(parsed.valueOf())) return value; return parsed.toLocaleString(); }; const formatIssueStatus = (value?: string | null) => { const labels: Record = { new: "New", triaging: "Triaging", planned: "Planned", in_progress: "In progress", blocked: "Blocked", awaiting_confirmation: "Waiting for reporter confirmation", done: "Resolved", closed: "Closed", }; return labels[String(value ?? "").toLowerCase()] ?? String(value ?? "Unknown").replaceAll("_", " "); }; function IssuePipeline({ item, compact = false }: { item: PortalItem; compact?: boolean }) { const workflow = item.issue?.workflow; const steps = workflow?.steps ?? []; const currentStep = workflow?.current_step ?? 1; const totalSteps = workflow?.total_steps ?? 6; const state = workflow?.state ?? "active"; if (compact) { return (
{workflow?.stage_label ?? formatIssueStatus(item.status)} Step {currentStep} of {totalSteps}
); } return (
Issue progress

{workflow?.headline ?? formatIssueStatus(item.status)}

{workflow?.message ?? "The support team will update this issue as work progresses."}

Step {currentStep} of {totalSteps}
    {steps.map((step, index) => (
  1. {step.label}
  2. ))}
); } const toPositiveInt = (value: string) => { const parsed = Number.parseInt(value, 10); if (Number.isNaN(parsed) || parsed <= 0) return null; return parsed; }; type PortalWorkspace = "request" | "issue"; type PortalClientProps = { workspace: PortalWorkspace; }; export default function PortalClient({ workspace }: PortalClientProps) { const router = useRouter(); const [me, setMe] = useState(null); const [overview, setOverview] = useState(null); const [items, setItems] = useState([]); const [selectedItemId, setSelectedItemId] = useState(null); const [selectedItem, setSelectedItem] = useState(null); const [comments, setComments] = useState([]); const [activity, setActivity] = useState([]); const [loadingItems, setLoadingItems] = useState(true); const [loadingItem, setLoadingItem] = useState(false); const [creating, setCreating] = useState(false); const [saving, setSaving] = useState(false); const [commenting, setCommenting] = useState(false); const [respondingResolution, setRespondingResolution] = useState(false); const [deleteConfirming, setDeleteConfirming] = useState(false); const [deleting, setDeleting] = useState(false); const [error, setError] = useState(null); const [status, setStatus] = useState(null); const [totalItems, setTotalItems] = useState(0); const [hasMore, setHasMore] = useState(false); const filterKind = workspace; const [filterStatus, setFilterStatus] = useState(""); const [filterMine, setFilterMine] = useState(false); const [filterSearch, setFilterSearch] = useState(""); const [createTitle, setCreateTitle] = useState(""); const [createDescription, setCreateDescription] = useState(""); const [createMediaType, setCreateMediaType] = useState(""); const [createYear, setCreateYear] = useState(""); const [createExternalRef, setCreateExternalRef] = useState(""); const [createPriority, setCreatePriority] = useState<"low" | "normal" | "high" | "urgent">("normal"); const [editTitle, setEditTitle] = useState(""); const [editDescription, setEditDescription] = useState(""); const [editMediaType, setEditMediaType] = useState(""); const [editYear, setEditYear] = useState(""); const [editExternalRef, setEditExternalRef] = useState(""); const [editStatus, setEditStatus] = useState("new"); const [editIssueType, setEditIssueType] = useState("general"); const [editRequestStatus, setEditRequestStatus] = useState("pending"); const [editMediaStatus, setEditMediaStatus] = useState("pending"); const [editPriority, setEditPriority] = useState("normal"); const [editAssignee, setEditAssignee] = useState(""); const [commentText, setCommentText] = useState(""); const [commentInternal, setCommentInternal] = useState(false); const [preselectedItemId, setPreselectedItemId] = useState(null); const [discoverQuery, setDiscoverQuery] = useState(""); const [discoverLoading, setDiscoverLoading] = useState(false); const [discoverResults, setDiscoverResults] = useState([]); const [discoverError, setDiscoverError] = useState(null); const [requestingTmdbIds, setRequestingTmdbIds] = useState>({}); const [issueCategory, setIssueCategory] = useState(null); const [issueMediaTitle, setIssueMediaTitle] = useState(""); const [issueMediaType, setIssueMediaType] = useState<"movie" | "tv">("movie"); const [issueSymptoms, setIssueSymptoms] = useState([]); const [issueDevices, setIssueDevices] = useState([]); const [mediaServerStatus, setMediaServerStatus] = useState(null); const [mediaServerChecking, setMediaServerChecking] = useState(false); const [mediaServerError, setMediaServerError] = useState(null); const [issueMediaQuery, setIssueMediaQuery] = useState(""); const [issueMediaSearching, setIssueMediaSearching] = useState(false); const [issueMediaResults, setIssueMediaResults] = useState([]); const [issueSelectedMedia, setIssueSelectedMedia] = useState(null); const [issueOptions, setIssueOptions] = useState(null); const [issueOptionsLoading, setIssueOptionsLoading] = useState(false); const [issueOptionsMessage, setIssueOptionsMessage] = useState(null); const [issueStep, setIssueStep] = useState("problem"); const issueOptionsVersion = useRef(0); const [activeSeasonNumber, setActiveSeasonNumber] = useState(null); const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState([]); const [selectedEpisodeIds, setSelectedEpisodeIds] = useState([]); const effectiveRole = useEffectiveRole(me?.role); const isAdmin = effectiveRole === "admin"; const isOwner = (item: PortalItem) => me?.username === item.created_by_username; const canConfirmResolution = (item: PortalItem) => Boolean(item.permissions?.can_confirm_resolution && (isAdmin || isOwner(item))); const canEditSelected = Boolean(selectedItem?.permissions?.can_edit && (isAdmin || isOwner(selectedItem))); const canModerateSelected = Boolean(isAdmin && selectedItem?.permissions?.can_moderate); const canDeleteSelected = Boolean(isAdmin && selectedItem?.permissions?.can_delete); const visibleComments = comments.filter((comment) => isAdmin || !comment.is_internal); const visibleActivity = activity.filter((entry) => isAdmin || entry.event_type !== "internal_note_added"); const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0); const workspaceLabel = workspace === "request" ? "request" : "issue"; const workspaceLabelPlural = workspace === "request" ? "requests" : "issues"; const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null; const issueNeedsServerCheck = issueCategory === "playback" || issueCategory === "service_unavailable"; const issueNeedsDevices = issueNeedsServerCheck || issueCategory === "audio" || issueCategory === "subtitle"; const issueRequiresExistingFile = issueCategory === "broken_media" || issueCategory === "wrong_content" || issueCategory === "audio" || issueCategory === "subtitle" || issueCategory === "playback"; const issueSupportsReplacement = issueCategory === "broken_media" || issueCategory === "wrong_content" || issueCategory === "audio" || (issueCategory === "playback" && mediaServerStatus?.status !== "down"); const selectedEpisodeOptions = (issueOptions?.episodes ?? []).filter((episode) => selectedEpisodeIds.includes(episode.id), ); const selectedReplacementFileIds = Array.from( new Set( selectedEpisodeOptions .map((episode) => episode.file_id) .filter((fileId): fileId is number => typeof fileId === "number" && fileId > 0), ), ); const missingEntireTitle = issueSymptoms.includes("Entire title is missing"); const missingSeasons = issueSymptoms.includes("Season is missing"); const missingEpisodes = issueSymptoms.includes("Episode is missing") || issueSymptoms.includes("Part or edition is missing"); const selectedMissingEpisodeIds = (issueOptions?.episodes ?? []) .filter((episode) => { if (missingEntireTitle) return episode.missing; if (missingEpisodes && selectedEpisodeIds.includes(episode.id)) return true; return missingSeasons && episode.missing && selectedSeasonNumbers.includes(episode.season_number); }) .map((episode) => episode.id); const movieTargetAvailable = !issueRequiresExistingFile || Boolean(issueOptions?.movie?.has_file); const issueTargetReady = Boolean( issueOptions && issueSymptoms.length > 0 && (issueOptions.request_type === "movie" ? movieTargetAvailable : issueCategory === "missing_content" ? missingEntireTitle || ((missingSeasons ? selectedSeasonNumbers.length > 0 : true) && (missingEpisodes ? selectedEpisodeIds.length > 0 : true)) : selectedEpisodeIds.length > 0), ); const issueNeedsTvTargets = issueOptions?.request_type === "tv" && !missingEntireTitle; const issueSteps: IssueStep[] = [ "problem", "media", "symptoms", ...(issueNeedsTvTargets ? ["targets" as const] : []), ...(issueNeedsDevices ? ["devices" as const] : []), "review", ]; const stepProps = (step: IssueStep) => ({ number: issueSteps.indexOf(step) + 1, active: issueStep === step, complete: issueSteps.indexOf(issueStep) > issueSteps.indexOf(step), onEdit: () => { if (issueOptionsLoading) { issueOptionsVersion.current += 1; setIssueOptionsLoading(false); } setIssueStep(step); }, }); const afterTargets: IssueStep = issueNeedsDevices ? "devices" : "review"; useEffect(() => { if (isAdmin) return; setDeleteConfirming(false); if (commentInternal) { // Do not turn an unfinished internal note into a public comment when preview changes. setCommentText(""); setCommentInternal(false); } }, [isAdmin, commentInternal]); useEffect(() => { if (typeof window === "undefined") return; const params = new URLSearchParams(window.location.search); const raw = params.get("item"); const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed); const requestId = Number.parseInt(params.get("reportRequest") ?? "", 10); const title = params.get("title")?.trim(); const type = params.get("type"); const rawYear = Number.parseInt(params.get("year") ?? "", 10); if (requestId > 0 && title && (type === "movie" || type === "tv")) { const media: DiscoveryResult = { title, year: rawYear >= 1870 && rawYear <= 2200 ? rawYear : null, type, requestId, statusLabel: "Ready to watch", accessible: true, }; setIssueSelectedMedia(media); setIssueMediaTitle(media.title); setIssueMediaType(type); setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ""}`); setIssueStep("problem"); } }, []); const loadMe = async () => { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/auth/me`); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return null; } throw new Error(`Failed to load session (${response.status})`); } const data = await response.json(); const profile: UserProfile = { username: data?.username ?? "unknown", role: data?.role ?? "user", }; setMe(profile); return profile; }; const loadOverview = async () => { try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/portal/overview?kind=${workspace}`); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } throw new Error(`Failed to load portal overview (${response.status})`); } const data = await response.json(); setOverview(data); } catch (err) { console.error(err); } }; const loadItem = async (itemId: number) => { setLoadingItem(true); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/portal/items/${itemId}`); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } if (response.status === 404) { setSelectedItem(null); setComments([]); setActivity([]); return; } throw new Error(`Failed to load portal item (${response.status})`); } const data = await response.json(); const item = (data?.item ?? null) as PortalItem | null; setSelectedItem(item); setComments(Array.isArray(data?.comments) ? data.comments : []); setActivity(Array.isArray(data?.activity) ? data.activity : []); } catch (err) { console.error(err); setError("Could not load portal item details."); } finally { setLoadingItem(false); } }; const loadItems = async (options?: { preferItemId?: number | null }) => { setLoadingItems(true); try { const baseUrl = getApiBase(); const params = new URLSearchParams({ limit: "60", offset: "0", }); params.set("kind", filterKind); if (filterStatus) params.set("status", filterStatus); if (filterMine) params.set("mine", "1"); const trimmedSearch = filterSearch.trim(); if (trimmedSearch) params.set("search", trimmedSearch); const response = await authFetch(`${baseUrl}/portal/items?${params.toString()}`); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } throw new Error(`Failed to load portal items (${response.status})`); } const data = await response.json(); const loadedItems = Array.isArray(data?.items) ? (data.items as PortalItem[]) : []; setItems(loadedItems); setTotalItems(Number(data?.total ?? loadedItems.length ?? 0)); setHasMore(Boolean(data?.has_more)); const preferred = options?.preferItemId ?? selectedItemId ?? preselectedItemId; if (preferred && loadedItems.some((item) => item.id === preferred)) { setSelectedItemId(preferred); } else if (loadedItems.length > 0 && workspace === "request") { setSelectedItemId(loadedItems[0].id); } else { setSelectedItemId(null); setSelectedItem(null); setComments([]); } } catch (err) { console.error(err); setError("Could not load portal items."); } finally { setLoadingItems(false); } }; const resolveTmdbArtworkUrl = (path?: string | null, size: "w185" | "w342" = "w185") => { if (!path) return null; const normalized = path.startsWith("/") ? path : `/${path}`; return `https://image.tmdb.org/t/p/${size}${normalized}`; }; const runDiscoverySearch = async (event?: React.FormEvent) => { if (event) event.preventDefault(); const query = discoverQuery.trim(); if (!query) { setDiscoverResults([]); setDiscoverError("Enter a title to search."); return; } setDiscoverLoading(true); setDiscoverError(null); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(query)}`); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } const text = await response.text(); throw new Error(text || `Search failed (${response.status})`); } const data = await response.json(); const mapped: DiscoveryResult[] = Array.isArray(data?.results) ? data.results.map((item: Record) => ({ title: typeof item.title === "string" ? item.title : "Untitled", year: typeof item.year === "number" ? item.year : null, type: item.type === "movie" || item.type === "tv" ? item.type : null, tmdbId: typeof item.tmdbId === "number" ? item.tmdbId : null, requestId: typeof item.requestId === "number" ? item.requestId : null, statusLabel: typeof item.statusLabel === "string" ? item.statusLabel : null, status: typeof item.status === "number" ? item.status : null, accessible: Boolean(item.accessible), posterPath: typeof item.posterPath === "string" ? item.posterPath : null, backdropPath: typeof item.backdropPath === "string" ? item.backdropPath : null, })) : []; setDiscoverResults(mapped); } catch (err) { console.error(err); setDiscoverResults([]); setDiscoverError(err instanceof Error ? err.message : "Search failed."); } finally { setDiscoverLoading(false); } }; const requestDiscoveryItem = async (item: DiscoveryResult) => { if (!item.tmdbId || !item.type) { setError("Could not request this result because required media details are missing."); return; } const key = `${item.type}:${item.tmdbId}`; setRequestingTmdbIds((prev) => ({ ...prev, [key]: true })); setError(null); setStatus(null); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/requests/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mediaType: item.type, tmdbId: item.tmdbId, }), }); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } const text = await response.text(); throw new Error(text || `Request failed (${response.status})`); } const data = await response.json(); const requestId = typeof data?.requestId === "number" ? data.requestId : null; const statusLabel = typeof data?.statusLabel === "string" ? data.statusLabel : item.statusLabel; const statusCode = typeof data?.statusCode === "number" ? data.statusCode : item.status; setDiscoverResults((prev) => prev.map((entry) => entry.tmdbId === item.tmdbId && entry.type === item.type ? { ...entry, requestId, statusLabel, status: statusCode, accessible: true, } : entry, ), ); if (requestId) { const mode = data?.status === "exists" ? "already exists" : "created"; setStatus(`Request ${mode}. Open request #${requestId} for the full pipeline.`); } else { setStatus("Request submitted."); } await Promise.all([loadItems(), loadOverview()]); } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not create request."); } finally { setRequestingTmdbIds((prev) => { const next = { ...prev }; delete next[key]; return next; }); } }; const loadIssueOptions = async (media: DiscoveryResult) => { const version = ++issueOptionsVersion.current; setIssueOptionsLoading(false); setIssueOptions(null); setActiveSeasonNumber(null); setSelectedSeasonNumbers([]); setSelectedEpisodeIds([]); if (!media.requestId) { setIssueOptionsMessage("This title is not linked to a Magent request yet."); return; } setIssueOptionsLoading(true); setIssueOptionsMessage(null); try { const response = await authFetch(`${getApiBase()}/requests/${media.requestId}/issue-options`); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } const text = await response.text(); throw new Error(text || "Could not load seasons and episodes from Sonarr/Radarr."); } const payload = (await response.json()) as IssueTargetOptions; if (version !== issueOptionsVersion.current) return; const verifiedMedia: DiscoveryResult = { ...media, title: payload.title, type: payload.request_type, }; setIssueSelectedMedia(verifiedMedia); setIssueMediaTitle(verifiedMedia.title); setIssueMediaType(payload.request_type); setIssueOptions(payload); setIssueOptionsMessage(payload.message ?? null); const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0]; setActiveSeasonNumber(firstSeason?.season_number ?? null); setIssueStep("symptoms"); } catch (err) { if (version !== issueOptionsVersion.current) return; console.error(err); setIssueOptionsMessage( err instanceof Error ? err.message : "Could not load seasons and episodes from Sonarr/Radarr.", ); } finally { if (version === issueOptionsVersion.current) setIssueOptionsLoading(false); } }; const searchIssueMedia = async (event?: React.FormEvent) => { event?.preventDefault(); const query = issueMediaQuery.trim(); if (!query) { setError("Enter the movie or TV show you are having trouble with."); return; } setIssueMediaSearching(true); setError(null); setIssueMediaResults([]); issueOptionsVersion.current += 1; setIssueOptionsLoading(false); setIssueSelectedMedia(null); setIssueMediaTitle(""); setIssueOptions(null); setActiveSeasonNumber(null); setSelectedSeasonNumbers([]); setSelectedEpisodeIds([]); try { const response = await authFetch(`${getApiBase()}/requests/search?query=${encodeURIComponent(query)}`); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } throw new Error("Magent could not search the media catalogue."); } const payload = await response.json(); const results: DiscoveryResult[] = Array.isArray(payload?.results) ? payload.results .filter((item: Record) => item.type === "movie" || item.type === "tv") .map((item: Record) => ({ title: typeof item.title === "string" ? item.title : "Untitled", year: typeof item.year === "number" ? item.year : null, type: item.type, tmdbId: typeof item.tmdbId === "number" ? item.tmdbId : null, requestId: typeof item.requestId === "number" ? item.requestId : null, statusLabel: typeof item.statusLabel === "string" ? item.statusLabel : null, status: typeof item.status === "number" ? item.status : null, accessible: Boolean(item.accessible), posterPath: typeof item.posterPath === "string" ? item.posterPath : null, backdropPath: typeof item.backdropPath === "string" ? item.backdropPath : null, })) : []; setIssueMediaResults(results.slice(0, 12)); if (results.length === 0) { setError("No matching movie or TV show was found. Try the title without a year."); } } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Magent could not search the media catalogue."); } finally { setIssueMediaSearching(false); } }; const selectIssueMedia = (media: DiscoveryResult) => { setIssueSymptoms([]); setIssueDevices([]); setIssueSelectedMedia(media); setIssueMediaTitle(media.title); setIssueMediaType(media.type === "tv" ? "tv" : "movie"); setIssueMediaResults([]); setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ""}`); setError(null); void loadIssueOptions(media); }; const checkMediaServer = async () => { setMediaServerChecking(true); setMediaServerError(null); try { const response = await authFetch(`${getApiBase()}/portal/issues/media-status`); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } throw new Error("The live media-server check is temporarily unavailable."); } const payload = (await response.json()) as MediaServerStatus; setMediaServerStatus(payload); } catch (err) { console.error(err); setMediaServerStatus(null); setMediaServerError( err instanceof Error ? err.message : "The live media-server check is temporarily unavailable.", ); } finally { setMediaServerChecking(false); } }; const chooseIssueCategory = (category: IssueCategoryId) => { setIssueCategory(category); setIssueSymptoms([]); setIssueDevices([]); setMediaServerStatus(null); setMediaServerError(null); setError(null); setStatus(null); const shouldLoadSelectedRequest = Boolean(issueSelectedMedia?.requestId && !issueOptions); setIssueStep(issueSelectedMedia && issueOptions ? "symptoms" : "media"); setSelectedSeasonNumbers([]); setSelectedEpisodeIds([]); if (category === "playback" || category === "service_unavailable") { void checkMediaServer(); } if (shouldLoadSelectedRequest && issueSelectedMedia) void loadIssueOptions(issueSelectedMedia); }; const toggleStringChoice = (value: string, setter: React.Dispatch>) => { setter((current) => (current.includes(value) ? current.filter((item) => item !== value) : [...current, value])); }; const runIssueAction = async (path: string, body: Record): Promise => { const response = await authFetch(`${getApiBase()}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!response.ok) { let detail = "The follow-up action could not be started."; try { const payload = await response.json(); if (typeof payload?.detail === "string") detail = payload.detail; } catch { // Keep the plain-language fallback. } throw new Error(detail); } const payload = await response.json(); return typeof payload?.message === "string" ? payload.message : "The follow-up action started."; }; const createGuidedIssue = async (event: React.FormEvent) => { event.preventDefault(); if (creating || issueStep !== "review") return; if (!selectedIssueDefinition || !issueCategory) { setError("Choose the problem that best matches what you are seeing."); return; } const cleanMediaTitle = issueMediaTitle.trim(); if (!cleanMediaTitle || !issueSelectedMedia?.requestId || !issueOptions) { setError("Search for and select a tracked movie or TV show first."); return; } if (issueSymptoms.length === 0) { setError("Choose what needs to be corrected."); return; } const isMovie = issueOptions.request_type === "movie"; if (isMovie && !movieTargetAvailable) { setError("There is no managed movie file available for this repair. Report it as missing instead."); return; } if (!isMovie && issueCategory === "missing_content" && missingSeasons && selectedSeasonNumbers.length === 0) { setError("Choose at least one missing season."); return; } if (!isMovie && issueCategory === "missing_content" && missingEpisodes && selectedEpisodeIds.length === 0) { setError("Choose at least one missing episode."); return; } if (!isMovie && issueCategory !== "missing_content" && selectedEpisodeIds.length === 0) { setError("Choose at least one affected episode."); return; } const movieFileId = issueOptions.movie?.file_id; const actionFileIds = isMovie ? (typeof movieFileId === "number" ? [movieFileId] : []) : selectedReplacementFileIds; if (issueSupportsReplacement && actionFileIds.length === 0) { setError("Sonarr/Radarr does not report a replaceable file for the selected content."); return; } setCreating(true); setError(null); setStatus(null); try { const diagnosticLines: string[] = []; if (mediaServerStatus) { diagnosticLines.push( `Media server check: ${mediaServerStatus.headline}`, `Checked: ${formatDate(mediaServerStatus.checked_at)}`, ); if (typeof mediaServerStatus.latency_ms === "number") { diagnosticLines.push(`Response time: ${mediaServerStatus.latency_ms} ms`); } if (mediaServerStatus.server?.restart_pending) { diagnosticLines.push("Server restart pending: yes"); } if (mediaServerStatus.activity?.available) { diagnosticLines.push( `Active streams: ${mediaServerStatus.activity.active_streams ?? 0}`, `Active transcodes: ${mediaServerStatus.activity.transcoding_streams ?? 0}`, ); } } else if (issueNeedsServerCheck) { diagnosticLines.push("Media server check: unavailable at the time of reporting"); } const description = [ `Problem: ${selectedIssueDefinition.label}`, `What needs correction: ${issueSymptoms.join(", ")}`, cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null, cleanMediaTitle ? `Media type: ${issueMediaType === "tv" ? "TV show" : "Movie"}` : null, issueSelectedMedia?.requestId ? `Magent request: #${issueSelectedMedia.requestId}` : null, selectedSeasonNumbers.length ? `Seasons: ${selectedSeasonNumbers.map((season) => `Season ${season}`).join(", ")}` : null, selectedEpisodeOptions.length ? `Episodes: ${selectedEpisodeOptions.map((episode) => episode.code).join(", ")}` : null, issueDevices.length ? `Devices: ${issueDevices.join(", ")}` : null, ...diagnosticLines, ] .filter((line): line is string => Boolean(line)) .join("\n"); const titleTarget = cleanMediaTitle; const resolvedIssueType = issueCategory === "playback" && issueSymptoms.some((symptom) => symptom.toLowerCase().includes("transcode")) ? "transcode" : selectedIssueDefinition.issueType; const priority = mediaServerStatus?.status === "down" || issueCategory === "service_unavailable" ? "high" : "normal"; const response = await authFetch(`${getApiBase()}/portal/items`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind: "issue", title: `${selectedIssueDefinition.titlePrefix}: ${titleTarget}`, description, issue_type: resolvedIssueType, media_type: cleanMediaTitle ? issueMediaType : null, external_ref: issueSelectedMedia?.requestId ? `/requests/${issueSelectedMedia.requestId}` : null, priority, }), }); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } const text = await response.text(); throw new Error(text || "Could not submit the issue."); } const data = await response.json(); const item = data?.item as PortalItem | undefined; let completionMessage = item?.id ? `Issue #${item.id} submitted.` : "Issue submitted."; const requestId = issueSelectedMedia.requestId; const actionBase = `/requests/${requestId}/actions`; let actionMessage = ""; if (issueOptions.can_act === false) { actionMessage = "Support has been given the selected title and affected content."; } else if (issueCategory === "missing_content") { actionMessage = await runIssueAction(`${actionBase}/search-missing`, { issue_id: item?.id ?? null, episode_ids: selectedMissingEpisodeIds, season_numbers: selectedSeasonNumbers, }); } else if (issueCategory === "subtitle") { actionMessage = await runIssueAction(`${actionBase}/repair-subtitles`, { issue_id: item?.id ?? null, episode_ids: isMovie ? [] : selectedEpisodeIds, forced: issueSymptoms.includes("Forced subtitles are missing"), }); } else if (issueSupportsReplacement) { actionMessage = await runIssueAction(`${actionBase}/replace`, { issue_id: item?.id ?? null, file_ids: actionFileIds, confirmed: true, }); } if (actionMessage) completionMessage = `${completionMessage} ${actionMessage}`; setStatus(completionMessage); setError(null); setIssueCategory(null); setIssueMediaTitle(""); setIssueMediaType("movie"); setIssueSymptoms([]); setIssueDevices([]); setMediaServerStatus(null); setIssueMediaQuery(""); setIssueMediaResults([]); setIssueSelectedMedia(null); setIssueOptions(null); setIssueOptionsMessage(null); setIssueStep("problem"); setActiveSeasonNumber(null); setSelectedSeasonNumbers([]); setSelectedEpisodeIds([]); await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()]); } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not submit the issue."); } finally { setCreating(false); } }; // biome-ignore lint/correctness/useExhaustiveDependencies: Bootstrap loaders are deliberately coordinated once per route mount. useEffect(() => { if (!getToken()) { router.push("/login"); return; } const bootstrap = async () => { try { setError(null); await loadMe(); await Promise.all([loadOverview(), loadItems({ preferItemId: preselectedItemId })]); } catch (err) { console.error(err); setError("Could not load request portal."); } }; void bootstrap(); }, [router]); // biome-ignore lint/correctness/useExhaustiveDependencies: Filter values intentionally trigger the loader without recreating it. useEffect(() => { if (!getToken()) { return; } void loadItems({ preferItemId: preselectedItemId }); }, [filterStatus, filterMine, filterSearch, workspace]); useEffect(() => { void workspace; setFilterStatus(""); setCreateMediaType(""); setCreateYear(""); setSelectedItemId(null); setSelectedItem(null); setComments([]); setActivity([]); }, [workspace]); // biome-ignore lint/correctness/useExhaustiveDependencies: Selection is the lifecycle key for this request. useEffect(() => { if (selectedItemId == null) return; void loadItem(selectedItemId); }, [selectedItemId]); useEffect(() => { if (!selectedItem) return; setEditTitle(selectedItem.title ?? ""); setEditDescription(selectedItem.description ?? ""); setEditMediaType(selectedItem.media_type ?? ""); setEditYear(selectedItem.year == null ? "" : String(selectedItem.year)); setEditExternalRef(selectedItem.external_ref ?? ""); setEditStatus(selectedItem.status ?? "new"); setEditIssueType(selectedItem.issue?.issue_type ?? "general"); setEditRequestStatus(selectedItem.workflow?.request_status ?? "pending"); setEditMediaStatus(selectedItem.workflow?.media_status ?? "pending"); setEditPriority(selectedItem.priority ?? "normal"); setEditAssignee(selectedItem.assignee_username ?? ""); }, [selectedItem]); const createItem = async (event: React.FormEvent) => { event.preventDefault(); setCreating(true); setError(null); setStatus(null); try { const payload: Record = { kind: workspace, title: createTitle, description: createDescription, media_type: workspace === "request" ? createMediaType || null : null, year: workspace === "request" && createYear.trim() ? toPositiveInt(createYear) : null, external_ref: createExternalRef || null, priority: createPriority, }; const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/portal/items`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } const text = await response.text(); throw new Error(text || "Could not create portal item."); } const data = await response.json(); const item = data?.item as PortalItem | undefined; setStatus(workspace === "request" ? "Request item created." : "Issue item created."); setCreateTitle(""); setCreateDescription(""); setCreateMediaType(""); setCreateYear(""); setCreateExternalRef(""); setCreatePriority("normal"); await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()]); } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not create portal item."); } finally { setCreating(false); } }; const saveItem = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedItem || !canEditSelected) return; setSaving(true); setError(null); setStatus(null); try { const payload: Record = { title: editTitle, description: editDescription, media_type: editMediaType || null, year: editYear.trim() ? toPositiveInt(editYear) : null, external_ref: editExternalRef || null, }; if (canModerateSelected) { if (selectedItem.kind === "request") { payload.request_status = editRequestStatus; payload.media_status = editMediaStatus; } else { payload.status = editStatus; payload.issue_type = editIssueType; } payload.priority = editPriority; payload.assignee_username = editAssignee || null; } const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } const text = await response.text(); throw new Error(text || "Could not update portal item."); } const data = await response.json(); setSelectedItem((data?.item ?? null) as PortalItem | null); setComments(Array.isArray(data?.comments) ? data.comments : []); setActivity(Array.isArray(data?.activity) ? data.activity : []); setStatus("Portal item updated."); await Promise.all([loadItems({ preferItemId: selectedItem.id }), loadOverview()]); } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not update portal item."); } finally { setSaving(false); } }; const postComment = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedItem) return; if (commentInternal && !isAdmin) return; if (!commentText.trim()) { setError("Comment message is required."); return; } setCommenting(true); setError(null); setStatus(null); try { const baseUrl = getApiBase(); const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}/comments`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: commentText, is_internal: isAdmin && commentInternal, }), }); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } const text = await response.text(); throw new Error(text || "Could not add comment."); } setCommentText(""); setCommentInternal(false); setStatus("Comment added."); await Promise.all([loadItem(selectedItem.id), loadItems({ preferItemId: selectedItem.id }), loadOverview()]); } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not add comment."); } finally { setCommenting(false); } }; const respondToResolution = async (resolved: boolean) => { if (!selectedItem || !canConfirmResolution(selectedItem)) return; setRespondingResolution(true); setError(null); setStatus(null); try { const response = await authFetch(`${getApiBase()}/portal/issues/${selectedItem.id}/resolution-response`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ resolved }), }); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } const text = await response.text(); throw new Error(text || "Could not record your confirmation."); } const data = await response.json(); setSelectedItem((data?.item ?? null) as PortalItem | null); setComments(Array.isArray(data?.comments) ? data.comments : []); setActivity(Array.isArray(data?.activity) ? data.activity : []); setStatus( resolved ? "Thanks. This issue has been closed." : "Thanks. The issue is back in progress for another look.", ); await Promise.all([loadItems({ preferItemId: selectedItem.id }), loadOverview()]); } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not record your confirmation."); } finally { setRespondingResolution(false); } }; const deleteIssue = async () => { if (selectedItem?.kind !== "issue" || !canDeleteSelected) return; setDeleting(true); setError(null); setStatus(null); const issueId = selectedItem.id; try { const response = await authFetch(`${getApiBase()}/portal/items/${issueId}`, { method: "DELETE", }); if (!response.ok) { if (response.status === 401) { clearToken(); router.push("/login"); return; } const payload = await response.json().catch(() => null); throw new Error(payload?.detail || `Could not delete issue (${response.status})`); } closeIssueModal(); setStatus(`Issue #${issueId} was deleted. The linked media request was not changed.`); await Promise.all([loadItems(), loadOverview()]); } catch (err) { console.error(err); setError(err instanceof Error ? err.message : "Could not delete the issue."); } finally { setDeleting(false); } }; const closeIssueModal = () => { setSelectedItemId(null); setSelectedItem(null); setComments([]); setActivity([]); setCommentText(""); setDeleteConfirming(false); }; useEffect(() => { void selectedItemId; setDeleteConfirming(false); }, [selectedItemId]); // biome-ignore lint/correctness/useExhaustiveDependencies: Modal cleanup is keyed by workspace and selected item. useEffect(() => { if (workspace !== "issue" || selectedItemId == null) return; const previousOverflow = document.body.style.overflow; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") closeIssueModal(); }; document.body.style.overflow = "hidden"; window.addEventListener("keydown", closeOnEscape); return () => { document.body.style.overflow = previousOverflow; window.removeEventListener("keydown", closeOnEscape); }; }, [workspace, selectedItemId]); if (loadingItems && !items.length) { return
Loading {workspace === "issue" ? "issues" : "requests"}...
; } return (
{visibleKindCount} reported {visibleKindCount === 1 ? "issue" : "issues"} ) : undefined } /> {workspace === "issue" && items .filter( (item) => item.status === "awaiting_confirmation" && canConfirmResolution(item) && item.created_by_username === me?.username, ) .map((item) => (

Is it fixed?

{item.title}

Answer YES or NO →
))} {workspace === "request" ? (
) : null} {error &&
{error}
} {status &&
{status}
} {workspace === "request" ? (

Search and request content

Search Seerr content directly, then submit a request in one click.

setDiscoverQuery(event.target.value)} placeholder="Search movies or TV shows" />
{discoverError &&
{discoverError}
}
{discoverLoading ? (
Searching Seerr…
) : discoverResults.length === 0 ? (
No discovery results yet.
) : ( discoverResults.map((item, index) => { const key = `${item.type ?? "unknown"}:${item.tmdbId ?? index}`; const requesting = Boolean(requestingTmdbIds[key]); const poster = resolveTmdbArtworkUrl(item.posterPath, "w185"); const hasRequest = typeof item.requestId === "number" && item.requestId > 0; return (
{poster ? ( ) : (
No artwork
)}
{item.title || "Untitled"} {item.type ?? "unknown"} {item.year ? {item.year} : null}

{hasRequest ? ( <> Already requested {item.statusLabel ? ` · ${item.statusLabel}` : ""} {item.requestId ? ` · #${item.requestId}` : ""} ) : ( "Not requested yet" )}

{hasRequest ? ( ) : ( )}
); }) )}
) : (
{issueSelectedMedia?.requestId ? (
Reporting a problem with {issueSelectedMedia.title} {issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ""} Request #{issueSelectedMedia.requestId} is already selected.
Back to request
) : null}
{ISSUE_CATEGORIES.map((category) => ( ))}
{selectedIssueDefinition && issueCategory ? ( <>
{issueMediaResults.length > 0 ? (
{issueMediaResults.map((media, index) => { const poster = resolveTmdbArtworkUrl(media.posterPath, "w185"); return ( ); })}
) : null} {issueSelectedMedia ? (
Selected title {issueSelectedMedia.title} {issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ""} {issueSelectedMedia.type === "tv" ? "TV show" : "Movie"} {issueSelectedMedia.requestId ? ` · Magent request #${issueSelectedMedia.requestId} · ${issueSelectedMedia.statusLabel ?? "Tracked"}` : " · No existing Magent request"}
{issueSelectedMedia.requestId ? ( ) : null}
) : null} {issueOptionsLoading ? (
{issueSelectedMedia?.type === "movie" ? "Checking the movie file" : "Reading seasons and episodes"}
) : null} {issueOptionsMessage && !issueOptions ? (
{issueOptionsMessage}
) : null}
{issueSelectedMedia && !issueOptionsLoading ? (
{issueOptions ? ( ) : issueSelectedMedia.requestId ? ( ) : null}
) : null}
{issueOptions ? ( <>
Choose all that apply
{(issueOptions.request_type === "movie" && issueCategory === "missing_content" ? ["Entire title is missing"] : ISSUE_SYMPTOMS[issueCategory] ).map((symptom) => { const selected = issueSymptoms.includes(symptom); return ( ); })}
{issueOptions.request_type === "movie" && !movieTargetAvailable ? (
No managed movie file is available to repair.
) : null}
{issueNeedsTvTargets ? ( `Season ${season}`), ...selectedEpisodeOptions.map((episode) => episode.code), ].join(", ")} > {issueSymptoms.length > 0 && issueOptions.request_type === "tv" && !missingEntireTitle ? (
{missingSeasons ? "Choose the missing seasons" : "Choose a season"} You can select more than one.
{missingSeasons ? (
{issueOptions.seasons.map((season) => { const selected = selectedSeasonNumbers.includes(season.season_number); return ( ); })}
) : null} {issueCategory !== "missing_content" || missingEpisodes ? (
{issueOptions.seasons.map((season) => ( ))}
) : null} {(issueCategory !== "missing_content" || missingEpisodes) && activeSeasonNumber !== null ? (
{issueOptions.episodes .filter( (episode) => episode.season_number === activeSeasonNumber && episode.released, ) .map((episode) => { const selected = selectedEpisodeIds.includes(episode.id); const disabled = issueRequiresExistingFile && !episode.has_file; return ( ); })}
) : null}
) : null}
{selectedSeasonNumbers.length ? `${selectedSeasonNumbers.length} season(s) selected` : ""} {selectedSeasonNumbers.length && selectedEpisodeIds.length ? " · " : ""} {selectedEpisodeIds.length ? `${selectedEpisodeIds.length} episode(s) selected` : ""}
) : null} {issueNeedsDevices ? ( {issueTargetReady && (issueNeedsServerCheck || issueCategory === "audio" || issueCategory === "subtitle") ? (
Choose all that apply
{DEVICE_OPTIONS.map((device) => ( ))}

{issueDevices.length ? `Selected: ${issueDevices.join(", ")}` : "Optional — these choices will be included in your report."}

) : null}
) : null} {issueNeedsServerCheck && issueTargetReady ? (
Live media-server check

{mediaServerChecking ? "Checking Jellyfin now..." : (mediaServerStatus?.headline ?? "Server check unavailable")}

{mediaServerChecking ? (
Contacting the media server
) : null} {mediaServerError ?
{mediaServerError}
: null} {mediaServerStatus ? ( <>

{mediaServerStatus.message}

Server API {mediaServerStatus.status === "down" ? "Unavailable" : "Responding"}
Response {mediaServerStatus.latency_ms ?? "--"} ms
Active streams {mediaServerStatus.activity?.available ? (mediaServerStatus.activity.active_streams ?? 0) : "--"}
Transcoding {mediaServerStatus.activity?.available ? (mediaServerStatus.activity.transcoding_streams ?? 0) : "--"}
) : null}
) : null} {issueTargetReady ? (
What will happen

{issueOptions?.can_act === false ? "The selected details will be sent to support." : selectedIssueDefinition.outcome}

{issueOptions?.can_act === false ? "The issue and every selection will be sent to support. Automatic fixes are not enabled for this account." : issueCategory === "subtitle" ? "The issue will be logged, then Bazarr will search for fresh subtitles for every selection." : issueCategory === "missing_content" ? "The issue will be logged, then the selected movie, seasons, or episodes will be sent back to the collection pipeline." : issueSupportsReplacement ? "The issue will be logged, then the selected content will be sent to Sonarr or Radarr for replacement." : "The live Jellyfin check will be attached so support can see the server state immediately."}

) : null}
) : null} ) : null}
)} {workspace === "request" ? ( <>
Total {workspace === "request" ? "requests" : "issues"} {visibleKindCount}
Total comments {Number(overview?.overview?.total_comments ?? 0)}
My items {Number(overview?.my_items ?? 0)}
Visible {items.length}

{workspace === "request" ? "Create request item" : "Create issue item"}

{workspace === "request" ? "Create and track request-related notes in a dedicated request workflow." : "Create and track operational issues in a dedicated issue workflow."}