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( artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE") 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( jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL") 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.branding import router as branding_router
from .routers.status import router as status_router from .routers.status import router as status_router
from .routers.feedback import router as feedback_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 .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging from .logging_config import configure_logging
from .runtime import get_runtime_settings from .runtime import get_runtime_settings
@@ -56,3 +57,4 @@ app.include_router(images_router)
app.include_router(branding_router) app.include_router(branding_router)
app.include_router(status_router) app.include_router(status_router)
app.include_router(feedback_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_time",
"requests_cleanup_days", "requests_cleanup_days",
"requests_data_source", "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]]: 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()) 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( def _resolve_arr_indexer_id(
indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str
) -> Optional[int]: ) -> Optional[int]:
@@ -1745,122 +1796,22 @@ async def action_grab(
bool(release_title), 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() runtime = get_runtime_settings()
if not download_url:
raise HTTPException(status_code=400, detail="Missing downloadUrl")
if snapshot.request_type.value == "tv": if snapshot.request_type.value == "tv":
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr")
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}
if snapshot.request_type.value == "movie": if snapshot.request_type.value == "movie":
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr")
if not client.configured(): if snapshot.request_type.value not in {"tv", "movie"}:
raise HTTPException(status_code=400, detail="Radarr not configured") raise HTTPException(status_code=400, detail="Unknown request type")
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) qbittorrent_added = await _fallback_qbittorrent_download(download_url, category)
if qbittorrent_added: 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})." 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( await asyncio.to_thread(
save_action, request_id, "grab", "Grab release", "ok", action_message save_action, request_id, "grab", "Grab release", "ok", action_message
) )
return {"status": "ok", "response": response} 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 = { _BOOL_FIELDS = {
"jellyfin_sync_to_arr", "jellyfin_sync_to_arr",
"site_banner_enabled",
} }

View File

