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
+11
View File
@@ -213,6 +213,17 @@ class JellyfinClient(ApiClient):
response.raise_for_status()
return response.json()
async def get_sessions(self) -> Optional[list[Dict[str, Any]]]:
if not self.base_url or not self.api_key:
return None
url = f"{self.base_url}/Sessions"
headers = self._emby_headers()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, list) else []
async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
+157
View File
@@ -1,12 +1,15 @@
from __future__ import annotations
import logging
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import get_current_user
from ..clients.jellyfin import JellyfinClient
from ..db import (
add_portal_comment,
count_portal_items,
@@ -18,6 +21,7 @@ from ..db import (
update_portal_item,
)
from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
logger = logging.getLogger(__name__)
@@ -55,6 +59,10 @@ PORTAL_MEDIA_STATUSES = {
PORTAL_ISSUE_TYPES = {
"general",
"playback",
"transcode",
"service_unavailable",
"broken_media",
"audio",
"subtitle",
"quality",
"metadata",
@@ -62,6 +70,9 @@ PORTAL_ISSUE_TYPES = {
"other",
}
_MEDIA_STATUS_CACHE: Dict[str, Any] = {"expires_at": 0.0, "payload": None}
_MEDIA_STATUS_CACHE_SECONDS = 15.0
REQUEST_STATUS_TRANSITIONS: Dict[str, set[str]] = {
"pending": {"pending", "approved", "declined"},
"approved": {"approved", "declined"},
@@ -339,6 +350,36 @@ def _is_owner(user: Dict[str, Any], item: Dict[str, Any]) -> bool:
return str(user.get("username") or "") == str(item.get("created_by_username") or "")
def _public_media_status_payload(
*,
status: str,
headline: str,
message: str,
latency_ms: Optional[int] = None,
version: Optional[str] = None,
restart_pending: Optional[bool] = None,
active_streams: Optional[int] = None,
transcoding_streams: Optional[int] = None,
session_check_available: bool = False,
) -> Dict[str, Any]:
return {
"checked_at": datetime.now(timezone.utc).isoformat(),
"status": status,
"headline": headline,
"message": message,
"latency_ms": latency_ms,
"server": {
"version": version,
"restart_pending": restart_pending,
},
"activity": {
"active_streams": active_streams,
"transcoding_streams": transcoding_streams,
"available": session_check_available,
},
}
def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
is_admin = _is_admin(user)
is_owner = _is_owner(user, item)
@@ -406,6 +447,122 @@ async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_use
}
@router.get("/issues/media-status")
async def portal_media_status() -> Dict[str, Any]:
"""Return a short, privacy-safe Jellyfin health check for guided issue reporting."""
now = time.monotonic()
cached_payload = _MEDIA_STATUS_CACHE.get("payload")
if isinstance(cached_payload, dict) and now < float(_MEDIA_STATUS_CACHE.get("expires_at") or 0):
return cached_payload
runtime = get_runtime_settings()
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not jellyfin.configured():
payload = _public_media_status_payload(
status="not_configured",
headline="Media server status is unavailable",
message="Magent cannot run a playback check right now. Your report can still be submitted.",
)
_MEDIA_STATUS_CACHE.update(
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
payload=payload,
)
return payload
started_at = time.perf_counter()
try:
system_info = await jellyfin.get_system_info()
except (httpx.HTTPError, RuntimeError, ValueError):
latency_ms = round((time.perf_counter() - started_at) * 1000)
payload = _public_media_status_payload(
status="down",
headline="The media server is not responding",
message=(
"This looks broader than one title. The report will include the failed server check "
"so an administrator can investigate the service first."
),
latency_ms=latency_ms,
)
_MEDIA_STATUS_CACHE.update(
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
payload=payload,
)
return payload
except Exception:
logger.exception("guided issue Jellyfin system check failed")
latency_ms = round((time.perf_counter() - started_at) * 1000)
payload = _public_media_status_payload(
status="down",
headline="The media server check failed",
message="Your report can still be submitted and will include this failed service check.",
latency_ms=latency_ms,
)
_MEDIA_STATUS_CACHE.update(
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
payload=payload,
)
return payload
latency_ms = round((time.perf_counter() - started_at) * 1000)
info = system_info if isinstance(system_info, dict) else {}
version_value = info.get("Version")
version = str(version_value).strip() if version_value is not None else None
restart_pending = bool(info.get("HasPendingRestart"))
session_check_available = False
active_streams: Optional[int] = None
transcoding_streams: Optional[int] = None
try:
sessions = await jellyfin.get_sessions()
if isinstance(sessions, list):
session_check_available = True
active_streams = sum(
1 for session in sessions if isinstance(session, dict) and session.get("NowPlayingItem")
)
transcoding_streams = sum(
1
for session in sessions
if isinstance(session, dict)
and session.get("NowPlayingItem")
and session.get("TranscodingInfo")
)
except Exception:
logger.warning("guided issue Jellyfin session check unavailable", exc_info=True)
if restart_pending:
status = "degraded"
headline = "Media server is online but needs attention"
message = "Jellyfin is responding, but it reports that a restart is pending."
elif session_check_available and active_streams:
status = "up"
headline = "Media server is online and actively streaming"
message = (
"Other playback is currently working, so this is more likely specific to the title, "
"audio track, subtitle, client, or transcode path."
)
else:
status = "up"
headline = "Media server is online"
message = "Jellyfin responded normally. Continue with the report if playback is still failing."
payload = _public_media_status_payload(
status=status,
headline=headline,
message=message,
latency_ms=latency_ms,
version=version,
restart_pending=restart_pending,
active_streams=active_streams,
transcoding_streams=transcoding_streams,
session_check_available=session_check_available,
)
_MEDIA_STATUS_CACHE.update(
expires_at=now + _MEDIA_STATUS_CACHE_SECONDS,
payload=payload,
)
return payload
@router.get("/items")
async def portal_list_items(
kind: Optional[str] = None,
+61
View File
@@ -1166,6 +1166,67 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
)
class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
portal_router._MEDIA_STATUS_CACHE.update(expires_at=0.0, payload=None)
async def test_media_status_is_live_and_removes_session_identity(self) -> None:
client = SimpleNamespace(
configured=lambda: True,
get_system_info=AsyncMock(
return_value={
"Version": "10.10.7",
"HasPendingRestart": False,
"WanAddress": "https://private.example",
}
),
get_sessions=AsyncMock(
return_value=[
{
"UserName": "private-user",
"DeviceName": "Living room television",
"NowPlayingItem": {"Name": "Private title"},
"TranscodingInfo": {"VideoCodec": "h264"},
}
]
),
)
with (
patch.object(portal_router, "get_runtime_settings", return_value=SimpleNamespace(
jellyfin_base_url="http://jellyfin",
jellyfin_api_key="secret",
)),
patch.object(portal_router, "JellyfinClient", return_value=client),
):
result = await portal_router.portal_media_status()
self.assertEqual(result["status"], "up")
self.assertEqual(result["activity"]["active_streams"], 1)
self.assertEqual(result["activity"]["transcoding_streams"], 1)
serialized = str(result)
self.assertNotIn("private-user", serialized)
self.assertNotIn("Living room television", serialized)
self.assertNotIn("Private title", serialized)
self.assertNotIn("private.example", serialized)
async def test_media_status_reports_unavailable_without_exposing_exception(self) -> None:
client = SimpleNamespace(
configured=lambda: True,
get_system_info=AsyncMock(side_effect=RuntimeError("secret upstream failure")),
)
with (
patch.object(portal_router, "get_runtime_settings", return_value=SimpleNamespace(
jellyfin_base_url="http://jellyfin",
jellyfin_api_key="secret",
)),
patch.object(portal_router, "JellyfinClient", return_value=client),
):
result = await portal_router.portal_media_status()
self.assertEqual(result["status"], "down")
self.assertNotIn("secret upstream failure", str(result))
class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
def test_legacy_request_status_maps_to_workflow(self) -> None:
item = {"kind": "request", "status": "in_progress"}
+372
View File
@@ -2514,3 +2514,375 @@ button:disabled,
.request-submit-progress p > small { grid-column: 2; }
.request-complete-actions { display: grid; }
}
/* Guided issue reporting */
.issue-portal-page {
gap: 20px;
}
.issue-portal-page > .issue-portal-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 0;
padding: 22px 24px;
background:
radial-gradient(circle at 100% 0%, rgba(126, 215, 255, 0.11), transparent 42%),
linear-gradient(135deg, rgba(90, 80, 240, 0.12), transparent 48%),
var(--ops-panel);
}
.issue-portal-hero > div:first-child {
display: grid;
gap: 5px;
max-width: 790px;
}
.issue-portal-hero h1 {
font-size: clamp(1.65rem, 3vw, 2.45rem);
letter-spacing: -0.035em;
}
.issue-portal-hero .lede {
max-width: 760px;
margin: 0;
}
.issue-hero-count {
display: grid;
flex: 0 0 auto;
min-width: 130px;
padding: 12px 16px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius);
background: rgba(255, 255, 255, 0.025);
}
.issue-hero-count strong {
color: var(--ops-cyan);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 1.45rem;
}
.issue-hero-count span {
color: var(--ops-muted);
font-size: 0.7rem;
text-transform: uppercase;
}
.issue-flow {
display: grid;
gap: 18px;
padding: 20px;
border: 1px solid var(--ops-line);
border-radius: var(--ops-radius-lg);
background: rgba(255, 255, 255, 0.018);
}
.issue-flow-heading {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 13px;
}
.issue-flow-heading > div {
display: grid;
gap: 4px;
}
.issue-flow-heading h2,
.issue-resolution-card h2,
.issue-history-heading h2,
.media-status-heading h3 {
margin: 0;
}
.issue-flow-heading p,
.issue-resolution-card p,
.media-status-check > p {
margin: 0;
color: var(--ops-muted);
line-height: 1.5;
}
.issue-step-number {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border: 1px solid rgba(126, 215, 255, 0.34);
border-radius: 50%;
background: rgba(14, 165, 233, 0.08);
color: var(--ops-cyan);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.7rem;
font-weight: 800;
}
.issue-category-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.issue-category-card {
display: grid;
align-content: start;
gap: 8px;
min-height: 190px;
padding: 16px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius-lg);
background: rgba(255, 255, 255, 0.022);
color: var(--ops-text);
text-align: left;
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.issue-category-card:hover {
transform: translateY(-2px);
border-color: rgba(126, 215, 255, 0.42);
}
.issue-category-card.is-selected {
border-color: rgba(72, 224, 178, 0.58);
background: linear-gradient(145deg, rgba(72, 224, 178, 0.09), rgba(14, 165, 233, 0.035));
box-shadow: 0 0 24px rgba(72, 224, 178, 0.06);
}
.issue-category-card strong {
font-size: 0.98rem;
}
.issue-category-card p,
.issue-category-card small {
margin: 0;
color: var(--ops-muted);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.45;
}
.issue-category-card small {
align-self: end;
margin-top: auto;
padding-top: 9px;
border-top: 1px solid var(--ops-line-soft);
color: var(--request-green);
font-size: 0.68rem;
}
.issue-category-marker {
justify-self: start;
padding: 4px 7px;
border: 1px solid rgba(126, 215, 255, 0.24);
border-radius: 999px;
color: var(--ops-cyan);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.58rem;
letter-spacing: 0.05em;
}
.issue-guided-form {
display: grid;
gap: 18px;
padding-top: 20px;
border-top: 1px solid var(--ops-line);
}
.issue-choice-field {
display: grid;
gap: 9px;
margin: 0;
padding: 0;
border: 0;
}
.issue-choice-field legend,
.issue-question-grid label > span {
margin-bottom: 7px;
color: var(--ops-muted);
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.issue-choice-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.issue-choice-row button {
border-color: var(--ops-line-soft);
background: rgba(255, 255, 255, 0.025);
color: var(--ops-muted);
}
.issue-choice-row button.is-selected {
border-color: rgba(126, 215, 255, 0.48);
background: rgba(14, 165, 233, 0.12);
color: var(--ops-text);
}
.issue-question-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.issue-question-grid label {
display: grid;
align-content: start;
}
.issue-field-span-2 {
grid-column: span 2;
}
.media-status-check {
display: grid;
gap: 13px;
padding: 16px;
border: 1px solid rgba(126, 215, 255, 0.28);
border-radius: var(--ops-radius-lg);
background: rgba(14, 165, 233, 0.045);
}
.media-status-check.media-status-up {
border-color: rgba(72, 224, 178, 0.34);
background: rgba(72, 224, 178, 0.045);
}
.media-status-check.media-status-degraded {
border-color: rgba(255, 192, 84, 0.36);
background: rgba(255, 192, 84, 0.05);
}
.media-status-check.media-status-down {
border-color: rgba(255, 86, 113, 0.38);
background: rgba(255, 86, 113, 0.05);
}
.media-status-heading,
.issue-history-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.media-status-heading > div,
.issue-history-heading > div {
display: grid;
gap: 4px;
}
.issue-live-scan {
display: flex;
align-items: center;
gap: 9px;
color: var(--ops-muted);
font-size: 0.75rem;
}
.issue-live-scan i {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--ops-cyan);
box-shadow: 0 0 14px var(--ops-cyan);
animation: request-operation-pulse 1.15s ease-in-out infinite;
}
.media-status-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.media-status-metrics > div {
display: grid;
gap: 3px;
min-width: 0;
padding: 10px;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius);
background: rgba(255, 255, 255, 0.022);
}
.media-status-metrics span {
color: var(--ops-muted);
font-size: 0.62rem;
text-transform: uppercase;
}
.media-status-metrics strong {
overflow: hidden;
font-size: 0.75rem;
text-overflow: ellipsis;
}
.issue-resolution-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 16px;
border: 1px solid rgba(72, 224, 178, 0.3);
border-radius: var(--ops-radius-lg);
background: linear-gradient(110deg, rgba(72, 224, 178, 0.07), rgba(14, 165, 233, 0.035));
}
.issue-resolution-card > div {
display: grid;
gap: 4px;
}
.issue-resolution-card > button {
min-width: 150px;
}
.issue-history-heading {
padding-top: 4px;
}
.issue-history-heading > span {
padding: 5px 9px;
border: 1px solid var(--ops-line-soft);
border-radius: 999px;
color: var(--ops-muted);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.65rem;
}
.issue-portal-page .portal-item-row p {
display: -webkit-box;
overflow: hidden;
white-space: pre-wrap;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
@media (max-width: 980px) {
.issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.media-status-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.issue-portal-page > .issue-portal-hero,
.media-status-heading,
.issue-history-heading { align-items: flex-start; flex-direction: column; }
.issue-hero-count { width: 100%; }
.issue-flow { padding: 14px; }
.issue-category-grid,
.issue-question-grid,
.media-status-metrics { grid-template-columns: 1fr; }
.issue-field-span-2 { grid-column: span 1; }
.issue-category-card { min-height: 0; }
.issue-resolution-card { grid-template-columns: auto minmax(0, 1fr); }
.issue-resolution-card > button { grid-column: 1 / -1; width: 100%; }
}
+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>