From f8770cb44a88aa2177327fa6e6f17238b0e21cdc Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Mon, 31 Aug 2026 21:46:43 +1200 Subject: [PATCH] Build guided media issue workflow --- backend/app/clients/jellyfin.py | 11 + backend/app/routers/portal.py | 157 ++++++++ backend/tests/test_backend_quality.py | 61 +++ frontend/app/ops-redesign.css | 372 +++++++++++++++++ frontend/app/portal/PortalClient.tsx | 560 ++++++++++++++++++++++++-- 5 files changed, 1124 insertions(+), 37 deletions(-) diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py index e744d3b..088313c 100644 --- a/backend/app/clients/jellyfin.py +++ b/backend/app/clients/jellyfin.py @@ -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 diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py index 2b95ff3..da78b88 100644 --- a/backend/app/routers/portal.py +++ b/backend/app/routers/portal.py @@ -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, diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index c61ab74..f96fc82 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -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"} diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 31ffd8b..d5c104d 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -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%; } +} diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx index 221f8cb..b469b23 100644 --- a/frontend/app/portal/PortalClient.tsx +++ b/frontend/app/portal/PortalClient.tsx @@ -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 = { + broken_media: ['Visual artefacts or corruption', 'Freezes at the same point', 'Stops before the end', 'File will not play'], + missing_content: ['Entire title is missing', 'Season is missing', 'Episode is missing', 'Part or edition is missing'], + audio: ['No audio', 'Wrong language', 'Commentary track only', 'Audio is out of sync', 'Audio is distorted'], + subtitle: ['Subtitles are missing', 'Wrong subtitles', 'Subtitles are out of sync', 'Forced subtitles are missing'], + playback: ['Will not start', 'Constant buffering', 'Transcode error', 'Stops during playback', 'Only fails on one device'], + service_unavailable: ['Grizzlyflix will not open', 'Every title fails', 'Login works but playback does not', 'Server error is shown'], +} + +const ISSUE_TYPE_LABELS: Record = { + general: 'General', + playback: 'Playback', + transcode: 'Transcoding', + service_unavailable: 'Server unavailable', + broken_media: 'Broken media', + missing_content: 'Missing content', + audio: 'Audio', + subtitle: 'Subtitles', + quality: 'Quality', + metadata: 'Metadata', + other: 'Other', +} + const STATUS_OPTIONS = [ { value: 'new', label: 'New' }, { value: 'triaging', label: 'Triaging' }, @@ -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([]) const [discoverError, setDiscoverError] = useState(null) const [requestingTmdbIds, setRequestingTmdbIds] = useState>({}) + const [issueCategory, setIssueCategory] = useState(null) + const [issueMediaTitle, setIssueMediaTitle] = useState('') + const [issueMediaType, setIssueMediaType] = useState<'movie' | 'tv'>('movie') + const [issueEpisode, setIssueEpisode] = useState('') + const [issueScope, setIssueScope] = useState<'one_title' | 'multiple_titles' | 'everything'>('one_title') + const [issueSymptom, setIssueSymptom] = useState('') + const [issueDevice, setIssueDevice] = useState('') + const [issueNotes, setIssueNotes] = useState('') + const [mediaServerStatus, setMediaServerStatus] = useState(null) + const [mediaServerChecking, setMediaServerChecking] = useState(false) + const [mediaServerError, setMediaServerError] = useState(null) const 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
Loading request portal...
+ return
Loading {workspace === 'issue' ? 'issues' : 'requests'}...
} return ( -
-
+
+
-

{workspace === 'request' ? 'Request portal' : 'Issue portal'}

+ {workspace === 'issue' ? Guided support : null} +

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

{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.'}

+ {workspace === 'issue' ? ( +
+ {visibleKindCount} + reported issues +
+ ) : null}
-
- - -
+ {workspace === 'request' ? ( +
+ + +
+ ) : null} {error &&
{error}
} {status &&
{status}
} @@ -790,13 +1074,180 @@ export default function PortalClient({ workspace }: PortalClientProps) {
) : ( -
-
- Issue workspace is for reporting problems and tracking resolution separately from content requests. +
+
+ 01 +
+ Choose a symptom +

Which best describes the problem?

+

Only the questions needed for that problem will appear next.

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

Tell us what is affected

+

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

+
+
+ + {issueCategory === 'playback' ? ( +
+ How widespread is it? +
+ {[ + ['one_title', 'One title'], + ['multiple_titles', 'Several titles'], + ['everything', 'Nothing will play'], + ].map(([value, label]) => ( + + ))} +
+
+ ) : null} + +
+ {issueNeedsMediaTitle ? ( + + ) : null} + {issueNeedsMediaTitle ? ( + + ) : null} + {issueNeedsMediaTitle && issueMediaType === 'tv' ? ( + + ) : null} + + {(issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? ( + + ) : null} +