Process 1 build 0203261953
This commit is contained in:
@@ -104,7 +104,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||
qbittorrent: 'Downloader connection settings.',
|
||||
requests: 'Control how often requests are refreshed and cleaned up.',
|
||||
log: 'Activity log for troubleshooting.',
|
||||
site: 'Sitewide banner, version, and changelog details.',
|
||||
site: 'Sitewide banner and version details. The changelog is generated from git history during release builds.',
|
||||
}
|
||||
|
||||
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||
@@ -555,7 +555,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
const isCacheSection = section === 'cache'
|
||||
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
||||
const artworkSettingKeys = new Set(['artwork_cache_mode'])
|
||||
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys])
|
||||
const generatedSettingKeys = new Set(['site_changelog'])
|
||||
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
|
||||
const requestSettingOrder = [
|
||||
'requests_poll_interval_seconds',
|
||||
'requests_delta_sync_interval_minutes',
|
||||
@@ -608,7 +609,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
items: (() => {
|
||||
const sectionItems = groupedSettings[sectionKey] ?? []
|
||||
const filtered =
|
||||
sectionKey === 'requests' || sectionKey === 'artwork'
|
||||
sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site'
|
||||
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
|
||||
: sectionItems
|
||||
if (sectionKey === 'requests') {
|
||||
@@ -940,8 +941,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
setSectionFeedback((current) => ({
|
||||
...current,
|
||||
[sectionGroup.key]: {
|
||||
tone: 'status',
|
||||
message: `Test email sent to ${data?.recipient_email ?? 'the configured mailbox'}.`,
|
||||
tone: data?.warning ? 'error' : 'status',
|
||||
message: data?.warning
|
||||
? `SMTP accepted a relay-mode test for ${data?.recipient_email ?? 'the configured mailbox'}, but delivery is not guaranteed. ${data.warning}`
|
||||
: `Test email sent to ${data?.recipient_email ?? 'the configured mailbox'}.`,
|
||||
},
|
||||
}))
|
||||
return
|
||||
|
||||
@@ -106,9 +106,9 @@ export default function AdminSystemGuidePage() {
|
||||
const rail = (
|
||||
<div className="admin-rail-stack">
|
||||
<div className="admin-rail-card">
|
||||
<span className="admin-rail-eyebrow">Guide map</span>
|
||||
<h2>Quick path</h2>
|
||||
<p>Identity → Intake → Queue → Download → Import → Playback.</p>
|
||||
<span className="admin-rail-eyebrow">How it works</span>
|
||||
<h2>Admin flow map</h2>
|
||||
<p>Identity → Request intake → Queue orchestration → Download → Import → Playback.</p>
|
||||
<span className="small-pill">Admin only</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,8 +116,8 @@ export default function AdminSystemGuidePage() {
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="System guide"
|
||||
subtitle="Admin-only architecture and operational flow for Magent."
|
||||
title="How it works"
|
||||
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
|
||||
rail={rail}
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/admin')}>
|
||||
@@ -129,7 +129,8 @@ export default function AdminSystemGuidePage() {
|
||||
<div className="admin-panel">
|
||||
<h2>End-to-end system flow</h2>
|
||||
<p className="lede">
|
||||
This is the exact runtime path for request processing and availability in the current build.
|
||||
This is the runtime path the platform follows from authentication through to playback
|
||||
availability.
|
||||
</p>
|
||||
<div className="system-flow-track">
|
||||
{REQUEST_FLOW.map((stage, index) => (
|
||||
@@ -155,6 +156,51 @@ export default function AdminSystemGuidePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<h2>What each service is responsible for</h2>
|
||||
<div className="system-guide-grid">
|
||||
<article className="system-guide-card">
|
||||
<h3>Magent</h3>
|
||||
<p>
|
||||
Handles authentication, request pages, live event updates, invite workflows,
|
||||
diagnostics, notifications, and admin operations.
|
||||
</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Seerr</h3>
|
||||
<p>
|
||||
Stores the request itself and remains the request-state source for approval and
|
||||
media request metadata.
|
||||
</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Jellyfin</h3>
|
||||
<p>
|
||||
Provides user sign-in identity and the final playback destination once content is
|
||||
available.
|
||||
</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Sonarr / Radarr</h3>
|
||||
<p>
|
||||
Control queue placement, quality-profile decisions, import handling, and release
|
||||
monitoring.
|
||||
</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Prowlarr</h3>
|
||||
<p>Provides search/indexer coverage for Arr-side release searches.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>qBittorrent</h3>
|
||||
<p>
|
||||
Executes the download and exposes live progress, paused states, and queue
|
||||
visibility.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<h2>Operational controls by area</h2>
|
||||
<div className="system-guide-grid">
|
||||
@@ -172,19 +218,48 @@ export default function AdminSystemGuidePage() {
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Invite management</h3>
|
||||
<p>Master template, profile assignment, invite access policy, and invite trace map lineage.</p>
|
||||
<p>
|
||||
Master template, profile assignment, invite access policy, invite emails, and trace
|
||||
map lineage.
|
||||
</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Requests + cache</h3>
|
||||
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Live request page</h3>
|
||||
<p>Event-stream updates for state, action history, and torrent progress without page refresh.</p>
|
||||
<h3>Maintenance + diagnostics</h3>
|
||||
<p>
|
||||
Connectivity checks, live diagnostics, database repair, cleanup, log review, and
|
||||
nuclear flush/resync operations.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<h2>User and invite model</h2>
|
||||
<ol className="system-decision-list">
|
||||
<li>
|
||||
Jellyfin is used for sign-in identity and user presence across the platform.
|
||||
</li>
|
||||
<li>
|
||||
Seerr provides request ownership and request-state data for Magent request pages.
|
||||
</li>
|
||||
<li>
|
||||
Invite links, invite profiles, blanket rules, and invite-access controls are managed
|
||||
inside Magent.
|
||||
</li>
|
||||
<li>
|
||||
If invite tracing is enabled, the lineage view shows who invited whom and how the
|
||||
chain branches.
|
||||
</li>
|
||||
<li>
|
||||
Cross-system removal and ban flows are initiated from Magent admin controls.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<h2>Stall recovery path (decision flow)</h2>
|
||||
<ol className="system-decision-list">
|
||||
@@ -205,6 +280,24 @@ export default function AdminSystemGuidePage() {
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<h2>Live update surfaces</h2>
|
||||
<div className="system-guide-grid">
|
||||
<article className="system-guide-card">
|
||||
<h3>Landing page</h3>
|
||||
<p>Recent requests and service summaries refresh live for signed-in users.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Request pages</h3>
|
||||
<p>Timeline state, queue activity, and torrent progress are pushed live without refresh.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Admin views</h3>
|
||||
<p>Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
|
||||
@@ -8,15 +8,42 @@ type SiteInfo = {
|
||||
changelog?: string
|
||||
}
|
||||
|
||||
const parseChangelog = (raw: string) =>
|
||||
raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
type ChangelogGroup = {
|
||||
date: string
|
||||
entries: string[]
|
||||
}
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
const parseChangelog = (raw: string): ChangelogGroup[] => {
|
||||
const groups: ChangelogGroup[] = []
|
||||
for (const rawLine of raw.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
const [candidateDate, ...messageParts] = line.split('|')
|
||||
if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
|
||||
const message = messageParts.join('|').trim()
|
||||
if (!message) continue
|
||||
const currentGroup = groups[groups.length - 1]
|
||||
if (currentGroup?.date === candidateDate) {
|
||||
currentGroup.entries.push(message)
|
||||
} else {
|
||||
groups.push({ date: candidateDate, entries: [message] })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (groups.length === 0) {
|
||||
groups.push({ date: 'Updates', entries: [line] })
|
||||
} else {
|
||||
groups[groups.length - 1].entries.push(line)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export default function ChangelogPage() {
|
||||
const router = useRouter()
|
||||
const [entries, setEntries] = useState<string[]>([])
|
||||
const [groups, setGroups] = useState<ChangelogGroup[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,11 +67,11 @@ export default function ChangelogPage() {
|
||||
}
|
||||
const data: SiteInfo = await response.json()
|
||||
if (!active) return
|
||||
setEntries(parseChangelog(data?.changelog ?? ''))
|
||||
setGroups(parseChangelog(data?.changelog ?? ''))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (!active) return
|
||||
setEntries([])
|
||||
setGroups([])
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
@@ -59,17 +86,24 @@ export default function ChangelogPage() {
|
||||
if (loading) {
|
||||
return <div className="loading-text">Loading changelog...</div>
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
if (groups.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>
|
||||
<div className="changelog-groups">
|
||||
{groups.map((group) => (
|
||||
<section key={group.date} className="changelog-group">
|
||||
<h2>{group.date}</h2>
|
||||
<ul className="changelog-list">
|
||||
{group.entries.map((entry, index) => (
|
||||
<li key={`${group.date}-${entry}-${index}`}>{entry}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}, [entries, loading])
|
||||
}, [groups, loading])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
@@ -2369,6 +2369,31 @@ button span {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.changelog-groups {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.changelog-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.changelog-group:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.changelog-group h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Professional UI Refresh (graphite / silver / black + subtle blue accents) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -3669,16 +3694,28 @@ button:disabled {
|
||||
.error-banner,
|
||||
.status-banner {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
border: 1px solid rgba(244, 114, 114, 0.2);
|
||||
background: rgba(244, 114, 114, 0.1);
|
||||
color: var(--error-ink);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .error-banner,
|
||||
[data-theme='dark'] .status-banner {
|
||||
.status-banner {
|
||||
border: 1px solid rgba(74, 222, 128, 0.24);
|
||||
background: rgba(74, 222, 128, 0.12);
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .error-banner {
|
||||
color: #ffd9d9;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .status-banner {
|
||||
color: #dcfce7;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
max-width: 520px;
|
||||
margin-inline: auto;
|
||||
@@ -6332,7 +6369,7 @@ textarea {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 52px;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--accent-2), rgba(255, 255, 255, 0));
|
||||
@@ -6409,3 +6446,62 @@ textarea {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Final header action layout */
|
||||
.header-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions .header-cta--left {
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.header-actions-center {
|
||||
grid-column: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-actions-right {
|
||||
grid-column: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.header-actions {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-actions .header-cta--left {
|
||||
grid-column: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions-center,
|
||||
.header-actions-right {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
width: 100%;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.header-actions-center {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.header-actions-right {
|
||||
grid-column: 1;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,220 +4,181 @@ export default function HowItWorksPage() {
|
||||
return (
|
||||
<main className="card how-page">
|
||||
<header className="how-hero">
|
||||
<p className="eyebrow">How this works</p>
|
||||
<h1>How Magent works now</h1>
|
||||
<p className="eyebrow">How it works</p>
|
||||
<h1>How Magent works for users</h1>
|
||||
<p className="lede">
|
||||
End-to-end request flow, live status updates, and the exact tools available to users and
|
||||
admins.
|
||||
Use Magent to find a request, watch it move through the pipeline, and know when it is
|
||||
ready without constantly refreshing the page.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="how-grid">
|
||||
<article className="how-card">
|
||||
<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
|
||||
approved.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h2>Sonarr / Radarr</h2>
|
||||
<p className="how-title">The library manager</p>
|
||||
<p>
|
||||
These add the request to the library list and decide what quality to look for.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h2>Prowlarr</h2>
|
||||
<p className="how-title">The search helper</p>
|
||||
<p>
|
||||
This checks your search sources and reports back what it finds.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h2>qBittorrent</h2>
|
||||
<p className="how-title">The downloader</p>
|
||||
<p>
|
||||
This downloads the file. Magent can tell if it is downloading, paused, or finished.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h2>Jellyfin</h2>
|
||||
<p className="how-title">The place you watch</p>
|
||||
<p>
|
||||
When the file is ready, Jellyfin shows it in your library so you can watch it.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>The pipeline (request to ready)</h2>
|
||||
<ol className="how-steps">
|
||||
<li>
|
||||
<strong>Request created</strong> in Seerr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Approved</strong> and sent to Sonarr/Radarr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Search runs</strong> against indexers via Prowlarr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Grabbed</strong> and downloaded by qBittorrent.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Imported</strong> by Sonarr/Radarr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Available</strong> in Jellyfin.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Live updates (no refresh needed)</h2>
|
||||
<div className="how-step-grid">
|
||||
<article className="how-step-card step-arr">
|
||||
<div className="step-badge">1</div>
|
||||
<h3>Request page updates in real time</h3>
|
||||
<p className="step-note">
|
||||
Status, timeline hops, and action history update automatically while you are viewing
|
||||
the request.
|
||||
<h2>What Magent is for</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Track requests</h3>
|
||||
<p>
|
||||
Search by title, year, or request number to open the request page and see where an
|
||||
item is up to.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-step-card step-qbit">
|
||||
<div className="step-badge">2</div>
|
||||
<h3>Download progress updates live</h3>
|
||||
<p className="step-note">
|
||||
Torrent progress, queue state, and downloader details refresh automatically so users
|
||||
do not need to hard refresh.
|
||||
<article className="how-card">
|
||||
<h3>See live progress</h3>
|
||||
<p>
|
||||
Request status, timeline events, and download progress update live while you are
|
||||
viewing the page.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-step-card step-jellyfin">
|
||||
<div className="step-badge">3</div>
|
||||
<h3>Ready state appears as soon as import finishes</h3>
|
||||
<p className="step-note">
|
||||
As soon as Sonarr/Radarr import completes and Jellyfin can serve it, the request page
|
||||
shows it as ready.
|
||||
<article className="how-card">
|
||||
<h3>Know when it is ready</h3>
|
||||
<p>
|
||||
When the request is fully imported and available, Magent shows it as ready and links
|
||||
you through to Jellyfin.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Request actions and when to use them</h2>
|
||||
<h2>The request pipeline</h2>
|
||||
<ol className="how-steps">
|
||||
<li>
|
||||
<strong>You request a movie or show</strong> through Seerr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Magent picks up the request</strong> and shows its current state.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The automation stack searches and downloads it</strong> if it can find a valid
|
||||
release.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The file is imported into the library</strong>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Jellyfin serves it</strong> once it is ready to watch.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>What the statuses usually mean</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Pending</h3>
|
||||
<p>The request exists, but it is still waiting for approval or the next step.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Approved / Processing</h3>
|
||||
<p>The request has been accepted and the automation tools are working on it.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Downloading</h3>
|
||||
<p>Magent can show live progress while the content is still being downloaded.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Ready</h3>
|
||||
<p>The item has been imported and should now be available in Jellyfin.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Partial / Waiting</h3>
|
||||
<p>
|
||||
Part of the workflow completed, but the request is still waiting on another service or
|
||||
on content becoming available.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Declined</h3>
|
||||
<p>The request was rejected or cannot proceed in its current form.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Live updates you can expect</h2>
|
||||
<div className="how-step-grid">
|
||||
<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>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Missing NEEDS_ADD / ADDED state transitions</li>
|
||||
<li>Queue repair after Arr-side cleanup</li>
|
||||
</ul>
|
||||
<h3>Recent requests refresh automatically</h3>
|
||||
<p className="step-note">
|
||||
Your request list and landing-page activity update automatically while you are signed
|
||||
in.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-arr">
|
||||
<div className="step-badge">2</div>
|
||||
<h3>Search releases</h3>
|
||||
<p className="step-note">Runs a search and shows concrete release options.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Manual selection of a specific release/indexer</li>
|
||||
<li>Checking whether results currently exist</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-prowlarr">
|
||||
<div className="step-badge">3</div>
|
||||
<h3>Search + auto-download</h3>
|
||||
<p className="step-note">Runs search and lets Arr pick/grab automatically.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Fast recovery when users have auto-search access</li>
|
||||
<li>Hands-off retry of stalled requests</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-qbit">
|
||||
<div className="step-badge">4</div>
|
||||
<h3>Resume download</h3>
|
||||
<p className="step-note">Resumes a paused/stopped torrent in qBittorrent.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Paused queue entries</li>
|
||||
<li>Downloader restarts</li>
|
||||
</ul>
|
||||
<div className="step-badge">2</div>
|
||||
<h3>Request pages update in real time</h3>
|
||||
<p className="step-note">
|
||||
State changes, timeline steps, and downloader progress are pushed to the page live.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-jellyfin">
|
||||
<div className="step-badge">5</div>
|
||||
<h3>Open in Jellyfin</h3>
|
||||
<p className="step-note">Available when the item is imported and linked to Jellyfin.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Immediate playback confirmation</li>
|
||||
<li>User handoff from request tracking to watching</li>
|
||||
</ul>
|
||||
<div className="step-badge">3</div>
|
||||
<h3>Ready state appears as soon as the import completes</h3>
|
||||
<p className="step-note">
|
||||
Once the content is actually available, Magent updates the request page without a hard
|
||||
refresh.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Invite and account flow</h2>
|
||||
<h2>User actions you may see</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Open request</h3>
|
||||
<p>Jump into the full request page to inspect the current state and activity.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Open in Jellyfin</h3>
|
||||
<p>Appears when the request is ready and Magent can link you through for playback.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Search + auto-download</h3>
|
||||
<p>
|
||||
Only appears for accounts that have been granted self-service download access by the
|
||||
admin team.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>My invites</h3>
|
||||
<p>
|
||||
If your account is allowed to invite others, you can create and manage invite links
|
||||
from your profile.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Invites and signup</h2>
|
||||
<ol className="how-steps">
|
||||
<li>
|
||||
<strong>Invite created</strong> by admin or eligible user.
|
||||
<strong>You receive an invite link</strong> by email or directly from the person who
|
||||
invited you.
|
||||
</li>
|
||||
<li>
|
||||
<strong>User signs up</strong> and Magent creates/links the account.
|
||||
<strong>You sign up through Magent</strong> and your account is linked into the media
|
||||
stack.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Profile/defaults apply</strong> (role, auto-search, expiry, invite access).
|
||||
<strong>Your account defaults apply</strong> based on the invite or your assigned
|
||||
profile.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Admin trace map</strong> can show inviter → invited lineage.
|
||||
<strong>You sign in and track requests</strong> from the landing page and your request
|
||||
pages.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Admin controls available</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>General</h3>
|
||||
<p>App URL/port, API URL/port, bind host, proxy base URL, and manual SSL bind options.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Notifications</h3>
|
||||
<p>Email, Discord, Telegram, push/mobile, and generic webhook provider settings.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Users</h3>
|
||||
<p>Bulk auto-search control, invite access control, per-user roles/profile/expiry, and system actions.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Invite management</h3>
|
||||
<p>Profiles, invites, blanket rules, master template, and trace map (list/graph with lineage).</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Request sync + cache</h3>
|
||||
<p>Control refresh/sync behavior, view all requests, and manage cached request records.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Maintenance + logs</h3>
|
||||
<p>Run cleanup/sync tasks, inspect operations, and diagnose pipeline issues quickly.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-callout">
|
||||
<h2>Why a request can still wait</h2>
|
||||
<h2>If a request looks stuck</h2>
|
||||
<p>
|
||||
If indexers do not return a valid release yet, Magent will show waiting/search states.
|
||||
That usually means content availability is the blocker, not a broken pipeline.
|
||||
A waiting request usually means no usable release has been found yet, the download is
|
||||
still in progress, or the import has not completed. Magent will keep updating as the
|
||||
underlying services move forward.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -9,6 +9,8 @@ type ProfileInfo = {
|
||||
role: string
|
||||
auth_provider: string
|
||||
invite_management_enabled?: boolean
|
||||
password_change_supported?: boolean
|
||||
password_provider?: 'local' | 'jellyfin' | null
|
||||
}
|
||||
|
||||
type ProfileStats = {
|
||||
@@ -81,7 +83,8 @@ export default function ProfilePage() {
|
||||
const [activity, setActivity] = useState<ProfileActivity | null>(null)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -124,12 +127,17 @@ export default function ProfilePage() {
|
||||
role: user?.role ?? 'user',
|
||||
auth_provider: user?.auth_provider ?? 'local',
|
||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||
password_change_supported: Boolean(user?.password_change_supported ?? false),
|
||||
password_provider:
|
||||
user?.password_provider === 'jellyfin' || user?.password_provider === 'local'
|
||||
? user.password_provider
|
||||
: null,
|
||||
})
|
||||
setStats(data?.stats ?? null)
|
||||
setActivity(data?.activity ?? null)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('Could not load your profile.')
|
||||
setStatus({ tone: 'error', message: 'Could not load your profile.' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -141,7 +149,11 @@ export default function ProfilePage() {
|
||||
event.preventDefault()
|
||||
setStatus(null)
|
||||
if (!currentPassword || !newPassword) {
|
||||
setStatus('Enter your current password and a new password.')
|
||||
setStatus({ tone: 'error', message: 'Enter your current password and a new password.' })
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setStatus({ tone: 'error', message: 'New password and confirmation do not match.' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -170,28 +182,32 @@ export default function ProfilePage() {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setStatus(
|
||||
data?.provider === 'jellyfin'
|
||||
? 'Password updated in Jellyfin (and Magent cache).'
|
||||
: 'Password updated.'
|
||||
)
|
||||
setConfirmPassword('')
|
||||
setStatus({
|
||||
tone: 'status',
|
||||
message:
|
||||
data?.provider === 'jellyfin'
|
||||
? 'Password updated across Jellyfin and Magent. Seerr continues to use the same Jellyfin password.'
|
||||
: 'Password updated.',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (err instanceof Error && err.message) {
|
||||
setStatus(`Could not update password. ${err.message}`)
|
||||
setStatus({ tone: 'error', message: `Could not update password. ${err.message}` })
|
||||
} else {
|
||||
setStatus('Could not update password. Check your current password.')
|
||||
setStatus({ tone: 'error', message: 'Could not update password. Check your current password.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authProvider = profile?.auth_provider ?? 'local'
|
||||
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
|
||||
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
|
||||
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
|
||||
const canChangePassword = Boolean(profile?.password_change_supported ?? (authProvider === 'local' || authProvider === 'jellyfin'))
|
||||
const securityHelpText =
|
||||
authProvider === 'jellyfin'
|
||||
? 'Changing your password here updates your Jellyfin account and refreshes Magent’s cached sign-in.'
|
||||
: authProvider === 'local'
|
||||
passwordProvider === 'jellyfin'
|
||||
? 'Reset your password here once. Magent updates Jellyfin directly, Seerr continues to use Jellyfin authentication, and Magent keeps the same password in sync.'
|
||||
: passwordProvider === 'local'
|
||||
? 'Change your Magent account password.'
|
||||
: 'Password changes are not available for this sign-in provider.'
|
||||
|
||||
@@ -206,11 +222,18 @@ export default function ProfilePage() {
|
||||
<h1>My profile</h1>
|
||||
<p className="lede">Review your account, activity, and security settings.</p>
|
||||
</div>
|
||||
{canManageInvites ? (
|
||||
{canManageInvites || canChangePassword ? (
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
|
||||
Open invite page
|
||||
</button>
|
||||
{canManageInvites ? (
|
||||
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
|
||||
Open invite page
|
||||
</button>
|
||||
) : null}
|
||||
{canChangePassword ? (
|
||||
<button type="button" onClick={() => selectTab('security')}>
|
||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -254,7 +277,7 @@ export default function ProfilePage() {
|
||||
className={activeTab === 'security' ? 'is-active' : ''}
|
||||
onClick={() => selectTab('security')}
|
||||
>
|
||||
Security
|
||||
Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,6 +299,23 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{canChangePassword ? (
|
||||
<div className="profile-quick-link-card">
|
||||
<div>
|
||||
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}</h2>
|
||||
<p className="lede">
|
||||
{passwordProvider === 'jellyfin'
|
||||
? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.'
|
||||
: 'Update your Magent account password.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" onClick={() => selectTab('security')}>
|
||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<h2>Account stats</h2>
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card">
|
||||
@@ -367,12 +407,12 @@ export default function ProfilePage() {
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
<h2>Security</h2>
|
||||
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}</h2>
|
||||
<div className="status-banner">{securityHelpText}</div>
|
||||
{canChangePassword ? (
|
||||
<form onSubmit={submit} className="auth-form profile-security-form">
|
||||
<label>
|
||||
Current password
|
||||
{passwordProvider === 'jellyfin' ? 'Current Jellyfin password' : 'Current password'}
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
@@ -381,7 +421,7 @@ export default function ProfilePage() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
{passwordProvider === 'jellyfin' ? 'New Jellyfin password' : 'New password'}
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
@@ -389,10 +429,23 @@ export default function ProfilePage() {
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
<label>
|
||||
Confirm new password
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{status ? (
|
||||
<div className={status.tone === 'error' ? 'error-banner' : 'status-banner'}>
|
||||
{status.message}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="auth-actions">
|
||||
<button type="submit">
|
||||
{authProvider === 'jellyfin' ? 'Update Jellyfin password' : 'Update password'}
|
||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Update password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -27,7 +27,7 @@ const NAV_GROUPS = [
|
||||
title: 'Admin',
|
||||
items: [
|
||||
{ href: '/admin/notifications', label: 'Notifications' },
|
||||
{ href: '/admin/system', label: 'System guide' },
|
||||
{ href: '/admin/system', label: 'How it works' },
|
||||
{ href: '/admin/site', label: 'Site' },
|
||||
{ href: '/users', label: 'Users' },
|
||||
{ href: '/admin/invites', label: 'Invite management' },
|
||||
|
||||
@@ -5,7 +5,6 @@ import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [signedIn, setSignedIn] = useState(false)
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
@@ -20,11 +19,9 @@ export default function HeaderActions() {
|
||||
if (!response.ok) {
|
||||
clearToken()
|
||||
setSignedIn(false)
|
||||
setRole(null)
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setRole(data?.role ?? null)
|
||||
await response.json()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
@@ -39,9 +36,13 @@ export default function HeaderActions() {
|
||||
return (
|
||||
<div className="header-actions">
|
||||
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
||||
<a href="/">Requests</a>
|
||||
<a href="/how-it-works">How it works</a>
|
||||
{role === 'admin' && <a href="/admin">Settings</a>}
|
||||
<div className="header-actions-center">
|
||||
<a href="/how-it-works">How it works</a>
|
||||
</div>
|
||||
<div className="header-actions-right">
|
||||
<a href="/">Requests</a>
|
||||
<a href="/profile/invites">Invites</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,9 +75,11 @@ export default function HeaderIdentity() {
|
||||
<a href="/profile" onClick={() => setOpen(false)}>
|
||||
My profile
|
||||
</a>
|
||||
<a href="/profile/invites" onClick={() => setOpen(false)}>
|
||||
My invites
|
||||
</a>
|
||||
{identity.role === 'admin' ? (
|
||||
<a href="/admin" onClick={() => setOpen(false)}>
|
||||
Settings
|
||||
</a>
|
||||
) : null}
|
||||
<a href="/changelog" onClick={() => setOpen(false)}>
|
||||
Changelog
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user