Build guided media issue workflow
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user