Streamline issue reporting and automate repairs
Magent CI/CD / verify (push) Successful in 10m41s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s

This commit is contained in:
2026-09-01 13:44:13 +12:00
parent 2adbed7259
commit e58614305e
12 changed files with 1223 additions and 333 deletions
+23 -3
View File
@@ -1,10 +1,10 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../lib/auth'
import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel'
import AdminShell from '../ui/AdminShell'
type AdminSetting = {
key: string
@@ -36,6 +36,7 @@ const SECTION_LABELS: Record<string, string> = {
cache: 'Request cache',
sonarr: 'Sonarr',
radarr: 'Radarr',
bazarr: 'Bazarr',
prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent',
logs: 'Activity log',
@@ -83,6 +84,7 @@ const URL_SETTINGS = new Set([
'jellyfin_public_url',
'sonarr_base_url',
'radarr_base_url',
'bazarr_base_url',
'prowlarr_base_url',
'qbittorrent_base_url',
])
@@ -115,6 +117,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'Sonarr connection and the default profile and library location for TV requests.',
radarr: 'Radarr connection and the default profile and library location for movie requests.',
bazarr: 'Bazarr connection used to find and replace movie and episode subtitles.',
prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
requests: 'Control how often requests are refreshed and cleaned up.',
@@ -134,6 +137,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
artwork: null,
sonarr: 'sonarr',
radarr: 'radarr',
bazarr: 'bazarr',
prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent',
requests: 'requests',
@@ -361,6 +365,14 @@ const STANDARD_SECTION_GROUPS: Record<
keys: ['radarr_quality_profile_id', 'radarr_root_folder'],
},
],
bazarr: [
{
key: 'bazarr-connection',
title: 'Connection',
description: 'Bazarr endpoint, API credential, and default language used by subtitle issue repairs.',
keys: ['bazarr_base_url', 'bazarr_api_key', 'bazarr_default_language'],
},
],
prowlarr: [
{
key: 'prowlarr-connection',
@@ -430,6 +442,9 @@ const STANDARD_SECTION_GROUPS: Record<
}
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
bazarr_base_url: 'Bazarr base URL',
bazarr_api_key: 'Bazarr API key',
bazarr_default_language: 'Default subtitle language',
issue_confirmation_contact_attempts: 'Confirmation emails before auto-close',
issue_confirmation_interval_value: 'Confirmation interval',
issue_confirmation_interval_unit: 'Interval unit',
@@ -993,6 +1008,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
sonarr_api_key: 'API key for Sonarr.',
bazarr_base_url: 'Bazarr server URL used for movie and episode subtitle repairs. Scheme is optional.',
bazarr_api_key: 'API key used to ask Bazarr for fresh subtitles.',
bazarr_default_language: 'Language code Bazarr should search for by default, such as en.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
@@ -1067,6 +1085,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
bazarr_base_url: 'https://bazarr.example.com or 10.30.1.81:6767',
bazarr_default_language: 'en',
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
+1
View File
@@ -8,6 +8,7 @@ const ALLOWED_SECTIONS = new Set([
'artwork',
'sonarr',
'radarr',
'bazarr',
'prowlarr',
'qbittorrent',
'requests',
+179
View File
@@ -2977,6 +2977,178 @@ button:disabled,
gap: 3px;
}
.issue-target-picker,
.issue-tv-targets {
display: grid;
gap: 12px;
}
.issue-target-picker {
padding-top: 2px;
}
.issue-choice-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.issue-choice-grid button,
.issue-season-grid button,
.issue-episode-grid button,
.issue-movie-target {
min-width: 0;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius);
background: rgba(255, 255, 255, 0.022);
color: var(--ops-text);
text-align: left;
}
.issue-choice-grid button {
display: flex;
align-items: center;
gap: 9px;
min-height: 52px;
padding: 10px 12px;
}
.issue-choice-grid button > span {
display: grid;
flex: 0 0 auto;
place-items: center;
width: 23px;
height: 23px;
border: 1px solid rgba(126, 215, 255, 0.3);
border-radius: 50%;
color: var(--ops-cyan);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.66rem;
}
.issue-choice-grid button strong,
.issue-season-grid button strong,
.issue-episode-grid button strong,
.issue-movie-target strong {
overflow-wrap: anywhere;
font-size: 0.76rem;
line-height: 1.35;
text-transform: none;
}
.issue-choice-grid button.is-selected,
.issue-season-grid button.is-selected,
.issue-episode-grid button.is-selected,
.issue-movie-target.is-selected {
border-color: rgba(72, 224, 178, 0.55);
background: rgba(72, 224, 178, 0.08);
box-shadow: 0 0 20px rgba(72, 224, 178, 0.06);
}
.issue-movie-target {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 13px;
}
.issue-movie-target > span {
flex: 0 0 auto;
color: var(--ops-cyan);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.65rem;
}
.issue-movie-target > div,
.issue-season-grid button,
.issue-episode-grid button {
display: grid;
gap: 4px;
}
.issue-movie-target small,
.issue-season-grid button small,
.issue-episode-grid button small {
color: var(--ops-muted);
font-size: 0.64rem;
font-weight: 600;
line-height: 1.4;
text-transform: none;
}
.issue-movie-target:disabled,
.issue-episode-grid button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.issue-target-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
padding-top: 2px;
}
.issue-target-heading strong {
font-size: 0.78rem;
}
.issue-target-heading small {
color: var(--ops-muted);
font-size: 0.66rem;
}
.issue-season-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.issue-season-grid button {
position: relative;
padding: 11px;
}
.issue-season-grid button b,
.issue-episode-grid button b {
justify-self: start;
padding: 3px 6px;
border-radius: 999px;
background: rgba(72, 224, 178, 0.13);
color: var(--request-green);
font-size: 0.56rem;
line-height: 1.2;
text-transform: uppercase;
}
.issue-season-tabs {
padding-top: 4px;
border-top: 1px solid var(--ops-line-soft);
}
.issue-episode-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
max-height: 390px;
overflow: auto;
padding-right: 2px;
}
.issue-episode-grid button {
align-content: start;
min-height: 91px;
padding: 11px;
}
.issue-episode-grid button > span {
color: var(--ops-cyan);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.62rem;
}
.issue-file-picker {
display: grid;
gap: 11px;
@@ -3212,6 +3384,9 @@ button:disabled,
@media (max-width: 980px) {
.issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.issue-choice-grid,
.issue-episode-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.issue-season-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.media-status-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@@ -3222,6 +3397,9 @@ button:disabled,
.issue-hero-count { width: 100%; }
.issue-flow { padding: 14px; }
.issue-category-grid,
.issue-choice-grid,
.issue-season-grid,
.issue-episode-grid,
.issue-question-grid,
.issue-media-results,
.media-status-metrics { grid-template-columns: 1fr; }
@@ -3231,6 +3409,7 @@ button:disabled,
.issue-resolution-card > button { grid-column: 1 / -1; width: 100%; }
.issue-media-search-row { grid-template-columns: 1fr; }
.issue-selected-media { align-items: stretch; flex-direction: column; }
.issue-target-heading { align-items: flex-start; flex-direction: column; }
.issue-linked-request { align-items: stretch; flex-direction: column; }
.issue-file-list > label { grid-template-columns: auto minmax(0, 1fr); }
.issue-file-list > label > b { grid-column: 2; }
+404 -307
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type PortalPermissions = {
@@ -126,13 +126,45 @@ type MediaServerStatus = {
}
}
type ReplacementFile = {
type IssueEpisodeOption = {
id: number
name: string
quality?: string | null
size?: number | null
season_number?: number | null
episodes?: string[]
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<{
@@ -149,7 +181,7 @@ const ISSUE_CATEGORIES: Array<{
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.',
outcome: 'The affected file will be replaced automatically.',
issueType: 'broken_media',
titlePrefix: 'Replace media',
},
@@ -158,7 +190,7 @@ const ISSUE_CATEGORIES: Array<{
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.',
outcome: 'The selected missing content will be sent back to Sonarr or Radarr.',
issueType: 'missing_content',
titlePrefix: 'Missing content',
},
@@ -167,7 +199,7 @@ const ISSUE_CATEGORIES: Array<{
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.',
outcome: 'The affected file will be replaced automatically.',
issueType: 'audio',
titlePrefix: 'Audio problem',
},
@@ -176,7 +208,7 @@ const ISSUE_CATEGORIES: Array<{
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.',
outcome: 'Bazarr will find a fresh subtitle without replacing the video.',
issueType: 'subtitle',
titlePrefix: 'Subtitle problem',
},
@@ -185,7 +217,7 @@ const ISSUE_CATEGORIES: Array<{
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.',
outcome: 'Magent will check Jellyfin and replace only the selected file when appropriate.',
issueType: 'playback',
titlePrefix: 'Playback problem',
},
@@ -194,7 +226,7 @@ const ISSUE_CATEGORIES: Array<{
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.',
outcome: 'Magent will check Jellyfin and attach the result to the issue.',
issueType: 'service_unavailable',
titlePrefix: 'Media server unavailable',
},
@@ -209,6 +241,8 @@ const ISSUE_SYMPTOMS: Record<IssueCategoryId, string[]> = {
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
const ISSUE_TYPE_LABELS: Record<string, string> = {
general: 'General',
playback: 'Playback',
@@ -311,13 +345,6 @@ const formatIssueStatus = (value?: string | null) => {
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
@@ -385,11 +412,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [issueCategory, setIssueCategory] = useState<IssueCategoryId | null>(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 [issueSymptoms, setIssueSymptoms] = useState<string[]>([])
const [issueDevices, setIssueDevices] = useState<string[]>([])
const [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null)
const [mediaServerChecking, setMediaServerChecking] = useState(false)
const [mediaServerError, setMediaServerError] = useState<string | null>(null)
@@ -397,13 +421,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [issueMediaSearching, setIssueMediaSearching] = useState(false)
const [issueMediaResults, setIssueMediaResults] = useState<DiscoveryResult[]>([])
const [issueSelectedMedia, setIssueSelectedMedia] = useState<DiscoveryResult | null>(null)
const [replacementFiles, setReplacementFiles] = useState<ReplacementFile[]>([])
const [replacementFilesLoading, setReplacementFilesLoading] = useState(false)
const [replacementFilesMessage, setReplacementFilesMessage] = useState<string | null>(null)
const [replacementFileFilter, setReplacementFileFilter] = useState('')
const [selectedReplacementFileId, setSelectedReplacementFileId] = useState<number | null>(null)
const [startReplacement, setStartReplacement] = useState(false)
const [replacementAllowed, setReplacementAllowed] = useState(true)
const [issueOptions, setIssueOptions] = useState<IssueTargetOptions | null>(null)
const [issueOptionsLoading, setIssueOptionsLoading] = useState(false)
const [issueOptionsMessage, setIssueOptionsMessage] = useState<string | null>(null)
const [movieTargetSelected, setMovieTargetSelected] = useState(false)
const [activeSeasonNumber, setActiveSeasonNumber] = useState<number | null>(null)
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([])
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([])
const isAdmin = me?.role === 'admin'
const visibleKindCount = Number(overview?.overview?.by_kind?.[workspace] ?? 0)
@@ -411,24 +435,50 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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 =
const issueNeedsMediaTitle = Boolean(issueCategory)
const issueRequiresExistingFile =
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))
})
issueCategory === 'playback'
const issueSupportsReplacement =
issueCategory === 'broken_media' ||
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'
? movieTargetSelected && movieTargetAvailable
: issueCategory === 'missing_content'
? (
missingEntireTitle ||
((missingSeasons ? selectedSeasonNumbers.length > 0 : true) &&
(missingEpisodes ? selectedEpisodeIds.length > 0 : true))
)
: selectedEpisodeIds.length > 0
)
)
useEffect(() => {
if (typeof window === 'undefined') return
@@ -676,23 +726,23 @@ export default function PortalClient({ workspace }: PortalClientProps) {
}
}
const loadReplacementFiles = async (media: DiscoveryResult) => {
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementAllowed(true)
setReplacementFileFilter('')
const loadIssueOptions = async (media: DiscoveryResult) => {
setIssueOptions(null)
setMovieTargetSelected(false)
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
if (!media.requestId) {
setReplacementFilesMessage(
'This title is not linked to an existing Magent request, so there is no managed file to replace.'
setIssueOptionsMessage(
'This title is not linked to a Magent request yet.'
)
return
}
setReplacementFilesLoading(true)
setReplacementFilesMessage(null)
setIssueOptionsLoading(true)
setIssueOptionsMessage(null)
try {
const response = await authFetch(
`${getApiBase()}/requests/${media.requestId}/replacement-options`
`${getApiBase()}/requests/${media.requestId}/issue-options`
)
if (!response.ok) {
if (response.status === 401) {
@@ -701,27 +751,20 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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)
throw new Error(text || 'Could not load seasons and episodes from Sonarr/Radarr.')
}
const payload = await response.json() as IssueTargetOptions
setIssueOptions(payload)
setIssueOptionsMessage(payload.message ?? null)
const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0]
setActiveSeasonNumber(firstSeason?.season_number ?? null)
} catch (err) {
console.error(err)
setReplacementFilesMessage(
err instanceof Error ? err.message : 'Could not load the managed files from Sonarr/Radarr.'
setIssueOptionsMessage(
err instanceof Error ? err.message : 'Could not load seasons and episodes from Sonarr/Radarr.'
)
} finally {
setReplacementFilesLoading(false)
setIssueOptionsLoading(false)
}
}
@@ -737,10 +780,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setIssueMediaResults([])
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementAllowed(true)
setIssueOptions(null)
setMovieTargetSelected(false)
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
try {
const response = await authFetch(
`${getApiBase()}/requests/search?query=${encodeURIComponent(query)}`
@@ -789,14 +833,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setIssueMediaResults([])
setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`)
setError(null)
if (issueSupportsReplacement) {
void loadReplacementFiles(media)
} else {
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementFilesMessage(null)
}
void loadIssueOptions(media)
}
const checkMediaServer = async () => {
@@ -827,32 +864,48 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const chooseIssueCategory = (category: IssueCategoryId) => {
setIssueCategory(category)
setIssueSymptom(ISSUE_SYMPTOMS[category][0] ?? '')
setIssueScope(category === 'service_unavailable' ? 'everything' : 'one_title')
setIssueSymptoms([])
setIssueDevices([])
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)
}
setMovieTargetSelected(false)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
if (category === 'playback' || category === 'service_unavailable') {
void checkMediaServer()
}
}
const toggleStringChoice = (
value: string,
selected: string[],
setter: React.Dispatch<React.SetStateAction<string[]>>,
) => {
setter(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value])
}
const runIssueAction = async (path: string, body: Record<string, unknown>): Promise<string> => {
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 (!selectedIssueDefinition || !issueCategory) {
@@ -860,40 +913,43 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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.')
if (!cleanMediaTitle || !issueSelectedMedia?.requestId || !issueOptions) {
setError('Search for and select a tracked movie or TV show first.')
return
}
if (startReplacement && (!issueSelectedMedia?.requestId || !selectedReplacementFile)) {
setError('Select the exact Sonarr/Radarr file before starting a replacement.')
if (issueSymptoms.length === 0) {
setError('Choose what needs to be corrected.')
return
}
if (startReplacement && !replacementAllowed) {
setError('Automatic replacement is disabled for this account. The issue can still be submitted.')
const isMovie = issueOptions.request_type === 'movie'
if (isMovie && !movieTargetSelected) {
setError('Select the movie to continue.')
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.'
)
) {
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 scopeLabel =
issueScope === 'everything'
? 'Everything / service-wide'
: issueScope === 'multiple_titles'
? 'Multiple titles'
: 'One title'
const diagnosticLines: string[] = []
if (mediaServerStatus) {
diagnosticLines.push(
@@ -918,24 +974,21 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const description = [
`Problem: ${selectedIssueDefinition.label}`,
`Symptom: ${issueSymptom}`,
`Scope: ${scopeLabel}`,
`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,
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,
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,
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 titleTarget = cleanMediaTitle
const resolvedIssueType =
issueCategory === 'playback' && issueSymptom.toLowerCase().includes('transcode')
issueCategory === 'playback' && issueSymptoms.some((symptom) => symptom.toLowerCase().includes('transcode'))
? 'transcode'
: selectedIssueDefinition.issueType
const priority =
@@ -969,56 +1022,50 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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}`
}
? `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(replacementFailure)
setError(null)
setIssueCategory(null)
setIssueMediaTitle('')
setIssueMediaType('movie')
setIssueEpisode('')
setIssueScope('one_title')
setIssueSymptom('')
setIssueDevice('')
setIssueNotes('')
setIssueSymptoms([])
setIssueDevices([])
setMediaServerStatus(null)
setIssueMediaQuery('')
setIssueMediaResults([])
setIssueSelectedMedia(null)
setReplacementFiles([])
setReplacementFilesMessage(null)
setReplacementFileFilter('')
setSelectedReplacementFileId(null)
setStartReplacement(false)
setReplacementAllowed(true)
setIssueOptions(null)
setIssueOptionsMessage(null)
setMovieTargetSelected(false)
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
await Promise.all([loadItems({ preferItemId: item?.id ?? null }), loadOverview()])
} catch (err) {
console.error(err)
@@ -1412,7 +1459,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<span className="issue-category-marker">{category.marker}</span>
<strong>{category.label}</strong>
<p>{category.description}</p>
<small>{category.outcome}</small>
</button>
))}
</div>
@@ -1428,37 +1474,6 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</div>
{issueCategory === 'playback' ? (
<fieldset className="issue-choice-field">
<legend>How widespread is it?</legend>
<div className="issue-choice-row">
{[
['one_title', 'One title'],
['multiple_titles', 'Several titles'],
['everything', 'Nothing will play'],
].map(([value, label]) => (
<button
key={value}
type="button"
className={issueScope === value ? 'is-selected' : ''}
onClick={() => {
const nextScope = value as typeof issueScope
setIssueScope(nextScope)
if (nextScope !== 'one_title') {
setStartReplacement(false)
setSelectedReplacementFileId(null)
} else if (issueSelectedMedia?.requestId) {
void loadReplacementFiles(issueSelectedMedia)
}
}}
>
{label}
</button>
))}
</div>
</fieldset>
) : null}
<div className="issue-question-grid">
{issueNeedsMediaTitle ? (
<div className="issue-media-finder issue-field-span-2">
@@ -1472,9 +1487,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
if (issueSelectedMedia) {
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setReplacementFiles([])
setSelectedReplacementFileId(null)
setStartReplacement(false)
setIssueOptions(null)
setMovieTargetSelected(false)
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
}
}}
placeholder="Search the Grizzlyflix catalogue"
@@ -1534,105 +1551,177 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
) : null}
{issueSelectedMedia && issueSupportsReplacement ? (
<div className="issue-file-picker">
<div>
<span className="section-kicker">Sonarr / Radarr files</span>
<h3>Select the exact file that is faulty</h3>
<p>The selected file is validated again before Magent allows replacement.</p>
</div>
{replacementFilesLoading ? (
<div className="issue-live-scan"><i /><span>Reading managed files from the collector</span></div>
) : null}
{replacementFilesMessage ? <div className="status-banner">{replacementFilesMessage}</div> : null}
{replacementFiles.length > 6 ? (
<input
value={replacementFileFilter}
onChange={(event) => setReplacementFileFilter(event.target.value)}
placeholder="Filter by season, episode, filename, or quality"
/>
) : null}
{visibleReplacementFiles.length > 0 ? (
<div className="issue-file-list">
{visibleReplacementFiles.map((file) => (
<label key={file.id} className={selectedReplacementFileId === file.id ? 'is-selected' : ''}>
<input
type="radio"
name="replacement-file"
checked={selectedReplacementFileId === file.id}
onChange={() => {
setSelectedReplacementFileId(file.id)
setStartReplacement(false)
{issueOptionsLoading ? (
<div className="issue-live-scan"><i /><span>Reading seasons and episodes</span></div>
) : null}
{issueOptionsMessage && !issueOptions ? <div className="status-banner">{issueOptionsMessage}</div> : null}
{issueOptions ? (
<div className="issue-target-picker">
<fieldset className="issue-choice-field">
<legend>What needs to be corrected?</legend>
<div className="issue-choice-grid">
{(issueOptions.request_type === 'movie' && issueCategory === 'missing_content'
? ['Entire title is missing']
: ISSUE_SYMPTOMS[issueCategory]).map((symptom) => {
const selected = issueSymptoms.includes(symptom)
return (
<button
key={symptom}
type="button"
className={selected ? 'is-selected' : ''}
onClick={() => {
if (symptom === 'Entire title is missing') {
setIssueSymptoms(selected ? [] : [symptom])
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
} else {
const withoutEntireTitle = issueSymptoms.filter((item) => item !== 'Entire title is missing')
const nextSymptoms = selected
? withoutEntireTitle.filter((item) => item !== symptom)
: [...withoutEntireTitle, symptom]
setIssueSymptoms(nextSymptoms)
if (symptom === 'Season is missing' && selected) {
setSelectedSeasonNumbers([])
}
if (
(symptom === 'Episode is missing' || symptom === 'Part or edition is missing') &&
selected &&
!nextSymptoms.includes('Episode is missing') &&
!nextSymptoms.includes('Part or edition is missing')
) {
setSelectedEpisodeIds([])
}
}
}}
/>
<span>
<strong>{file.episodes?.length ? file.episodes.join(', ') : issueSelectedMedia.title}</strong>
<small>{file.name}</small>
</span>
<b>{[file.quality, formatFileSize(file.size)].filter(Boolean).join(' · ')}</b>
</label>
))}
>
<span>{selected ? '✓' : '+'}</span>
<strong>{issueOptions.request_type === 'movie' && symptom === 'Entire title is missing' ? 'Movie is missing' : symptom}</strong>
</button>
)
})}
</div>
) : null}
{selectedReplacementFile && replacementAllowed ? (
<label className="issue-replacement-toggle">
<input
type="checkbox"
checked={startReplacement}
onChange={(event) => setStartReplacement(event.target.checked)}
/>
<span>
<strong>Replace this file after submitting the issue</strong>
</fieldset>
{issueSymptoms.length > 0 && issueOptions.request_type === 'movie' ? (
<button
type="button"
className={`issue-movie-target ${movieTargetSelected ? 'is-selected' : ''}`}
disabled={!movieTargetAvailable}
onClick={() => setMovieTargetSelected((current) => !current)}
>
<span>{movieTargetSelected ? '✓' : 'MOVIE'}</span>
<div>
<strong>{issueOptions.title}</strong>
<small>
Removes only this managed file, then immediately starts a replacement search through {issueMediaType === 'tv' ? 'Sonarr' : 'Radarr'}.
{!movieTargetAvailable
? 'No managed file is available for this repair'
: issueOptions.movie?.best_fit
? 'This is the best fit'
: issueOptions.movie?.has_file
? 'Ready to select'
: 'Missing in Radarr'}
</small>
</span>
</label>
</div>
</button>
) : null}
{issueSymptoms.length > 0 && issueOptions.request_type === 'tv' && !missingEntireTitle ? (
<div className="issue-tv-targets">
<div className="issue-target-heading">
<strong>{missingSeasons ? 'Choose the missing seasons' : 'Choose a season'}</strong>
<small>You can select more than one.</small>
</div>
{missingSeasons ? (
<div className="issue-season-grid">
{issueOptions.seasons.map((season) => {
const selected = selectedSeasonNumbers.includes(season.season_number)
return (
<button
key={season.season_number}
type="button"
className={selected ? 'is-selected' : ''}
onClick={() => setSelectedSeasonNumbers((current) => current.includes(season.season_number)
? current.filter((value) => value !== season.season_number)
: [...current, season.season_number])}
>
<strong>{season.label}</strong>
<small>{season.missing_count} missing · {season.available_count} available</small>
{season.best_fit ? <b>This is the best fit</b> : null}
</button>
)
})}
</div>
) : null}
{(issueCategory !== 'missing_content' || missingEpisodes) ? (
<div className="issue-season-grid issue-season-tabs">
{issueOptions.seasons.map((season) => (
<button
key={season.season_number}
type="button"
className={activeSeasonNumber === season.season_number ? 'is-selected' : ''}
onClick={() => setActiveSeasonNumber(season.season_number)}
>
<strong>{season.label}</strong>
<small>{season.episode_count} episodes</small>
</button>
))}
</div>
) : null}
{(issueCategory !== 'missing_content' || missingEpisodes) && activeSeasonNumber !== null ? (
<div className="issue-episode-grid">
{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 (
<button
key={episode.id}
type="button"
disabled={disabled}
className={selected ? 'is-selected' : ''}
onClick={() => setSelectedEpisodeIds((current) => current.includes(episode.id)
? current.filter((value) => value !== episode.id)
: [...current, episode.id])}
>
<span>{selected ? '✓' : episode.code}</span>
<strong>{episode.title}</strong>
<small>{episode.missing ? 'Missing in Sonarr' : episode.has_file ? 'Ready to select' : 'No file in Sonarr'}</small>
{episode.best_fit ? <b>This is the best fit</b> : null}
</button>
)
})}
</div>
) : null}
</div>
) : null}
</div>
) : null}
</div>
) : null}
{issueNeedsMediaTitle && issueMediaType === 'tv' ? (
<label>
<span>Season / episode</span>
<input
value={issueEpisode}
onChange={(event) => setIssueEpisode(event.target.value)}
placeholder="For example S02 E04"
/>
</label>
{issueTargetReady && (issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? (
<fieldset className="issue-choice-field issue-field-span-2">
<legend>Where did it happen? <small>Choose all that apply</small></legend>
<div className="issue-choice-row">
{DEVICE_OPTIONS.map((device) => (
<button
key={device}
type="button"
className={issueDevices.includes(device) ? 'is-selected' : ''}
onClick={() => toggleStringChoice(device, issueDevices, setIssueDevices)}
>
{device}
</button>
))}
</div>
</fieldset>
) : null}
<label className={issueNeedsMediaTitle && issueMediaType === 'tv' ? 'issue-field-span-2' : ''}>
<span>What happens?</span>
<select value={issueSymptom} onChange={(event) => setIssueSymptom(event.target.value)}>
{ISSUE_SYMPTOMS[issueCategory].map((symptom) => (
<option key={symptom} value={symptom}>{symptom}</option>
))}
</select>
</label>
{(issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? (
<label>
<span>Device or app</span>
<input
value={issueDevice}
onChange={(event) => setIssueDevice(event.target.value)}
placeholder="For example Samsung TV or Chrome"
/>
</label>
) : null}
<label className="issue-field-span-2">
<span>Anything else we should know?</span>
<textarea
rows={3}
value={issueNotes}
onChange={(event) => setIssueNotes(event.target.value)}
placeholder="Optional error message, timestamp, language, edition, or anything unusual"
/>
</label>
</div>
{issueNeedsServerCheck ? (
{issueNeedsServerCheck && issueTargetReady ? (
<section className={`media-status-check media-status-${mediaServerStatus?.status ?? 'checking'}`}>
<div className="media-status-heading">
<div>
@@ -1663,23 +1752,31 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</section>
) : null}
<section className="issue-resolution-card">
{issueTargetReady ? <section className="issue-resolution-card">
<span className="issue-step-number">03</span>
<div>
<span className="section-kicker">Recommended path</span>
<h2>{selectedIssueDefinition.outcome.replace('Likely action: ', '')}</h2>
<span className="section-kicker">What will happen</span>
<h2>
{issueOptions?.can_act === false
? 'The selected details will be sent to support.'
: selectedIssueDefinition.outcome}
</h2>
<p>
{startReplacement && selectedReplacementFile
? `After confirmation, ${selectedReplacementFile.name} will be removed and the collector will start searching immediately.`
: issueNeedsServerCheck
? 'The live check above will be saved in the report, giving administrators immediate context.'
: 'Submit this report and it will arrive with the replacement or collection path already identified.'}
{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.'}
</p>
</div>
<button type="submit" disabled={creating || mediaServerChecking}>
{creating ? 'Working...' : startReplacement ? 'Submit issue & replace file' : 'Submit issue'}
{creating ? 'Working...' : 'Submit and start fix'}
</button>
</section>
</section> : null}
</form>
) : null}
</section>
+1
View File
@@ -19,6 +19,7 @@ const NAV_GROUPS = [
{ href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr' },
{ href: '/admin/bazarr', label: 'Bazarr' },
{ href: '/admin/prowlarr', label: 'Prowlarr' },
{ href: '/admin/qbittorrent', label: 'qBittorrent' },
],