Finalize dev-1.3 upgrades and Seerr updates

This commit is contained in:
2026-02-28 21:41:16 +13:00
parent 05a3d1e3b0
commit c205df4367
27 changed files with 1201 additions and 214 deletions

View File

@@ -22,7 +22,8 @@ const SECTION_LABELS: Record<string, string> = {
magent: 'Magent',
general: 'General',
notifications: 'Notifications',
jellyseerr: 'Jellyseerr',
seerr: 'Seerr',
jellyseerr: 'Seerr',
jellyfin: 'Jellyfin',
artwork: 'Artwork cache',
cache: 'Cache Control',
@@ -89,7 +90,8 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.',
notifications:
'Notification providers and delivery channel settings used by Magent messaging features.',
jellyseerr: 'Connect the request system where users submit content.',
seerr: 'Connect Seerr where users submit content requests.',
jellyseerr: 'Connect Seerr where users submit content requests.',
jellyfin: 'Control Jellyfin login and availability checks.',
artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved requests cache and refresh behavior.',
@@ -106,6 +108,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
magent: 'magent',
general: 'magent',
notifications: 'magent',
seerr: 'jellyseerr',
jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin',
artwork: null,
@@ -234,6 +237,8 @@ const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
}
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
jellyseerr_base_url: 'Seerr base URL',
jellyseerr_api_key: 'Seerr API key',
magent_application_url: 'Application URL',
magent_application_port: 'Application port',
magent_api_url: 'API URL',
@@ -278,6 +283,7 @@ const labelFromKey = (key: string) =>
SETTING_LABEL_OVERRIDES[key] ??
key
.replaceAll('_', ' ')
.replace('jellyseerr', 'Seerr')
.replace('base url', 'URL')
.replace('api key', 'API key')
.replace('quality profile id', 'Quality profile ID')
@@ -289,7 +295,7 @@ const labelFromKey = (key: string) =>
.replace('requests full sync time', 'Daily full refresh time (24h)')
.replace('requests cleanup time', 'Daily history cleanup time (24h)')
.replace('requests cleanup days', 'History retention window (days)')
.replace('requests data source', 'Request source (cache vs Jellyseerr)')
.replace('requests data source', 'Request source (cache vs Seerr)')
.replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode')
@@ -352,6 +358,21 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const requestsSyncRef = useRef<any | null>(null)
const artworkPrefetchRef = useRef<any | null>(null)
const computeProgressPercent = (
completedValue: unknown,
totalValue: unknown,
statusValue: unknown
): number => {
if (String(statusValue).toLowerCase() === 'completed') {
return 100
}
const completed = Number(completedValue)
const total = Number(totalValue)
if (!Number.isFinite(completed) || !Number.isFinite(total) || total <= 0 || completed <= 0) {
return 0
}
return Math.max(0, Math.min(100, Math.round((completed / total) * 100)))
}
const loadSettings = useCallback(async () => {
const baseUrl = getApiBase()
@@ -642,7 +663,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
magent_notify_webhook_url:
'Generic webhook endpoint for custom integrations or automation flows.',
jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
'Base URL for your Seerr server (FQDN or IP). Scheme is optional.',
jellyseerr_api_key: 'API key used to read requests and status.',
jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
@@ -677,7 +698,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
requests_cleanup_time: 'Daily time to trim old request history.',
requests_cleanup_days: 'History older than this is removed during cleanup.',
requests_data_source:
'Pick where Magent should read requests from. Cache-only avoids Jellyseerr lookups on reads.',
'Pick where Magent should read requests from. Cache-only avoids Seerr lookups on reads.',
log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.',
site_build_number: 'Build number shown in the account menu (auto-set from releases).',
@@ -805,6 +826,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const syncRequests = async () => {
setRequestsSyncStatus(null)
setRequestsSync({
status: 'running',
stored: 0,
total: 0,
skip: 0,
message: 'Starting sync',
})
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync`, {
@@ -829,6 +857,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const syncRequestsDelta = async () => {
setRequestsSyncStatus(null)
setRequestsSync({
status: 'running',
stored: 0,
total: 0,
skip: 0,
message: 'Starting delta sync',
})
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync/delta`, {
@@ -853,6 +888,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const prefetchArtwork = async () => {
setArtworkPrefetchStatus(null)
setArtworkPrefetch({
status: 'running',
processed: 0,
total: 0,
message: 'Starting artwork caching',
})
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/prefetch`, {
@@ -877,6 +918,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const prefetchArtworkMissing = async () => {
setArtworkPrefetchStatus(null)
setArtworkPrefetch({
status: 'running',
processed: 0,
total: 0,
message: 'Starting missing artwork caching',
})
try {
const baseUrl = getApiBase()
const response = await authFetch(
@@ -1202,7 +1249,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setMaintenanceBusy(true)
if (typeof window !== 'undefined') {
const ok = window.confirm(
'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Jellyseerr. Continue?'
'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Seerr. Continue?'
)
if (!ok) {
setMaintenanceBusy(false)
@@ -1264,7 +1311,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const cacheSourceLabel =
formValues.requests_data_source === 'always_js'
? 'Jellyseerr direct'
? 'Seerr direct'
: formValues.requests_data_source === 'prefer_cache'
? 'Saved requests only'
: 'Saved requests only'
@@ -1485,22 +1532,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</span>
</div>
<div
className={`progress ${artworkPrefetch.total ? '' : 'progress-indeterminate'} ${
artworkPrefetch.status === 'completed' ? 'progress-complete' : ''
}`}
className={`progress ${artworkPrefetch.status === 'completed' ? 'progress-complete' : ''}`}
>
<div
className="progress-fill"
style={{
width:
artworkPrefetch.status === 'completed'
? '100%'
: artworkPrefetch.total
? `${Math.min(
100,
Math.round((artworkPrefetch.processed / artworkPrefetch.total) * 100)
)}%`
: '30%',
width: `${computeProgressPercent(
artworkPrefetch.processed,
artworkPrefetch.total,
artworkPrefetch.status
)}%`,
}}
/>
</div>
@@ -1517,22 +1558,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</span>
</div>
<div
className={`progress ${requestsSync.total ? '' : 'progress-indeterminate'} ${
requestsSync.status === 'completed' ? 'progress-complete' : ''
}`}
className={`progress ${requestsSync.status === 'completed' ? 'progress-complete' : ''}`}
>
<div
className="progress-fill"
style={{
width:
requestsSync.status === 'completed'
? '100%'
: requestsSync.total
? `${Math.min(
100,
Math.round((requestsSync.stored / requestsSync.total) * 100)
)}%`
: '30%',
width: `${computeProgressPercent(
requestsSync.stored,
requestsSync.total,
requestsSync.status
)}%`,
}}
/>
</div>
@@ -1860,7 +1895,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}))
}
>
<option value="always_js">Always use Jellyseerr (slower)</option>
<option value="always_js">Always use Seerr (slower)</option>
<option value="prefer_cache">
Use saved requests only (fastest)
</option>
@@ -2005,7 +2040,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<h2>Maintenance</h2>
</div>
<div className="status-banner">
Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Jellyseerr users/requests.
Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Seerr users/requests.
</div>
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
<div className="maintenance-grid">

View File

@@ -2,6 +2,7 @@ import { notFound } from 'next/navigation'
import SettingsPage from '../SettingsPage'
const ALLOWED_SECTIONS = new Set([
'seerr',
'jellyseerr',
'jellyfin',
'artwork',
@@ -20,12 +21,13 @@ const ALLOWED_SECTIONS = new Set([
])
type PageProps = {
params: { section: string }
params: Promise<{ section: string }>
}
export default function AdminSectionPage({ params }: PageProps) {
if (!ALLOWED_SECTIONS.has(params.section)) {
export default async function AdminSectionPage({ params }: PageProps) {
const { section } = await params
if (!ALLOWED_SECTIONS.has(section)) {
notFound()
}
return <SettingsPage section={params.section} />
return <SettingsPage section={section} />
}

View File

@@ -21,7 +21,7 @@ const REQUEST_FLOW: FlowStage[] = [
},
{
title: 'Request intake',
input: 'Jellyseerr request ID',
input: 'Seerr request ID',
action: 'Magent snapshots request + media metadata',
output: 'Unified request state',
},

View File

@@ -2197,7 +2197,7 @@ button span {
pointer-events: none;
}
.step-jellyseerr::before {
.step-seerr::before {
background: linear-gradient(135deg, rgba(255, 187, 92, 0.35), transparent 60%);
}

View File

@@ -14,7 +14,7 @@ export default function HowItWorksPage() {
<section className="how-grid">
<article className="how-card">
<h2>Jellyseerr</h2>
<h2>Seerr</h2>
<p className="how-title">The request box</p>
<p>
This is where you ask for a movie or show. It keeps the request and whether it is
@@ -55,7 +55,7 @@ export default function HowItWorksPage() {
<h2>The pipeline (request to ready)</h2>
<ol className="how-steps">
<li>
<strong>Request created</strong> in Jellyseerr.
<strong>Request created</strong> in Seerr.
</li>
<li>
<strong>Approved</strong> and sent to Sonarr/Radarr.
@@ -108,7 +108,7 @@ export default function HowItWorksPage() {
<section className="how-flow">
<h2>Request actions and when to use them</h2>
<div className="how-step-grid">
<article className="how-step-card step-jellyseerr">
<article className="how-step-card step-seerr">
<div className="step-badge">1</div>
<h3>Re-add to Arr</h3>
<p className="step-note">Use when a request is approved but never entered the Arr queue.</p>

View File

@@ -352,7 +352,7 @@ export default function HomePage() {
<div className="system-list">
{(() => {
const order = [
'Jellyseerr',
'Seerr',
'Sonarr',
'Radarr',
'Prowlarr',

View File

@@ -2,7 +2,7 @@
import Image from 'next/image'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useParams, useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../../lib/auth'
type TimelineHop = {
@@ -140,7 +140,7 @@ const friendlyState = (value: string) => {
}
const friendlyTimelineStatus = (service: string, status: string) => {
if (service === 'Jellyseerr') {
if (service === 'Seerr') {
const map: Record<string, string> = {
Pending: 'Waiting for approval',
Approved: 'Approved',
@@ -195,7 +195,9 @@ const friendlyTimelineStatus = (service: string, status: string) => {
return status
}
export default function RequestTimelinePage({ params }: { params: { id: string } }) {
export default function RequestTimelinePage() {
const params = useParams<{ id: string | string[] }>()
const requestId = Array.isArray(params?.id) ? params.id[0] : params?.id
const router = useRouter()
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
const [loading, setLoading] = useState(true)
@@ -208,6 +210,9 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
useEffect(() => {
if (!requestId) {
return
}
const load = async () => {
try {
if (!getToken()) {
@@ -216,9 +221,9 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
}
const baseUrl = getApiBase()
const [snapshotResponse, historyResponse, actionsResponse] = await Promise.all([
authFetch(`${baseUrl}/requests/${params.id}/snapshot`),
authFetch(`${baseUrl}/requests/${params.id}/history?limit=5`),
authFetch(`${baseUrl}/requests/${params.id}/actions?limit=5`),
authFetch(`${baseUrl}/requests/${requestId}/snapshot`),
authFetch(`${baseUrl}/requests/${requestId}/history?limit=5`),
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=5`),
])
if (snapshotResponse.status === 401) {
@@ -252,10 +257,10 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
}
load()
}, [params.id, router])
}, [requestId, router])
useEffect(() => {
if (!getToken()) {
if (!getToken() || !requestId) {
return
}
const baseUrl = getApiBase()
@@ -267,7 +272,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const streamToken = await getEventStreamToken()
if (closed) return
const streamUrl = `${baseUrl}/events/requests/${encodeURIComponent(
params.id
requestId
)}/stream?stream_token=${encodeURIComponent(streamToken)}`
source = new EventSource(streamUrl)
@@ -278,7 +283,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
if (!payload || typeof payload !== 'object' || payload.type !== 'request_live') {
return
}
if (String(payload.request_id ?? '') !== String(params.id)) {
if (String(payload.request_id ?? '') !== String(requestId)) {
return
}
if (payload.snapshot && typeof payload.snapshot === 'object') {
@@ -310,7 +315,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
closed = true
source?.close()
}
}, [params.id])
}, [requestId])
if (loading) {
return (
@@ -337,7 +342,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string }
const arrStageLabel =
snapshot.state === 'NEEDS_ADD' ? 'Push to Sonarr/Radarr' : 'Library queue'
const pipelineSteps = [
{ key: 'Jellyseerr', label: 'Jellyseerr' },
{ key: 'Seerr', label: 'Seerr' },
{ key: 'Sonarr/Radarr', label: arrStageLabel },
{ key: 'Prowlarr', label: 'Search' },
{ key: 'qBittorrent', label: 'Download' },

View File

@@ -7,7 +7,7 @@ const NAV_GROUPS = [
title: 'Services',
items: [
{ href: '/admin/general', label: 'General' },
{ href: '/admin/jellyseerr', label: 'Jellyseerr' },
{ href: '/admin/seerr', label: 'Seerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' },
{ href: '/admin/radarr', label: 'Radarr' },

View File

@@ -460,7 +460,7 @@ export default function UserDetailPage() {
</div>
<div className="user-detail-meta-grid">
<div className="user-detail-meta-item">
<span className="label">Jellyseerr ID</span>
<span className="label">Seerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
</div>
<div className="user-detail-meta-item">

View File

@@ -155,7 +155,7 @@ export default function UsersPage() {
await loadUsers()
} catch (err) {
console.error(err)
setJellyseerrSyncStatus('Could not sync Jellyseerr users.')
setJellyseerrSyncStatus('Could not sync Seerr users.')
} finally {
setJellyseerrSyncBusy(false)
}
@@ -163,7 +163,7 @@ export default function UsersPage() {
const resyncJellyseerrUsers = async () => {
const confirmed = window.confirm(
'This will remove all non-admin users and re-import from Jellyseerr. Continue?'
'This will remove all non-admin users and re-import from Seerr. Continue?'
)
if (!confirmed) return
setJellyseerrSyncStatus(null)
@@ -184,7 +184,7 @@ export default function UsersPage() {
await loadUsers()
} catch (err) {
console.error(err)
setJellyseerrSyncStatus('Could not resync Jellyseerr users.')
setJellyseerrSyncStatus('Could not resync Seerr users.')
} finally {
setJellyseerrResyncBusy(false)
}
@@ -322,17 +322,17 @@ export default function UsersPage() {
</div>
</div>
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Jellyseerr sync</span>
<span className="users-page-toolbar-label">Seerr sync</span>
<div className="users-page-toolbar-actions">
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
{jellyseerrSyncBusy ? 'Syncing Jellyseerr users...' : 'Sync Jellyseerr users'}
{jellyseerrSyncBusy ? 'Syncing Seerr users...' : 'Sync Seerr users'}
</button>
<button
type="button"
onClick={resyncJellyseerrUsers}
disabled={jellyseerrResyncBusy}
>
{jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'}
{jellyseerrResyncBusy ? 'Resyncing Seerr users...' : 'Resync Seerr users'}
</button>
</div>
</div>