6 Commits

7 changed files with 225 additions and 24 deletions

View File

@@ -1 +1 @@
271261202 271261524

View File

@@ -1,6 +1,7 @@
import os import os
import re import re
import mimetypes import mimetypes
import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Response from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
@@ -12,6 +13,7 @@ router = APIRouter(prefix="/images", tags=["images"])
_TMDB_BASE = "https://image.tmdb.org/t/p" _TMDB_BASE = "https://image.tmdb.org/t/p"
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"} _ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
logger = logging.getLogger(__name__)
def _safe_filename(path: str) -> str: def _safe_filename(path: str) -> str:
@@ -91,6 +93,8 @@ async def tmdb_image(path: str, size: str = "w342"):
if os.path.exists(file_path): if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg" media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
return FileResponse(file_path, media_type=media_type, headers=headers) return FileResponse(file_path, media_type=media_type, headers=headers)
raise HTTPException(status_code=502, detail="Image cache failed") logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
except httpx.HTTPError as exc: except (httpx.HTTPError, OSError) as exc:
raise HTTPException(status_code=502, detail=f"Image fetch failed: {exc}") from exc logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
return RedirectResponse(url=url)

View File

@@ -1,6 +1,6 @@
from typing import Any, Dict from typing import Any, Dict
import httpx import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user from ..auth import get_current_user
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
@@ -93,3 +93,42 @@ async def services_status() -> Dict[str, Any]:
overall = "degraded" overall = "degraded"
return {"overall": overall, "services": services} return {"overall": overall, "services": services}
@router.post("/services/{service}/test")
async def test_service(service: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
service_key = service.strip().lower()
checks = {
"jellyseerr": (
"Jellyseerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
),
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
}
if service_key not in checks:
raise HTTPException(status_code=404, detail="Unknown service")
name, configured, func = checks[service_key]
result = await _check(name, configured, func)
if name == "Prowlarr" and result.get("status") == "up":
health = result.get("detail")
if isinstance(health, list) and health:
result["status"] = "degraded"
result["message"] = "Health warnings"
return result

View File

@@ -11,7 +11,14 @@ from ..clients.radarr import RadarrClient
from ..clients.prowlarr import ProwlarrClient from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient from ..clients.qbittorrent import QBittorrentClient
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..db import save_snapshot, get_request_cache_payload, get_recent_snapshots, get_setting, set_setting from ..db import (
save_snapshot,
get_request_cache_payload,
get_request_cache_by_id,
get_recent_snapshots,
get_setting,
set_setting,
)
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -219,6 +226,19 @@ async def build_snapshot(request_id: str) -> Snapshot:
logging.getLogger(__name__).debug( logging.getLogger(__name__).debug(
"snapshot cache miss: request_id=%s mode=%s", request_id, mode "snapshot cache miss: request_id=%s mode=%s", request_id, mode
) )
if cached_request is not None:
cache_meta = get_request_cache_by_id(int(request_id))
cached_title = cache_meta.get("title") if cache_meta else None
if cached_title and isinstance(cached_request, dict):
media = cached_request.get("media")
if not isinstance(media, dict):
media = {}
cached_request["media"] = media
if not media.get("title") and not media.get("name"):
media["title"] = cached_title
media["name"] = cached_title
if not cached_request.get("title") and not cached_request.get("name"):
cached_request["title"] = cached_title
allow_remote = mode == "always_js" and jellyseerr.configured() allow_remote = mode == "always_js" and jellyseerr.configured()
if not jellyseerr.configured() and not cached_request: if not jellyseerr.configured() and not cached_request:
@@ -259,10 +279,18 @@ async def build_snapshot(request_id: str) -> Snapshot:
jelly_status = jelly_request.get("status", "unknown") jelly_status = jelly_request.get("status", "unknown")
jelly_status_label = _status_label(jelly_status) jelly_status_label = _status_label(jelly_status)
jelly_type = jelly_request.get("type") or "unknown" jelly_type = jelly_request.get("type") or "unknown"
snapshot.title = jelly_request.get("media", {}).get("title", "Unknown")
snapshot.year = jelly_request.get("media", {}).get("year")
snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown
media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {} media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {}
if not isinstance(media, dict):
media = {}
snapshot.title = (
media.get("title")
or media.get("name")
or jelly_request.get("title")
or jelly_request.get("name")
or "Unknown"
)
snapshot.year = media.get("year") or jelly_request.get("year")
snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown
poster_path = None poster_path = None
backdrop_path = None backdrop_path = None
if isinstance(media, dict): if isinstance(media, dict):

View File

@@ -125,6 +125,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [cacheRows, setCacheRows] = useState<any[]>([]) const [cacheRows, setCacheRows] = useState<any[]>([])
const [cacheCount, setCacheCount] = useState(50) const [cacheCount, setCacheCount] = useState(50)
const [cacheStatus, setCacheStatus] = useState<string | null>(null) const [cacheStatus, setCacheStatus] = useState<string | null>(null)
const [cacheLoading, setCacheLoading] = useState(false)
const [requestsSync, setRequestsSync] = useState<any | null>(null) const [requestsSync, setRequestsSync] = useState<any | null>(null)
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null) const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
const [artworkSummary, setArtworkSummary] = useState<any | null>(null) const [artworkSummary, setArtworkSummary] = useState<any | null>(null)
@@ -667,6 +668,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const loadCache = async () => { const loadCache = async () => {
setCacheStatus(null) setCacheStatus(null)
setCacheLoading(true)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetch(
@@ -689,6 +691,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '') ? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load cache.' : 'Could not load cache.'
setCacheStatus(message) setCacheStatus(message)
} finally {
setCacheLoading(false)
} }
} }
@@ -1378,8 +1382,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<option value={200}>200</option> <option value={200}>200</option>
</select> </select>
</label> </label>
<button type="button" onClick={loadCache}> <button type="button" onClick={loadCache} disabled={cacheLoading}>
Load saved requests {cacheLoading ? (
<>
<span className="spinner button-spinner" aria-hidden="true" />
Loading saved requests
</>
) : (
'Load saved requests'
)}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1415,6 +1415,24 @@ button span {
font-size: 13px; font-size: 13px;
} }
.system-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.system-test-message {
font-size: 11px;
color: var(--ink-muted);
}
.system-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.system-dot { .system-dot {
width: 10px; width: 10px;
height: 10px; height: 10px;
@@ -1444,10 +1462,28 @@ button span {
} }
.system-state { .system-state {
margin-left: auto;
color: var(--ink-muted); color: var(--ink-muted);
} }
.system-test {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.08);
color: var(--ink-muted);
font-size: 11px;
letter-spacing: 0.02em;
}
.system-test:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.16);
}
.system-test:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pipeline-map { .pipeline-map {
border-radius: 16px; border-radius: 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -1689,6 +1725,16 @@ button span {
animation: spin 0.9s linear infinite; animation: spin 0.9s linear infinite;
} }
.button-spinner {
width: 16px;
height: 16px;
border-width: 2px;
box-shadow: none;
margin-right: 8px;
vertical-align: middle;
display: inline-block;
}
.loading-text { .loading-text {
font-size: 16px; font-size: 16px;
color: var(--ink-muted); color: var(--ink-muted);

View File

@@ -30,6 +30,8 @@ export default function HomePage() {
>(null) >(null)
const [servicesLoading, setServicesLoading] = useState(false) const [servicesLoading, setServicesLoading] = useState(false)
const [servicesError, setServicesError] = useState<string | null>(null) const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
@@ -42,6 +44,61 @@ export default function HomePage() {
void runSearch(trimmed) void runSearch(trimmed)
} }
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
const updateServiceStatus = (name: string, status: string, message?: string) => {
setServicesStatus((prev) => {
if (!prev) return prev
return {
...prev,
services: prev.services.map((service) =>
service.name === name ? { ...service, status, message } : service
),
}
})
}
const testService = async (name: string) => {
const slug = toServiceSlug(name)
setServiceTesting((prev) => ({ ...prev, [name]: true }))
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
method: 'POST',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Service test failed: ${response.status}`)
}
const data = await response.json()
const status = data?.status ?? 'unknown'
const message =
data?.message ||
(status === 'up'
? 'API OK'
: status === 'down'
? 'API unreachable'
: status === 'degraded'
? 'Health warnings'
: status === 'not_configured'
? 'Not configured'
: 'Unknown')
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
updateServiceStatus(name, status, data?.message)
} catch (error) {
console.error(error)
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
} finally {
setServiceTesting((prev) => ({ ...prev, [name]: false }))
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -214,10 +271,17 @@ export default function HomePage() {
return order.map((name) => { return order.map((name) => {
const item = items.find((entry) => entry.name === name) const item = items.find((entry) => entry.name === name)
const status = item?.status ?? 'unknown' const status = item?.status ?? 'unknown'
const testing = serviceTesting[name] ?? false
return ( return (
<div key={name} className={`system-item system-${status}`}> <div key={name} className={`system-item system-${status}`}>
<span className="system-dot" /> <span className="system-dot" />
<div className="system-meta">
<span className="system-name">{name}</span> <span className="system-name">{name}</span>
{serviceTestResults[name] && (
<span className="system-test-message">{serviceTestResults[name]}</span>
)}
</div>
<div className="system-actions">
<span className="system-state"> <span className="system-state">
{status === 'up' {status === 'up'
? 'Up' ? 'Up'
@@ -229,6 +293,15 @@ export default function HomePage() {
? 'Not configured' ? 'Not configured'
: 'Unknown'} : 'Unknown'}
</span> </span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</button>
</div>
</div> </div>
) )
}) })