'use client' import ResolutionChoice from '../ui/ResolutionChoice' import PageHeading from '../ui/PageHeading' import IssueFlowStep from './IssueFlowStep' import { useRouter } from 'next/navigation' import { useEffect, useRef, useState } from 'react' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' 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 Grizzlyflix.', 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: 'Grizzlyflix 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: ['Grizzlyflix 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 isAdmin = me?.role === 'admin' 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 (typeof window === 'undefined') return const raw = new URLSearchParams(window.location.search).get('item') if (!raw) { setPreselectedItemId(null) return } const parsed = Number.parseInt(raw, 10) setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed) }, []) 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`) 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: any) => ({ title: 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: item?.statusLabel ?? null, status: typeof item?.status === 'number' ? item.status : null, accessible: Boolean(item?.accessible), posterPath: item?.posterPath ?? null, backdropPath: 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 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: any) => item?.type === 'movie' || item?.type === 'tv') .map((item: any) => ({ title: 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: item?.statusLabel ?? null, status: typeof item?.status === 'number' ? item.status : null, accessible: Boolean(item?.accessible), posterPath: item?.posterPath ?? null, backdropPath: 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) setIssueStep(issueSelectedMedia && issueOptions ? 'symptoms' : 'media') setSelectedSeasonNumbers([]) setSelectedEpisodeIds([]) if (category === 'playback' || category === 'service_unavailable') { void checkMediaServer() } } 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) } } 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() // eslint-disable-next-line react-hooks/exhaustive-deps }, [router]) useEffect(() => { if (!getToken()) { return } void loadItems({ preferItemId: preselectedItemId }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [filterStatus, filterMine, filterSearch, workspace]) useEffect(() => { setFilterStatus('') setCreateMediaType('') setCreateYear('') setSelectedItemId(null) setSelectedItem(null) setComments([]) setActivity([]) // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspace]) useEffect(() => { if (selectedItemId == null) return void loadItem(selectedItemId) // eslint-disable-next-line react-hooks/exhaustive-deps }, [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) 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 (selectedItem.permissions?.can_moderate) { 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 (!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: 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) 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' || !selectedItem.permissions?.can_delete) 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(() => { setDeleteConfirming(false) }, [selectedItemId]) 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) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [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' && item.permissions?.can_confirm_resolution && 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 ? ( ) : ( )}
) }) )}
) : (
{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.'}