Build guided media issue workflow
Magent CI/CD / verify (push) Successful in 10m42s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 19s

This commit is contained in:
2026-08-31 21:46:43 +12:00
parent 0e04d219a0
commit f8770cb44a
5 changed files with 1124 additions and 37 deletions
+523 -37
View File
@@ -80,6 +80,119 @@ type DiscoveryResult = {
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
}
}
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<IssueCategoryId, string[]> = {
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<string, string> = {
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' },
@@ -200,6 +313,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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')
@@ -213,11 +327,28 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [discoverResults, setDiscoverResults] = useState<DiscoveryResult[]>([])
const [discoverError, setDiscoverError] = useState<string | null>(null)
const [requestingTmdbIds, setRequestingTmdbIds] = useState<Record<string, boolean>>({})
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 [mediaServerStatus, setMediaServerStatus] = useState<MediaServerStatus | null>(null)
const [mediaServerChecking, setMediaServerChecking] = useState(false)
const [mediaServerError, setMediaServerError] = useState<string | null>(null)
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 === 'everything')
useEffect(() => {
if (typeof window === 'undefined') return
@@ -463,6 +594,158 @@ export default function PortalClient({ workspace }: PortalClientProps) {
}
}
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)
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) {
setError('Enter the affected movie or TV show so the file can be identified.')
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,
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,
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
setStatus(
item?.id
? `Issue #${item.id} submitted with the troubleshooting details.`
: 'Issue submitted with the troubleshooting details.'
)
setIssueCategory(null)
setIssueMediaTitle('')
setIssueMediaType('movie')
setIssueEpisode('')
setIssueScope('one_title')
setIssueSymptom('')
setIssueDevice('')
setIssueNotes('')
setMediaServerStatus(null)
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')
@@ -514,6 +797,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
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')
@@ -591,6 +875,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
payload.media_status = editMediaStatus
} else {
payload.status = editStatus
payload.issue_type = editIssueType
}
payload.priority = editPriority
payload.assignee_username = editAssignee || null
@@ -672,40 +957,39 @@ export default function PortalClient({ workspace }: PortalClientProps) {
}
if (loadingItems && !items.length) {
return <main className="card">Loading request portal...</main>
return <main className="card">Loading {workspace === 'issue' ? 'issues' : 'requests'}...</main>
}
return (
<main className="card portal-page">
<div className="user-directory-panel-header">
<main className={`card portal-page ${workspace === 'issue' ? 'issue-portal-page' : ''}`}>
<div className={`user-directory-panel-header ${workspace === 'issue' ? 'issue-portal-hero' : ''}`}>
<div>
<h1>{workspace === 'request' ? 'Request portal' : 'Issue portal'}</h1>
{workspace === 'issue' ? <span className="section-kicker">Guided support</span> : null}
<h1>{workspace === 'request' ? 'Request portal' : 'What is going wrong?'}</h1>
<p className="lede">
{workspace === 'request'
? 'Search and track content requests through the delivery pipeline.'
: 'Raise operational issues and manage resolution updates.'}
: 'Choose the symptom and Magent will collect the right details, check the media server when relevant, and recommend the next action.'}
</p>
</div>
{workspace === 'issue' ? (
<div className="issue-hero-count">
<strong>{visibleKindCount}</strong>
<span>reported issues</span>
</div>
) : null}
</div>
<section className="portal-workspace-switch">
<button
type="button"
className={workspace === 'request' ? 'is-active' : ''}
onClick={() => router.push('/new-requests')}
disabled={workspace === 'request'}
>
New requests
</button>
<button
type="button"
className={workspace === 'issue' ? 'is-active' : ''}
onClick={() => router.push('/portal/issues')}
disabled={workspace === 'issue'}
>
Issues
</button>
</section>
{workspace === 'request' ? (
<section className="portal-workspace-switch">
<button type="button" className="is-active" disabled>
New requests
</button>
<button type="button" onClick={() => router.push('/portal/issues')}>
Issues
</button>
</section>
) : null}
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
@@ -790,13 +1074,180 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</section>
) : (
<section className="admin-panel">
<div className="status-banner">
Issue workspace is for reporting problems and tracking resolution separately from content requests.
<section className="issue-flow">
<div className="issue-flow-heading">
<span className="issue-step-number">01</span>
<div>
<span className="section-kicker">Choose a symptom</span>
<h2>Which best describes the problem?</h2>
<p>Only the questions needed for that problem will appear next.</p>
</div>
</div>
<div className="issue-category-grid">
{ISSUE_CATEGORIES.map((category) => (
<button
key={category.id}
type="button"
className={`issue-category-card ${issueCategory === category.id ? 'is-selected' : ''}`}
onClick={() => chooseIssueCategory(category.id)}
>
<span className="issue-category-marker">{category.marker}</span>
<strong>{category.label}</strong>
<p>{category.description}</p>
<small>{category.outcome}</small>
</button>
))}
</div>
{selectedIssueDefinition && issueCategory ? (
<form className="issue-guided-form" onSubmit={createGuidedIssue}>
<div className="issue-flow-heading">
<span className="issue-step-number">02</span>
<div>
<span className="section-kicker">Narrow it down</span>
<h2>Tell us what is affected</h2>
<p>Magent will attach these details to the issue so nobody has to ask for them again.</p>
</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={() => setIssueScope(value as typeof issueScope)}
>
{label}
</button>
))}
</div>
</fieldset>
) : null}
<div className="issue-question-grid">
{issueNeedsMediaTitle ? (
<label className="issue-field-span-2">
<span>Affected movie or TV show</span>
<input
required
value={issueMediaTitle}
onChange={(event) => setIssueMediaTitle(event.target.value)}
placeholder="Start typing the exact title"
/>
</label>
) : null}
{issueNeedsMediaTitle ? (
<label>
<span>Media type</span>
<select
value={issueMediaType}
onChange={(event) => setIssueMediaType(event.target.value as 'movie' | 'tv')}
>
<option value="movie">Movie</option>
<option value="tv">TV show</option>
</select>
</label>
) : null}
{issueNeedsMediaTitle && issueMediaType === 'tv' ? (
<label>
<span>Season / episode</span>
<input
value={issueEpisode}
onChange={(event) => setIssueEpisode(event.target.value)}
placeholder="For example S02 E04"
/>
</label>
) : 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 ? (
<section className={`media-status-check media-status-${mediaServerStatus?.status ?? 'checking'}`}>
<div className="media-status-heading">
<div>
<span className="section-kicker">Live media-server check</span>
<h3>
{mediaServerChecking
? 'Checking Jellyfin now...'
: mediaServerStatus?.headline ?? 'Server check unavailable'}
</h3>
</div>
<button type="button" className="ghost-button" onClick={() => void checkMediaServer()} disabled={mediaServerChecking}>
{mediaServerChecking ? 'Checking...' : 'Check again'}
</button>
</div>
{mediaServerChecking ? <div className="issue-live-scan"><i /><span>Contacting the media server</span></div> : null}
{mediaServerError ? <div className="error-banner">{mediaServerError}</div> : null}
{mediaServerStatus ? (
<>
<p>{mediaServerStatus.message}</p>
<div className="media-status-metrics">
<div><span>Server API</span><strong>{mediaServerStatus.status === 'down' ? 'Unavailable' : 'Responding'}</strong></div>
<div><span>Response</span><strong>{mediaServerStatus.latency_ms ?? '--'} ms</strong></div>
<div><span>Active streams</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.active_streams ?? 0 : '--'}</strong></div>
<div><span>Transcoding</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.transcoding_streams ?? 0 : '--'}</strong></div>
</div>
</>
) : null}
</section>
) : null}
<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>
<p>
{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.'}
</p>
</div>
<button type="submit" disabled={creating || mediaServerChecking}>
{creating ? 'Submitting...' : 'Submit issue'}
</button>
</section>
</form>
) : null}
</section>
)}
{workspace === 'request' ? (
<>
<section className="portal-overview-grid">
<div className="portal-overview-card">
<span>Total {workspace === 'request' ? 'requests' : 'issues'}</span>
@@ -908,6 +1359,18 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</form>
</section>
</>
) : null}
{workspace === 'issue' ? (
<div className="issue-history-heading">
<div>
<span className="section-kicker">Issue history</span>
<h2>Reported problems</h2>
</div>
<span>{totalItems} total</span>
</div>
) : null}
<section className="portal-toolbar">
<label>
@@ -970,7 +1433,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<div className="portal-item-row-main">
<div className="portal-item-row-title">
<strong>{item.title}</strong>
<span className="small-pill">{item.kind}</span>
<span className="small-pill">
{item.kind === 'issue'
? ISSUE_TYPE_LABELS[item.issue?.issue_type ?? 'general'] ?? 'Issue'
: item.kind}
</span>
<span className="small-pill is-muted">{item.priority}</span>
</div>
<p>{item.description}</p>
@@ -1013,6 +1480,15 @@ export default function PortalClient({ workspace }: PortalClientProps) {
<p className="lede">
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
</p>
{selectedItem.kind === 'issue' ? (
<p className="lede">
Category:{' '}
<strong>
{ISSUE_TYPE_LABELS[selectedItem.issue?.issue_type ?? 'general'] ?? 'General'}
</strong>
{selectedItem.issue?.is_resolved ? ' · Resolved' : ''}
</p>
) : null}
{selectedItem.kind === 'request' && (
<p className="lede">
Pipeline:{' '}
@@ -1111,16 +1587,26 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</label>
</>
) : (
<label>
<span>Status</span>
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<>
<label>
<span>Status</span>
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Issue category</span>
<select value={editIssueType} onChange={(event) => setEditIssueType(event.target.value)}>
{Object.entries(ISSUE_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
</>
)}
<label>
<span>Priority</span>