Add site banner, build number, and changelog

This commit is contained in:
2026-01-25 14:28:16 +13:00
parent cf4277d10c
commit 38eee2407b
15 changed files with 419 additions and 118 deletions

View File

@@ -38,6 +38,21 @@ class Settings(BaseSettings):
artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
)
site_build_number: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BUILD_NUMBER")
)
site_banner_enabled: bool = Field(
default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED")
)
site_banner_message: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE")
)
site_banner_tone: str = Field(
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
)
site_changelog: Optional[str] = Field(
default=None, validation_alias=AliasChoices("SITE_CHANGELOG")
)
jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")

View File

@@ -18,6 +18,7 @@ from .routers.images import router as images_router
from .routers.branding import router as branding_router
from .routers.status import router as status_router
from .routers.feedback import router as feedback_router
from .routers.site import router as site_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging
from .runtime import get_runtime_settings
@@ -56,3 +57,4 @@ app.include_router(images_router)
app.include_router(branding_router)
app.include_router(status_router)
app.include_router(feedback_router)
app.include_router(site_router)

View File

@@ -77,6 +77,11 @@ SETTING_KEYS: List[str] = [
"requests_cleanup_time",
"requests_cleanup_days",
"requests_data_source",
"site_build_number",
"site_banner_enabled",
"site_banner_message",
"site_banner_tone",
"site_changelog",
]
def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:

View File

