'use client' import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' type PortalPermissions = { can_edit?: boolean can_comment?: boolean can_moderate?: 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 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' | '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 ReplacementFile = { id: number name: string quality?: string | null size?: number | null season_number?: number | null episodes?: 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: 'Likely action: replace the affected media file.', issueType: 'broken_media', titlePrefix: 'Replace media', }, { 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: 'Likely action: check the request pipeline, then collect the missing media.', 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: 'Likely action: replace the file or correct its audio tracks.', issueType: 'audio', titlePrefix: 'Audio problem', }, { id: 'subtitle', marker: 'SUBS', label: 'Subtitles are wrong', description: 'Missing, incorrect, forced, unreadable, or out-of-sync subtitles.', outcome: 'Likely action: repair the subtitle track or replace the media.', 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 before deciding whether this is file-, device-, or server-related.', 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 the media server and include the result with the report.', 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'], 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 ISSUE_TYPE_LABELS: Record = { general: 'General', playback: 'Playback', transcode: 'Transcoding', service_unavailable: 'Server unavailable', broken_media: 'Broken media', 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('_', ' ') } const formatFileSize = (value?: number | null) => { if (!value || value <= 0) return 'Size unavailable' const units = ['B', 'KB', 'MB', 'GB', 'TB'] const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1) return `${(value / 1024 ** unitIndex).toFixed(unitIndex >= 3 ? 1 : 0)} ${units[unitIndex]}` } 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 [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 [issueEpisode, setIssueEpisode] = useState('') const [issueScope, setIssueScope] = useState<'one_title' | 'multiple_titles' | 'everything'>('one_title') const [issueSymptom, setIssueSymptom] = useState('') const [issueDevice, setIssueDevice] = useState('') const [issueNotes, setIssueNotes] = 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 [replacementFiles, setReplacementFiles] = useState([]) const [replacementFilesLoading, setReplacementFilesLoading] = useState(false) const [replacementFilesMessage, setReplacementFilesMessage] = useState(null) const [replacementFileFilter, setReplacementFileFilter] = useState('') const [selectedReplacementFileId, setSelectedReplacementFileId] = useState(null) const [startReplacement, setStartReplacement] = useState(false) const [replacementAllowed, setReplacementAllowed] = useState(true) 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 issueNeedsMediaTitle = Boolean(issueCategory) && issueCategory !== 'service_unavailable' && !(issueCategory === 'playback' && issueScope !== 'one_title') const issueSupportsReplacement = issueCategory === 'broken_media' || issueCategory === 'audio' || issueCategory === 'subtitle' || (issueCategory === 'playback' && issueScope === 'one_title') const selectedReplacementFile = replacementFiles.find((file) => file.id === selectedReplacementFileId) ?? null const visibleReplacementFiles = replacementFiles.filter((file) => { const query = replacementFileFilter.trim().toLowerCase() if (!query) return true return [file.name, file.quality, ...(file.episodes ?? [])] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(query)) }) 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) { 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 loadReplacementFiles = async (media: DiscoveryResult) => { setReplacementFiles([]) setSelectedReplacementFileId(null) setStartReplacement(false) setReplacementAllowed(true) setReplacementFileFilter('') if (!media.requestId) { setReplacementFilesMessage( 'This title is not linked to an existing Magent request, so there is no managed file to replace.' ) return } setReplacementFilesLoading(true) setReplacementFilesMessage(null) try { const response = await authFetch( `${getApiBase()}/requests/${media.requestId}/replacement-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 the managed files from Sonarr/Radarr.') } const payload = await response.json() const files = Array.isArray(payload?.files) ? payload.files as ReplacementFile[] : [] setReplacementFiles(files) setReplacementAllowed(payload?.can_replace !== false) setReplacementFilesMessage( payload?.can_replace === false ? 'Your account can report this file, but automatic replacement is disabled.' : payload?.message ?? null ) if (files.length === 1 && payload?.can_replace !== false) { setSelectedReplacementFileId(files[0].id) } } catch (err) { console.error(err) setReplacementFilesMessage( err instanceof Error ? err.message : 'Could not load the managed files from Sonarr/Radarr.' ) } finally { setReplacementFilesLoading(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([]) setIssueSelectedMedia(null) setIssueMediaTitle('') setReplacementFiles([]) setSelectedReplacementFileId(null) setStartReplacement(false) setReplacementAllowed(true) 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) => { setIssueSelectedMedia(media) setIssueMediaTitle(media.title) setIssueMediaType(media.type === 'tv' ? 'tv' : 'movie') setIssueMediaResults([]) setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`) setError(null) if (issueSupportsReplacement) { void loadReplacementFiles(media) } else { setReplacementFiles([]) setSelectedReplacementFileId(null) setStartReplacement(false) setReplacementFilesMessage(null) } } 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) setIssueSymptom(ISSUE_SYMPTOMS[category][0] ?? '') setIssueScope(category === 'service_unavailable' ? 'everything' : 'one_title') setMediaServerStatus(null) setMediaServerError(null) setError(null) setStatus(null) const supportsReplacement = ['broken_media', 'audio', 'subtitle', 'playback'].includes(category) if (category === 'service_unavailable') { setIssueSelectedMedia(null) setIssueMediaTitle('') setIssueMediaQuery('') setIssueMediaResults([]) } if (supportsReplacement && issueSelectedMedia?.requestId) { void loadReplacementFiles(issueSelectedMedia) } else if (!supportsReplacement) { setReplacementFiles([]) setSelectedReplacementFileId(null) setStartReplacement(false) setReplacementFilesMessage(null) } if (category === 'playback' || category === 'service_unavailable') { void checkMediaServer() } } const createGuidedIssue = async (event: React.FormEvent) => { event.preventDefault() if (!selectedIssueDefinition || !issueCategory) { setError('Choose the problem that best matches what you are seeing.') return } const cleanMediaTitle = issueMediaTitle.trim() if (issueNeedsMediaTitle && (!cleanMediaTitle || !issueSelectedMedia)) { setError('Search for and select the exact movie or TV show so the managed file can be identified.') return } if (startReplacement && (!issueSelectedMedia?.requestId || !selectedReplacementFile)) { setError('Select the exact Sonarr/Radarr file before starting a replacement.') return } if (startReplacement && !replacementAllowed) { setError('Automatic replacement is disabled for this account. The issue can still be submitted.') return } if ( startReplacement && selectedReplacementFile && !window.confirm( `Replace “${selectedReplacementFile.name}”?\n\n` + 'The current managed file will be removed and Sonarr/Radarr will immediately search for a replacement. ' + 'The title may be unavailable until the new download is imported.' ) ) { return } setCreating(true) setError(null) setStatus(null) try { const scopeLabel = issueScope === 'everything' ? 'Everything / service-wide' : issueScope === 'multiple_titles' ? 'Multiple titles' : 'One title' 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}`, `Symptom: ${issueSymptom}`, `Scope: ${scopeLabel}`, cleanMediaTitle ? `Affected title: ${cleanMediaTitle}` : null, cleanMediaTitle ? `Media type: ${issueMediaType === 'tv' ? 'TV show' : 'Movie'}` : null, issueSelectedMedia?.requestId ? `Magent request: #${issueSelectedMedia.requestId}` : null, selectedReplacementFile ? `Managed file: ${selectedReplacementFile.name}` : null, startReplacement ? 'Replacement action: confirmed by the reporting user' : null, issueEpisode.trim() ? `Season / episode / part: ${issueEpisode.trim()}` : null, issueDevice.trim() ? `Device or app: ${issueDevice.trim()}` : null, ...diagnosticLines, issueNotes.trim() ? `Additional information: ${issueNotes.trim()}` : null, ] .filter((line): line is string => Boolean(line)) .join('\n') const titleTarget = cleanMediaTitle || (issueScope === 'everything' ? 'all playback' : 'multiple titles') const resolvedIssueType = issueCategory === 'playback' && issueSymptom.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 with the troubleshooting details.` : 'Issue submitted with the troubleshooting details.' let replacementFailure: string | null = null if (startReplacement && issueSelectedMedia?.requestId && selectedReplacementFile) { const replacementResponse = await authFetch( `${getApiBase()}/requests/${issueSelectedMedia.requestId}/actions/replace`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_id: selectedReplacementFile.id, confirmed: true, issue_id: item?.id ?? null, }), } ) if (replacementResponse.ok) { const replacementPayload = await replacementResponse.json() completionMessage = `${completionMessage} ${replacementPayload?.message ?? 'Replacement search started.'}` } else { let replacementDetail = 'The replacement action could not be started.' try { const replacementPayload = await replacementResponse.json() if (typeof replacementPayload?.detail === 'string') replacementDetail = replacementPayload.detail } catch { // Keep the user-safe fallback message. } replacementFailure = `The issue was saved, but ${replacementDetail}` } } setStatus(completionMessage) setError(replacementFailure) setIssueCategory(null) setIssueMediaTitle('') setIssueMediaType('movie') setIssueEpisode('') setIssueScope('one_title') setIssueSymptom('') setIssueDevice('') setIssueNotes('') setMediaServerStatus(null) setIssueMediaQuery('') setIssueMediaResults([]) setIssueSelectedMedia(null) setReplacementFiles([]) setReplacementFilesMessage(null) setReplacementFileFilter('') setSelectedReplacementFileId(null) setStartReplacement(false) setReplacementAllowed(true) 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) } } if (loadingItems && !items.length) { return
Loading {workspace === 'issue' ? 'issues' : 'requests'}...
} return (
{workspace === 'issue' ? Guided support : null}

{workspace === 'request' ? 'Request portal' : 'What is going wrong?'}

{workspace === 'request' ? 'Search and track content requests through the delivery pipeline.' : 'Choose the symptom and Magent will collect the right details, check the media server when relevant, and recommend the next action.'}

{workspace === 'issue' ? (
{visibleKindCount} reported issues
) : null}
{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 ? ( ) : ( )}
) }) )}
) : (
01
Choose a symptom

Which best describes the problem?

Only the questions needed for that problem will appear next.

{ISSUE_CATEGORIES.map((category) => ( ))}
{selectedIssueDefinition && issueCategory ? (
02
Narrow it down

Tell us what is affected

Magent will attach these details to the issue so nobody has to ask for them again.

{issueCategory === 'playback' ? (
How widespread is it?
{[ ['one_title', 'One title'], ['multiple_titles', 'Several titles'], ['everything', 'Nothing will play'], ].map(([value, label]) => ( ))}
) : null}
{issueNeedsMediaTitle ? (
{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} {issueSelectedMedia && issueSupportsReplacement ? (
Sonarr / Radarr files

Select the exact file that is faulty

The selected file is validated again before Magent allows replacement.

{replacementFilesLoading ? (
Reading managed files from the collector
) : null} {replacementFilesMessage ?
{replacementFilesMessage}
: null} {replacementFiles.length > 6 ? ( setReplacementFileFilter(event.target.value)} placeholder="Filter by season, episode, filename, or quality" /> ) : null} {visibleReplacementFiles.length > 0 ? (
{visibleReplacementFiles.map((file) => ( ))}
) : null} {selectedReplacementFile && replacementAllowed ? ( ) : null}
) : null}
) : null} {issueNeedsMediaTitle && issueMediaType === 'tv' ? ( ) : null} {(issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? ( ) : null}