diff --git a/.build_number b/.build_number
index 3a3390b..632b7a0 100644
--- a/.build_number
+++ b/.build_number
@@ -1 +1 @@
-0203261511
+0203261608
diff --git a/backend/app/build_info.py b/backend/app/build_info.py
index 2cbf598..a40700a 100644
--- a/backend/app/build_info.py
+++ b/backend/app/build_info.py
@@ -1,4 +1,4 @@
-BUILD_NUMBER = "0203261511"
+BUILD_NUMBER = "0203261608"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Seerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Seerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx
index 4d449df..067c176 100644
--- a/frontend/app/admin/SettingsPage.tsx
+++ b/frontend/app/admin/SettingsPage.tsx
@@ -1613,11 +1613,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
>
{status &&
{status}
}
{settingsSections.length > 0 ? (
-
+
{settingsSections
.filter(shouldRenderSection)
.map((sectionGroup) => (
-
+
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
@@ -2228,6 +2228,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
) : null}
)}
{showLogs && (
-
+
Activity log
@@ -2283,7 +2284,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
)}
{showCacheExtras && (
-
+
Saved requests (cache)
@@ -2312,7 +2313,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
)}
{showMaintenance && (
-
+
Maintenance
@@ -2379,7 +2380,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
)}
{showRequestsExtras && (
-
+
Scheduled tasks
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index a0aafad..045ab6b 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -1545,6 +1545,13 @@ button span {
align-items: end;
}
+.settings-section-actions .settings-action-button {
+ width: 190px;
+ min-width: 190px;
+ flex: 0 0 190px;
+ justify-content: center;
+}
+
.settings-inline-field {
display: grid;
gap: 6px;
@@ -6214,6 +6221,12 @@ textarea {
width: 100%;
}
+ .settings-section-actions .settings-action-button {
+ width: 100%;
+ min-width: 0;
+ flex-basis: auto;
+ }
+
.sync-meta,
.diagnostic-card-top,
.diagnostics-category-header,
@@ -6233,3 +6246,166 @@ textarea {
overflow-wrap: anywhere;
}
}
+
+/* Final admin shell + settings section cleanup */
+.admin-shell,
+.admin-shell-nav,
+.admin-card,
+.admin-shell-rail,
+.admin-sidebar,
+.admin-panel {
+ min-width: 0;
+}
+
+.admin-shell {
+ display: grid;
+ grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
+ grid-template-areas: "nav main";
+ gap: 22px;
+ align-items: start;
+}
+
+.admin-shell.admin-shell--with-rail {
+ grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(300px, 380px);
+ grid-template-areas: "nav main rail";
+}
+
+.admin-shell-nav {
+ grid-area: nav;
+}
+
+.admin-card {
+ grid-area: main;
+}
+
+.admin-shell-rail {
+ grid-area: rail;
+ position: sticky;
+ top: 20px;
+ align-self: start;
+ display: grid;
+ gap: 10px;
+}
+
+.admin-zone-stack {
+ gap: 18px;
+}
+
+.admin-zone {
+ display: grid;
+ gap: 14px;
+ padding: 18px;
+ border-radius: 14px;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.015)),
+ rgba(255, 255, 255, 0.012);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
+}
+
+[data-theme='light'] .admin-zone {
+ border-color: rgba(17, 19, 24, 0.08);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.72)),
+ rgba(17, 19, 24, 0.018);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
+}
+
+.admin-zone .section-header {
+ align-items: flex-start;
+ padding-bottom: 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+[data-theme='light'] .admin-zone .section-header {
+ border-bottom-color: rgba(17, 19, 24, 0.08);
+}
+
+.admin-zone .section-header h2 {
+ position: relative;
+ display: inline-block;
+ padding-bottom: 8px;
+}
+
+.admin-zone .section-header h2::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ width: 52px;
+ height: 2px;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--accent-2), rgba(255, 255, 255, 0));
+}
+
+.admin-zone .section-subtitle {
+ margin-top: -4px;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+@media (max-width: 1280px) {
+ .admin-shell {
+ grid-template-columns: minmax(220px, 250px) minmax(0, 1fr);
+ grid-template-areas: "nav main";
+ }
+
+ .admin-shell.admin-shell--with-rail {
+ grid-template-areas:
+ "nav main"
+ "nav rail";
+ }
+
+ .admin-shell-rail {
+ position: static;
+ top: auto;
+ width: 100%;
+ }
+}
+
+@media (max-width: 1080px) {
+ .admin-shell,
+ .admin-shell.admin-shell--with-rail {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 16px;
+ }
+
+ .admin-shell {
+ grid-template-areas:
+ "nav"
+ "main";
+ }
+
+ .admin-shell.admin-shell--with-rail {
+ grid-template-areas:
+ "nav"
+ "main"
+ "rail";
+ }
+
+ .admin-shell-nav,
+ .admin-card,
+ .admin-shell-rail {
+ width: 100%;
+ }
+
+ .admin-shell-nav {
+ position: static;
+ top: auto;
+ }
+
+ .admin-grid,
+ .users-page-toolbar-grid,
+ .users-summary-grid,
+ .users-page-overview-grid,
+ .maintenance-action-grid,
+ .schedule-grid,
+ .diagnostics-inline-summary,
+ .diagnostics-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-zone {
+ padding: 16px;
+ }
+}
diff --git a/frontend/app/ui/AdminShell.tsx b/frontend/app/ui/AdminShell.tsx
index f195cf2..cc6ebe5 100644
--- a/frontend/app/ui/AdminShell.tsx
+++ b/frontend/app/ui/AdminShell.tsx
@@ -12,8 +12,10 @@ type AdminShellProps = {
}
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
+ const hasRail = Boolean(rail)
+
return (
-
+
@@ -27,16 +29,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
{children}
-
+ {hasRail ?
: null}
)
}
diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1
index 70bed2e..70d288b 100644
--- a/scripts/build_release.ps1
+++ b/scripts/build_release.ps1
@@ -4,7 +4,7 @@ $repoRoot = Resolve-Path "$PSScriptRoot\\.."
Set-Location $repoRoot
$now = Get-Date
-$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("M"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
+$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
Write-Host "Build number: $buildNumber"
diff --git a/scripts/process1.ps1 b/scripts/process1.ps1
new file mode 100644
index 0000000..62143d4
--- /dev/null
+++ b/scripts/process1.ps1
@@ -0,0 +1,275 @@
+param(
+ [string]$CommitMessage,
+ [switch]$SkipCommit,
+ [switch]$SkipDiscord
+)
+
+$ErrorActionPreference = "Stop"
+
+$repoRoot = Resolve-Path "$PSScriptRoot\.."
+Set-Location $repoRoot
+
+$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+$script:CurrentStep = "initializing"
+
+function Write-TextFile {
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)][string]$Content
+ )
+
+ $fullPath = Join-Path $repoRoot $Path
+ $normalized = $Content -replace "`r`n", "`n"
+ [System.IO.File]::WriteAllText($fullPath, $normalized, $Utf8NoBom)
+}
+
+function Read-TextFile {
+ param([Parameter(Mandatory = $true)][string]$Path)
+
+ $fullPath = Join-Path $repoRoot $Path
+ return [System.IO.File]::ReadAllText($fullPath)
+}
+
+function Get-EnvFileValue {
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)][string]$Name
+ )
+
+ if (-not (Test-Path $Path)) {
+ return $null
+ }
+
+ $match = Select-String -Path $Path -Pattern "^$([regex]::Escape($Name))=(.*)$" | Select-Object -First 1
+ if (-not $match) {
+ return $null
+ }
+
+ return $match.Matches[0].Groups[1].Value.Trim()
+}
+
+function Get-DiscordWebhookUrl {
+ $candidateNames = @(
+ "PROCESS_DISCORD_WEBHOOK_URL",
+ "MAGENT_NOTIFY_DISCORD_WEBHOOK_URL",
+ "DISCORD_WEBHOOK_URL"
+ )
+
+ foreach ($name in $candidateNames) {
+ $value = [System.Environment]::GetEnvironmentVariable($name)
+ if (-not [string]::IsNullOrWhiteSpace($value)) {
+ return $value.Trim()
+ }
+ }
+
+ foreach ($name in $candidateNames) {
+ $value = Get-EnvFileValue -Path ".env" -Name $name
+ if (-not [string]::IsNullOrWhiteSpace($value)) {
+ return $value.Trim()
+ }
+ }
+
+ $configPath = Join-Path $repoRoot "backend/app/config.py"
+ if (Test-Path $configPath) {
+ $configContent = Read-TextFile -Path "backend/app/config.py"
+ $match = [regex]::Match(
+ $configContent,
+ 'discord_webhook_url:\s*Optional\[str\]\s*=\s*Field\(\s*default="([^"]+)"',
+ [System.Text.RegularExpressions.RegexOptions]::Singleline
+ )
+ if ($match.Success) {
+ return $match.Groups[1].Value.Trim()
+ }
+ }
+
+ return $null
+}
+
+function Send-DiscordUpdate {
+ param(
+ [Parameter(Mandatory = $true)][string]$Title,
+ [Parameter(Mandatory = $true)][string]$Body
+ )
+
+ if ($SkipDiscord) {
+ Write-Host "Skipping Discord notification."
+ return
+ }
+
+ $webhookUrl = Get-DiscordWebhookUrl
+ if ([string]::IsNullOrWhiteSpace($webhookUrl)) {
+ Write-Warning "Discord webhook not configured for Process 1."
+ return
+ }
+
+ $content = "**$Title**`n$Body"
+ Invoke-RestMethod -Method Post -Uri $webhookUrl -ContentType "application/json" -Body (@{ content = $content } | ConvertTo-Json -Compress) | Out-Null
+}
+
+function Get-BuildNumber {
+ $current = ""
+ if (Test-Path ".build_number") {
+ $current = (Get-Content ".build_number" -Raw).Trim()
+ }
+
+ $candidate = Get-Date
+ $buildNumber = $candidate.ToString("ddMMyyHHmm")
+ if ($buildNumber -eq $current) {
+ $buildNumber = $candidate.AddMinutes(1).ToString("ddMMyyHHmm")
+ }
+
+ return $buildNumber
+}
+
+function Wait-ForHttp {
+ param(
+ [Parameter(Mandatory = $true)][string]$Url,
+ [int]$Attempts = 30,
+ [int]$DelaySeconds = 2
+ )
+
+ $lastError = $null
+ for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
+ try {
+ return Invoke-RestMethod -Uri $Url -TimeoutSec 10
+ } catch {
+ $lastError = $_
+ Start-Sleep -Seconds $DelaySeconds
+ }
+ }
+
+ throw $lastError
+}
+
+function Update-BuildFiles {
+ param([Parameter(Mandatory = $true)][string]$BuildNumber)
+
+ Write-TextFile -Path ".build_number" -Content "$BuildNumber`n"
+
+ $buildInfo = Read-TextFile -Path "backend/app/build_info.py"
+ $updatedBuildInfo = [regex]::Replace(
+ $buildInfo,
+ '^BUILD_NUMBER = "\d+"$',
+ "BUILD_NUMBER = `"$BuildNumber`"",
+ [System.Text.RegularExpressions.RegexOptions]::Multiline
+ )
+ Write-TextFile -Path "backend/app/build_info.py" -Content $updatedBuildInfo
+
+ $envPath = Join-Path $repoRoot ".env"
+ if (Test-Path $envPath) {
+ $envContent = Read-TextFile -Path ".env"
+ if ($envContent -match '^BUILD_NUMBER=.*$') {
+ $updatedEnv = [regex]::Replace(
+ $envContent,
+ '^BUILD_NUMBER=.*$',
+ "BUILD_NUMBER=$BuildNumber",
+ [System.Text.RegularExpressions.RegexOptions]::Multiline
+ )
+ } else {
+ $updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent"
+ }
+ Write-TextFile -Path ".env" -Content $updatedEnv
+ }
+
+ Push-Location frontend
+ try {
+ npm version $BuildNumber --no-git-tag-version --allow-same-version | Out-Null
+ } finally {
+ Pop-Location
+ }
+}
+
+function Get-ChangedFilesSummary {
+ $files = git diff --cached --name-only
+ if (-not $files) {
+ return "No staged files"
+ }
+
+ $count = ($files | Measure-Object).Count
+ $sample = $files | Select-Object -First 8
+ $summary = ($sample -join ", ")
+ if ($count -gt $sample.Count) {
+ $summary = "$summary, +$($count - $sample.Count) more"
+ }
+
+ return "$count files: $summary"
+}
+
+$buildNumber = $null
+$branch = $null
+$commit = $null
+$publicInfo = $null
+$changedFiles = "No staged files"
+
+try {
+ $branch = (git rev-parse --abbrev-ref HEAD).Trim()
+ $buildNumber = Get-BuildNumber
+ Write-Host "Process 1 build number: $buildNumber"
+
+ $script:CurrentStep = "updating build metadata"
+ Update-BuildFiles -BuildNumber $buildNumber
+
+ $script:CurrentStep = "rebuilding local docker stack"
+ docker compose up -d --build
+
+ $script:CurrentStep = "verifying backend health"
+ $health = Wait-ForHttp -Url "http://127.0.0.1:8000/health"
+ if ($health.status -ne "ok") {
+ throw "Health endpoint returned unexpected payload: $($health | ConvertTo-Json -Compress)"
+ }
+
+ $script:CurrentStep = "verifying public build metadata"
+ $publicInfo = Wait-ForHttp -Url "http://127.0.0.1:8000/site/public"
+ if ($publicInfo.buildNumber -ne $buildNumber) {
+ throw "Public build number mismatch. Expected $buildNumber but got $($publicInfo.buildNumber)."
+ }
+
+ $script:CurrentStep = "committing changes"
+ git add -A
+ $changedFiles = Get-ChangedFilesSummary
+ if ((git status --short).Trim()) {
+ if (-not $SkipCommit) {
+ if ([string]::IsNullOrWhiteSpace($CommitMessage)) {
+ $CommitMessage = "Process 1 build $buildNumber"
+ }
+ git commit -m $CommitMessage
+ }
+ }
+
+ $commit = (git rev-parse --short HEAD).Trim()
+
+ $body = @(
+ "Build: $buildNumber"
+ "Branch: $branch"
+ "Commit: $commit"
+ "Health: ok"
+ "Public build: $($publicInfo.buildNumber)"
+ "Changes: $changedFiles"
+ ) -join "`n"
+ Send-DiscordUpdate -Title "Process 1 complete" -Body $body
+
+ Write-Host "Process 1 completed successfully."
+} catch {
+ $failureCommit = ""
+ try {
+ $failureCommit = (git rev-parse --short HEAD).Trim()
+ } catch {
+ $failureCommit = "unknown"
+ }
+
+ $failureBody = @(
+ "Build: $buildNumber"
+ "Branch: $branch"
+ "Commit: $failureCommit"
+ "Step: $script:CurrentStep"
+ "Error: $($_.Exception.Message)"
+ ) -join "`n"
+
+ try {
+ Send-DiscordUpdate -Title "Process 1 failed" -Body $failureBody
+ } catch {
+ Write-Warning "Failed to send Discord failure notification: $($_.Exception.Message)"
+ }
+
+ throw
+}