@@ -1031,6 +1031,57 @@ def _normalize_indexer_name(value: Optional[str]) -> str:
return "".join(ch for ch in value.lower().strip() if ch.isalnum())
def _log_arr_http_error(service_label: str, action: str, exc: httpx.HTTPStatusError) -> None:
if exc.response is None:
logger.warning("%s %s failed: %s", service_label, action, exc)
return
status = exc.response.status_code
body = exc.response.text
if isinstance(body, str):
body = body.strip()
if len(body) > 800:
body = f"{body[:800]}...(truncated)"
logger.warning("%s %s failed: status=%s body=%s", service_label, action, status, body)
def _format_rejections(rejections: Any) -> Optional[str]:
if isinstance(rejections, str):
return rejections.strip() or None
if isinstance(rejections, list):
reasons = []
for item in rejections:
reason = None
if isinstance(item, dict):
reason = (
item.get("reason")
or item.get("message")
or item.get("errorMessage")
)
if not reason and item is not None:
reason = str(item)
if isinstance(reason, str) and reason.strip():
reasons.append(reason.strip())
if reasons:
return "; ".join(reasons)
return None
def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]:
if not isinstance(response, dict):
return True, None
rejections = response.get("rejections") or response.get("rejectionReasons")
reason = _format_rejections(rejections)
if reason:
return False, reason
if response.get("rejected") is True:
return False, "rejected"
if response.get("downloadAllowed") is False:
return False, "download not allowed"
if response.get("approved") is False:
return False, "not approved"
return True, None
def _resolve_arr_indexer_id(
indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str
) -> Optional[int]:
@@ -1745,122 +1796,22 @@ async def action_grab(
bool(release_title),
)
push_payload = None
if download_url and release_title:
push_payload = {
"title": release_title,
"downloadUrl": download_url,
"protocol": release_protocol,
"publishDate": release_publish,
"size": release_size,
"indexer": indexer_name,
"guid": guid,
"seeders": release_seeders,
"leechers": release_leechers,
}
runtime = get_runtime_settings()
if not download_url:
raise HTTPException(status_code=400, detail="Missing downloadUrl")
if snapshot.request_type.value == "tv":
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Sonarr not configured")
try:
indexers = await client.get_indexers()
resolved_indexer_id = _resolve_arr_indexer_id(indexers, indexer_name, indexer_id, "Sonarr")
response = None
action_message = "Grab sent to Sonarr."
if resolved_indexer_id is not None:
indexer_id = resolved_indexer_id
logger.info("Sonarr grab: attempting DownloadRelease command.")
try:
response = await client.download_release(str(guid), int(indexer_id))
except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code in {404, 405}:
logger.info("Sonarr grab: DownloadRelease unsupported; will try release push.")
response = None
else:
raise
if response is None and push_payload:
logger.info("Sonarr grab: attempting release push.")
try:
response = await client.push_release(push_payload)
except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code == 404:
logger.info("Sonarr grab: release push not supported.")
else:
raise
if response is None:
category = _resolve_qbittorrent_category(
runtime.sonarr_qbittorrent_category, "sonarr"
)
qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
if qbittorrent_added:
action_message = f"Grab sent to qBittorrent (category {category})."
response = {"qbittorrent": "queued"}
else:
if resolved_indexer_id is None:
detail = "Indexer not found in Sonarr and no release push available."
elif not push_payload:
detail = "Sonarr did not accept the grab request (no release URL available)."
else:
detail = "Sonarr did not accept the grab request (DownloadRelease unsupported)."
raise HTTPException(status_code=400, detail=detail)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=f"Sonarr grab failed: {exc}") from exc
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", action_message
)
return {"status": "ok", "response": response}
category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr")
if snapshot.request_type.value == "movie":
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Radarr not configured")
try:
indexers = await client.get_indexers()
resolved_indexer_id = _resolve_arr_indexer_id(indexers, indexer_name, indexer_id, "Radarr")
response = None
action_message = "Grab sent to Radarr."
if resolved_indexer_id is not None:
indexer_id = resolved_indexer_id
logger.info("Radarr grab: attempting DownloadRelease command.")
try:
response = await client.download_release(str(guid), int(indexer_id))
except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code in {404, 405}:
logger.info("Radarr grab: DownloadRelease unsupported; will try release push.")
response = None
else:
raise
if response is None and push_payload:
logger.info("Radarr grab: attempting release push.")
try:
response = await client.push_release(push_payload)
except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code == 404:
logger.info("Radarr grab: release push not supported.")
else:
raise
if response is None:
category = _resolve_qbittorrent_category(
runtime.radarr_qbittorrent_category, "radarr"
)
qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
if qbittorrent_added:
action_message = f"Grab sent to qBittorrent (category {category})."
response = {"qbittorrent": "queued"}
else:
if resolved_indexer_id is None:
detail = "Indexer not found in Radarr and no release push available."
elif not push_payload:
detail = "Radarr did not accept the grab request (no release URL available)."
else:
detail = "Radarr did not accept the grab request (DownloadRelease unsupported)."
raise HTTPException(status_code=400, detail=detail)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=f"Radarr grab failed: {exc}") from exc
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", action_message
)
return {"status": "ok", "response": response}
category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr")
if snapshot.request_type.value not in {"tv", "movie"}:
raise HTTPException(status_code=400, detail="Unknown request type")
qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
if not qbittorrent_added:
raise HTTPException(status_code=400, detail="Failed to add torrent to qBittorrent")
action_message = f"Grab sent to qBittorrent (category {category})."
await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", action_message
)
return {"status": "ok", "response": {"qbittorrent": "queued"}}
raise HTTPException(status_code=400, detail="Unknown request type")

View File

@@ -0,0 +1,39 @@
from typing import Any, Dict
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/site", tags=["site"])
_BANNER_TONES = {"info", "warning", "error", "maintenance"}
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
runtime = get_runtime_settings()
banner_message = (runtime.site_banner_message or "").strip()
tone = (runtime.site_banner_tone or "info").strip().lower()
if tone not in _BANNER_TONES:
tone = "info"
info = {
"buildNumber": (runtime.site_build_number or "").strip(),
"banner": {
"enabled": bool(runtime.site_banner_enabled and banner_message),
"message": banner_message,
"tone": tone,
},
}
if include_changelog:
info["changelog"] = (runtime.site_changelog or "").strip()
return info
@router.get("/public")
async def site_public() -> Dict[str, Any]:
return _build_site_info(False)
@router.get("/info")
async def site_info(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
return _build_site_info(True)

View File

@@ -12,6 +12,7 @@ _INT_FIELDS = {
}
_BOOL_FIELDS = {
"jellyfin_sync_to_arr",
"site_banner_enabled",
}