Replaces the sequential worker with a download-manager-style pool (§0 item 4). Model: the user's connection is the limit; semantics are fair-share ACROSS feeds, not goroutine-per-book (a global FIFO would make Book B wait behind Book A's 100 files). - Atomic claim: GetNextQueuedEpisode + SetPodcastEpisodeDownloading was a read-then-write race under concurrency; ClaimQueuedPodcastEpisode converts queued→downloading in ONE guarded statement (two claimants, one winner — the loser gets ErrNoRows). SetPodcastEpisodeDownloading retired. - Queue tickets: download_queue_pos BIGINT fed by a Postgres SEQUENCE stamped at enqueue — the enqueue loop's ordering IS the claim order (the foundation §0 item 5 builds on; previously all rows in a batch shared one updated_at, so within-feed order was arbitrary). - Pool: up to podcast_download_concurrency (default 4, live range 1–16) claim-loop goroutines per drain round; the setting re-reads every round so admin changes take effect without restart. - Fair share: claim prefers the feed least-recently-served (pickFeedToServe; never-served first), earliest ticket within a feed — books proceed in parallel while chapters land in order (start listening while the rest download). - Bandwidth: shared token bucket across all workers (podcast_download_speed_mbps, default 0 = unlimited); take() reserves bandwidth and callers sleep outside the lock. - Settings: new float setting type end to end (registry defaults + clamping, save validation, admin UI number-with-step input) + the two schema seeds; worker wires the registry via SetSettings (tests run on registered defaults). - Tests: unit coverage for the bucket reservation math + fair-share pick; integration TestPodcastDownloadPool pins ticket order = enqueue order, claim exclusivity, and a full 3-episode pool drain. Dev-DB note: the column/sequence/settings were applied live (idempotent DDL, same statements the schema runs at next boot) since the running container predates them.
294 lines
13 KiB
Templ
294 lines
13 KiB
Templ
package templates
|
||
|
||
import (
|
||
"bookhoard/internal/version"
|
||
"fmt"
|
||
)
|
||
|
||
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>System Settings - Bookhoard</title>
|
||
<link href="/static/style.css" rel="stylesheet"/>
|
||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||
</head>
|
||
<body class={ "theme-" + user.Theme }>
|
||
@Header(user, "/admin/settings")
|
||
<main class="p-8">
|
||
<div class="max-w-4xl">
|
||
<div class="mb-8">
|
||
<div class="flex items-center gap-3 mb-1">
|
||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||
@Icon("gear", "h-5 w-5")
|
||
</span>
|
||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">System Settings</h1>
|
||
</div>
|
||
<p class="text-sm" style="color: var(--text-secondary)">Configure your Bookhoard instance</p>
|
||
</div>
|
||
if errorMessage != "" {
|
||
<div class="mb-6 p-4 rounded-xl border flex items-start gap-3" style="background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); border-color: var(--status-danger); color: var(--status-danger);">
|
||
@Icon("alert", "h-5 w-5 shrink-0 mt-0.5")
|
||
<span>{ errorMessage }</span>
|
||
</div>
|
||
}
|
||
<form id="settings-form" data-settings-save="settings" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
|
||
<div class="card p-6">
|
||
<div class="flex items-center gap-2 mb-6">
|
||
@Icon("globe", "h-5 w-5 shrink-0")
|
||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Base URL</h3>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Base URL</label>
|
||
<input
|
||
type="url"
|
||
name="base_url"
|
||
value={ systemConfig["base_url"] }
|
||
placeholder="https://books.example.com"
|
||
class="input"
|
||
required
|
||
/>
|
||
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
|
||
</div>
|
||
<div class="mt-6 flex justify-end">
|
||
<button type="submit" class="btn btn-primary" hx-disabled-elt="this">
|
||
@Icon("save", "h-4 w-4")
|
||
Save Settings
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="mt-6 card p-6">
|
||
<div class="flex items-center gap-2 mb-6">
|
||
@Icon("clock", "h-5 w-5 shrink-0")
|
||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">System Defaults</h3>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Default Timezone</label>
|
||
<select
|
||
name="default_timezone"
|
||
id="default_timezone"
|
||
class="input"
|
||
>
|
||
<option value="UTC" selected?={ systemConfig["default_timezone"] == "UTC" }>UTC (UTC+0)</option>
|
||
<option value="Pacific/Honolulu" selected?={ systemConfig["default_timezone"] == "Pacific/Honolulu" }>Hawaii (UTC-10)</option>
|
||
<option value="America/Anchorage" selected?={ systemConfig["default_timezone"] == "America/Anchorage" }>Alaska (UTC-9/-8)</option>
|
||
<option value="America/Los_Angeles" selected?={ systemConfig["default_timezone"] == "America/Los_Angeles" }>Pacific (UTC-8/-7)</option>
|
||
<option value="America/Denver" selected?={ systemConfig["default_timezone"] == "America/Denver" }>Mountain (UTC-7/-6)</option>
|
||
<option value="America/Phoenix" selected?={ systemConfig["default_timezone"] == "America/Phoenix" }>Mountain - no DST (UTC-7)</option>
|
||
<option value="America/Chicago" selected?={ systemConfig["default_timezone"] == "America/Chicago" }>Central (UTC-6/-5)</option>
|
||
<option value="America/New_York" selected?={ systemConfig["default_timezone"] == "America/New_York" }>Eastern (UTC-5/-4)</option>
|
||
<option value="America/Sao_Paulo" selected?={ systemConfig["default_timezone"] == "America/Sao_Paulo" }>Brasilia (UTC-3/-2)</option>
|
||
<option value="Europe/London" selected?={ systemConfig["default_timezone"] == "Europe/London" }>British (UTC+0/+1)</option>
|
||
<option value="Europe/Paris" selected?={ systemConfig["default_timezone"] == "Europe/Paris" }>Central European (UTC+1/+2)</option>
|
||
<option value="Europe/Helsinki" selected?={ systemConfig["default_timezone"] == "Europe/Helsinki" }>Eastern European (UTC+2/+3)</option>
|
||
<option value="Europe/Moscow" selected?={ systemConfig["default_timezone"] == "Europe/Moscow" }>Moscow (UTC+3)</option>
|
||
<option value="Asia/Tehran" selected?={ systemConfig["default_timezone"] == "Asia/Tehran" }>Iran (UTC+3:30)</option>
|
||
<option value="Asia/Dubai" selected?={ systemConfig["default_timezone"] == "Asia/Dubai" }>Gulf (UTC+4)</option>
|
||
<option value="Asia/Karachi" selected?={ systemConfig["default_timezone"] == "Asia/Karachi" }>Pakistan (UTC+5)</option>
|
||
<option value="Asia/Kolkata" selected?={ systemConfig["default_timezone"] == "Asia/Kolkata" }>India (UTC+5:30)</option>
|
||
<option value="Asia/Dhaka" selected?={ systemConfig["default_timezone"] == "Asia/Dhaka" }>Bangladesh (UTC+6)</option>
|
||
<option value="Asia/Bangkok" selected?={ systemConfig["default_timezone"] == "Asia/Bangkok" }>Indochina (UTC+7)</option>
|
||
<option value="Asia/Shanghai" selected?={ systemConfig["default_timezone"] == "Asia/Shanghai" }>China (UTC+8)</option>
|
||
<option value="Asia/Tokyo" selected?={ systemConfig["default_timezone"] == "Asia/Tokyo" }>Japan/Korea (UTC+9)</option>
|
||
<option value="Australia/Darwin" selected?={ systemConfig["default_timezone"] == "Australia/Darwin" }>Australian Central (UTC+9:30)</option>
|
||
<option value="Australia/Sydney" selected?={ systemConfig["default_timezone"] == "Australia/Sydney" }>Australian Eastern (UTC+10/+11)</option>
|
||
<option value="Pacific/Auckland" selected?={ systemConfig["default_timezone"] == "Pacific/Auckland" }>New Zealand (UTC+12/+13)</option>
|
||
</select>
|
||
<p class="text-sm mt-2" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
<div class="mt-6 card p-6">
|
||
<div class="flex items-center gap-2 mb-4">
|
||
@Icon("external", "h-5 w-5 shrink-0")
|
||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">URL Paths</h3>
|
||
</div>
|
||
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
|
||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">OPDS:</span> { systemConfig["base_url"] }/opds</p>
|
||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">API:</span> { systemConfig["base_url"] }/api</p>
|
||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Device Sync:</span> { systemConfig["base_url"] }/api/sync</p>
|
||
</div>
|
||
</div>
|
||
@ScanSettingsSection(scanSettings)
|
||
@TunableSettingsSection(liveGroups, false)
|
||
@TunableSettingsSection(restartGroups, true)
|
||
@AboutSection()
|
||
</div>
|
||
</main>
|
||
</body>
|
||
</html>
|
||
}
|
||
|
||
templ ScanSettingsSection(scanSettings ScanSettingsData) {
|
||
<div id="scan-settings-section" class="mt-6 card p-6">
|
||
<div class="flex items-center gap-2 mb-6">
|
||
@Icon("refresh", "h-5 w-5 shrink-0")
|
||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Scanning</h3>
|
||
</div>
|
||
<form
|
||
data-settings-save="scan"
|
||
hx-put="/admin/settings/scan"
|
||
hx-swap="none"
|
||
>
|
||
<div class="space-y-4">
|
||
<div class="flex items-center justify-between gap-4">
|
||
<div>
|
||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)">Auto-Scan</label>
|
||
<p class="text-sm" style="color: var(--text-secondary)">Watch libraries for file changes on startup</p>
|
||
</div>
|
||
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||
<input
|
||
type="checkbox"
|
||
name="auto_scan_enabled"
|
||
value="true"
|
||
checked?={ scanSettings.AutoScanEnabled }
|
||
class="sr-only peer"
|
||
/>
|
||
<div class="w-11 h-6 rounded-full peer peer-checked:bg-brand transition-colors" style="background-color: color-mix(in srgb, var(--text-primary) 15%, transparent);"></div>
|
||
<div class="absolute left-0.5 top-0.5 bg-white rounded-full w-5 h-5 transition-transform peer-checked:translate-x-5"></div>
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Scan Interval (seconds)</label>
|
||
<input
|
||
type="number"
|
||
name="scan_poll_interval_seconds"
|
||
value={ fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds) }
|
||
min="1"
|
||
max="3600"
|
||
class="input"
|
||
required
|
||
/>
|
||
<p class="text-sm mt-2" style="color: var(--text-secondary)">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p>
|
||
</div>
|
||
<div class="flex justify-end">
|
||
<button type="submit" class="btn btn-primary" hx-disabled-elt="this">
|
||
@Icon("save", "h-4 w-4")
|
||
Save Scan Settings
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
}
|
||
|
||
// TunableSettingsSection renders the editable tunables for a given bucket
|
||
// (live vs restart-required). Within the card, settings are clustered into
|
||
// labeled sub-sections by Group (e.g. "Password Quality", "Device Rate Limits").
|
||
templ TunableSettingsSection(groups []SettingGroup, restartRequired bool) {
|
||
<div class="mt-6 card p-6">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
if restartRequired {
|
||
@Icon("alert", "h-5 w-5 shrink-0")
|
||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Tunable Settings — Restart Required</h3>
|
||
} else {
|
||
@Icon("settings", "h-5 w-5 shrink-0")
|
||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Tunable Settings — Live</h3>
|
||
}
|
||
</div>
|
||
if restartRequired {
|
||
<p class="text-sm mb-4" style="color: var(--status-warning)">Changes are saved immediately but only take effect after the server restarts.</p>
|
||
} else {
|
||
<p class="text-sm mb-4" style="color: var(--text-secondary)">Changes apply immediately — no restart needed.</p>
|
||
}
|
||
for _, g := range groups {
|
||
<div class="mt-5 first:mt-0">
|
||
<h4 class="text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)">{ g.Name }</h4>
|
||
<div>
|
||
for _, e := range g.Entries {
|
||
@TunableSettingRow(e)
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
// TunableSettingRow renders a single editable setting as an inline HTMX form.
|
||
templ TunableSettingRow(e SettingEntry) {
|
||
<div class="flex flex-col sm:flex-row sm:items-center gap-3 py-3" style="border-top: 1px solid color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||
<div class="flex-1 min-w-0">
|
||
<label class="block text-sm font-medium" style="color: var(--text-primary)">{ e.Description }</label>
|
||
if !e.IsDefault {
|
||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ e.Key } — modified from default</p>
|
||
} else {
|
||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ e.Key }</p>
|
||
}
|
||
</div>
|
||
<form
|
||
class="flex items-center gap-2 shrink-0"
|
||
data-settings-save="tunable"
|
||
hx-put="/admin/settings/tunable"
|
||
hx-target={ "#status-" + e.Key }
|
||
hx-swap="innerHTML"
|
||
hx-disinherit="*"
|
||
>
|
||
<input type="hidden" name="key" value={ e.Key }/>
|
||
if e.Type == "bool" {
|
||
<select name="value" class="input py-1.5 text-sm w-28">
|
||
<option value="true" selected?={ e.Value == "true" }>Yes</option>
|
||
<option value="false" selected?={ e.Value != "true" }>No</option>
|
||
</select>
|
||
} else if e.Type == "int" {
|
||
<input
|
||
type="number"
|
||
name="value"
|
||
value={ e.Value }
|
||
if e.Min != "" {
|
||
min={ e.Min }
|
||
}
|
||
if e.Max != "" {
|
||
max={ e.Max }
|
||
}
|
||
class="input py-1.5 text-sm w-32"
|
||
/>
|
||
} else if e.Type == "float" {
|
||
<input
|
||
type="number"
|
||
step="any"
|
||
name="value"
|
||
value={ e.Value }
|
||
if e.Min != "" {
|
||
min={ e.Min }
|
||
}
|
||
if e.Max != "" {
|
||
max={ e.Max }
|
||
}
|
||
class="input py-1.5 text-sm w-32"
|
||
/>
|
||
} else {
|
||
<input
|
||
type="text"
|
||
name="value"
|
||
value={ e.Value }
|
||
class="input py-1.5 text-sm w-40"
|
||
/>
|
||
}
|
||
<button type="submit" class="btn btn-secondary px-3 py-1.5 text-sm" hx-disabled-elt="this">
|
||
@Icon("save", "h-3.5 w-3.5")
|
||
Save
|
||
</button>
|
||
</form>
|
||
<span id={ "status-" + e.Key } class="text-xs w-24 text-right" style="color: var(--text-secondary)"></span>
|
||
</div>
|
||
}
|
||
|
||
// AboutSection shows build metadata, rendered at the bottom of the System
|
||
// Settings page. Add future rows (disk space, uptime, Go version, …) to the
|
||
// list below.
|
||
templ AboutSection() {
|
||
<div class="mt-6 card p-6">
|
||
<div class="flex items-center gap-2 mb-4">
|
||
@Icon("info", "h-5 w-5 shrink-0")
|
||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">About</h3>
|
||
</div>
|
||
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
|
||
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Version:</span> { version.Version }</p>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
|