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,