@@ -29,9 +29,12 @@ const SECTION_LABELS: Record<string, string> = {
qbittorrent: 'qBittorrent', qbittorrent: 'qBittorrent',
log: 'Activity log', log: 'Activity log',
requests: 'Request syncing', requests: 'Request syncing',
site: 'Site',
} }
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr']) const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = { const SECTION_DESCRIPTIONS: Record<string, string> = {
jellyseerr: 'Connect the request system where users submit content.', jellyseerr: 'Connect the request system where users submit content.',
@@ -44,6 +47,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
qbittorrent: 'Downloader connection settings.', qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.', requests: 'Sync and refresh cadence for requests.',
log: 'Activity log for troubleshooting.', log: 'Activity log for troubleshooting.',
site: 'Sitewide banner, version, and changelog details.',
} }
const SETTINGS_SECTION_MAP: Record<string, string | null> = { const SETTINGS_SECTION_MAP: Record<string, string | null> = {
@@ -58,6 +62,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
cache: null, cache: null,
logs: 'log', logs: 'log',
maintenance: null, maintenance: null,
site: 'site',
} }
const labelFromKey = (key: string) => const labelFromKey = (key: string) =>
@@ -78,6 +83,11 @@ const labelFromKey = (key: string) =>
.replace('jellyfin public url', 'Jellyfin public URL') .replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr') .replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode') .replace('artwork cache mode', 'Artwork cache mode')
.replace('site build number', 'Build number')
.replace('site banner enabled', 'Sitewide banner enabled')
.replace('site banner message', 'Sitewide banner message')
.replace('site banner tone', 'Sitewide banner tone')
.replace('site changelog', 'Changelog text')
type SettingsPageProps = { type SettingsPageProps = {
section: string section: string
@@ -290,6 +300,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
requests_data_source: 'Pick where Magent should read requests from.', requests_data_source: 'Pick where Magent should read requests from.',
log_level: 'How much detail is written to the activity log.', log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.', log_file: 'Where the activity log is stored.',
site_build_number: 'Version or build identifier shown in the footer.',
site_banner_enabled: 'Enable a sitewide banner for announcements.',
site_banner_message: 'Short banner message for maintenance or updates.',
site_banner_tone: 'Visual tone for the banner.',
site_changelog: 'One update per line for the public changelog.',
} }
const buildSelectOptions = ( const buildSelectOptions = (
@@ -1008,6 +1023,34 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </label>
) )
} }
if (setting.key === 'site_banner_tone') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'info'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
{BANNER_TONES.map((tone) => (
<option key={tone} value={tone}>
{tone.charAt(0).toUpperCase() + tone.slice(1)}
</option>
))}
</select>
</label>
)
}
if ( if (
setting.key === 'requests_full_sync_time' || setting.key === 'requests_full_sync_time' ||
setting.key === 'requests_cleanup_time' setting.key === 'requests_cleanup_time'
@@ -1086,6 +1129,35 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </label>
) )
} }
if (TEXTAREA_SETTINGS.has(setting.key)) {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
{setting.sensitive && setting.isSet ? '  stored' : ''}
</span>
</span>
<textarea
name={setting.key}
rows={setting.key === 'site_changelog' ? 6 : 3}
placeholder={
setting.key === 'site_changelog'
? 'One update per line.'
: ''
}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row"> <span className="label-row">

View File

@@ -13,6 +13,7 @@ const ALLOWED_SECTIONS = new Set([
'cache', 'cache',
'logs', 'logs',
'maintenance', 'maintenance',
'site',
]) ])
type PageProps = { type PageProps = {

View File

@@ -0,0 +1,85 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type SiteInfo = {
changelog?: string
}
const parseChangelog = (raw: string) =>
raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
export default function ChangelogPage() {
const router = useRouter()
const [entries, setEntries] = useState<string[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = getToken()
if (!token) {
router.push('/login')
return
}
let active = true
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/site/info`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error('Failed to load changelog')
}
const data: SiteInfo = await response.json()
if (!active) return
setEntries(parseChangelog(data?.changelog ?? ''))
} catch (err) {
console.error(err)
if (!active) return
setEntries([])
} finally {
if (active) setLoading(false)
}
}
void load()
return () => {
active = false
}
}, [router])
const content = useMemo(() => {
if (loading) {
return <div className="loading-text">Loading changelog...</div>
}
if (entries.length === 0) {
return <div className="meta">No updates posted yet.</div>
}
return (
<ul className="changelog-list">
{entries.map((entry, index) => (
<li key={`${entry}-${index}`}>{entry}</li>
))}
</ul>
)
}, [entries, loading])
return (
<div className="page">
<section className="card changelog-card">
<div className="changelog-header">
<h1>Changelog</h1>
<p className="lede">Latest updates and release notes.</p>
</div>
{content}
</section>
</div>
)
}

View File

@@ -968,6 +968,49 @@ button span {
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.site-banner {
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
font-size: 14px;
}
.site-banner--info {
background: rgba(59, 130, 246, 0.18);
border-color: rgba(59, 130, 246, 0.4);
}
.site-banner--warning {
background: rgba(255, 200, 87, 0.22);
border-color: rgba(255, 200, 87, 0.5);
}
.site-banner--error {
background: rgba(255, 59, 48, 0.2);
border-color: rgba(255, 59, 48, 0.4);
}
.site-banner--maintenance {
background: rgba(255, 107, 43, 0.18);
border-color: rgba(255, 107, 43, 0.4);
}
.site-version {
position: fixed;
left: 16px;
bottom: 12px;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--ink-muted);
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.08);
z-index: 30;
}
.recent-header { .recent-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1589,3 +1632,21 @@ button span {
display: grid; display: grid;
gap: 8px; gap: 8px;
} }
.changelog-card {
gap: 18px;
}
.changelog-header {
display: grid;
gap: 8px;
}
.changelog-list {
list-style: disc;
padding-left: 22px;
display: grid;
gap: 10px;
color: var(--ink-muted);
font-size: 15px;
}

View File

@@ -5,6 +5,7 @@ import HeaderIdentity from './ui/HeaderIdentity'
import ThemeToggle from './ui/ThemeToggle' import ThemeToggle from './ui/ThemeToggle'
import BrandingFavicon from './ui/BrandingFavicon' import BrandingFavicon from './ui/BrandingFavicon'
import BrandingLogo from './ui/BrandingLogo' import BrandingLogo from './ui/BrandingLogo'
import SiteStatus from './ui/SiteStatus'
export const metadata = { export const metadata = {
title: 'Magent', title: 'Magent',
@@ -35,6 +36,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<HeaderActions /> <HeaderActions />
</div> </div>
</header> </header>
<SiteStatus />
{children} {children}
</div> </div>
</body> </body>

View File

@@ -617,8 +617,8 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const text = await response.text() const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`) throw new Error(text || `Request failed: ${response.status}`)
} }
setActionMessage('Download sent to Sonarr/Radarr.') setActionMessage('Download sent to qBittorrent.')
setModalMessage('Download sent to Sonarr/Radarr.') setModalMessage('Download sent to qBittorrent.')
} catch (error) { } catch (error) {
console.error(error) console.error(error)
const message = 'Download failed. Check the logs.' const message = 'Download failed. Check the logs.'

View File

@@ -25,6 +25,7 @@ const NAV_GROUPS = [
{ {
title: 'Admin', title: 'Admin',
items: [ items: [
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' }, { href: '/users', label: 'Users' },
{ href: '/admin/logs', label: 'Activity log' }, { href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' }, { href: '/admin/maintenance', label: 'Maintenance' },

View File

@@ -49,6 +49,7 @@ export default function HeaderActions() {
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a> <a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
<a href="/">Requests</a> <a href="/">Requests</a>
<a href="/how-it-works">How it works</a> <a href="/how-it-works">How it works</a>
<a href="/changelog">Changelog</a>
<a href="/profile">My profile</a> <a href="/profile">My profile</a>
{role === 'admin' && <a href="/admin">Settings</a>} {role === 'admin' && <a href="/admin">Settings</a>}
<button type="button" className="header-link" onClick={signOut}> <button type="button" className="header-link" onClick={signOut}>

View File

@@ -0,0 +1,65 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type BannerInfo = {
enabled: boolean
message: string
tone?: string
}
type SiteInfo = {
buildNumber?: string
banner?: BannerInfo
}
const buildRequest = () => {
const token = getToken()
const baseUrl = getApiBase()
const url = token ? `${baseUrl}/site/info` : `${baseUrl}/site/public`
const fetcher = token ? authFetch : fetch
return { token, url, fetcher }
}
export default function SiteStatus() {
const [info, setInfo] = useState<SiteInfo | null>(null)
useEffect(() => {
let active = true
const load = async () => {
try {
const { token, url, fetcher } = buildRequest()
const response = await fetcher(url)
if (!response.ok) {
if (response.status === 401 && token) {
clearToken()
}
return
}
const data = await response.json()
if (!active) return
setInfo(data)
} catch (err) {
console.error(err)
}
}
void load()
return () => {
active = false
}
}, [])
const banner = info?.banner
const tone = banner?.tone || 'info'
return (
<>
{banner?.enabled && banner.message ? (
<div className={`site-banner site-banner--${tone}`}>{banner.message}</div>
) : null}
{info?.buildNumber ? (
<div className="site-version">Build {info.buildNumber}</div>
) : null}
</>
)
}