From 2b6cf7104ae8dffbac2d0397e8399402e41e11da Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:26:12 -0500 Subject: [PATCH 01/40] chore(ui): foundation primitives for DS chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the cross-cutting bits everything else depends on: - Sticky topbar with breadcrumbs and a global search field that submits to /library?search=, plus a "Destinations" link in the sidebar nav. - CSS primitives matching the DesignSystem mockup: .page / .page-header, .section-h, .stat-grid (label/value/delta cards), .tabs with counts, .filter-bar, .table-wrap + .tbl v2 + .table-pagination, .dl-card, .lib-card, .dest-grid + .dest-card with type ribbons + coverage meter, .pool-card refinements, refined .sync-status-v2 + .sync-phases.v2, .modal-overlay shell, and .btn-outline-ds / .btn-ghost / .btn-split. - Global add-destination modal shell in base.html + AudplexusDestModal helper that opens on HTMX swap, listens for HX-Trigger dest-created, closes on backdrop/Escape/×. - Template helpers: firstTwo, joinDestinationNames, coveragePct, add, sub. Registered destination_picker_body and destination_form_body as bare-body template names in multiRender so the same partials can be rendered with or without base layout chrome. Note: server.go and base.html see additional changes in later commits for wizard routes, dashboard JS, destinations modal handlers, library pagination, and diagnostics endpoints. Foundation is committed here so the per-feature commits read cleanly on top. --- internal/web/server.go | 208 ++++++- internal/web/static/style.css | 896 ++++++++++++++++++++++++++++++- internal/web/templates/base.html | 120 ++++- 3 files changed, 1204 insertions(+), 20 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index 2d7ca3f..76b0b04 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -233,6 +233,8 @@ func (s *Server) setupTemplates() { "mul": func(a float64, b float64) float64 { return a * b }, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, "deref": func(t *time.Time) time.Time { if t == nil { return time.Time{} @@ -246,6 +248,25 @@ func (s *Server) setupTemplates() { return template.HTML(htmlPolicy.Sanitize(raw)) }, "hasSuffix": strings.HasSuffix, + "coveragePct": func(part, total int) int { + if total <= 0 { + return 0 + } + return int(float64(part) / float64(total) * 100) + }, + "firstTwo": func(s string) string { + if len(s) <= 2 { + return strings.ToUpper(s) + } + return strings.ToUpper(s[:2]) + }, + "joinDestinationNames": func(dests []destinationSummaryView) string { + names := make([]string, 0, len(dests)) + for _, d := range dests { + names = append(names, d.DisplayName) + } + return strings.Join(names, ", ") + }, "dict": func(values ...any) (map[string]any, error) { if len(values)%2 != 0 { return nil, fmt.Errorf("dict expects an even number of args") @@ -304,6 +325,18 @@ func (s *Server) setupTemplates() { r.templates[name] = partialSet } + // Bare body fragments for the destination modal. Both files are + // full pages too (their `content` blocks render directly at + // /destinations/new and /destinations/:id/edit), but we also want to + // render JUST the inner form for modal swaps — so we expose the + // inner define blocks under their own keys. The multiRender's + // Instance() path takes the template name as the entry point, so + // "destination_picker_body" / "destination_form_body" execute the + // matching {{define …}} block directly without going through base. + destModalSet := template.Must(template.Must(template.New("dest_modal").Funcs(funcMap).ParseFS(templateFS, "templates/destinations_new.html", "templates/destinations_form.html")).Clone()) + r.templates["destination_picker_body"] = destModalSet + r.templates["destination_form_body"] = destModalSet + s.router.HTMLRender = r } @@ -317,6 +350,12 @@ func (s *Server) setupRoutes() { }) static.StaticFS("/", http.FS(staticSub)) + // First-run gate — runs before page handlers and redirects to the + // setup wizard on fresh installs (no auth + no "onboarded" setting). + // Settings, diagnostics, the wizard itself, and POST/api routes pass + // through so users can always escape and re-trigger the wizard. + s.router.Use(s.firstRunGate) + // Pages s.router.GET("/", s.handleDashboard) s.router.GET("/library", s.handleLibrary) @@ -325,6 +364,14 @@ func (s *Server) setupRoutes() { s.router.GET("/settings", s.handleSettings) s.router.GET("/diagnostics", s.handleDiagnostics) + // Setup wizard (5 steps). State is server-driven via ?step= so no JS + // is needed; "onboarded" setting tracks completion. + s.router.GET("/setup", s.handleSetupWizard) + s.router.POST("/setup/marketplace", s.handleSetupMarketplace) + s.router.POST("/setup/finish", s.handleSetupFinish) + s.router.GET("/setup/skip", s.handleSetupSkip) + s.router.POST("/setup/restart", s.handleSetupRestart) + // Auth s.router.POST("/auth/marketplace", s.handleAudibleMarketplaceSelect) s.router.POST("/auth/start", s.handleAuthStart) @@ -339,9 +386,15 @@ func (s *Server) setupRoutes() { // Library destinations CRUD (multi-destination model). Two-page flow // for add (type picker → type-specific form) and delete (GET confirm // → POST with confirm=1) keeps the UI JS-free + back-button-safe. + s.router.GET("/destinations", s.handleDestinations) s.router.GET("/destinations/new", s.handleDestinationsNewPicker) s.router.POST("/destinations/new", s.handleDestinationsNewForm) s.router.POST("/destinations/create", s.handleDestinationsCreate) + // Modal counterparts — return bare body partials so any page can open + // the add-destination flow in an overlay (wizard step 3, Destinations + // page, settings shortcut). Same backend, same validation. + s.router.GET("/destinations/modal", s.handleDestinationsModalPicker) + s.router.POST("/destinations/modal/form", s.handleDestinationsModalForm) // Test connection — runs a live LibraryItemCount probe with current // form values. HTMX-targeted; renders a small HTML fragment. s.router.POST("/destinations/test", s.handleDestinationTest) @@ -406,6 +459,10 @@ func (s *Server) setupRoutes() { api.GET("/diagnostics/compare", s.handleDiagnosticsCompare) api.POST("/diagnostics/targeted-scan", s.handleDiagnosticsTargetedScan) + // DS-style ops tab — read-only system state. JSON so the + // browser can pretty-render in the diagnostics page. + api.GET("/diagnostics/env", s.handleDiagnosticsEnv) + api.GET("/diagnostics/logs/tail", s.handleDiagnosticsLogsTail) api.POST("/downloads/redownload/:asin", s.handleRedownload) // Per-book conversion between m4b and chapter-split mp3. @@ -565,12 +622,14 @@ type destinationSummaryView struct { TypeLabel string Enabled bool Configured bool + URL string ItemCount int ItemCountSet bool Coverage int CoverageSet bool Health string // "healthy" | "failed" | "never" | "not_configured" HealthDetail string // for failed: shorter human message + LastError string LastCheckedAt *time.Time } @@ -600,7 +659,9 @@ func (s *Server) destinationSummaries(ctx context.Context, completeBooks int) [] TypeLabel: destinationTypeLabel(row.Type), Enabled: row.Enabled, Configured: destinationConfigured(&row), + URL: row.URL, Health: summarizeHealth(&row), + LastError: row.LastHealthCheckErr, LastCheckedAt: row.LastHealthCheckAt, } @@ -756,14 +817,25 @@ func (s *Server) handleDashboardDownloads(c *gin.Context) { func (s *Server) handleLibrary(c *gin.Context) { ctx := c.Request.Context() + const pageSize = 50 + + pageNum := 1 + if v := c.Query("page"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + pageNum = n + } + } + filter := database.BookFilter{ Search: c.Query("search"), SortBy: c.DefaultQuery("sort", "purchase_date"), SortDir: c.DefaultQuery("dir", "desc"), - Limit: 50, + Limit: pageSize, + Offset: (pageNum - 1) * pageSize, } - if statusStr := c.Query("status"); statusStr != "" { + statusStr := c.Query("status") + if statusStr != "" { status := database.BookStatus(statusStr) filter.Status = &status } @@ -775,12 +847,33 @@ func (s *Server) handleLibrary(c *gin.Context) { return } + counts := s.libraryStatusCounts(ctx) + + totalPages := (total + pageSize - 1) / pageSize + if totalPages < 1 { + totalPages = 1 + } + from := filter.Offset + 1 + to := filter.Offset + len(books) + if total == 0 { + from = 0 + to = 0 + } + data := gin.H{ - "Books": books, - "Total": total, - "Filter": filter, - "Page": "library", - "BookActions": buildLibraryBookActions(books, s.settingBool(ctx, library.SettingKeyAutoQueueNewBooks, false)), + "Books": books, + "Total": total, + "Filter": filter, + "Page": "library", + "BookActions": buildLibraryBookActions(books, s.settingBool(ctx, library.SettingKeyAutoQueueNewBooks, false)), + "StatusCounts": counts, + "ActiveStatus": statusStr, + "PageNum": pageNum, + "TotalPages": totalPages, + "PageSize": pageSize, + "PageFrom": from, + "PageTo": to, + "PageNums": paginationNumbers(pageNum, totalPages), } // For HTMX partial requests, render only the table body @@ -791,6 +884,89 @@ func (s *Server) handleLibrary(c *gin.Context) { c.HTML(http.StatusOK, "library.html", data) } +// libraryStatusCounts returns the count of books per status bucket used by +// the filter tabs on the library page. We map fine-grained pipeline statuses +// (downloading/decrypting/processing) into the "in_progress" bucket and +// (queued/skipped) into "waiting" to mirror the dashboard mental model. +func (s *Server) libraryStatusCounts(ctx context.Context) map[string]int { + out := map[string]int{ + "all": 0, + "new": 0, + "in_progress": 0, + "waiting": 0, + "complete": 0, + "failed": 0, + } + + _, total, err := s.db.ListBooks(ctx, database.BookFilter{Limit: 1}) + if err != nil { + return out + } + out["all"] = total + + for _, st := range []database.BookStatus{ + database.BookStatusNew, + database.BookStatusComplete, + database.BookStatusFailed, + database.BookStatusQueued, + database.BookStatusDownloading, + database.BookStatusDecrypting, + database.BookStatusProcessing, + } { + s2 := st + _, n, err := s.db.ListBooks(ctx, database.BookFilter{Status: &s2, Limit: 1}) + if err != nil { + continue + } + switch st { + case database.BookStatusNew: + out["new"] = n + case database.BookStatusComplete: + out["complete"] = n + case database.BookStatusFailed: + out["failed"] = n + case database.BookStatusQueued: + out["waiting"] += n + case database.BookStatusDownloading, database.BookStatusDecrypting, database.BookStatusProcessing: + out["in_progress"] += n + } + } + return out +} + +// paginationNumbers returns a compact paginator sequence with ellipsis +// markers. The marker is the literal "…" string, which the template +// renders as a non-clickable span. +func paginationNumbers(current, total int) []string { + if total <= 7 { + out := make([]string, 0, total) + for p := 1; p <= total; p++ { + out = append(out, strconv.Itoa(p)) + } + return out + } + out := []string{"1"} + if current > 3 { + out = append(out, "…") + } + start := current - 1 + if start < 2 { + start = 2 + } + end := current + 1 + if end > total-1 { + end = total - 1 + } + for p := start; p <= end; p++ { + out = append(out, strconv.Itoa(p)) + } + if current < total-2 { + out = append(out, "…") + } + out = append(out, strconv.Itoa(total)) + return out +} + type libraryBookAction struct { ShowConvert bool ConvertLabel string @@ -2141,12 +2317,15 @@ func (s *Server) handleFactoryReset(c *gin.Context) { s.downloads.Start(context.Background()) webLog.Info().Msg("factory reset complete — database wiped, downloads cleared, credentials removed, pipeline restarted") + // db.Reset() truncates settings, so the onboarded flag is gone — the + // firstRunGate will already redirect to /setup on the next page hit. + // Send the user there explicitly so the flow is obvious. if c.GetHeader("HX-Request") == "true" { - c.Header("HX-Redirect", "/settings") - c.HTML(http.StatusOK, "settings_saved.html", gin.H{"Message": "Factory reset complete. Redirecting…"}) + c.Header("HX-Redirect", "/setup") + c.HTML(http.StatusOK, "settings_saved.html", gin.H{"Message": "Factory reset complete. Redirecting to setup…"}) return } - c.Redirect(http.StatusSeeOther, "/settings") + c.Redirect(http.StatusSeeOther, "/setup") } // purgeDirectory removes all files and subdirectories inside dir, but keeps dir itself. @@ -2343,6 +2522,15 @@ func (s *Server) handleAuthCallback(c *gin.Context) { } webLog.Info().Msg("authentication successful") + + // If the user is still in onboarding, bounce back into the wizard at + // the Storage step rather than dropping them on Settings — the wizard + // is what kicked them out to Amazon in the first place. + if s.isFirstRun(ctx) { + c.Redirect(http.StatusSeeOther, "/setup?step=2") + return + } + s.renderAuthPage(c, http.StatusOK, gin.H{ "Authenticated": true, "Success": "Successfully authenticated with Audible!", diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 056c303..f76494d 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -130,17 +130,902 @@ body { color: var(--text); } -/* Main content */ -.content { +/* Main column (right of sidebar) */ +.main { margin-left: 220px; - padding: 2rem; flex: 1; - max-width: 1200px; + width: calc(100% - 220px); + display: flex; + flex-direction: column; + min-width: 0; +} + +/* Topbar — sticky breadcrumbs + global search */ +.topbar { + position: sticky; + top: 0; + z-index: 50; + height: 56px; + background: var(--bg); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 1.75rem; +} +.topbar .crumbs { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--text-muted); +} +.topbar .crumbs .sep { opacity: 0.4; } +.topbar .crumbs .current { + color: var(--text); + font-weight: 500; +} +.topbar .topbar-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; +} +.topbar-search { + display: flex; + align-items: center; + gap: 0.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.35rem 0.6rem; + min-width: 280px; + color: var(--text-muted); + margin: 0; +} +.topbar-search input { + background: transparent; + border: 0; + color: var(--text); + outline: none; + font-size: 0.85rem; + width: 100%; + font-family: inherit; +} +.topbar-search:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.12); +} + +/* Main content (below topbar) */ +.content { + padding: 1.5rem 1.75rem 3rem; + max-width: 1680px; + width: 100%; } h1 { margin-bottom: 1.5rem; font-size: 1.5rem; } h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); } +/* ───────────────────────────────────────────────────────── + DesignSystem foundation — page header, sections, refined + stat cards, tabs, dl-cards, lib-cards, table v2, pagination. + Coexists with legacy classes above; new pages opt in via + .page wrapper and .stat-grid (not .stats-grid). + ───────────────────────────────────────────────────────── */ + +.page { width: 100%; } + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} +.page-header h1 { + font-size: 1.6rem; + font-weight: 700; + line-height: 1.1; + margin: 0; +} +.page-header .sub { + color: var(--text-muted); + font-size: 0.9rem; + margin-top: 0.25rem; + max-width: 620px; +} +.page-header .actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +.section-h { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 1.75rem 0 0.75rem; + display: flex; + align-items: center; + gap: 0.75rem; +} +.section-h .count { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + padding: 1px 8px; + font-size: 0.7rem; + color: var(--text); +} +.section-h .right { + margin-left: auto; + display: flex; + gap: 0.5rem; + align-items: center; + text-transform: none; + letter-spacing: normal; + font-weight: 400; + font-size: 0.8rem; +} +.section-h .right a { + color: #78b7ff; + text-decoration: none; +} +.section-h .right a:hover { text-decoration: underline; } + +/* Refined stat cards — left-aligned with label/value/delta */ +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; +} +.stat-grid .stat-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.1rem; + text-align: left; + display: flex; + flex-direction: column; + gap: 0.15rem; + position: relative; + overflow: hidden; +} +.stat-grid .stat-card .label { + font-size: 0.72rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.07em; + font-weight: 600; +} +.stat-grid .stat-card .value { + font-size: 1.7rem; + font-weight: 700; + color: var(--text); + line-height: 1.15; + letter-spacing: -0.01em; +} +.stat-grid .stat-card.accent .value { color: var(--accent); } +.stat-grid .stat-card .delta { + font-size: 0.72rem; + color: var(--text-muted); + margin-top: 0.2rem; +} +.stat-grid .stat-card .delta.up { color: #6ad06e; } +.stat-grid .stat-card .delta.down { color: #ff7c7c; } +.stat-grid .stat-card .delta.warn { color: #ffb547; } + +/* Filter tabs (Library) */ +.tabs { + display: flex; + border-bottom: 1px solid var(--border); + margin-bottom: 1.25rem; + gap: 1rem; + flex-wrap: wrap; +} +.tabs a, .tabs button { + background: transparent; + border: 0; + color: var(--text-muted); + padding: 0.65rem 0.25rem; + font: inherit; + font-size: 0.9rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + text-decoration: none; +} +.tabs a:hover, .tabs button:hover { color: var(--text); } +.tabs a.active, .tabs button.active { + color: var(--text); + border-bottom-color: var(--accent); + font-weight: 600; +} +.tabs .count { + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.7rem; + padding: 1px 6px; + border-radius: 999px; + font-weight: 500; +} +.tabs a.active .count, .tabs button.active .count { + background: rgba(233,69,96,0.15); + color: var(--accent); + border-color: rgba(233,69,96,0.3); +} + +/* Filter bar card (Library) */ +.filter-bar { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.75rem 0.9rem; + margin-bottom: 1rem; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} +.filter-bar .search-input { + flex: 1; + min-width: 280px; + max-width: 480px; + display: flex; + align-items: center; + gap: 0.45rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 7px; + padding: 0.35rem 0.6rem; + color: var(--text-muted); +} +.filter-bar .search-input input { + background: transparent; + border: 0; + outline: none; + color: var(--text); + flex: 1; + font-size: 0.85rem; + font-family: inherit; +} +.filter-bar select { + width: auto; + flex: 0 0 auto; + padding: 0.4rem 0.55rem; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 7px; + font-size: 0.85rem; + color-scheme: dark; +} + +/* Card primitive */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.1rem; +} + +/* Sync status v2 — banner with phase pills */ +.sync-status-v2 { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.9rem 1rem; + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 1.25rem; +} +.sync-status-v2 .sync-head { + display: flex; + align-items: center; + gap: 0.65rem; + font-size: 0.85rem; +} +.sync-status-v2 .sync-head .pulse-dot { + width: 8px; height: 8px; border-radius: 999px; + background: var(--accent); + box-shadow: 0 0 0 4px rgba(233,69,96,0.2); + animation: pulse 1s ease-in-out infinite; +} +.sync-status-v2 .sync-head .ok-dot { + width: 8px; height: 8px; border-radius: 999px; + background: var(--success); + box-shadow: 0 0 0 3px rgba(76,175,80,0.18); +} +.sync-status-v2 .sync-head .mode { color: var(--text-muted); } +.sync-status-v2 .sync-head .right { + margin-left: auto; + font-size: 0.75rem; + color: var(--text-muted); +} + +/* Refined sync phases grid (DesignSystem look) — applied alongside legacy + .sync-phase rules. Stacked classes (.sync-phases.v2) keep backward + compat with the existing horizontal flex layout used elsewhere. */ +.sync-phases.v2 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.5rem; +} +.sync-phases.v2 .sync-phase { + background: var(--bg); + border: 1px solid var(--border); + border-left-width: 1px; + border-radius: 8px; + padding: 0.55rem 0.65rem; + min-height: 70px; + transition: border-color 0.2s; + flex: none; +} +.sync-phases.v2 .sync-phase.complete { border-color: rgba(76,175,80,0.4); border-left-color: rgba(76,175,80,0.4); } +.sync-phases.v2 .sync-phase.running { border-color: var(--accent); border-left-color: var(--accent); background: rgba(233,69,96,0.05); } +.sync-phases.v2 .sync-phase.failed { border-color: rgba(244,67,54,0.5); border-left-color: rgba(244,67,54,0.5); } + +/* Pool cards refinements (existing .pool-card kept; add meter+name) */ +.pool-card .pool-name { font-weight: 600; font-size: 0.9rem; } +.pool-card .pool-meter { + height: 6px; + background: var(--bg); + border-radius: 3px; + overflow: hidden; + margin: 0.5rem 0 0.55rem; +} +.pool-card .pool-meter .fill { + height: 100%; + background: var(--accent); + transition: width 0.3s; +} +.pool-card .pool-stats b { + color: var(--text); + font-weight: 600; +} + +/* Download card v2 (richer dl-card) */ +.dl-list { display: flex; flex-direction: column; gap: 0.55rem; } +.dl-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.85rem 1rem; +} +.dl-card .dl-head { + display: flex; + gap: 0.85rem; + align-items: flex-start; +} +.dl-card .dl-cover { + width: 38px; + height: 57px; + flex-shrink: 0; + border-radius: 4px; + background: linear-gradient(135deg, #0f3460, #16213e); + object-fit: cover; +} +.dl-card .dl-body { flex: 1; min-width: 0; } +.dl-card .dl-title { + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 2px; + color: var(--text); +} +.dl-card .dl-by { + color: var(--text-muted); + font-size: 0.76rem; +} +.dl-card .dl-meta-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.35rem; + flex-wrap: wrap; + font-size: 0.73rem; + color: var(--text-muted); +} +.dl-card .dl-meta-row .pct { + color: var(--text); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-weight: 600; +} +.dl-card .dl-actions { display: flex; gap: 0.35rem; flex-shrink: 0; } +.dl-card .dl-progress { + margin-top: 0.55rem; + height: 6px; + background: var(--bg); + border-radius: 3px; + overflow: hidden; +} +.dl-card .dl-progress .bar { + height: 100%; + background: var(--accent); + transition: width 0.3s; +} +.dl-card.waiting .dl-progress .bar { background: var(--info); } +.dl-card.failed { border-color: rgba(244,67,54,0.35); } + +/* Destination grid v2 */ +.dest-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.75rem; +} +.dest-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.9rem 1rem; + display: flex; + flex-direction: column; + gap: 0.55rem; + position: relative; + overflow: hidden; +} +.dest-card .dest-ribbon { + position: absolute; top: 0; left: 0; right: 0; height: 3px; +} +.dest-card.plex .dest-ribbon { background: linear-gradient(90deg, #1ddbe9, #114e88); } +.dest-card.emby .dest-ribbon { background: linear-gradient(90deg, #52b54b, #154e15); } +.dest-card.jellyfin .dest-ribbon { background: linear-gradient(90deg, #aa5cc3, #00a4dc); } +.dest-card.abs .dest-ribbon { background: linear-gradient(90deg, #f4a261, #e76f51); } +.dest-card .dest-head { + display: flex; + align-items: center; + gap: 0.55rem; +} +.dest-card .type-icon { + width: 28px; height: 28px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} +.dest-card.plex .type-icon { background: linear-gradient(135deg, #1ddbe9, #114e88); } +.dest-card.emby .type-icon { background: linear-gradient(135deg, #52b54b, #154e15); } +.dest-card.jellyfin .type-icon { background: linear-gradient(135deg, #aa5cc3, #00a4dc); } +.dest-card.abs .type-icon { background: linear-gradient(135deg, #f4a261, #e76f51); } +.dest-card .dest-name { + font-weight: 600; + font-size: 0.95rem; + color: var(--text); +} +.dest-card .dest-url { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.72rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dest-card .dest-row { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.76rem; + color: var(--text-muted); +} +.dest-card .dest-row b { color: var(--text); font-weight: 600; } +.dest-card .coverage-meter { + flex: 1; + height: 5px; + background: var(--bg); + border-radius: 3px; + overflow: hidden; +} +.dest-card .coverage-meter .fill { + height: 100%; + background: var(--success); +} +.dest-card .coverage-meter .fill.warn { background: var(--warning); } +.dest-card .coverage-meter .fill.bad { background: var(--error); } +.dest-card .dest-checked { + font-size: 0.72rem; + color: var(--text-muted); +} + +/* Table v2 (Library) */ +.table-wrap { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} +.tbl { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.tbl thead th { + padding: 0.6rem 0.9rem; + text-align: left; + background: var(--surface-2); + color: var(--text-muted); + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.tbl thead th.sortable { cursor: pointer; user-select: none; } +.tbl thead th.sortable a { color: inherit; text-decoration: none; display: inline-flex; gap: 0.3rem; align-items: center; } +.tbl thead th.sortable:hover { color: var(--text); } +.tbl thead th .sort-ind { opacity: 0.5; font-size: 0.7rem; } +.tbl thead th.sorted .sort-ind { opacity: 1; color: var(--accent); } +.tbl tbody td { + padding: 0.65rem 0.9rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.tbl tbody tr:last-child td { border-bottom: 0; } +.tbl tbody tr { transition: background 0.12s; } +.tbl tbody tr:hover { background: rgba(255,255,255,0.035); } +.tbl .col-cover { width: 60px; } +.tbl .col-actions { width: 1%; white-space: nowrap; text-align: right; } +.cell-title { + display: flex; + flex-direction: column; + gap: 2px; +} +.cell-title a { + font-weight: 600; + color: var(--text); + text-decoration: none; +} +.cell-title a:hover { color: #78b7ff; } +.cell-title small { + color: var(--text-muted); + font-size: 0.73rem; +} +.cell-mono { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.78rem; + color: var(--text-muted); +} + +/* Destination chips (in row) */ +.dest-chip { + width: 18px; + height: 18px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 0.6rem; + font-weight: 700; +} +.dest-chip.plex { background: linear-gradient(135deg, #1ddbe9, #114e88); } +.dest-chip.abs { background: linear-gradient(135deg, #f4a261, #e76f51); } +.dest-chip.emby { background: linear-gradient(135deg, #52b54b, #154e15); } +.dest-chip.jellyfin { background: linear-gradient(135deg, #aa5cc3, #00a4dc); } + +/* Pagination */ +.table-pagination { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 0.9rem; + border-top: 1px solid var(--border); + background: var(--surface); + font-size: 0.8rem; + color: var(--text-muted); + flex-wrap: wrap; +} +.table-pagination .pager { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.25rem; +} +.pg-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + min-width: 28px; + height: 28px; + padding: 0 0.45rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + display: inline-flex; + align-items: center; + justify-content: center; + font-family: inherit; + text-decoration: none; +} +.pg-btn:hover { background: var(--surface-2); color: var(--text); } +.pg-btn.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.pg-btn[aria-disabled="true"], .pg-btn:disabled { + opacity: 0.35; + cursor: not-allowed; + pointer-events: none; +} + +/* Refined buttons — DesignSystem variants alongside legacy ones */ +.btn-outline-ds { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} +.btn-outline-ds:hover { + background: var(--surface); + border-color: var(--text-muted); +} +.btn-ghost { + background: transparent; + color: var(--text-muted); + border: 1px solid transparent; +} +.btn-ghost:hover { background: var(--surface); color: var(--text); } + +.btn-split { + display: inline-flex; + gap: 1px; + background: var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.btn-split .btn { border-radius: 0; } +.btn-split .btn:first-child { + border-top-left-radius: var(--radius); + border-bottom-left-radius: var(--radius); +} +.btn-split .btn:last-child { + border-top-right-radius: var(--radius); + border-bottom-right-radius: var(--radius); +} + +/* Library grid card view */ +.lib-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); + gap: 0.85rem; +} +.lib-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; + display: flex; + flex-direction: column; + text-decoration: none; + color: inherit; +} +.lib-card:hover { + transform: translateY(-2px); + border-color: var(--accent); + box-shadow: 0 8px 22px rgba(0,0,0,0.35); +} +.lib-card-cover { + position: relative; + aspect-ratio: 2/3; + overflow: hidden; + background: linear-gradient(135deg, #0f3460, #16213e); +} +.lib-card-cover img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.lib-card-badges { + position: absolute; + top: 6px; + left: 6px; + display: flex; + gap: 4px; +} +.lib-card-status { + position: absolute; + bottom: 6px; + left: 6px; + right: 6px; +} +.lib-card-body { + padding: 0.55rem 0.65rem 0.65rem; + display: flex; + flex-direction: column; + gap: 2px; +} +.lib-card-title { + font-size: 0.82rem; + font-weight: 600; + line-height: 1.25; + color: var(--text); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.lib-card-author { + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.lib-card-series { + font-size: 0.68rem; + color: var(--accent); + font-style: italic; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} +.lib-card-meta { + font-size: 0.68rem; + color: var(--text-muted); + display: flex; + gap: 0.3rem; + align-items: center; + margin-top: 0.35rem; + padding-top: 0.35rem; + border-top: 1px solid var(--border); +} +.lib-card-meta .dot { opacity: 0.5; } + +/* Settings page — 2-column layout with sticky right-rail TOC */ +.settings-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 2rem; + align-items: start; +} +.settings-nav { + position: sticky; + top: calc(56px + 16px); /* topbar height + breathing room */ + display: flex; + flex-direction: column; + gap: 1px; + align-self: start; +} +.settings-nav-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; + padding: 0 0.75rem 0.5rem; +} +.settings-nav-link { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.75rem; + background: transparent; + border: 0; + color: var(--text-muted); + font-family: inherit; + font-size: 0.85rem; + text-align: left; + cursor: pointer; + border-left: 2px solid transparent; + border-radius: 0; + text-decoration: none; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} +.settings-nav-link:hover { color: var(--text); } +.settings-nav-link.active { + color: var(--text); + border-left-color: var(--accent); + background: rgba(233, 69, 96, 0.05); + font-weight: 600; +} +.settings-nav-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + opacity: 0.35; + transition: opacity 0.15s ease, transform 0.15s ease; + flex-shrink: 0; +} +.settings-nav-link.active .settings-nav-dot { + opacity: 1; + transform: scale(1.5); + background: var(--accent); +} +.settings-content > .settings-section { scroll-margin-top: calc(56px + 16px); } +@media (max-width: 980px) { + .settings-layout { grid-template-columns: 1fr; } + .settings-nav { position: static; flex-direction: row; flex-wrap: wrap; gap: 0.25rem; } + .settings-nav-label { width: 100%; padding-bottom: 0.25rem; } + .settings-nav-link { border-left: 0; border-bottom: 2px solid transparent; } + .settings-nav-link.active { border-left: 0; border-bottom-color: var(--accent); } +} + +/* Modal overlay (add-destination, etc.) */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: none; + align-items: flex-start; + justify-content: center; + padding: 1.5rem; +} +.modal-overlay.open { display: flex; } +.modal-overlay[hidden] { display: none !important; } +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(7, 10, 18, 0.72); + backdrop-filter: blur(2px); +} +.modal-overlay .modal-dialog { + position: relative; + width: min(820px, calc(100vw - 2rem)); + max-height: calc(100vh - 3rem); + margin: 2rem auto 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 22px 48px rgba(0, 0, 0, 0.5); + overflow: auto; + z-index: 1; +} +.modal-overlay .modal-close { + position: sticky; + top: 0.5rem; + float: right; + margin: 0.5rem 0.5rem 0 0; + width: 2rem; + height: 2rem; + border-radius: 999px; + background: var(--surface-2); + color: var(--text); + border: 1px solid var(--border); + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + z-index: 2; +} +.modal-overlay .modal-close:hover { background: var(--border); } +.modal-overlay .modal-body { padding: 1.25rem 1.5rem 1.5rem; } +@media (max-width: 860px) { + .modal-overlay .modal-dialog { + width: calc(100vw - 1rem); + margin: 0.5rem auto 0; + max-height: calc(100vh - 1rem); + } + .modal-overlay .modal-body { padding: 0.9rem; } +} + +/* Empty state card */ +.empty-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 2rem; + text-align: center; + color: var(--text-muted); +} + /* Stats grid */ .stats-grid { display: grid; @@ -917,7 +1802,10 @@ h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); } .sidebar-header { display: none; } .nav-links { display: flex; } .nav-links li a { padding: 0.75rem; } + .main { margin-left: 0; width: 100%; } .content { margin-left: 0; padding: 1rem; } + .topbar { padding: 0 1rem; } + .topbar-search { min-width: 0; flex: 1; } body { flex-direction: column; } .book-detail { flex-direction: column; } .stats-grid { grid-template-columns: repeat(2, 1fr); } diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index 655a614..d0019fd 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -22,13 +22,52 @@

Audplexus

  • Dashboard
  • Library
  • Pipeline
  • +
  • Destinations
  • Diagnostics
  • Settings
  • -
    - {{template "content" .}} -
    +
    + {{$pageLabels := dict "dashboard" "Dashboard" "library" "Library" "pipeline" "Pipeline" "destinations" "Destinations" "destinations_new_picker" "Destinations" "destinations_new_form" "Destinations" "diagnostics" "Diagnostics" "settings" "Settings"}} + {{$current := index $pageLabels .Page}} + +
    + {{template "content" .}} +
    +
    + + {{/* + Global add-destination modal. Any page can open it by triggering + hx-get="/destinations/modal" hx-target="#dest-modal-content". + The .open class controls visibility; the AudplexusDestModal helper + below handles open/close + listens for the dest-created event so + successful creates auto-close and refresh the page. + */}} + +
    + + {{end}} From 7d73e17a4ad6972be0e0d45b3621716fd3dd7a2d Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:26:50 -0500 Subject: [PATCH 02/40] feat(setup): first-run onboarding wizard A 5-step server-rendered wizard (no JS state) that gates new installs: - /setup hosts the wizard; ?step= drives panel visibility. Steps: Welcome, Audible region/sign-in, Storage (read-only paths), add Destinations, Done. - firstRunGate middleware redirects /, /library, /downloads, /destinations to /setup until the "onboarded" setting is "1". Settings, diagnostics, the wizard itself, all POST/api routes pass through so users can always escape and so the wizard's own subroutes keep working. - handleAuthCallback bounces back to /setup?step=2 when in onboarding mode so the Amazon redirect lands in the wizard, not on Settings. - POST /setup/finish marks onboarded=1 and fires a background quick sync if authenticated. /setup/skip and /setup/restart are also wired. The "Re-run Wizard" button in Settings and the factory-reset redirect to /setup land in a later commit alongside the rest of the Settings template refresh. Routes registered in server.go (firstRunGate hookup, /setup/* paths, auth-callback wizard bounce, factory-reset redirect) shipped with the foundation commit. --- internal/web/setup_handlers.go | 158 ++++++++++++++++++++++++++ internal/web/templates/setup.html | 181 ++++++++++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 internal/web/setup_handlers.go create mode 100644 internal/web/templates/setup.html diff --git a/internal/web/setup_handlers.go b/internal/web/setup_handlers.go new file mode 100644 index 0000000..8c332ed --- /dev/null +++ b/internal/web/setup_handlers.go @@ -0,0 +1,158 @@ +package web + +import ( + "context" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mstrhakr/audplexus/internal/database" +) + +// settingKeyOnboarded marks the setup wizard as completed. Stored as "1" +// once the user has either finished or explicitly skipped onboarding so +// they aren't redirected back to /setup on every visit. +const settingKeyOnboarded = "onboarded" + +// setupStep is the static descriptor for a wizard step (rendered as the +// breadcrumb chip in the header). Step bodies live in setup.html keyed on +// the numeric index. +type setupStep struct { + NumLabel string + Label string +} + +var setupSteps = []setupStep{ + {NumLabel: "1", Label: "Welcome"}, + {NumLabel: "2", Label: "Audible"}, + {NumLabel: "3", Label: "Storage"}, + {NumLabel: "4", Label: "Destinations"}, + {NumLabel: "5", Label: "Done"}, +} + +// isFirstRun returns true when the wizard has neither been completed nor +// skipped yet. Used by the dashboard gate to redirect new installs to +// /setup automatically. +func (s *Server) isFirstRun(ctx context.Context) bool { + return !s.settingBool(ctx, settingKeyOnboarded, false) +} + +// handleSetupWizard renders the onboarding wizard. ?step= selects which +// panel to show; out-of-range values clamp to the first step. +func (s *Server) handleSetupWizard(c *gin.Context) { + ctx := c.Request.Context() + + step := 0 + if v := c.Query("step"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + step = n + } + } + if step < 0 { + step = 0 + } + if step >= len(setupSteps) { + step = len(setupSteps) - 1 + } + + data := s.authBaseData(ctx) + data["Page"] = "setup" + data["Steps"] = setupSteps + data["CurrentStep"] = step + data["AudiobooksPath"] = s.audiobooksPath + data["DownloadsPath"] = s.downloadsPath + + // Destinations summary for steps 3 (list) and 4 (recap). We use the + // rich destinationSummaries shape so the picker-list shows type + + // display name + URL consistently with the live Destinations page. + completeStatus := database.BookStatusComplete + _, completeBooks, _ := s.db.ListBooks(ctx, database.BookFilter{Status: &completeStatus, Limit: 1}) + data["Destinations"] = s.destinationSummaries(ctx, completeBooks) + + // On the Audible step, if the user has already picked a marketplace + // but isn't authenticated yet, generate the auth URL so the page can + // render the "Open Amazon sign-in" button without a separate POST. + if step == 1 && !s.audible.IsAuthenticated() { + if authURL, err := s.audible.GetAuthURL(); err == nil { + data["AuthURL"] = authURL.URL + data["CodeVerifier"] = authURL.CodeVerifier + data["DeviceSerial"] = authURL.DeviceSerial + } + } + + c.HTML(http.StatusOK, "setup.html", data) +} + +// handleSetupMarketplace persists the marketplace selection submitted by +// the wizard's Audible step. Distinct from /auth/marketplace because we +// don't want to render settings.html on success — we want to bounce back +// into the wizard with the auth URL ready. +func (s *Server) handleSetupMarketplace(c *gin.Context) { + mp := strings.ToLower(strings.TrimSpace(c.PostForm("marketplace"))) + if mp == "" { + c.Redirect(http.StatusSeeOther, "/setup?step=1") + return + } + _ = s.db.SetSetting(c.Request.Context(), "audible_marketplace", mp) + c.Redirect(http.StatusSeeOther, "/setup?step=1") +} + +// handleSetupFinish marks onboarding complete and bounces to the dashboard. +// Kicks a quick sync only if the user actually authenticated — otherwise +// nothing to sync against. +func (s *Server) handleSetupFinish(c *gin.Context) { + ctx := c.Request.Context() + _ = s.db.SetSetting(ctx, settingKeyOnboarded, "1") + + if s.audible.IsAuthenticated() { + // Fire-and-forget — sync runs asynchronously; UI will show progress + // via the existing /api/sync/status polling on the dashboard. + go func() { + if _, err := s.sync.QuickSync(context.Background()); err != nil { + webLog.Warn().Err(err).Msg("setup: first quick sync failed") + } + }() + } + + c.Redirect(http.StatusSeeOther, "/") +} + +// handleSetupSkip marks onboarding complete without running anything — +// the "Skip setup" link on step 0. Useful for testing or for users who +// configured everything via env vars before first boot. +func (s *Server) handleSetupSkip(c *gin.Context) { + _ = s.db.SetSetting(c.Request.Context(), settingKeyOnboarded, "1") + c.Redirect(http.StatusSeeOther, "/") +} + +// handleSetupRestart clears the onboarded flag and routes the user back +// to step 0. Triggered by the "Re-run setup wizard" button in Settings +// (for testing) and from the post-factory-reset redirect. +func (s *Server) handleSetupRestart(c *gin.Context) { + _ = s.db.SetSetting(c.Request.Context(), settingKeyOnboarded, "") + c.Redirect(http.StatusSeeOther, "/setup") +} + +// firstRunGate redirects unauthenticated, not-yet-onboarded visitors from +// the main app pages to the wizard. Settings, diagnostics, the wizard +// itself, and POST/api routes pass through so users can always escape and +// so the wizard's own subroutes (auth callback, etc.) keep working. +func (s *Server) firstRunGate(c *gin.Context) { + if c.Request.Method != http.MethodGet { + c.Next() + return + } + if !s.isFirstRun(c.Request.Context()) { + c.Next() + return + } + + switch c.Request.URL.Path { + case "/", "/library", "/downloads", "/destinations": + c.Redirect(http.StatusSeeOther, "/setup") + c.Abort() + return + } + c.Next() +} diff --git a/internal/web/templates/setup.html b/internal/web/templates/setup.html new file mode 100644 index 0000000..1f7c746 --- /dev/null +++ b/internal/web/templates/setup.html @@ -0,0 +1,181 @@ +{{define "content"}} +
    +
    +
    + +

    Welcome to Audplexus

    Audible → Your Library · Setup

    +
    + +
      + {{range $i, $s := .Steps}} +
    1. + {{if lt $i $.CurrentStep}} + {{$s.NumLabel}} + {{$s.Label}} + {{else}} + {{$s.NumLabel}} + {{$s.Label}} + {{end}} + {{if lt $i (sub (len $.Steps) 1)}}{{end}} +
    2. + {{end}} +
    + +
    + {{if eq .CurrentStep 0}} +

    Let's get your library moving.

    +

    Audplexus syncs your Audible purchases, decrypts and converts them to m4b, then pushes them into your media servers. Setup takes about 4 minutes.

    +
    +
    +
    Sync
    +
    Pull your Audible catalog on a schedule.
    +
    +
    +
    Process
    +
    Download, decrypt, tag, and organize.
    +
    +
    +
    Distribute
    +
    Push to Plex, Emby, Jellyfin, or Audiobookshelf.
    +
    +
    +
    +
    You'll need your Audible login and at least one media server URL. We never store your Audible password — only an activation token.
    +
    + {{end}} + + {{if eq .CurrentStep 1}} +

    Connect your Audible account

    +

    Pick your Audible region. The next step redirects you through Amazon's official sign-in to capture an activation token — we never see your password.

    + + {{if .Authenticated}} +
    + +
    Already signed in. Audible region: {{.CurrentMarketplace}}. Continue to the next step.
    +
    + {{else if .AuthURL}} +
    +
    + Step 1 of 2 — Sign in with Amazon
    + Open Amazon sign-in → +

    Amazon will redirect you back here when done.

    +
    +
    + {{else}} +
    + +
    + {{range .Marketplaces}} + + {{end}} +
    + +
    + {{if .Error}} +
    {{.Error}}
    + {{end}} + {{end}} + {{end}} + + {{if eq .CurrentStep 2}} +

    Storage

    +

    Audplexus writes processed audiobooks to the path mounted into the container. Your media servers should be able to read this same path.

    +
    + +
    {{.AudiobooksPath}}
    +
    Configured at container startup. If this isn't right, mount a different volume to /audiobooks in your compose file.
    +
    +
    + +
    {{.DownloadsPath}}
    +
    Temporary working directory for in-flight downloads. Doesn't need to be visible to media servers.
    +
    +
    +
    Naming templates and tagging options are configurable later from Settings.
    +
    + {{end}} + + {{if eq .CurrentStep 3}} +

    Add a destination

    +

    Connect at least one media server so Audplexus knows where to push processed files. You can add more later.

    + + {{if .Destinations}} +
    + {{range .Destinations}} +
    + + {{firstTwo .TypeLabel}} + {{.DisplayName}} + + {{if .URL}}{{.URL}}{{else}}{{.TypeLabel}}{{end}} +
    + {{end}} +
    + + {{else}} + +

    Or skip for now — files will still be written to disk.

    + {{end}} + +
    +
    You can also skip this step and add destinations from the Destinations page anytime.
    +
    + {{end}} + + {{if eq .CurrentStep 4}} +

    You're all set.

    +

    Audplexus is ready to run its first sync. You can change anything from Settings later.

    +
    +
    +
    Region
    {{.CurrentMarketplace}}
    +
    Audible
    {{if .Authenticated}}Signed in ✓{{else}}Not signed in{{end}}
    +
    Library
    {{.AudiobooksPath}}
    +
    Destinations
    +
    + {{if .Destinations}}{{joinDestinationNames .Destinations}}{{else}}None — skipped{{end}} +
    +
    +
    + {{if .Authenticated}} +
    First sync will start automatically. Expect 1–3 minutes for a typical library.
    + {{else}} +
    !
    You're not signed in to Audible yet. You can finish setup and connect later from Settings, but nothing will sync until you do.
    + {{end}} + {{end}} +
    + +
    + {{if gt .CurrentStep 0}} + Back + {{else}} + Skip setup + {{end}} +
    + {{if lt .CurrentStep (sub (len .Steps) 1)}} + {{if and (eq .CurrentStep 1) (not .Authenticated)}} + Skip sign-in for now + {{end}} + Continue → + {{else}} +
    + +
    + {{end}} +
    +
    +
    +
    +{{end}} From d54feacc1acd09e28566701be930fc37bf167c7f Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:27:11 -0500 Subject: [PATCH 03/40] feat(ui): dashboard refresh Restructures the dashboard around the DS layout while keeping the existing HTMX polling + SSE pipeline behaviour intact: - dashboard.html: .page wrapper + .page-header with the existing action cluster (Quick/Full sync, Queue all new, Retry failed, Reorganize, Pause/Resume queue). Two-column "active pipeline + pools" grid below the SSE-driven cards. - dashboard_summary.html: label/value/delta stat cards (with coverage %, waiting count, "all clear"/"needs attention" hints). Destinations rendered with .dest-card + ribbons + coverage meter + type-icon chips. Old text-centred stat-card markup retired in favor of the new grid. - dashboard_downloads.html: section-h headers with counts, .tbl styling for done/failed tables, .empty-card empty states. - sync_status.html: banner head with pulse/ok dot + v2 phase grid via the new .sync-status-v2 / .sync-phases.v2 classes. Phase progress bars and sub-phase rendering kept verbatim. base.html SSE renderers (pool cards + active pipeline cards) need to emit the new class names (.pool-name, .pool-meter, .dl-card v2). Those JS changes shipped with the foundation commit since they live in base.html, which is owned there. --- internal/web/templates/dashboard.html | 116 +++++++++++------- .../web/templates/dashboard_downloads.html | 80 +++++++----- internal/web/templates/dashboard_summary.html | 112 +++++++++-------- internal/web/templates/sync_status.html | 37 +++--- 4 files changed, 194 insertions(+), 151 deletions(-) diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index efc2845..758f539 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -1,47 +1,69 @@ -{{define "content"}} -

    Dashboard

    - -
    -
    - - -
    - - - - - -
    - -
    - Queue paused: {{.QueuePauseReason}} -
    - -
    - -
    - -
    - {{template "dashboard_summary.html" .}} -
    - -

    Worker Pools

    -
    - -

    Active Pipeline

    -
    - -
    - {{template "dashboard_downloads.html" .}} -
    -{{end}} \ No newline at end of file +{{define "content"}} +
    + + +
    + Queue paused: {{.QueuePauseReason}} +
    + + + +
    + {{template "dashboard_summary.html" .}} +
    + +
    + Active pipeline + View full pipeline → +
    +
    +
    +
    +
    + +
    + {{template "dashboard_downloads.html" .}} +
    +
    + + +{{end}} diff --git a/internal/web/templates/dashboard_downloads.html b/internal/web/templates/dashboard_downloads.html index 4e97e36..66a9d4e 100644 --- a/internal/web/templates/dashboard_downloads.html +++ b/internal/web/templates/dashboard_downloads.html @@ -1,33 +1,47 @@ -{{define "dashboard_downloads.html"}} -

    Done ({{len .DoneDownloads}})

    - - - - {{range .DoneDownloads}} - - - - - - {{else}} - - {{end}} - -
    ASINTitleCompleted
    {{.ASIN}}{{$title := index $.DownloadTitles .ASIN}}{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}}{{if .CompletedAt}}{{formatDate (deref .CompletedAt)}}{{end}}
    No completed downloads yet
    - -

    Failed ({{.FailedDL}})

    - - - - {{range .FailedDownloads}} - - - - - - {{else}} - - {{end}} - -
    ASINErrorTitle
    {{.ASIN}}{{.Error}}{{$title := index $.DownloadTitles .ASIN}}{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}}
    No failed downloads
    -{{end}} +{{define "dashboard_downloads.html"}} +{{if .DoneDownloads}} +
    + Recent completions {{len .DoneDownloads}} +
    +
    + + + + {{range .DoneDownloads}} + + + + + + {{end}} + +
    TitleASINCompleted
    {{$title := index $.DownloadTitles .ASIN}}{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}}{{.ASIN}}{{if .CompletedAt}}{{formatDate (deref .CompletedAt)}}{{end}}
    +
    +{{else}} +
    Recent completions 0
    +
    No completed downloads yet
    +{{end}} + +
    + Recent failures {{.FailedDL}} + See all failed → +
    +{{if .FailedDownloads}} +
    + + + + {{range .FailedDownloads}} + + + + + + {{end}} + +
    TitleASINError
    {{$title := index $.DownloadTitles .ASIN}}{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}}{{.ASIN}}{{.Error}}
    +
    +{{else}} +
    No failed downloads
    +{{end}} +{{end}} diff --git a/internal/web/templates/dashboard_summary.html b/internal/web/templates/dashboard_summary.html index c70cd6c..62d2ee0 100644 --- a/internal/web/templates/dashboard_summary.html +++ b/internal/web/templates/dashboard_summary.html @@ -1,85 +1,91 @@ {{define "dashboard_summary.html"}} -
    +
    At a glance
    +
    -
    {{.TotalBooks}}
    -
    Total Books
    +
    Library Items
    +
    {{.TotalBooks}}
    +
    total synced from Audible
    -
    {{.CompleteBooks}}
    -
    Downloaded
    +
    Downloaded
    +
    {{.CompleteBooks}}
    +
    {{if gt .TotalBooks 0}}{{coveragePct .CompleteBooks .TotalBooks}}% of library{{else}}—{{end}}
    -
    {{.NewBooks}}
    -
    New
    +
    New
    +
    {{.NewBooks}}
    +
    pending action
    -
    -
    {{.ActiveDL}}
    -
    Active
    -
    -
    -
    {{.PendingDL}}
    -
    Pending
    +
    +
    In Progress
    +
    {{.ActiveDL}}
    +
    {{.PendingDL}} waiting
    -
    {{.FailedDL}}
    -
    Failed
    +
    Failed
    +
    {{.FailedDL}}
    +
    {{if gt .FailedDL 0}}needs attention{{else}}all clear{{end}}
    -
    -

    Destinations

    +
    +
    + Library destinations + {{if .DestinationSummaries}}{{len .DestinationSummaries}}{{end}} + Add destination +
    {{if .DestinationSummaries}} - {{/* aria-live=polite + atomic=false → SR users hear destination - additions/removals and badge state changes (Healthy → Failed) - WITHOUT chatter on every 12s count tick. The numeric
    - elements have aria-live=off so count fluctuations stay silent. */}} -
      +
        {{range .DestinationSummaries}} -
      • -

        - {{.DisplayName}} -

        -

        - {{.TypeLabel}} - - {{if not .Enabled}}Disabled - {{else if eq .Health "healthy"}}Healthy - {{else if eq .Health "failed"}}Failed - {{else if eq .Health "never"}}Never checked - {{else}}Not configured{{end}} -

        - {{if .ItemCountSet}} -
        -
        -
        Items
        -
        {{.ItemCount}}
        +
      • + +
        + {{firstTwo .TypeLabel}} +
        +
        {{.DisplayName}}
        +
        {{.TypeLabel}}
        - {{if .CoverageSet}}
        -
        Coverage
        -
        {{.Coverage}}%
        + {{if not .Enabled}}Disabled + {{else if eq .Health "healthy"}}Healthy + {{else if eq .Health "failed"}}Failed + {{else if eq .Health "never"}}Never checked + {{else}}Not configured{{end}}
        +
        + {{if .ItemCountSet}} +
        + {{.ItemCount}} items + {{if .CoverageSet}} + + + + {{.Coverage}}% {{end}} -
      • +
    + {{end}} + {{if .HealthDetail}} +
    {{.HealthDetail}}
    {{end}} {{if .LastCheckedAt}} -

    - Last checked: -

    +
    + Last checked +
    {{end}} {{end}} {{else}} -

    No destinations configured. Add a library destination to fan out scans after each download.

    +
    + No destinations configured. Add a library destination to fan out scans after each download. +
    {{end}} {{if .LastSync}} -
    - Last Sync: {{formatDate .LastSync.StartedAt}} — - {{.LastSync.BooksFound}} found, {{.LastSync.BooksAdded}} added - ({{.LastSync.Status}}) +
    + Last Sync: {{formatDate .LastSync.StartedAt}} — + {{.LastSync.BooksFound}} found, {{.LastSync.BooksAdded}} added ({{.LastSync.Status}})
    {{end}} {{end}} diff --git a/internal/web/templates/sync_status.html b/internal/web/templates/sync_status.html index df5108e..df52f8a 100644 --- a/internal/web/templates/sync_status.html +++ b/internal/web/templates/sync_status.html @@ -1,24 +1,25 @@ {{define "sync_status.html"}} -
    - {{if .Running}} -
    - {{if eq .Mode "quick"}}Quick{{else}}Full{{end}} sync in progress -
    - {{else if eq .Status "failed"}} -
    - Sync failed{{if .Error}}: {{.Error}}{{end}} -
    - {{else if eq .Status "partial"}} -
    - Sync finished with errors -
    - {{else if eq .Status "complete"}} -
    - {{if .Message}}{{.Message}}{{else}}Sync complete{{end}} +
    +
    + {{if .Running}} + + {{if eq .Mode "quick"}}Quick{{else}}Full{{end}} sync in progress + {{else if eq .Status "failed"}} + + Sync failed{{if .Error}}: {{.Error}}{{end}} + {{else if eq .Status "partial"}} + + Sync finished with errors + {{else if eq .Status "complete"}} + + {{if .Message}}{{.Message}}{{else}}Last sync complete{{end}} + {{else}} + + No sync running + {{end}}
    - {{end}} {{if .Phases}} -
    +
    {{range .Phases}}
    From 08178b3de4cdef1afb3baa93452d5dfc7325a74a Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:27:29 -0500 Subject: [PATCH 04/40] feat(ui): library refresh with status tabs and pagination - library.html: .page wrapper + .page-header, status filter tabs with per-status counts (All/New/In Progress/Waiting/Complete/Failed), filter-bar card with search + status + sort + direction selects, kept the existing book-detail modal markup intact. - library_table.html: .table-wrap + .tbl v2, sortable header links that preserve current filter state, new columns (Series, Purchased, On Disk), full pagination control with ellipsis-aware page numbers and "X-Y of N" summary. - library_row.html: cell-title with narrator small-text, mono cells for duration/purchased, on-disk chip (.dest-chip.abs), kept all existing per-row action buttons (Queue/Retry/Convert/Delete/Cancel) and HTMX hookups unchanged. handleLibrary in server.go adds pagination (?page=), libraryStatusCounts for the tab badges, and a paginationNumbers helper for the compact pager. Those shipped with the foundation commit. --- internal/web/templates/library.html | 485 +++++++++++----------- internal/web/templates/library_row.html | 21 +- internal/web/templates/library_table.html | 87 +++- 3 files changed, 323 insertions(+), 270 deletions(-) diff --git a/internal/web/templates/library.html b/internal/web/templates/library.html index 4746873..5997e89 100644 --- a/internal/web/templates/library.html +++ b/internal/web/templates/library.html @@ -1,243 +1,242 @@ -{{define "content"}} -

    Library

    - -
    -
    - - -
    - - - -
    - -
    - -
    - - - -
    - {{template "library_table.html" .}} -
    - - - - - - -{{end}} +{{define "content"}} +
    + + + + + {{$active := .ActiveStatus}} + {{$search := .Filter.Search}} + + +
    +
    + + +
    + + + +
    + +
    + {{template "library_table.html" .}} +
    + + +
    + + + + +{{end}} diff --git a/internal/web/templates/library_row.html b/internal/web/templates/library_row.html index 2fba00f..bd22e21 100644 --- a/internal/web/templates/library_row.html +++ b/internal/web/templates/library_row.html @@ -1,16 +1,27 @@ {{define "library_row.html"}} - + {{if .Book.CoverURL}}{{end}} - {{.Book.Title}} - {{if .Book.Series}}
    {{.Book.Series}} #{{.Book.SeriesPosition}}{{end}} +
    + {{.Book.Title}} + {{if .Book.Narrator}}narr. {{.Book.Narrator}}{{end}} +
    {{.Book.Author}} - {{formatDuration .Book.Duration}} + + {{if .Book.Series}}{{.Book.Series}} #{{.Book.SeriesPosition}} + {{else}}{{end}} + + {{formatDuration .Book.Duration}} + {{formatDate .Book.PurchaseDate}} {{.Book.Status}} + {{if .Book.FilePath}}A + {{else}}{{end}} + +
    {{if eq (printf "%s" .Book.Status) "new"}} @@ -31,7 +42,7 @@ hx-confirm="{{.BookAction.DeleteConfirm}}" hx-target="#book-row-{{.Book.ID}}" hx-swap="outerHTML" - class="btn btn-small btn-danger">Delete Files + class="btn btn-small btn-danger">Delete {{end}}
    diff --git a/internal/web/templates/library_table.html b/internal/web/templates/library_table.html index 03855ff..2a61145 100644 --- a/internal/web/templates/library_table.html +++ b/internal/web/templates/library_table.html @@ -1,22 +1,65 @@ -{{define "library_table.html"}} -

    {{.Total}} books

    - - - - - - - - - - - - - {{range .Books}} - {{template "library_row.html" (dict "Book" . "BookAction" (index $.BookActions .ID))}} - {{else}} - - {{end}} - -
    CoverTitleAuthorDurationStatusActions
    No books found
    -{{end}} +{{define "library_table.html"}} +{{$search := .Filter.Search}} +{{$status := .ActiveStatus}} +{{$sort := .Filter.SortBy}} +{{$dir := .Filter.SortDir}} +
    + + + + + + + + + + + + + + + + {{range .Books}} + {{template "library_row.html" (dict "Book" . "BookAction" (index $.BookActions .ID))}} + {{else}} + + {{end}} + +
    Cover + Title {{if eq $sort "title"}}{{if eq $dir "asc"}}↑{{else}}↓{{end}}{{else}}↕{{end}} + + Author {{if eq $sort "author"}}{{if eq $dir "asc"}}↑{{else}}↓{{end}}{{else}}↕{{end}} + SeriesDuration + Purchased {{if eq $sort "purchase_date"}}{{if eq $dir "asc"}}↑{{else}}↓{{end}}{{else}}↕{{end}} + + Status {{if eq $sort "status"}}{{if eq $dir "asc"}}↑{{else}}↓{{end}}{{else}}↕{{end}} + On DiskActions
    No books match these filters.
    +
    + + {{if eq .Total 0}}0{{else}}{{.PageFrom}}–{{.PageTo}}{{end}} of {{.Total}} + +
    + {{$base := printf "/library?sort=%s&dir=%s" $sort $dir}} + {{if $search}}{{$base = printf "%s&search=%s" $base $search}}{{end}} + {{if $status}}{{$base = printf "%s&status=%s" $base $status}}{{end}} + {{if le .PageNum 1}} + + {{else}} + + {{end}} + {{range .PageNums}} + {{if eq . "…"}} + + {{else}} + {{.}} + {{end}} + {{end}} + {{if ge .PageNum .TotalPages}} + + {{else}} + + {{end}} +
    +
    +
    +{{end}} From 489a4c6e02a200d57b50397f3e6e11f2a2de557f Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:27:51 -0500 Subject: [PATCH 05/40] feat(destinations): split into its own page + modal-able add flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destinations used to live as a section inside Settings. Pulled out into a top-level page with richer per-card detail (item count, coverage, last error, last checked) and a modal-able add flow that the wizard and the destinations page share. - destinations.html (new): page-header with "N of M healthy" subtitle, dest-grid using the v2 dest-card (ribbon + type-icon + coverage meter + inline last-error alert), per-card Test/Edit/Enable/Disable/Delete actions, empty-state CTA opens the modal. - destinations_new.html: extracted "destination_picker_body" partial so it renders both as a full page (/destinations/new) and as a bare body inside the modal. Picker submit target is data-driven (PickerAction). - destinations_form.html: extracted "destination_form_body" partial. Modal mode posts via HTMX with hx-headers X-Dest-Modal=1 so the create handler branches between "redirect to /destinations" and "fire HX-Trigger dest-created + render success fragment". Plex PIN flow, Discover-libraries chains, Test Connection all kept verbatim. - handleDestinationsCreate: branches on X-Dest-Modal. Modal path emits HX-Trigger so the parent page auto-closes the modal and reloads. Legacy path redirects to /destinations (was /settings#library- destinations; e2e test updated accordingly). - New /destinations/modal and /destinations/modal/form routes return the bare body partials for HTMX. - destinationSummaryView gained URL + LastError fields so the new destinations page can show inline error banners. The settings.html "Manage destinations →" link replacing the old inline section lands with the Settings TOC commit. --- internal/web/destinations_e2e_test.go | 4 +- internal/web/destinations_handlers.go | 107 ++++++++++++++++- internal/web/templates/destinations.html | 110 ++++++++++++++++++ internal/web/templates/destinations_form.html | 72 ++++++++---- internal/web/templates/destinations_new.html | 65 ++++++----- 5 files changed, 302 insertions(+), 56 deletions(-) create mode 100644 internal/web/templates/destinations.html diff --git a/internal/web/destinations_e2e_test.go b/internal/web/destinations_e2e_test.go index c3ba0d3..b66e23c 100644 --- a/internal/web/destinations_e2e_test.go +++ b/internal/web/destinations_e2e_test.go @@ -53,8 +53,8 @@ func TestHandleDestinationsCreate_E2E(t *testing.T) { if w.Code != http.StatusSeeOther { t.Fatalf("status = %d, want %d", w.Code, http.StatusSeeOther) } - if got := w.Header().Get("Location"); got != "/settings#library-destinations" { - t.Fatalf("Location = %q, want /settings#library-destinations", got) + if got := w.Header().Get("Location"); got != "/destinations" { + t.Fatalf("Location = %q, want /destinations", got) } rows, err := db.ListLibraryDestinations(context.Background()) diff --git a/internal/web/destinations_handlers.go b/internal/web/destinations_handlers.go index ffa7502..8337603 100644 --- a/internal/web/destinations_handlers.go +++ b/internal/web/destinations_handlers.go @@ -100,15 +100,75 @@ func destinationConfigured(d *database.LibraryDestination) bool { return false } +// handleDestinations renders the standalone Destinations page — split out +// of Settings so destination management has room for richer per-card detail +// (item count, coverage, last error) and a clear top-level CTA to add more. +func (s *Server) handleDestinations(c *gin.Context) { + ctx := c.Request.Context() + completeStatus := database.BookStatusComplete + _, completeBooks, _ := s.db.ListBooks(ctx, database.BookFilter{Status: &completeStatus, Limit: 1}) + + dests := s.destinationSummaries(ctx, completeBooks) + + healthy := 0 + for _, d := range dests { + if d.Health == "healthy" { + healthy++ + } + } + + data := s.authBaseData(ctx) + data["Page"] = "destinations" + data["Destinations"] = dests + data["HealthyCount"] = healthy + data["TotalCount"] = len(dests) + c.HTML(http.StatusOK, "destinations.html", data) +} + // handleDestinationsNewPicker renders the type-picker page (step 1 of 2 // in the add flow). Two-page server flow rather than a JS-toggled single // form — simpler, validation cleaner, no JS dependency. func (s *Server) handleDestinationsNewPicker(c *gin.Context) { data := s.authBaseData(c.Request.Context()) data["Page"] = "destinations_new_picker" + data["PickerAction"] = "/destinations/new" + data["ModalMode"] = false c.HTML(http.StatusOK, "destinations_new.html", data) } +// handleDestinationsModalPicker returns just the type-picker body (no +// page chrome) for HTMX modal opens from the wizard, the Destinations +// page, etc. Picker submits to /destinations/modal/form so the response +// also comes back as a bare body that swaps into the same modal slot. +func (s *Server) handleDestinationsModalPicker(c *gin.Context) { + data := s.authBaseData(c.Request.Context()) + data["PickerAction"] = "/destinations/modal/form" + data["ModalMode"] = true + c.HTML(http.StatusOK, "destination_picker_body", data) +} + +// handleDestinationsModalForm returns just the type-specific form body +// (no page chrome) for the modal flow. Picks up where the modal picker +// left off; on submit the form POSTs to /destinations/create with the +// X-Dest-Modal header so handleDestinationsCreate knows to respond with +// an HX-Trigger close instead of redirecting to /destinations. +func (s *Server) handleDestinationsModalForm(c *gin.Context) { + t := strings.ToLower(strings.TrimSpace(c.PostForm("type"))) + if !validDestinationType(t) { + c.HTML(http.StatusBadRequest, "destination_picker_body", gin.H{ + "PickerAction": "/destinations/modal/form", + "ModalMode": true, + "Error": "Pick a destination type.", + }) + return + } + data := s.authBaseData(c.Request.Context()) + data["DestType"] = t + data["DestTypeLabel"] = destinationTypeLabel(database.LibraryDestinationType(t)) + data["ModalMode"] = true + c.HTML(http.StatusOK, "destination_form_body", data) +} + // handleDestinationsNewForm renders the type-specific config form (step 2) // when the user submits the type picker. func (s *Server) handleDestinationsNewForm(c *gin.Context) { @@ -896,26 +956,65 @@ func writeSensitiveHTML(c *gin.Context, body string) { } // handleDestinationsCreate persists a new destination after the form submit. +// Behaves differently depending on the modal-mode header: +// +// - X-Dest-Modal=1: render an HX-Trigger response so the modal closes +// and the parent page (Destinations list, wizard step 3) refreshes +// without a full reload. +// - otherwise: legacy redirect to /destinations (full-page form path). func (s *Server) handleDestinationsCreate(c *gin.Context) { + modal := c.GetHeader("X-Dest-Modal") == "1" + d, err := s.destinationFromForm(c, "") if err != nil { + if modal { + data := s.authBaseData(c.Request.Context()) + data["DestType"] = c.PostForm("type") + data["DestTypeLabel"] = destinationTypeLabel(database.LibraryDestinationType(c.PostForm("type"))) + data["ModalMode"] = true + data["FormError"] = err.Error() + c.HTML(http.StatusBadRequest, "destination_form_body", data) + return + } s.renderAuthPage(c, http.StatusBadRequest, gin.H{"Error": err.Error()}) return } // Disambiguate display names — turns "Plex" + "Plex" into "Plex" + // "Plex (2)". Only fires when a collision exists, so a single Plex - // destination stays plainly named "Plex". The Plex server-picker - // onchange autofills display_name from the discovered server name - // when present, so the typical Plex flow never hits this fallback. + // destination stays plainly named "Plex". d.DisplayName = s.uniqueDisplayName(c.Request.Context(), d.DisplayName, "") d.ID = uuid.NewString() d.Enabled = true d.CreatedAt = time.Now().UTC() if err := s.db.CreateLibraryDestination(c.Request.Context(), d); err != nil { + if modal { + data := s.authBaseData(c.Request.Context()) + data["DestType"] = string(d.Type) + data["DestTypeLabel"] = destinationTypeLabel(d.Type) + data["ModalMode"] = true + data["FormError"] = "Could not create destination: " + err.Error() + c.HTML(http.StatusInternalServerError, "destination_form_body", data) + return + } s.renderAuthPage(c, http.StatusInternalServerError, gin.H{"Error": "Could not create destination: " + err.Error()}) return } - c.Redirect(http.StatusSeeOther, "/settings#library-destinations") + + if modal { + // HX-Trigger fires a "dest-created" event on document.body so the + // page that opened the modal can refresh its destinations list / + // advance its state. The body is the success confirmation that + // replaces the form inside the modal until the page reacts. + c.Header("HX-Trigger", `{"dest-created":{"id":"`+d.ID+`","name":"`+d.DisplayName+`"}}`) + c.HTML(http.StatusOK, "destination_form_body", gin.H{ + "ModalMode": true, + "ModalSuccess": true, + "DestTypeLabel": destinationTypeLabel(d.Type), + "Dest": d, + }) + return + } + c.Redirect(http.StatusSeeOther, "/destinations") } // uniqueDisplayName appends " (2)", " (3)", … to candidate when another diff --git a/internal/web/templates/destinations.html b/internal/web/templates/destinations.html new file mode 100644 index 0000000..9a7b1af --- /dev/null +++ b/internal/web/templates/destinations.html @@ -0,0 +1,110 @@ +{{define "content"}} +
    + + + {{if .Destinations}} +
    + {{range .Destinations}} +
    + +
    + {{firstTwo .TypeLabel}} +
    +
    {{.DisplayName}}
    +
    {{if .URL}}{{.URL}}{{else}}{{.TypeLabel}}{{end}}
    +
    +
    + {{if not .Enabled}}Disabled + {{else if eq .Health "healthy"}}Healthy + {{else if eq .Health "failed"}}Failed + {{else if eq .Health "never"}}Never checked + {{else}}Not configured{{end}} +
    +
    + + {{if .ItemCountSet}} +
    + {{.ItemCount}} items + {{if .CoverageSet}} + + + + {{.Coverage}}% + {{end}} +
    + {{end}} + + {{if and (eq .Health "failed") .LastError}} +
    + {{.LastError}} +
    + {{else if .HealthDetail}} +
    {{.HealthDetail}}
    + {{end}} + +
    + {{if .LastCheckedAt}} + + Last checked + + {{else}} + Never checked + {{end}} + + + + Edit {{.DisplayName}} + +
    + +
    +
    + +
    +
    +
    +
    + {{end}} +
    + {{else}} + + {{end}} +
    +{{end}} diff --git a/internal/web/templates/destinations_form.html b/internal/web/templates/destinations_form.html index 6e58e2f..edef868 100644 --- a/internal/web/templates/destinations_form.html +++ b/internal/web/templates/destinations_form.html @@ -1,27 +1,37 @@ -{{define "content"}} +{{define "destination_form_body"}} +{{if .ModalSuccess}} +
    + +
    + {{.DestTypeLabel}} destination added. +
    Refreshing…
    +
    +
    +{{else}} {{$isEdit := .Dest}} {{$dt := .DestType}} -
    -
    -

    {{if $isEdit}}Edit {{.DestTypeLabel}} Destination{{else}}Add {{.DestTypeLabel}} Destination{{end}}

    -

    - {{if $isEdit}}Update the connection details below. Leave the API key / token blank to keep the existing value. - {{else}}Fill in the connection details. Audplexus will fan out post-download work to this destination.{{end}} -

    -
    +{{$modal := .ModalMode}} +
    +

    {{if $isEdit}}Edit {{.DestTypeLabel}} Destination{{else}}Add {{.DestTypeLabel}} Destination{{end}}

    +

    + {{if $isEdit}}Update the connection details below. Leave the API key / token blank to keep the existing value. + {{else}}Fill in the connection details. Audplexus will fan out post-download work to this destination.{{end}} +

    +
    - {{if .FormError}} - - {{end}} +{{if .FormError}} + +{{end}} -
    -
    -

    Connection

    -
    + {{if not $isEdit}}{{end}}
    @@ -409,7 +419,11 @@

    Connection

    - Cancel + {{if $modal}} + + {{else}} + Cancel + {{end}}
    -
    -
    +{{end}} +{{end}} + +{{define "content"}} +
    + +
    + {{template "destination_form_body" .}} +
    {{end}} diff --git a/internal/web/templates/destinations_new.html b/internal/web/templates/destinations_new.html index c4b9487..15ea41a 100644 --- a/internal/web/templates/destinations_new.html +++ b/internal/web/templates/destinations_new.html @@ -1,30 +1,41 @@ -{{define "content"}} -
    -
    -

    Add Library Destination

    -

    Pick the kind of server Audplexus should push to. The next step asks for the type-specific configuration.

    -
    +{{define "destination_picker_body"}} +{{/* + Type picker — shown both on the standalone /destinations/new page AND + inside the modal opened from the wizard, Destinations page, etc. + Forms post to /destinations/new (legacy full-page) or + /destinations/modal/form (modal mode); the chrome decides which. +*/}} +
    +
    + Choose a destination type +

    One click to continue to that destination's setup form.

    +
    + + + + +
    +
    + {{if not .ModalMode}} +
    + Cancel + Pick a destination type above. +
    + {{end}} +
    +{{end}} -
    -
    -

    Destination type

    -
    -
    - Choose a destination type -

    One click to continue to that destination's setup flow.

    -
    - - - - -
    -
    -
    - Cancel - Pick a destination type above. -
    -
    -
    -
    +{{define "content"}} +
    + +
    + {{template "destination_picker_body" .}} +
    {{end}} From d02d683057ac8b5860f2447f3de8401f37cc8efe Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:28:21 -0500 Subject: [PATCH 06/40] feat(diagnostics): logs & env tab + settings scroll-spy TOC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additive changes that share a commit because they're cosmetic finish-out for the last two pages in the DS pass. Diagnostics — adds a "Logs & Environment" tab alongside the existing Drift Inbox (which is unchanged): - diagnostics.html: tab strip at the top, .page wrapper, two tab-panels. New tab has a 2-col grid: live log tail (1024-line ring, 2s poll, level filter, pause toggle) on the left, Environment + Connection-tests cards on the right. - /api/diagnostics/env returns Go version, OS/arch, CPU count, paths, log level, marketplace, auth state, last sync. - /api/diagnostics/logs/tail?n= returns recent lines from the new in-memory ring buffer. - logging.go: 1024-entry ringBuffer teed into outputWriter() via io.MultiWriter. Existing stderr / ConsoleWriter output is unchanged. New TailLogs(n) + RingEntry exported. - logging_test.go: TestOutputWriterMode rewritten to verify the writer end-to-end (writes reach the ring buffer in both JSON and console modes); new TestTailLogsRingBufferOrder covers oldest-first ordering + eviction + n cap. Settings — wraps the existing 990-line template in DS chrome without removing any sections or fields: - settings.html: .page wrapper + .page-header, left-rail sticky .settings-nav with IntersectionObserver scroll-spy. Each existing
    tagged with an id/data-section attribute for jumpability — inner markup untouched. - Inline destinations section replaced with a "Manage destinations →" link to the new /destinations page. - New "Re-run Setup Wizard" danger-zone row that POSTs to /setup/restart (for testing the wizard without a full factory reset). Factory-reset description updated to mention the post-reset /setup redirect. /api/diagnostics route registrations shipped with the foundation commit. --- internal/logging/logging.go | 86 +++++++- internal/logging/logging_test.go | 61 +++++- internal/web/diagnostics_handlers.go | 53 +++++ internal/web/templates/diagnostics.html | 264 ++++++++++++++++++++++-- internal/web/templates/settings.html | 147 +++++++------ 5 files changed, 529 insertions(+), 82 deletions(-) diff --git a/internal/logging/logging.go b/internal/logging/logging.go index d3b4c9f..1084977 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -38,8 +38,85 @@ var ( globalLevel = zerolog.InfoLevel useJSONOutput = true levelMu sync.RWMutex + + // ringBuf is a small in-memory tail of recent log lines, exposed via + // the web UI's diagnostics tab. Capped to 1024 entries; older lines + // drop off the back. Threads through io.MultiWriter so all log output + // also still goes to stderr — this is additive, not a replacement. + ringBuf = newRingBuffer(1024) ) +// RingEntry is a single tail-buffer log line as captured by the +// in-memory ring buffer. Time is the time the line was written. +type RingEntry struct { + Time time.Time `json:"time"` + Line string `json:"line"` +} + +type ringBuffer struct { + mu sync.RWMutex + entries []RingEntry + head int + cap int + count int +} + +func newRingBuffer(capacity int) *ringBuffer { + return &ringBuffer{entries: make([]RingEntry, capacity), cap: capacity} +} + +// Write implements io.Writer. Splits on newlines so multi-line writes +// (rare from zerolog, but possible from ConsoleWriter) land as separate +// entries. Empty trailing tokens are dropped. +func (rb *ringBuffer) Write(p []byte) (int, error) { + now := time.Now() + lines := strings.Split(strings.TrimRight(string(p), "\n"), "\n") + rb.mu.Lock() + for _, l := range lines { + if l == "" { + continue + } + rb.entries[rb.head] = RingEntry{Time: now, Line: l} + rb.head = (rb.head + 1) % rb.cap + if rb.count < rb.cap { + rb.count++ + } + } + rb.mu.Unlock() + return len(p), nil +} + +// Snapshot returns the most recent n entries (oldest first). n=0 returns +// everything currently in the buffer. +func (rb *ringBuffer) Snapshot(n int) []RingEntry { + rb.mu.RLock() + defer rb.mu.RUnlock() + if rb.count == 0 { + return nil + } + if n <= 0 || n > rb.count { + n = rb.count + } + out := make([]RingEntry, 0, n) + // Oldest valid entry is (head - count) mod cap. + start := (rb.head - rb.count + rb.cap) % rb.cap + skip := rb.count - n + for i := 0; i < rb.count; i++ { + if i < skip { + continue + } + out = append(out, rb.entries[(start+i)%rb.cap]) + } + return out +} + +// TailLogs returns the most recent n lines captured by the in-memory +// ring buffer. n=0 returns everything currently buffered (up to 1024). +// Safe for concurrent use. +func TailLogs(n int) []RingEntry { + return ringBuf.Snapshot(n) +} + // Init configures the global logging defaults. Call once at startup. func Init(level string, jsonOutput bool) { levelMu.Lock() @@ -75,13 +152,16 @@ func setGlobalLogger(zl zerolog.Logger) { } func outputWriter() io.Writer { + // io.MultiWriter tees every line to the ring buffer in addition to + // stderr / ConsoleWriter, so /api/diagnostics/logs/tail can serve a + // recent-history snapshot without us having to scrape the docker log. if useJSONOutput { - return os.Stderr + return io.MultiWriter(os.Stderr, ringBuf) } - return zerolog.ConsoleWriter{ + return io.MultiWriter(zerolog.ConsoleWriter{ Out: os.Stderr, TimeFormat: "15:04:05", - } + }, ringBuf) } func (l *Logger) build() zerolog.Logger { diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go index ed97d44..056fbe2 100644 --- a/internal/logging/logging_test.go +++ b/internal/logging/logging_test.go @@ -2,7 +2,7 @@ package logging import ( "errors" - "os" + "strings" "testing" "github.com/rs/zerolog" @@ -43,15 +43,66 @@ func TestInitAndSetLevel(t *testing.T) { } func TestOutputWriterMode(t *testing.T) { + // outputWriter() now returns an io.MultiWriter that tees to both + // stderr (or ConsoleWriter) AND the in-memory ring buffer so the + // diagnostics page can serve a recent-log tail. The test asserts the + // mode-dependent behavior end-to-end: a JSON-mode write reaches the + // ring buffer as raw JSON, while console mode reaches it as the + // pretty-printed ConsoleWriter format. Both prove the writer is wired. + // Reset state BEFORE calling outputWriter(): the MultiWriter closes + // over the *current* ringBuf value, so swapping the package var + // after construction would write to a stale buffer. Also reset the + // zerolog global level — sibling tests (TestInitAndSetLevel) can + // leave it at "error", which would filter our Info() writes before + // they ever reach the writer. + ringBuf = newRingBuffer(16) + zerolog.SetGlobalLevel(zerolog.InfoLevel) useJSONOutput = true w := outputWriter() - if w != os.Stderr { - t.Fatalf("outputWriter() in json mode should return stderr") + if w == nil { + t.Fatalf("outputWriter() returned nil in json mode") + } + zl := zerolog.New(w).With().Timestamp().Logger() + zl.Info().Str("k", "v").Msg("json-mode") + jsonTail := ringBuf.Snapshot(0) + if len(jsonTail) == 0 || !strings.Contains(jsonTail[len(jsonTail)-1].Line, `"k":"v"`) { + t.Fatalf("json-mode writer did not reach ring buffer; tail=%v", jsonTail) } + ringBuf = newRingBuffer(16) useJSONOutput = false - if _, ok := outputWriter().(zerolog.ConsoleWriter); !ok { - t.Fatalf("outputWriter() in console mode should return zerolog.ConsoleWriter") + w2 := outputWriter() + if w2 == nil { + t.Fatalf("outputWriter() returned nil in console mode") + } + zl2 := zerolog.New(w2).With().Timestamp().Logger() + zl2.Info().Str("k", "v").Msg("console-mode") + consoleTail := ringBuf.Snapshot(0) + if len(consoleTail) == 0 || !strings.Contains(consoleTail[len(consoleTail)-1].Line, "console-mode") { + t.Fatalf("console-mode writer did not reach ring buffer; tail=%v", consoleTail) + } +} + +func TestTailLogsRingBufferOrder(t *testing.T) { + ringBuf = newRingBuffer(3) + // Three writes, no eviction yet. + _, _ = ringBuf.Write([]byte("a\n")) + _, _ = ringBuf.Write([]byte("b\n")) + _, _ = ringBuf.Write([]byte("c\n")) + got := TailLogs(0) + if len(got) != 3 || got[0].Line != "a" || got[2].Line != "c" { + t.Fatalf("expected oldest-first [a b c], got %+v", got) + } + // Fourth write evicts the oldest. + _, _ = ringBuf.Write([]byte("d\n")) + got = TailLogs(0) + if len(got) != 3 || got[0].Line != "b" || got[2].Line != "d" { + t.Fatalf("expected [b c d] after eviction, got %+v", got) + } + // Limit n. + got = TailLogs(2) + if len(got) != 2 || got[0].Line != "c" || got[1].Line != "d" { + t.Fatalf("expected last 2 = [c d], got %+v", got) } } diff --git a/internal/web/diagnostics_handlers.go b/internal/web/diagnostics_handlers.go index e408442..5d22a9d 100644 --- a/internal/web/diagnostics_handlers.go +++ b/internal/web/diagnostics_handlers.go @@ -10,13 +10,16 @@ import ( "net/url" "os" "path/filepath" + "runtime" "sort" + "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/mstrhakr/audplexus/internal/database" "github.com/mstrhakr/audplexus/internal/library" + "github.com/mstrhakr/audplexus/internal/logging" "github.com/mstrhakr/audplexus/internal/mediaserver" ) @@ -930,3 +933,53 @@ func normalizeDiagnosticsPathKey(p string) string { return strings.ToLower(strings.Join(parts, "/")) } + +// handleDiagnosticsEnv returns a JSON snapshot of runtime + path +// info for the DS-style "Logs & Environment" diagnostics tab. Read-only. +func (s *Server) handleDiagnosticsEnv(c *gin.Context) { + ctx := c.Request.Context() + marketplace := "us" + if stored, _ := s.db.GetSetting(ctx, "audible_marketplace"); strings.TrimSpace(stored) != "" { + marketplace = strings.TrimSpace(stored) + } else if creds := s.audible.GetCredentials(); creds != nil && creds.Marketplace != "" { + marketplace = creds.Marketplace + } + + var lastSyncOut gin.H + if last, err := s.db.GetLastSync(ctx); err == nil && last != nil { + lastSyncOut = gin.H{ + "started_at": last.StartedAt, + "completed_at": last.CompletedAt, + "status": last.Status, + "books_found": last.BooksFound, + "books_added": last.BooksAdded, + } + } + + c.JSON(http.StatusOK, gin.H{ + "go_version": runtime.Version(), + "os_arch": runtime.GOOS + "/" + runtime.GOARCH, + "num_cpu": runtime.NumCPU(), + "audiobooks_path": s.audiobooksPath, + "downloads_path": s.downloadsPath, + "config_path": s.configPath, + "log_level": logging.GetLevel(), + "authenticated": s.audible.IsAuthenticated(), + "marketplace": marketplace, + "last_sync": lastSyncOut, + "server_time": time.Now(), + }) +} + +// handleDiagnosticsLogsTail returns the most recent log lines from the +// in-memory ring buffer (capped at 1024). Polled by the diagnostics +// page every 2s while the Logs panel is visible. +func (s *Server) handleDiagnosticsLogsTail(c *gin.Context) { + n := 200 + if v := c.Query("n"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 && parsed <= 1024 { + n = parsed + } + } + c.JSON(http.StatusOK, gin.H{"entries": logging.TailLogs(n)}) +} diff --git a/internal/web/templates/diagnostics.html b/internal/web/templates/diagnostics.html index fdf5e4f..e53f0d5 100644 --- a/internal/web/templates/diagnostics.html +++ b/internal/web/templates/diagnostics.html @@ -1,23 +1,258 @@ {{define "content"}} -

    Diagnostics

    -

    Destination-first diagnostics for already-downloaded books. Resolution order: targeted scan first, redownload last resort.

    +
    + -
    -
    Loading diagnostics...
    -
    +
    + + +
    -
    - - - - -
    + {{/* ── Tab 1: Drift Inbox (existing tooling, unchanged) ── */}} +
    +

    Destination-first diagnostics for already-downloaded books. Resolution order: targeted scan first, redownload last resort.

    + +
    +
    Loading diagnostics...
    +
    + +
    + + + + +
    + +

    Issue Inbox

    +
    +

    Loading...

    +
    +
    + + {{/* ── Tab 2: Logs & Environment ──────────────────────────── */}} +
    + + + + + {{end}} From 8a33a15633b095493cb82b41d4a4243073492d0d Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:31:38 -0500 Subject: [PATCH 07/40] feat(ui): pipeline refresh with completed-window filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the Pipeline page around DS chrome while keeping all the existing tooling — including Worker Pools, which the DesignSystem mock dropped but the live app needs: - downloads.html: .page wrapper + page-header (Active/Waiting counts in the subtitle), Retry-All-Failed + Queue-pause toggle in the action cluster, the existing queue-paused-banner kept. - Worker Pools section retained above the tab strip — #pipeline-pools is still the target of the SSE renderer in base.html, no JS changes. - Tab strip: Active / Waiting / Failed / Completed. Each tab has an href that server-renders the same panel (so deep links + middle-click open work), with inline JS that toggles panel visibility without a round-trip when clicked. - Active tab keeps #pipeline-cards as the SSE insertion target so the live download cards keep working. Rows are pre-seeded server-side as .dl-card v2 so there's no empty flash before the first SSE tick. - Waiting / Failed tabs use .table-wrap + .tbl v2; per-row Cancel / Retry buttons preserved as HTMX hookups. - Completed tab is now windowed (24h default) with a 7d / 30d / All selector — completed history grows unbounded on a busy install, so scoping the default to "what just finished" makes the page actually useful again. "Showing N of M total completions" exposes the wider totals so users know what bigger windows would reveal. handleDownloads gains ?tab= + ?completed_window= query params. Filter helper filterCompletedByWindow falls back to UpdatedAt when CompletedAt is nil so legacy rows still surface under wider windows. --- internal/web/server.go | 112 +++++++++-- internal/web/templates/downloads.html | 265 ++++++++++++++++++-------- 2 files changed, 281 insertions(+), 96 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index 76b0b04..8a19f4e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1226,7 +1226,13 @@ func humanFileSize(sizeBytes int64) string { return fmt.Sprintf("%.1f %s", size, units[unitIdx]) } -// handleDownloads renders the download queue page. +// handleDownloads renders the pipeline page (Active / Waiting / Failed +// / Completed tabs over the worker-pool view). +// +// The Completed tab is windowed because completed history grows +// unbounded on a busy install — the UI is much more useful when scoped +// to "what just finished". Default window is 24h; supported values are +// 24h, 7d, 30d, all. Selected via ?completed_window=… func (s *Server) handleDownloads(c *gin.Context) { ctx := c.Request.Context() _, _ = s.clearStaleFailedDownloads(ctx) @@ -1236,31 +1242,111 @@ func (s *Server) handleDownloads(c *gin.Context) { pendingStatus := database.DownloadStatusPending pending, _ := s.db.ListDownloads(ctx, &pendingStatus) completeStatus := database.DownloadStatusComplete - complete, _ := s.db.ListDownloads(ctx, &completeStatus) + completeAll, _ := s.db.ListDownloads(ctx, &completeStatus) failedStatus := database.DownloadStatusFailed allFailed, _ := s.db.ListDownloads(ctx, &failedStatus) recentErrors := deduplicateFailedByASIN(allFailed) queueState := s.downloads.QueueState() - rowsForTitles := make([]database.DownloadQueue, 0, len(active)+len(pending)+len(complete)+len(allFailed)) + window := parseCompletedWindow(c.Query("completed_window")) + complete := filterCompletedByWindow(completeAll, window) + + rowsForTitles := make([]database.DownloadQueue, 0, len(active)+len(pending)+len(completeAll)+len(allFailed)) rowsForTitles = append(rowsForTitles, active...) rowsForTitles = append(rowsForTitles, pending...) - rowsForTitles = append(rowsForTitles, complete...) + rowsForTitles = append(rowsForTitles, completeAll...) rowsForTitles = append(rowsForTitles, allFailed...) + // Tab default is "active" — most common landing intent. Tab=completed + // shows the windowed slice; the other tabs ignore the window param. + tab := strings.ToLower(strings.TrimSpace(c.Query("tab"))) + switch tab { + case "active", "waiting", "failed", "completed": + default: + tab = "active" + } + c.HTML(http.StatusOK, "downloads.html", gin.H{ - "Active": active, - "Pending": pending, - "Complete": complete, - "RecentErrors": recentErrors, - "DownloadTitles": s.getDownloadTitles(ctx, rowsForTitles), - "QueuePaused": queueState.Paused, - "QueuePauseReason": queueState.Reason, - "QueuePausedAt": queueState.PausedAt, - "Page": "pipeline", + "Active": active, + "Pending": pending, + "Complete": complete, + "CompleteTotal": len(completeAll), + "RecentErrors": recentErrors, + "DownloadTitles": s.getDownloadTitles(ctx, rowsForTitles), + "QueuePaused": queueState.Paused, + "QueuePauseReason": queueState.Reason, + "QueuePausedAt": queueState.PausedAt, + "Page": "pipeline", + "ActiveTab": tab, + "CompletedWindow": window, + "CompletedWindowOpts": completedWindowOptions(), }) } +// completedWindowOption is a single option in the Completed tab's +// time-window selector. +type completedWindowOption struct { + Value string + Label string +} + +func completedWindowOptions() []completedWindowOption { + return []completedWindowOption{ + {Value: "24h", Label: "Last 24 hours"}, + {Value: "7d", Label: "Last 7 days"}, + {Value: "30d", Label: "Last 30 days"}, + {Value: "all", Label: "All time"}, + } +} + +// parseCompletedWindow normalizes the ?completed_window= value. Anything +// unrecognized falls back to "24h" so the Completed tab never shows the +// firehose of all-time history on a casual click-through. +func parseCompletedWindow(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "7d": + return "7d" + case "30d": + return "30d" + case "all": + return "all" + default: + return "24h" + } +} + +// filterCompletedByWindow returns the subset of completed download rows +// whose CompletedAt timestamp falls within the chosen window. "all" +// returns the input as-is. Rows with a nil CompletedAt fall back to +// UpdatedAt so legacy rows that never recorded a completion stamp still +// show up under wider windows. +func filterCompletedByWindow(rows []database.DownloadQueue, window string) []database.DownloadQueue { + if window == "all" { + return rows + } + var cutoff time.Duration + switch window { + case "7d": + cutoff = 7 * 24 * time.Hour + case "30d": + cutoff = 30 * 24 * time.Hour + default: + cutoff = 24 * time.Hour + } + threshold := time.Now().Add(-cutoff) + out := make([]database.DownloadQueue, 0, len(rows)) + for _, r := range rows { + ts := r.UpdatedAt + if r.CompletedAt != nil { + ts = *r.CompletedAt + } + if ts.After(threshold) { + out = append(out, r) + } + } + return out +} + // deduplicateFailedByASIN returns at most one entry per ASIN from a slice of // failed download queue rows, keeping the most recently updated entry. func deduplicateFailedByASIN(rows []database.DownloadQueue) []database.DownloadQueue { diff --git a/internal/web/templates/downloads.html b/internal/web/templates/downloads.html index 226a423..e8f2246 100644 --- a/internal/web/templates/downloads.html +++ b/internal/web/templates/downloads.html @@ -1,83 +1,182 @@ -{{define "content"}} -

    Pipeline

    - -
    - - - -
    - -
    - Queue paused: {{.QueuePauseReason}} -
    - -

    Worker Pools

    -
    - -

    Active Pipeline

    -
    - {{range .Active}} -
    -
    - {{$title := index $.DownloadTitles .ASIN}}{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}} - downloading {{printf "%.0f" (mul .Progress 100)}}% -
    -
    -
    -
    -
    -
    - {{else}} -

    No active downloads

    - {{end}} -
    - -

    Pending ({{len .Pending}})

    - - - - {{range .Pending}} - - - - - - - {{else}} - - {{end}} - -
    ASINPriorityQueuedActions
    {{.ASIN}}{{.Priority}}{{formatDate .CreatedAt}}
    None
    - -

    Completed ({{len .Complete}})

    - - - - {{range .Complete}} - - - - - - {{else}} - - {{end}} - -
    ASINTitleCompleted
    {{.ASIN}}{{$title := index $.DownloadTitles .ASIN}}{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}}{{if .CompletedAt}}{{formatDate (deref .CompletedAt)}}{{end}}
    None
    - -

    Recent Errors ({{len .RecentErrors}})

    - - - - {{range .RecentErrors}} - - - - - - {{else}} - - {{end}} - -
    TitleErrorActions
    {{with index $.DownloadTitles .ASIN}}{{.}}{{else}}{{.ASIN}}{{end}}{{.Error}}
    None
    -{{end}} +{{define "content"}} +{{$tab := .ActiveTab}} +{{$window := .CompletedWindow}} +
    + + +
    + Queue paused: {{.QueuePauseReason}} +
    + + {{/* Worker Pools — kept from the live app (DesignSystem omitted these + but they're useful operationally so we don't drop them). + #pipeline-pools is populated by the SSE renderer in base.html. */}} +
    Worker pools
    +
    + + {{/* Tab strip — Active / Waiting / Failed / Completed. Each tab is + server-rendered (?tab=) so links are shareable, but the JS at + the bottom toggles panels without a round-trip when clicked. */}} + + + {{/* Active tab — live SSE-driven pipeline cards. #pipeline-cards is + the target of the renderPipelineCard JS in base.html, so the ID + must stay. We seed it server-side with whatever's active right + now to avoid the empty flash before the first SSE tick. */}} +
    +
    + {{range .Active}} +
    +
    +
    +
    {{$title := index $.DownloadTitles .ASIN}}{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}}
    +
    {{.ASIN}}
    +
    + downloading + {{printf "%.0f" (mul .Progress 100)}}% +
    +
    +
    +
    +
    + {{else}} +
    No active downloads
    + {{end}} +
    +
    + + {{/* Waiting tab — queued items not yet pulled by a download worker. */}} +
    +
    + + + + {{range .Pending}} + + + + + + + + {{else}} + + {{end}} + +
    TitleASINPriorityQueuedActions
    {{$title := index $.DownloadTitles .ASIN}}{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}}{{.ASIN}}{{.Priority}}{{formatDate .CreatedAt}} + +
    No queued items
    +
    +
    + + {{/* Failed tab — dedup'd by ASIN. Each row has a Retry button. */}} +
    +
    + + + + {{range .RecentErrors}} + + + + + + + {{else}} + + {{end}} + +
    TitleASINErrorActions
    {{with index $.DownloadTitles .ASIN}}{{.}}{{else}}{{.ASIN}}{{end}}{{.ASIN}}{{.Error}} + +
    No failed items
    +
    +
    + + {{/* Completed tab — windowed (24h default). The window select is a + plain GET form so the URL stays bookmarkable. + CompleteTotal is shown next to the window label so users can + see how many rows the wider windows would surface. */}} +
    +
    +
    + + + + + Showing {{len .Complete}} of {{.CompleteTotal}} total completions + +
    +
    +
    + + + + {{range .Complete}} + + + + + + {{else}} + + {{end}} + +
    TitleASINCompleted
    {{$title := index $.DownloadTitles .ASIN}}{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}}{{.ASIN}}{{if .CompletedAt}}{{formatDate (deref .CompletedAt)}}{{else}}{{formatDate .UpdatedAt}}{{end}}
    No completions in this window
    +
    +
    +
    + + +{{end}} From 30d501505c9bf8179c7eae3f5bb15957b904540e Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:41:17 -0500 Subject: [PATCH 08/40] feat(ui): sidebar icons, badges, and footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches the sidebar up with the DesignSystem spec. Adds: - Icons (inline SVGs, no external dep) for each nav link. - Count badges: Library shows new-book count (muted style), Pipeline shows active-download count (accent), Destinations shows failed- destination count (danger). Each badge hides when its count is zero. - Footer with a health dot, version (pulled from runtime/debug build info; "dev" in non-release builds), and uptime ("6d 14h"). - Active-link styling now matches the DS: 7px-rounded pill with an accent left bar. Server.startedAt set at NewServer construction; computeSidebar runs a handful of LIMIT-1 counts per full-page render (cheap, and the dash/ pipeline pages refresh their numbers on their own polls anyway). withSidebar(ctx, data) merges the snapshot into every full-page render's template data — HTMX fragment responses skip base.html so they're unaffected. CSS: .nav-icon, .nav-badge (default/muted/danger), .sidebar-footer + .health-dot (with warning variant). Existing .nav-links li a restyled from block → flex with icon+label+badge layout. --- internal/web/destinations_handlers.go | 26 +++--- internal/web/diagnostics_handlers.go | 5 +- internal/web/server.go | 116 ++++++++++++++++++++++++-- internal/web/setup_handlers.go | 2 +- internal/web/static/style.css | 84 ++++++++++++++++++- internal/web/templates/base.html | 55 ++++++++++-- 6 files changed, 258 insertions(+), 30 deletions(-) diff --git a/internal/web/destinations_handlers.go b/internal/web/destinations_handlers.go index 8337603..b8a5ed6 100644 --- a/internal/web/destinations_handlers.go +++ b/internal/web/destinations_handlers.go @@ -122,18 +122,19 @@ func (s *Server) handleDestinations(c *gin.Context) { data["Destinations"] = dests data["HealthyCount"] = healthy data["TotalCount"] = len(dests) - c.HTML(http.StatusOK, "destinations.html", data) + c.HTML(http.StatusOK, "destinations.html", s.withSidebar(ctx, data)) } // handleDestinationsNewPicker renders the type-picker page (step 1 of 2 // in the add flow). Two-page server flow rather than a JS-toggled single // form — simpler, validation cleaner, no JS dependency. func (s *Server) handleDestinationsNewPicker(c *gin.Context) { - data := s.authBaseData(c.Request.Context()) + ctx := c.Request.Context() + data := s.authBaseData(ctx) data["Page"] = "destinations_new_picker" data["PickerAction"] = "/destinations/new" data["ModalMode"] = false - c.HTML(http.StatusOK, "destinations_new.html", data) + c.HTML(http.StatusOK, "destinations_new.html", s.withSidebar(ctx, data)) } // handleDestinationsModalPicker returns just the type-picker body (no @@ -177,11 +178,12 @@ func (s *Server) handleDestinationsNewForm(c *gin.Context) { s.renderAuthPage(c, http.StatusBadRequest, gin.H{"Error": "Pick a destination type."}) return } - data := s.authBaseData(c.Request.Context()) + ctx := c.Request.Context() + data := s.authBaseData(ctx) data["Page"] = "destinations_new_form" data["DestType"] = t data["DestTypeLabel"] = destinationTypeLabel(database.LibraryDestinationType(t)) - c.HTML(http.StatusOK, "destinations_form.html", data) + c.HTML(http.StatusOK, "destinations_form.html", s.withSidebar(ctx, data)) } // handleDestinationTest performs a live health check against the @@ -1058,18 +1060,19 @@ func (s *Server) uniqueDisplayName(ctx context.Context, candidate, excludeID str // values (PlexToken, APIKey) are NOT prefilled into the template — leaving // the field blank means "keep existing"; entering a new value rotates. func (s *Server) handleDestinationEditForm(c *gin.Context) { + ctx := c.Request.Context() id := c.Param("id") - row, err := s.db.GetLibraryDestination(c.Request.Context(), id) + row, err := s.db.GetLibraryDestination(ctx, id) if err != nil || row == nil { s.renderAuthPage(c, http.StatusNotFound, gin.H{"Error": "Destination not found."}) return } - data := s.authBaseData(c.Request.Context()) + data := s.authBaseData(ctx) data["Page"] = "destinations_edit" data["DestType"] = string(row.Type) data["DestTypeLabel"] = destinationTypeLabel(row.Type) data["Dest"] = row // template uses Dest.DisplayName, .URL, .LibraryID, .PlexSectionID - c.HTML(http.StatusOK, "destinations_form.html", data) + c.HTML(http.StatusOK, "destinations_form.html", s.withSidebar(ctx, data)) } // handleDestinationUpdate persists an edit. Sensitive fields (PlexToken, @@ -1137,8 +1140,9 @@ func (s *Server) handleDestinationToggle(c *gin.Context) { // - first POST (no `confirm` field) renders the confirmation page // - second POST (confirm=1, set by the confirmation page's submit) deletes func (s *Server) handleDestinationDelete(c *gin.Context) { + ctx := c.Request.Context() id := c.Param("id") - d, err := s.db.GetLibraryDestination(c.Request.Context(), id) + d, err := s.db.GetLibraryDestination(ctx, id) if err != nil || d == nil { s.renderAuthPage(c, http.StatusNotFound, gin.H{"Error": "Destination not found."}) return @@ -1146,7 +1150,7 @@ func (s *Server) handleDestinationDelete(c *gin.Context) { if c.PostForm("confirm") != "1" { // First POST: render the confirmation page. - data := s.authBaseData(c.Request.Context()) + data := s.authBaseData(ctx) data["Page"] = "destinations_delete" data["Dest"] = destinationView{ ID: d.ID, @@ -1154,7 +1158,7 @@ func (s *Server) handleDestinationDelete(c *gin.Context) { Type: string(d.Type), TypeLabel: destinationTypeLabel(d.Type), } - c.HTML(http.StatusOK, "destinations_delete.html", data) + c.HTML(http.StatusOK, "destinations_delete.html", s.withSidebar(ctx, data)) return } diff --git a/internal/web/diagnostics_handlers.go b/internal/web/diagnostics_handlers.go index 5d22a9d..40625ae 100644 --- a/internal/web/diagnostics_handlers.go +++ b/internal/web/diagnostics_handlers.go @@ -85,14 +85,15 @@ type diagnosticsDestinationInventory struct { } func (s *Server) handleDiagnostics(c *gin.Context) { + ctx := c.Request.Context() marketplace := "us" if creds := s.audible.GetCredentials(); creds != nil && creds.Marketplace != "" { marketplace = creds.Marketplace } - c.HTML(http.StatusOK, "diagnostics.html", gin.H{ + c.HTML(http.StatusOK, "diagnostics.html", s.withSidebar(ctx, gin.H{ "Page": "diagnostics", "UserMarketplace": marketplace, - }) + })) } func (s *Server) handleDiagnosticsCompare(c *gin.Context) { diff --git a/internal/web/server.go b/internal/web/server.go index 8a19f4e..8204f43 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -16,6 +16,7 @@ import ( "net/netip" "os" "path/filepath" + "runtime/debug" "sort" "strconv" "strings" @@ -58,6 +59,10 @@ type Server struct { audiobooksPath string downloadsPath string configPath string + // startedAt is set at construction and used by the sidebar footer + // to render uptime ("6d 14h"). Not used for any business logic, so + // no need to plumb it through; reads from time.Since(s.startedAt). + startedAt time.Time // destItemCountCache caches per-destination LibraryItemCount results // (30s TTL) so the dashboard's 12s poll doesn't hammer remote servers. // Keyed by destination ID; entries expire independently. @@ -104,6 +109,7 @@ func NewServer( audiobooksPath: audiobooksPath, downloadsPath: downloadsPath, configPath: configPath, + startedAt: time.Now(), } // Wire up the media-server sync callbacks. With multi-destination, the @@ -557,7 +563,7 @@ func normalizeClientIPForLog(ip string) string { func (s *Server) handleDashboard(c *gin.Context) { ctx := c.Request.Context() _, _ = s.clearStaleFailedDownloads(ctx) - c.HTML(http.StatusOK, "dashboard.html", s.getDashboardData(ctx)) + c.HTML(http.StatusOK, "dashboard.html", s.withSidebar(ctx, s.getDashboardData(ctx))) } func (s *Server) getDashboardData(ctx context.Context) gin.H { @@ -881,7 +887,7 @@ func (s *Server) handleLibrary(c *gin.Context) { c.HTML(http.StatusOK, "library_table.html", data) return } - c.HTML(http.StatusOK, "library.html", data) + c.HTML(http.StatusOK, "library.html", s.withSidebar(ctx, data)) } // libraryStatusCounts returns the count of books per status bucket used by @@ -1266,7 +1272,7 @@ func (s *Server) handleDownloads(c *gin.Context) { tab = "active" } - c.HTML(http.StatusOK, "downloads.html", gin.H{ + c.HTML(http.StatusOK, "downloads.html", s.withSidebar(ctx, gin.H{ "Active": active, "Pending": pending, "Complete": complete, @@ -1280,7 +1286,7 @@ func (s *Server) handleDownloads(c *gin.Context) { "ActiveTab": tab, "CompletedWindow": window, "CompletedWindowOpts": completedWindowOptions(), - }) + })) } // completedWindowOption is a single option in the Completed tab's @@ -1408,8 +1414,9 @@ func (s *Server) clearStaleFailedDownloads(ctx context.Context) (int, error) { // handleSettings renders the settings page. func (s *Server) handleSettings(c *gin.Context) { - data := s.settingsPageData(c.Request.Context()) - c.HTML(http.StatusOK, "settings.html", data) + ctx := c.Request.Context() + data := s.settingsPageData(ctx) + c.HTML(http.StatusOK, "settings.html", s.withSidebar(ctx, data)) } // handleTagProfileSelect persists the active tag profile. Strict validation @@ -1525,6 +1532,98 @@ func (s *Server) settingsPageData(ctx context.Context) gin.H { return data } +// sidebarData is the per-render snapshot used by the sidebar in base.html: +// the badge counts, version, and uptime string. Computed cheaply per full- +// page render (a handful of LIMIT 1 / len() queries) — for HTMX fragment +// responses the value is omitted since base.html isn't part of the swap. +type sidebarData struct { + NewBooks int + ActiveDL int + FailedDestAlert int + Version string + Uptime string + Healthy bool // true when no failed destinations and Audible auth is good +} + +// computeSidebar gathers the fields the sidebar template renders. Cheap +// enough to call per full-page request (a few LIMIT-1 counts), but the +// numbers are stale across the lifetime of the page; the dashboard and +// pipeline pages refresh them on their HTMX polls anyway. +func (s *Server) computeSidebar(ctx context.Context) sidebarData { + out := sidebarData{ + Version: serverVersion(), + Uptime: formatUptime(time.Since(s.startedAt)), + } + + newStatus := database.BookStatusNew + _, newCount, _ := s.db.ListBooks(ctx, database.BookFilter{Status: &newStatus, Limit: 1}) + out.NewBooks = newCount + + activeStatus := database.DownloadStatusActive + activeDL, _ := s.db.ListDownloads(ctx, &activeStatus) + out.ActiveDL = len(activeDL) + + // Failed-destination alert count drives the danger-styled badge on the + // Destinations nav link. We look at the saved health column rather + // than re-probing remotes — the dashboard's destination summaries do + // the live probing on their own poll. + if rows, err := s.db.ListLibraryDestinations(ctx); err == nil { + for _, r := range rows { + if !r.Enabled { + continue + } + if r.LastHealthCheckOK != nil && !*r.LastHealthCheckOK { + out.FailedDestAlert++ + } + } + } + + out.Healthy = out.FailedDestAlert == 0 && s.audible.IsAuthenticated() + return out +} + +// serverVersion returns a printable version string. Pulled from the Go +// build info — in a release build this is the module version tag; in +// "go run" / dev builds it's "(devel)". Trimmed to something compact. +func serverVersion() string { + if info, ok := debug.ReadBuildInfo(); ok { + v := info.Main.Version + if v != "" && v != "(devel)" { + return v + } + } + return "dev" +} + +// formatUptime returns a compact "6d 14h" / "3h 12m" / "47s" string. +func formatUptime(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + h := int(d / time.Hour) + m := int(d/time.Minute) - h*60 + return fmt.Sprintf("%dh %dm", h, m) + } + days := int(d / (24 * time.Hour)) + h := int(d/time.Hour) - days*24 + return fmt.Sprintf("%dd %dh", days, h) +} + +// withSidebar merges the sidebar snapshot into the page data map. Use on +// every full-page render so base.html can render the nav badges + footer. +// Safe to call with a nil/empty map. +func (s *Server) withSidebar(ctx context.Context, data gin.H) gin.H { + if data == nil { + data = gin.H{} + } + data["Sidebar"] = s.computeSidebar(ctx) + return data +} + // settingBool reads a boolean setting from DB, returning the given default // when the key is absent (empty string). func (s *Server) settingBool(ctx context.Context, key string, defaultVal bool) bool { @@ -2482,13 +2581,14 @@ func (s *Server) authBaseData(ctx context.Context) gin.H { } func (s *Server) renderAuthPage(c *gin.Context, status int, extra gin.H) { - data := s.settingsPageData(c.Request.Context()) + ctx := c.Request.Context() + data := s.settingsPageData(ctx) data["FocusSection"] = "auth" for k, v := range extra { data[k] = v } data["Page"] = "settings" - c.HTML(status, "settings.html", data) + c.HTML(status, "settings.html", s.withSidebar(ctx, data)) } // handleAudibleMarketplaceSelect updates the preferred Audible marketplace. diff --git a/internal/web/setup_handlers.go b/internal/web/setup_handlers.go index 8c332ed..f5b45c5 100644 --- a/internal/web/setup_handlers.go +++ b/internal/web/setup_handlers.go @@ -81,7 +81,7 @@ func (s *Server) handleSetupWizard(c *gin.Context) { } } - c.HTML(http.StatusOK, "setup.html", data) + c.HTML(http.StatusOK, "setup.html", s.withSidebar(ctx, data)) } // handleSetupMarketplace persists the marketplace selection submitted by diff --git a/internal/web/static/style.css b/internal/web/static/style.css index f76494d..65852a8 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -117,11 +117,16 @@ body { } .nav-links li a { - display: block; - padding: 0.75rem 1.25rem; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.55rem 1rem; + margin: 0 0.5rem; + border-radius: 7px; color: var(--text-muted); text-decoration: none; transition: background 0.2s, color 0.2s; + position: relative; } .nav-links li a:hover, @@ -130,6 +135,81 @@ body { color: var(--text); } +.nav-links li a.active { font-weight: 600; } +.nav-links li a.active::before { + content: ""; + position: absolute; + left: -0.5rem; + top: 8px; + bottom: 8px; + width: 3px; + background: var(--accent); + border-radius: 0 3px 3px 0; +} + +.nav-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + opacity: 0.85; +} + +.nav-badge { + margin-left: auto; + font-size: 0.65rem; + padding: 1px 6px; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-weight: 600; + min-width: 18px; + text-align: center; + line-height: 1.3; +} +.nav-badge.muted { + background: var(--surface-2); + color: var(--text-muted); + border: 1px solid var(--border); +} +.nav-badge.danger { + background: var(--error); + color: #fff; +} + +/* Footer (health dot + version + uptime) */ +.sidebar { display: flex; flex-direction: column; } +.sidebar-footer { + margin-top: auto; + padding: 0.75rem 1rem; + border-top: 1px solid var(--border); + display: flex; + align-items: center; + gap: 0.6rem; +} +.sidebar-footer .health-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--success); + box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.18); + flex-shrink: 0; +} +.sidebar-footer .health-dot-warn { + background: var(--warning); + box-shadow: 0 0 0 3px rgba(255, 152, 0, 0.18); +} +.sidebar-footer .meta { + font-size: 0.72rem; + color: var(--text-muted); + line-height: 1.3; + min-width: 0; +} +.sidebar-footer .meta strong { + color: var(--text); + font-size: 0.76rem; + display: block; +} + /* Main column (right of sidebar) */ .main { margin-left: 220px; diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index d0019fd..3e9f096 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -10,6 +10,7 @@ + {{$sb := .Sidebar}}
    + {{if $sb}} + + {{end}}
    {{$pageLabels := dict "dashboard" "Dashboard" "library" "Library" "pipeline" "Pipeline" "destinations" "Destinations" "destinations_new_picker" "Destinations" "destinations_new_form" "Destinations" "diagnostics" "Diagnostics" "settings" "Settings"}} From 205e50b976f9f563155842485ce288efea4c6983 Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:43:23 -0500 Subject: [PATCH 09/40] feat(ui): book detail modal restyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyles the book detail modal (book_detail_panel.html) to the DS 2-column layout. Same data, much denser presentation: - Left column: cover image with a stacked action group below (Queue Download / Retry, Convert m4b↔mp3, Delete Files, Open on Audible). All existing HTMX hookups preserved. - Right column: title + series sub-line, status-and-on-disk badge row, .meta-grid
    (Author/Narrator/Duration/Released/Purchased/ Publisher/ASIN/Folder), per-destination sync list, files-on-disk
    , and description with safe-HTML rendering. - Per-destination sync list is new — joins enabled destinations with the per-(book, dest) sync table and renders each row with a chip, display name, and a state badge (Synced/Syncing/Pending/Failed/ Orphaned/Removed). Destinations the book hasn't touched yet show as "Pending" so users can see where the book hasn't landed. handleBookDetail gains BookDestinationStatuses via a new bookDestinationStatuses helper that maps database.BookDestinationSyncState into a small UI vocabulary. CSS: .book-detail switched from flex to grid (220px + 1fr), new .cover-wrap / .cover-actions / .badge-row / .meta-grid / .section-label / .dest-status-list rules. --- internal/web/server.go | 62 +++++++++ internal/web/static/style.css | 85 ++++++++++-- internal/web/templates/book_detail_panel.html | 126 ++++++++++++------ 3 files changed, 219 insertions(+), 54 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index 8204f43..995645d 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1080,6 +1080,7 @@ func (s *Server) handleBookDetail(c *gin.Context) { "BookFiles": files, "BookFileCount": len(files), "BookAction": buildLibraryBookActions([]database.Book{*book}, s.settingBool(ctx, library.SettingKeyAutoQueueNewBooks, false))[book.ID], + "BookDestinationStatuses": s.bookDestinationStatuses(ctx, book.ID), } if c.Query("view") == "modal" || c.GetHeader("HX-Request") == "true" { @@ -1532,6 +1533,67 @@ func (s *Server) settingsPageData(ctx context.Context) gin.H { return data } +// bookDestinationStatusView is one row in the per-destination sync list +// rendered on the book detail modal. Sync state is mapped to a small +// vocabulary the template can render as a badge. +type bookDestinationStatusView struct { + ID string + DisplayName string + Type string + TypeLabel string + State string // "synced" | "syncing" | "pending" | "failed" | "orphaned" | "removed" | "not_configured" + Label string // human label for the state badge +} + +// bookDestinationStatuses joins enabled destinations with the per-book +// sync table. Destinations that haven't been touched for this book yet +// surface as "Pending" — gives the user a clear picture of where the +// book has and hasn't landed. +func (s *Server) bookDestinationStatuses(ctx context.Context, bookID int64) []bookDestinationStatusView { + rows, err := s.db.ListLibraryDestinations(ctx) + if err != nil || len(rows) == 0 { + return nil + } + syncs, _ := s.db.GetBookDestinations(ctx, bookID) + byDestID := make(map[string]database.BookDestination, len(syncs)) + for _, s := range syncs { + byDestID[s.DestinationID] = s + } + + out := make([]bookDestinationStatusView, 0, len(rows)) + for _, r := range rows { + if !r.Enabled { + continue + } + v := bookDestinationStatusView{ + ID: r.ID, + DisplayName: r.DisplayName, + Type: string(r.Type), + TypeLabel: destinationTypeLabel(r.Type), + } + if sync, ok := byDestID[r.ID]; ok { + switch sync.SyncState { + case database.BookDestSyncSynced: + v.State, v.Label = "synced", "Synced" + case database.BookDestSyncSyncing: + v.State, v.Label = "syncing", "Syncing" + case database.BookDestSyncFailed: + v.State, v.Label = "failed", "Failed" + case database.BookDestSyncOrphaned: + v.State, v.Label = "orphaned", "Orphaned" + case database.BookDestSyncRemovedFromDestination: + v.State, v.Label = "removed", "Removed" + default: + v.State, v.Label = "pending", "Pending" + } + } else { + v.State, v.Label = "pending", "Pending" + } + out = append(out, v) + } + return out +} + // sidebarData is the per-render snapshot used by the sidebar in base.html: // the badge counts, version, and uptime string. Computed cheaply per full- // page render (a handful of LIMIT 1 / len() queries) — for HTMX fragment diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 65852a8..62e98b5 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -1298,19 +1298,84 @@ h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); } /* Book detail */ .book-detail { + display: grid; + grid-template-columns: 220px 1fr; + gap: 1.75rem; + background: transparent; + border: 0; + border-radius: 0; + padding: 0; +} +.book-detail .cover-wrap { display: flex; flex-direction: column; gap: 0.75rem; } +.book-detail .cover-wrap img { + width: 100%; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + background: linear-gradient(135deg, #0f3460, #16213e); +} +.book-detail .cover-actions { display: flex; - gap: 2rem; - background: var(--surface); + flex-direction: column; + gap: 0.4rem; +} +.book-detail .cover-actions .btn { width: 100%; justify-content: center; } +.book-detail h2 { + font-size: 1.35rem; + font-weight: 700; + line-height: 1.2; + margin: 0 0 0.4rem; +} +.book-detail .series { + color: var(--accent); + font-style: italic; + font-size: 0.9rem; + margin-bottom: 1rem; +} +.book-detail .badge-row { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} +.book-detail .meta-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.3rem 1rem; + font-size: 0.85rem; + margin: 1rem 0; +} +.book-detail .meta-grid dt { color: var(--text-muted); font-weight: 500; } +.book-detail .meta-grid dd { color: var(--text); margin: 0; } +.book-detail .section-label { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.07em; + margin: 1.25rem 0 0.5rem; +} +.book-detail .dest-status-list { + list-style: none; + padding: 0; + margin: 0.5rem 0 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.82rem; +} +.book-detail .dest-status-list li { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.4rem 0.6rem; + background: var(--bg); border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1.5rem; + border-radius: 6px; } - -.book-cover img { max-width: 250px; border-radius: var(--radius); } -.book-info h2 { margin: 0 0 0.5rem; } -.book-info p { margin: 0.35rem 0; color: var(--text-muted); } -.book-info strong { color: var(--text); } -.book-actions { margin: 1rem 0; } +.book-detail .dest-status-list .pull-right { margin-left: auto; } +.book-detail .desc { color: var(--text); line-height: 1.65; font-size: 0.9rem; margin-top: 1.5rem; } +.book-detail .desc p { margin: 0.55rem 0; } +.book-actions { margin: 1rem 0 1.25rem; display: flex; gap: 0.5rem; flex-wrap: wrap; } .description { margin-top: 1.5rem; } .description p { line-height: 1.6; } .series { color: var(--accent); font-style: italic; } diff --git a/internal/web/templates/book_detail_panel.html b/internal/web/templates/book_detail_panel.html index 18de439..72fb4f6 100644 --- a/internal/web/templates/book_detail_panel.html +++ b/internal/web/templates/book_detail_panel.html @@ -1,26 +1,90 @@ {{define "book_detail_panel.html"}}
    {{if .Message}} -
    {{.Message}}
    +
    + +
    {{.Message}}
    +
    {{end}} -
    - {{if .Book.CoverURL}}Cover{{end}} + +
    + {{if .Book.CoverURL}} + Cover for {{.Book.Title}} + {{else}} + + {{end}} +
    + {{if eq (printf "%s" .Book.Status) "new"}} + + {{else if eq (printf "%s" .Book.Status) "failed"}} + + {{end}} + + {{if .BookAction.ShowConvert}} + + {{end}} + + {{if .BookAction.ShowDelete}} + + {{end}} + + {{if .Book.ASIN}} + Open on Audible + {{end}} +
    -
    + +

    {{.Book.Title}}

    - {{if .Book.Series}}

    {{.Book.Series}} #{{.Book.SeriesPosition}}

    {{end}} -

    Author: {{.Book.Author}}

    -

    Narrator: {{.Book.Narrator}}

    -

    Publisher: {{.Book.Publisher}}

    -

    Duration: {{formatDuration .Book.Duration}}

    -

    Purchased: {{formatDate .Book.PurchaseDate}}

    -

    Released: {{formatDate .Book.ReleaseDate}}

    -

    Status: {{.Book.Status}}

    -

    ASIN: {{.Book.ASIN}}

    - {{if .BookFolderPath}}

    Folder: {{.BookFolderPath}}

    {{end}} + {{if .Book.Series}}
    {{.Book.Series}} · Book {{.Book.SeriesPosition}}
    {{end}} + +
    + {{.Book.Status}} + {{if .Book.FilePath}}On disk{{end}} +
    + +
    +
    Author
    {{.Book.Author}}
    +
    Narrator
    {{if .Book.Narrator}}{{.Book.Narrator}}{{else}}—{{end}}
    +
    Duration
    {{formatDuration .Book.Duration}}
    +
    Released
    {{formatDate .Book.ReleaseDate}}
    +
    Purchased
    {{formatDate .Book.PurchaseDate}}
    +
    Publisher
    {{if .Book.Publisher}}{{.Book.Publisher}}{{else}}—{{end}}
    +
    ASIN
    {{.Book.ASIN}}
    + {{if .BookFolderPath}}
    Folder
    {{.BookFolderPath}}
    {{end}} +
    + + {{if .BookDestinationStatuses}} + +
      + {{range .BookDestinationStatuses}} +
    • + {{firstTwo .TypeLabel}} + {{.DisplayName}} + + {{if eq .State "synced"}}{{.Label}} + {{else if eq .State "syncing"}}{{.Label}} + {{else if eq .State "failed"}}{{.Label}} + {{else if eq .State "orphaned"}}{{.Label}} + {{else if eq .State "removed"}}{{.Label}} + {{else}}{{.Label}}{{end}} + +
    • + {{end}} +
    + {{end}} + {{if gt .BookFileCount 0}} +
    - Show files and details ({{.BookFileCount}}) + Show files and details @@ -44,38 +108,12 @@

    {{.Book.Title}}

    {{else if .Book.FilePath}} -

    Files: Unable to list files from disk right now.

    +

    Unable to list files from disk right now.

    {{end}} -
    - {{if eq (printf "%s" .Book.Status) "new"}} - - {{end}} - {{if and (eq (printf "%s" .Book.Status) "complete") .Book.FilePath}} - {{if hasSuffix .Book.FilePath ".m4b"}} - - {{else}} - - {{end}} - {{end}} - {{if .BookAction.ShowDelete}} - - {{end}} -
    - {{if .Book.Description}} -
    -

    Description

    +
    +
    {{safeHTML .Book.Description}}
    {{end}} From 9fac11141fd468bda5418ffe0ec81957d83970bf Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:43:59 -0500 Subject: [PATCH 10/40] feat(ui): destinations delete confirm page refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the standalone delete-confirm page in line with the DS chrome — .page wrapper + page-header, card + .alert err for the warning, DS button variants. The Cancel link now points to /destinations (the new top-level page) instead of /settings#library-destinations. Most users will hit the inline confirm() dialog from the destinations card grid, so this page is mostly a fallback / direct-URL nav path, but it now matches the rest of the app visually. --- .../web/templates/destinations_delete.html | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/internal/web/templates/destinations_delete.html b/internal/web/templates/destinations_delete.html index 78d20e2..19efab1 100644 --- a/internal/web/templates/destinations_delete.html +++ b/internal/web/templates/destinations_delete.html @@ -1,27 +1,34 @@ {{define "content"}} -
    -
    -

    Delete Library Destination

    -
    +
    + -
    -
    -

    Delete "{{.Dest.DisplayName}}"?

    -

    - This removes the {{.Dest.TypeLabel}} destination from Audplexus. - Your media files and the {{.Dest.TypeLabel}} server itself are not touched. - Audplexus will stop fanning out post-download work to this destination. -

    -

    This cannot be undone. The per-(book, destination) sync history for this destination is also deleted.

    - -
    - -
    - Cancel - +
    +
    + ! +
    + Delete "{{.Dest.DisplayName}}"? +
    + This removes the {{.Dest.TypeLabel}} destination from Audplexus. + Your media files and the {{.Dest.TypeLabel}} server itself are not touched — + Audplexus will simply stop fanning out post-download work here. +
    +
    + This cannot be undone. The per-(book, destination) sync + history for this destination is also deleted.
    - -
    -
    +
    +
    + +
    + + Cancel + +
    +
    {{end}} From df5764e359d98c22d251040b43aa1c22b4dbfc55 Mon Sep 17 00:00:00 2001 From: Wutname1 Date: Wed, 20 May 2026 00:54:39 -0500 Subject: [PATCH 11/40] refactor(destinations): collapse New/Edit/Delete to a single modal flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit and Delete were the last full-page navigations in the destinations CRUD — Edit went to /destinations/:id/edit, Delete went to a separate confirm page. Both now open the same modal that New already used, which removes a class of code (page-chrome wrappers around partials, two-state POST handler for the delete confirm, withSidebar/authBaseData merges for templates that never had real page context anyway). Template-side - destinations_new.html: drop the {{define "content"}} wrapper. Only the destination_picker_body partial remains, and its action is hard-wired to /destinations/modal/form (no more PickerAction data field). - destinations_form.html: drop the content wrapper + drop the $modal guard (always true now). Form posts via hx-post with X-Dest-Modal=1. Success body grew a ModalSuccessVerb so the same partial renders "added" vs "updated" without two near-identical blocks. - destinations_delete.html: converted from full-page to a destination_delete_body partial. Confirm body has its own hx-post + X-Dest-Modal hookup; success body re-uses the same alert.ok pattern. - destinations.html: Edit + Delete buttons swapped from and
    + native confirm() to +
    {{if .DestinationSummaries}}
      @@ -77,7 +79,9 @@
    {{else}}
    - No destinations configured. Add a library destination to fan out scans after each download. + No destinations configured. + + to fan out scans after each download.
    {{end}} diff --git a/internal/web/templates/destinations.html b/internal/web/templates/destinations.html index 9a7b1af..b391da4 100644 --- a/internal/web/templates/destinations.html +++ b/internal/web/templates/destinations.html @@ -73,21 +73,24 @@

    Destinations

    {{if and (eq .Health "failed") .LastError}}aria-describedby="dest-{{.ID}}-err"{{end}}> Test - Edit {{.DisplayName}} - + -
    - -
    +
    diff --git a/internal/web/templates/destinations_delete.html b/internal/web/templates/destinations_delete.html index 19efab1..d967403 100644 --- a/internal/web/templates/destinations_delete.html +++ b/internal/web/templates/destinations_delete.html @@ -1,34 +1,42 @@ -{{define "content"}} -
    - `) } func htmlEscape(s string) string { @@ -923,6 +928,66 @@ func htmlEscape(s string) string { return r.Replace(s) } +// errTagRe matches an HTML tag for stripping. The bodies our backends +// embed in error strings are typically tiny upstream-server error pages +// (Plex / Emby / Jellyfin / ABS), so a regex is cheaper than spinning +// up html/parser and we don't need element-level fidelity here. +var errTagRe = regexp.MustCompile(`<[^>]*>`) + +// errSpaceRe collapses runs of whitespace (including the newlines left +// behind after stripping // blocks) into a single space. +var errSpaceRe = regexp.MustCompile(`\s+`) + +// errHTTPCodeRe extracts the numeric status code from a "<verb> returned +// <code>: <body>" error string. The first capture is the code itself. +var errHTTPCodeRe = regexp.MustCompile(`returned (\d{3})[:\s]`) + +// cleanErrorForDisplay turns a raw backend error message — which may +// embed an HTML error page from Plex/Emby/Jellyfin/ABS — into something +// that fits on one line in a UI badge. It: +// - strips HTML tags +// - collapses whitespace +// - rewrites common HTTP status codes into friendly hints +// - truncates very long messages so a 2KB upstream body doesn't +// blow out the card layout +// +// The raw error is still logged at recordDestinationHealth's caller +// chain (debug log), so we don't lose forensic detail. +func cleanErrorForDisplay(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + + var hint string + if m := errHTTPCodeRe.FindStringSubmatch(s); m != nil { + switch m[1] { + case "401": + hint = "Authentication failed — check the token or API key." + case "403": + hint = "Authentication accepted but the account is not authorized for this resource." + case "404": + hint = "Server reachable but the configured library/section was not found." + case "500", "502", "503", "504": + hint = "Upstream server error — the media server returned " + m[1] + "." + } + } + + s = errTagRe.ReplaceAllString(s, " ") + s = errSpaceRe.ReplaceAllString(s, " ") + s = strings.TrimSpace(s) + + const maxLen = 200 + if len(s) > maxLen { + s = strings.TrimSpace(s[:maxLen]) + "…" + } + + if hint != "" { + return hint + } + return s +} + // writeSensitiveHTML emits an HTML fragment that may carry secrets // (Plex auth tokens, API keys discovered via plex.tv, library UUIDs). // Cache-Control: no-store + Pragma: no-cache prevent any proxy or diff --git a/internal/web/static/style.css b/internal/web/static/style.css index a5df48c..cf0c017 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -791,6 +791,16 @@ h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); } font-size: 0.72rem; color: var(--text-muted); } +.dest-card-disabled { + background: #222837; +} +.dest-card-disabled .dest-name, +.dest-card-disabled .type-icon { + opacity: 0.7; +} +.dest-card-disabled .dest-ribbon { + opacity: 0.35; +} .dest-card .dest-head-text { min-width: 0; flex: 1; } .dest-card .dest-head-status { flex-shrink: 0; } .dest-card .dest-row-help { @@ -1603,6 +1613,61 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } } .btn-icon svg { display: block; } +/* ── Test-connection button states ─────────────────────────────────── + The button keeps its default content; while the request is in flight + or right after, an overlay icon hides the content. JS toggles a + `data-test-state` attribute: "loading" | "ok" | "fail". When idle the + attribute is absent and the default content shows normally. */ +.btn[data-test-state] { + position: relative; + pointer-events: none; +} +.btn[data-test-state] > * { visibility: hidden; } +.btn[data-test-state]::after { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + font-weight: 700; +} +.btn[data-test-state="loading"]::after { + content: ""; + background: + conic-gradient(from 0deg, transparent 0deg, currentColor 270deg, transparent 360deg); + -webkit-mask: radial-gradient(circle, transparent 5px, #000 6px); + mask: radial-gradient(circle, transparent 5px, #000 6px); + width: 16px; + height: 16px; + margin: auto; + inset: 0; + border-radius: 999px; + animation: btn-test-spin 0.7s linear infinite; +} +.btn[data-test-state="ok"] { + background: rgba(76, 175, 80, 0.18); + border-color: var(--success); + color: var(--success); +} +.btn[data-test-state="ok"]::after { + content: "\2713"; + color: var(--success); +} +.btn[data-test-state="fail"] { + background: rgba(244, 67, 54, 0.18); + border-color: var(--error); + color: var(--error); +} +.btn[data-test-state="fail"]::after { + content: "\2715"; + color: var(--error); +} + +@keyframes btn-test-spin { + to { transform: rotate(360deg); } +} + /* ── Dashboard table links (Recent completions / failures) ───────── */ .tbl a.book-detail-link, .tbl a.book-detail-link:hover, diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index 69e22f6..edbc76b 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -981,6 +981,58 @@ <h2 id="dest-modal-title" class="visually-hidden">Add destination</h2> })(); </script> + <script> + // Global Test-Connection feedback. Any button marked + // data-test-button gets: + // • data-test-state="loading" while the HTMX request is in flight + // • data-test-state="ok" / "fail" for ~2.4s based on the response + // • attribute removed after the linger window so the default icon + // comes back + // The response body is the renderTestResult fragment ("Connected." / + // "Failed."), so we just look at the prefix. + (function() { + var LINGER_MS = 2400; + var clearTimers = new WeakMap(); + + function scheduleClear(btn) { + var existing = clearTimers.get(btn); + if (existing) clearTimeout(existing); + var t = setTimeout(function() { + btn.removeAttribute('data-test-state'); + clearTimers.delete(btn); + }, LINGER_MS); + clearTimers.set(btn, t); + } + + document.body.addEventListener('htmx:beforeRequest', function(ev) { + var btn = ev.target; + if (!(btn instanceof HTMLElement) || !btn.hasAttribute('data-test-button')) return; + var existing = clearTimers.get(btn); + if (existing) { + clearTimeout(existing); + clearTimers.delete(btn); + } + btn.setAttribute('data-test-state', 'loading'); + }); + + document.body.addEventListener('htmx:afterRequest', function(ev) { + var btn = ev.target; + if (!(btn instanceof HTMLElement) || !btn.hasAttribute('data-test-button')) return; + var xhr = ev.detail && ev.detail.xhr; + var text = xhr ? String(xhr.responseText || '') : ''; + var ok = !!(ev.detail && ev.detail.successful) && text.indexOf('Connected.') !== -1; + btn.setAttribute('data-test-state', ok ? 'ok' : 'fail'); + scheduleClear(btn); + // After the icon-state linger, reload the page so the card's + // health badge / last-checked timestamp picks up the new + // values. Only buttons opted into this via data-test-reload. + if (btn.hasAttribute('data-test-reload')) { + setTimeout(function() { location.reload(); }, LINGER_MS + 200); + } + }); + })(); + </script> + <script> // AudplexusDestModal — global helper that drives the add-destination // modal. Pages open it via hx-get="/destinations/modal" with target diff --git a/internal/web/templates/dashboard_summary.html b/internal/web/templates/dashboard_summary.html index 60e567b..1e411ea 100644 --- a/internal/web/templates/dashboard_summary.html +++ b/internal/web/templates/dashboard_summary.html @@ -42,7 +42,7 @@ {{if .DestinationSummaries}} <ul role="list" aria-live="polite" aria-atomic="false" class="dest-grid destination-summary-list" style="list-style:none;padding:0;margin:0"> {{range .DestinationSummaries}} - <li class="dest-card {{.Type}}" id="dest-card-{{.ID}}" aria-labelledby="dest-sum-{{.ID}}-name"> + <li class="dest-card {{.Type}}{{if not .Enabled}} dest-card-disabled{{end}}" id="dest-card-{{.ID}}" aria-labelledby="dest-sum-{{.ID}}-name"> <span class="dest-ribbon"></span> <div class="dest-head"> <span class="type-icon">{{firstTwo .TypeLabel}}</span> @@ -79,11 +79,9 @@ {{else}}Never checked{{end}} </span> <span class="dest-actions-buttons"> - <button type="button" class="btn btn-ghost btn-icon" + <button type="button" class="btn btn-ghost btn-icon" data-test-button data-test-reload hx-post="/destinations/{{.ID}}/test" hx-swap="none" - hx-on::before-request="this.disabled=true" - hx-on::after-request="setTimeout(function(){location.reload()},600)" title="Test connection" aria-label="Test {{.DisplayName}}"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> </button> diff --git a/internal/web/templates/destinations_card.html b/internal/web/templates/destinations_card.html index a0860b0..9aac808 100644 --- a/internal/web/templates/destinations_card.html +++ b/internal/web/templates/destinations_card.html @@ -11,7 +11,7 @@ page that's $dest inside a range. */}} {{with .Dest}} -<div class="dest-card {{.Type}}" id="dest-card-{{.ID}}" aria-labelledby="dest-{{.ID}}-name"> +<div class="dest-card {{.Type}}{{if not .Enabled}} dest-card-disabled{{end}}" id="dest-card-{{.ID}}" aria-labelledby="dest-{{.ID}}-name"> <span class="dest-ribbon"></span> <div class="dest-head"> @@ -89,11 +89,9 @@ {{end}} </span> <span class="dest-actions-buttons"> - <button type="button" class="btn btn-ghost btn-small" + <button type="button" class="btn btn-ghost btn-small" data-test-button data-test-reload hx-post="/destinations/{{.ID}}/test" hx-swap="none" - hx-on::before-request="this.disabled=true" - hx-on::after-request="setTimeout(function(){location.reload()},600)" {{if and (eq .Health "failed") .LastError}}aria-describedby="dest-{{.ID}}-err"{{end}}> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> Test diff --git a/internal/web/templates/destinations_form.html b/internal/web/templates/destinations_form.html index 66d364f..a24e3b2 100644 --- a/internal/web/templates/destinations_form.html +++ b/internal/web/templates/destinations_form.html @@ -423,12 +423,11 @@ <h2 id="form-heading" style="margin:0">{{if $isEdit}}Edit {{.DestTypeLabel}} Des <div style="display:flex;gap:.75rem;margin-top:1rem;flex-wrap:wrap"> <button type="button" class="btn btn-outline-ds" onclick="window.AudplexusDestModal && window.AudplexusDestModal.close()">Cancel</button> - <button type="button" class="btn btn-secondary" + <button type="button" class="btn btn-secondary" data-test-button hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/test{{else}}/destinations/test{{end}}" hx-include="closest form" hx-target="#test-result" hx-swap="innerHTML" - hx-disabled-elt="this" hx-on::before-request="document.getElementById('test-result').setAttribute('aria-busy', 'true'); document.getElementById('test-result').textContent = 'Testing connection…';" hx-on::after-request="document.getElementById('test-result').setAttribute('aria-busy', 'false');"> Test Connection From 604cf7f1ef22a27f653ad60081ffbabbe6b199be Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Wed, 20 May 2026 22:51:55 -0500 Subject: [PATCH 25/40] feat: Add destination logos for supported media servers Introduce a new template function `destLogo` and corresponding static image assets to display custom logos for Plex, Emby, Jellyfin, and Audiobookshelf destinations in the UI. This enhances visual distinction and user experience. --- internal/web/server.go | 17 ++++++++ internal/web/static/audiobookshelf.png | Bin 0 -> 9515 bytes internal/web/static/emby.png | Bin 0 -> 4220 bytes internal/web/static/jellyfin.png | Bin 0 -> 11833 bytes internal/web/static/plex.png | Bin 0 -> 5134 bytes internal/web/static/style.css | 37 +++++++++++++++++- internal/web/templates/book_detail_panel.html | 2 +- internal/web/templates/dashboard_summary.html | 2 +- internal/web/templates/destinations_card.html | 2 +- internal/web/templates/destinations_new.html | 8 ++-- internal/web/templates/setup.html | 5 ++- 11 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 internal/web/static/audiobookshelf.png create mode 100644 internal/web/static/emby.png create mode 100644 internal/web/static/jellyfin.png create mode 100644 internal/web/static/plex.png diff --git a/internal/web/server.go b/internal/web/server.go index 5c2f4b5..af9d50b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -281,6 +281,23 @@ func (s *Server) setupTemplates() { } return strings.ToUpper(s[:2]) }, + // destLogo maps a destination type key to its static logo URL. + // `abs` is the internal short code; the file on disk is named + // audiobookshelf.png so we expand it here. Returns "" for + // unknown types so the template can fall back to a letter pill. + "destLogo": func(t string) string { + switch strings.ToLower(strings.TrimSpace(t)) { + case "plex": + return "/static/plex.png" + case "emby": + return "/static/emby.png" + case "jellyfin": + return "/static/jellyfin.png" + case "abs", "audiobookshelf": + return "/static/audiobookshelf.png" + } + return "" + }, "joinDestinationNames": func(dests []destinationSummaryView) string { names := make([]string, 0, len(dests)) for _, d := range dests { diff --git a/internal/web/static/audiobookshelf.png b/internal/web/static/audiobookshelf.png new file mode 100644 index 0000000000000000000000000000000000000000..24c827b8d9e8e332a4b915c5c0bddc0741b0bf98 GIT binary patch literal 9515 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 literal 0 HcmV?d00001 diff --git a/internal/web/static/emby.png b/internal/web/static/emby.png new file mode 100644 index 0000000000000000000000000000000000000000..4807546de9c0454f35018f50e48e51455ada4f48 GIT binary patch literal 4220 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 GC;A^nfa~1= literal 0 HcmV?d00001 diff --git a/internal/web/static/jellyfin.png b/internal/web/static/jellyfin.png new file mode 100644 index 0000000000000000000000000000000000000000..a76e6e713527624e74e0ae817ef984cedbdee771 GIT binary patch literal 11833 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 LD2P{x8v6e?F=#^y literal 0 HcmV?d00001 diff --git a/internal/web/static/plex.png b/internal/web/static/plex.png new file mode 100644 index 0000000000000000000000000000000000000000..07863727c811dfd305b77653be46998b063b76d3 GIT binary patch literal 5134 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 literal 0 HcmV?d00001 diff --git a/internal/web/static/style.css b/internal/web/static/style.css index cf0c017..9af6982 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -739,7 +739,7 @@ h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); } gap: 0.55rem; } .dest-card .type-icon { - width: 28px; height: 28px; + width: 32px; height: 32px; border-radius: 6px; display: inline-flex; align-items: center; @@ -748,7 +748,23 @@ h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); } font-weight: 700; color: #fff; flex-shrink: 0; + overflow: hidden; + background: rgba(255, 255, 255, 0.04); + padding: 3px; +} +.dest-card .type-icon img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; } +/* When the logo img is present, drop the per-type gradient backgrounds + so the actual brand mark shows on neutral chrome. */ +.dest-card .type-icon:has(img) { background: rgba(255, 255, 255, 0.06); } +.dest-card.plex .type-icon:has(img), +.dest-card.emby .type-icon:has(img), +.dest-card.jellyfin .type-icon:has(img), +.dest-card.abs .type-icon:has(img) { background-image: none; } .dest-card.plex .type-icon { background: linear-gradient(135deg, #1ddbe9, #114e88); } .dest-card.emby .type-icon { background: linear-gradient(135deg, #52b54b, #154e15); } .dest-card.jellyfin .type-icon { background: linear-gradient(135deg, #aa5cc3, #00a4dc); } @@ -943,6 +959,13 @@ h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); } .dest-chip.abs { background: linear-gradient(135deg, #f4a261, #e76f51); } .dest-chip.emby { background: linear-gradient(135deg, #52b54b, #154e15); } .dest-chip.jellyfin { background: linear-gradient(135deg, #aa5cc3, #00a4dc); } +.dest-chip img { + width: 100%; height: 100%; object-fit: contain; display: block; +} +.dest-chip:has(img) { + background: rgba(255, 255, 255, 0.06); + padding: 2px; +} /* Pagination */ .table-pagination { @@ -2633,6 +2656,18 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: 0.75rem; font-weight: 700; flex-shrink: 0; } +.picker .lg .pill.pill-logo, +.pill.pill-logo { + background: rgba(255, 255, 255, 0.06); + padding: 3px; + overflow: hidden; +} +.pill.pill-logo img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} .picker .desc { color: var(--text-muted); font-size: 0.76rem; line-height: 1.4; } .picker-list { display: flex; flex-direction: column; gap: 0.5rem; } diff --git a/internal/web/templates/book_detail_panel.html b/internal/web/templates/book_detail_panel.html index cf2ced9..e89ba6c 100644 --- a/internal/web/templates/book_detail_panel.html +++ b/internal/web/templates/book_detail_panel.html @@ -100,7 +100,7 @@ <h3 class="section-label">Destinations</h3> <ul class="dest-status-list"> {{range .BookDestinationStatuses}} <li> - <span class="dest-chip {{.Type}}">{{firstTwo .TypeLabel}}</span> + <span class="dest-chip {{.Type}}">{{$logo := destLogo .Type}}{{if $logo}}<img src="{{$logo}}" alt="{{.TypeLabel}}">{{else}}{{firstTwo .TypeLabel}}{{end}}</span> <span>{{.DisplayName}}</span> <span class="pull-right"> {{if eq .State "synced"}}<span class="badge badge-success">{{.Label}}</span> diff --git a/internal/web/templates/dashboard_summary.html b/internal/web/templates/dashboard_summary.html index 1e411ea..5fcb9a1 100644 --- a/internal/web/templates/dashboard_summary.html +++ b/internal/web/templates/dashboard_summary.html @@ -45,7 +45,7 @@ <li class="dest-card {{.Type}}{{if not .Enabled}} dest-card-disabled{{end}}" id="dest-card-{{.ID}}" aria-labelledby="dest-sum-{{.ID}}-name"> <span class="dest-ribbon"></span> <div class="dest-head"> - <span class="type-icon">{{firstTwo .TypeLabel}}</span> + <span class="type-icon">{{$logo := destLogo .Type}}{{if $logo}}<img src="{{$logo}}" alt="{{.TypeLabel}}">{{else}}{{firstTwo .TypeLabel}}{{end}}</span> <div class="dest-head-text"> <div class="dest-name" id="dest-sum-{{.ID}}-name">{{.DisplayName}}</div> <div class="dest-url">{{if .URL}}{{.URL}}{{else}}{{.TypeLabel}}{{end}}</div> diff --git a/internal/web/templates/destinations_card.html b/internal/web/templates/destinations_card.html index 9aac808..5acc72e 100644 --- a/internal/web/templates/destinations_card.html +++ b/internal/web/templates/destinations_card.html @@ -15,7 +15,7 @@ <span class="dest-ribbon"></span> <div class="dest-head"> - <span class="type-icon">{{firstTwo .TypeLabel}}</span> + <span class="type-icon">{{$logo := destLogo .Type}}{{if $logo}}<img src="{{$logo}}" alt="{{.TypeLabel}}">{{else}}{{firstTwo .TypeLabel}}{{end}}</span> <div class="dest-head-text"> <div class="dest-name" id="dest-{{.ID}}-name">{{.DisplayName}}</div> <div class="dest-url">{{if .URL}}{{.URL}}{{else}}{{.TypeLabel}}{{end}}</div> diff --git a/internal/web/templates/destinations_new.html b/internal/web/templates/destinations_new.html index f1cc36b..7004587 100644 --- a/internal/web/templates/destinations_new.html +++ b/internal/web/templates/destinations_new.html @@ -15,19 +15,19 @@ <h2 style="margin:0">Add destination</h2> <p class="destination-type-help muted" id="destination-type-help">One click to continue to that destination's setup form.</p> <div class="destination-type-grid picker-grid" role="group" aria-describedby="destination-type-help"> <button type="submit" class="picker destination-type-btn" name="type" value="plex"> - <span class="lg"><span class="pill" style="background:linear-gradient(135deg,#e5a00d,#7a560a)">PL</span>Plex</span> + <span class="lg"><span class="pill pill-logo"><img src="/static/plex.png" alt=""></span>Plex</span> <span class="desc">Plex Media Server</span> </button> <button type="submit" class="picker destination-type-btn" name="type" value="emby"> - <span class="lg"><span class="pill" style="background:linear-gradient(135deg,#52b54b,#2e6b2a)">EM</span>Emby</span> + <span class="lg"><span class="pill pill-logo"><img src="/static/emby.png" alt=""></span>Emby</span> <span class="desc">Emby Media Server</span> </button> <button type="submit" class="picker destination-type-btn" name="type" value="jellyfin"> - <span class="lg"><span class="pill" style="background:linear-gradient(135deg,#aa5cc3,#4f2766)">JE</span>Jellyfin</span> + <span class="lg"><span class="pill pill-logo"><img src="/static/jellyfin.png" alt=""></span>Jellyfin</span> <span class="desc">Jellyfin Media Server</span> </button> <button type="submit" class="picker destination-type-btn" name="type" value="abs"> - <span class="lg"><span class="pill" style="background:linear-gradient(135deg,#1ddbe9,#114e88)">AB</span>Audiobookshelf</span> + <span class="lg"><span class="pill pill-logo"><img src="/static/audiobookshelf.png" alt=""></span>Audiobookshelf</span> <span class="desc">Self-hosted audiobook server</span> </button> </div> diff --git a/internal/web/templates/setup.html b/internal/web/templates/setup.html index ac99b15..cfa96ff 100644 --- a/internal/web/templates/setup.html +++ b/internal/web/templates/setup.html @@ -125,7 +125,10 @@ <h2>Add a destination</h2> {{range .Destinations}} <div class="picker selected" style="cursor:default"> <span class="lg"> - <span class="pill" style="background:linear-gradient(135deg,#1ddbe9,#114e88)">{{firstTwo .TypeLabel}}</span> + {{$logo := destLogo .Type}} + <span class="pill {{if $logo}}pill-logo{{end}}"> + {{if $logo}}<img src="{{$logo}}" alt="{{.TypeLabel}}">{{else}}{{firstTwo .TypeLabel}}{{end}} + </span> {{.DisplayName}} </span> <span class="meta">{{if .URL}}{{.URL}}{{else}}{{.TypeLabel}}{{end}}</span> From 99cf09e69233c1285d07fb4339e73038a9e4cb5d Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Wed, 20 May 2026 23:06:27 -0500 Subject: [PATCH 26/40] feat: Redesign destination create, edit, and delete forms Implement a new design system for destination forms, featuring an identity header, sectioned layout with eyebrow dividers, guided stepper for Plex setup, consolidated help disclosures, and a sticky action bar. This significantly improves the user experience and visual consistency of these critical forms, including a safer, type-to-confirm flow for deletion. --- internal/web/static/style.css | 334 ++++++++ .../web/templates/destinations_delete.html | 64 +- internal/web/templates/destinations_form.html | 711 +++++++++--------- 3 files changed, 738 insertions(+), 371 deletions(-) diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 9af6982..f1250d0 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -2670,6 +2670,340 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } } .picker .desc { color: var(--text-muted); font-size: 0.76rem; line-height: 1.4; } +/* ── Destination create/edit/delete forms ──────────────────────────── + Editorial sectioned layout — identity header, eyebrow-labeled + dividers, guided Plex stepper, consolidated help expandos, sticky + action bar that pins to the modal's scroll container. */ + +.dest-form-head { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.4rem 0 1.1rem; + margin: 0 0 1rem; + border-bottom: 1px solid var(--border); + position: relative; +} +.dest-form-head::after { + content: ""; + position: absolute; + left: 0; right: 0; bottom: -1px; + height: 1px; + background: linear-gradient(90deg, var(--accent), transparent 50%); + opacity: 0.55; +} +.dest-form-head--danger::after { + background: linear-gradient(90deg, var(--error), transparent 50%); +} + +.dest-form-head-mark { + width: 44px; + height: 44px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; +} +.dest-form-head-mark img { + width: 30px; + height: 30px; + object-fit: contain; + display: block; +} +.dest-form-head-mark span { + font-family: var(--font-mono); + font-size: 0.85rem; + font-weight: 700; + color: var(--text); +} + +.dest-form-head-text { min-width: 0; flex: 1; } +.dest-form-head-text h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + color: var(--text-strong); + letter-spacing: -0.01em; + line-height: 1.15; +} +.dest-form-head-text p { + margin: 0.35rem 0 0; + font-size: 0.85rem; + color: var(--text-muted); + line-height: 1.5; + max-width: 56ch; +} + +.dest-form-eyebrow { + font-family: var(--font-sans); + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--plex-cyan); + opacity: 0.95; +} + +/* Form spine */ +.dest-form { display: flex; flex-direction: column; gap: 1.5rem; } +.dest-form-section { display: flex; flex-direction: column; gap: 0.9rem; } +.dest-form-section-head { + display: flex; + align-items: center; + gap: 0.7rem; +} +.dest-form-section-rule { + flex: 1; + height: 1px; + background: linear-gradient(90deg, var(--border), transparent); +} +.dest-form .form-group { margin-bottom: 0; } +.form-label-aside { + font-size: 0.66rem; + font-weight: 500; + color: var(--text-muted); + letter-spacing: 0.04em; + text-transform: lowercase; + margin-left: 0.4rem; +} + +/* Guided steps (Plex) */ +.dest-guided { + display: flex; + flex-direction: column; + gap: 0.55rem; + margin-top: 0.35rem; +} +.dest-guided-step { + display: flex; + align-items: flex-start; + gap: 0.85rem; + padding: 0.85rem 0.95rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + transition: opacity 0.2s, border-color 0.2s, background 0.2s; + position: relative; +} +.dest-guided-step.is-dim { opacity: 0.55; } +.dest-guided-step.is-done { + border-color: rgba(76, 175, 80, 0.32); + background: rgba(76, 175, 80, 0.04); +} +.dest-guided-num { + width: 26px; + height: 26px; + border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; + transition: background 0.2s, color 0.2s, border-color 0.2s, box-shadow 0.2s; +} +.dest-guided-step.is-done .dest-guided-num { + background: var(--success); + border-color: var(--success); + color: transparent; + box-shadow: 0 0 0 4px rgba(76, 175, 80, 0.16); +} +.dest-guided-step.is-done .dest-guided-num::before { + content: "✓"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-family: var(--font-sans); + font-size: 0.95rem; +} +.dest-guided-body { min-width: 0; flex: 1; } +.dest-guided-row { + display: flex; + align-items: center; + gap: 0.85rem; + flex-wrap: wrap; +} +.dest-guided-row--standalone { + padding: 0.85rem 0.95rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 0.4rem; +} +.dest-guided-text { min-width: 0; flex: 1; } +.dest-guided-title { + font-size: 0.92rem; + font-weight: 600; + color: var(--text); +} +.dest-guided-desc { + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.45; + margin-top: 1px; +} +.dest-guided-row > .btn { flex-shrink: 0; } +.dest-guided-result:empty { display: none; } +.dest-guided-result { + margin-top: 0.6rem; + font-size: 0.82rem; + color: var(--text-muted); +} + +/* Disclosure (Advanced / Path translation / Setup help) */ +.dest-form-disclosure { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} +.dest-form-disclosure > summary { + list-style: none; + cursor: pointer; + padding: 0.7rem 0.95rem; + display: flex; + align-items: center; + gap: 0.65rem; + user-select: none; +} +.dest-form-disclosure > summary::-webkit-details-marker { display: none; } +.dest-form-disclosure > summary::marker { content: ""; } +.dest-form-disclosure > summary::before { + content: "›"; + font-size: 1.1rem; + color: var(--text-muted); + transform: translateY(-1px); + transition: transform 0.15s; + line-height: 1; +} +.dest-form-disclosure[open] > summary::before { transform: rotate(90deg) translateX(-1px); } +.dest-form-disclosure-label { + font-weight: 600; + color: var(--text); + font-size: 0.88rem; +} +.dest-form-disclosure-hint { + color: var(--text-muted); + font-size: 0.76rem; + margin-left: auto; + text-align: right; + line-height: 1.35; + max-width: 52%; +} +.dest-form-disclosure-body { + padding: 0.85rem 0.95rem 1rem; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +/* Setup help */ +.dest-form-help .dest-form-disclosure-body { gap: 1.1rem; } +.dest-help-block { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.7rem 0.85rem; +} +.dest-help-eyebrow { + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--plex-cyan); + margin-bottom: 0.4rem; +} +.dest-help-block ol { + margin: 0; + padding-left: 1.15rem; + font-size: 0.82rem; + line-height: 1.55; + color: var(--text); +} +.dest-help-block li + li { margin-top: 0.2rem; } +.dest-help-block code { + font-family: var(--font-mono); + background: rgba(0, 0, 0, 0.35); + color: var(--plex-cyan); + padding: 1px 5px; + border-radius: 4px; + font-size: 0.76rem; +} +.dest-help-block a { + color: var(--plex-cyan); + text-decoration: underline; + text-decoration-color: rgba(124, 201, 255, 0.4); + text-underline-offset: 2px; +} +.dest-help-block a:hover { text-decoration-color: var(--plex-cyan); } + +/* Sticky action bar */ +.dest-form-test-result:empty { display: none; } +.dest-form-test-result { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.6rem 0.8rem; + font-size: 0.85rem; + color: var(--text); + background: var(--bg); +} +.dest-form-actions { + position: sticky; + bottom: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.85rem 1.5rem; + margin: 0 -1.5rem -1.5rem; + background: linear-gradient(to bottom, + color-mix(in srgb, var(--surface) 70%, transparent), + var(--surface) 38%); + border-top: 1px solid var(--border); + backdrop-filter: blur(6px); +} +.dest-form-actions .spacer { flex: 1; } + +@media (max-width: 860px) { + .dest-form-actions { + margin: 0 -0.9rem -0.9rem; + padding: 0.85rem 0.9rem; + } + .dest-form-disclosure-hint { + max-width: 100%; + margin-left: 0; + margin-top: 0.1rem; + text-align: left; + } + .dest-form-disclosure > summary { flex-wrap: wrap; } +} + +/* Delete confirmation */ +.dest-delete-confirm-input { + font-family: var(--font-mono); + text-align: center; + letter-spacing: 0.3em; + text-transform: uppercase; + font-size: 1rem; +} +.dest-delete-confirm-input.is-armed { + border-color: var(--error); + box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.18); +} + .picker-list { display: flex; flex-direction: column; gap: 0.5rem; } .picker-list .picker { flex-direction: row; align-items: center; gap: 0.85rem; } .picker-list .picker .lg { flex: 1; } diff --git a/internal/web/templates/destinations_delete.html b/internal/web/templates/destinations_delete.html index d967403..d839b2f 100644 --- a/internal/web/templates/destinations_delete.html +++ b/internal/web/templates/destinations_delete.html @@ -8,23 +8,28 @@ </div> </div> {{else}} -<header style="margin-bottom:1rem"> - <h2 style="margin:0">Delete destination</h2> - <p class="muted" style="margin:.35rem 0 0 0">{{.Dest.TypeLabel}} · {{.Dest.DisplayName}}</p> +{{$logo := destLogo .Dest.Type}} + +<header class="dest-form-head dest-form-head--danger"> + <div class="dest-form-head-mark"> + {{if $logo}}<img src="{{$logo}}" alt="">{{else}}<span>{{firstTwo .Dest.TypeLabel}}</span>{{end}} + </div> + <div class="dest-form-head-text"> + <div class="dest-form-eyebrow" style="color:#ff9b9b">Delete destination</div> + <h2 id="form-heading">{{.Dest.DisplayName}}</h2> + <p>{{.Dest.TypeLabel}}{{if .Dest.URL}} · {{.Dest.URL}}{{end}}</p> + </div> </header> <div class="alert err" style="margin-bottom:1rem"> <span class="ico">!</span> <div> - <strong>Delete "{{.Dest.DisplayName}}"?</strong> - <div style="font-size:.85rem;margin-top:.35rem;line-height:1.5"> - This removes the {{.Dest.TypeLabel}} destination from Audplexus. + <strong>This removes the {{.Dest.TypeLabel}} destination from Audplexus.</strong> + <div style="font-size:.85rem;margin-top:.35rem;line-height:1.55"> Your media files and the {{.Dest.TypeLabel}} server itself are not touched — - Audplexus will simply stop fanning out post-download work here. - </div> - <div style="font-size:.85rem;margin-top:.5rem"> - <strong>This cannot be undone.</strong> The per-(book, destination) sync - history for this destination is also deleted. + Audplexus will simply stop fanning out post-download work here. The + per-(book, destination) sync history for this destination is also deleted. + <strong>This cannot be undone.</strong> </div> </div> </div> @@ -33,10 +38,41 @@ <h2 style="margin:0">Delete destination</h2> hx-target="#dest-modal-content" hx-swap="innerHTML" hx-headers='{"X-Dest-Modal":"1"}' - style="display:flex;gap:.6rem;margin:0"> + class="dest-form" + id="dest-delete-form"> <input type="hidden" name="confirm" value="1"> - <button type="button" class="btn btn-outline-ds" onclick="window.AudplexusDestModal && window.AudplexusDestModal.close()">Cancel</button> - <button type="submit" class="btn btn-danger">Permanently delete</button> + + <div class="form-group"> + <label for="dest-delete-confirm"> + Type <code>DELETE</code> to confirm + </label> + <input id="dest-delete-confirm" type="text" autocomplete="off" spellcheck="false" + class="dest-delete-confirm-input" placeholder="DELETE" aria-describedby="dest-delete-confirm-help"> + <small id="dest-delete-confirm-help">Case-sensitive. Guards against accidental removal of a healthy destination.</small> + </div> + + <div class="dest-form-actions"> + <button type="button" class="btn btn-outline-ds" onclick="window.AudplexusDestModal && window.AudplexusDestModal.close()">Cancel</button> + <span class="spacer" aria-hidden="true"></span> + <button type="submit" class="btn btn-danger" id="dest-delete-submit" disabled> + Permanently delete + </button> + </div> </form> + +<script> +(function() { + var input = document.getElementById('dest-delete-confirm'); + var submit = document.getElementById('dest-delete-submit'); + if (!input || !submit) return; + function check() { + var ok = input.value === 'DELETE'; + submit.disabled = !ok; + input.classList.toggle('is-armed', ok); + } + input.addEventListener('input', check); + check(); +})(); +</script> {{end}} {{end}} diff --git a/internal/web/templates/destinations_form.html b/internal/web/templates/destinations_form.html index a24e3b2..d438629 100644 --- a/internal/web/templates/destinations_form.html +++ b/internal/web/templates/destinations_form.html @@ -10,23 +10,33 @@ {{else}} {{/* Destination create/edit form — rendered inside the global modal in - base.html. The same partial drives both flows; $isEdit (.Dest set) - flips the form action between create and update. There's no full-page - fallback any more — every entry point opens the modal. + base.html. $isEdit (.Dest set) flips the form action between create + and update. Layout follows the DS aesthetic: typed identity header + with the brand logo, sectioned body with eyebrow dividers, sticky + action bar pinned to the bottom of the modal. */}} {{$isEdit := .Dest}} {{$dt := .DestType}} -<header style="margin-bottom:1rem"> - <h2 id="form-heading" style="margin:0">{{if $isEdit}}Edit {{.DestTypeLabel}} Destination{{else}}Add {{.DestTypeLabel}} Destination{{end}}</h2> - <p class="muted" style="margin:.35rem 0 0 0"> - {{if $isEdit}}Update the connection details below. Leave the API key / token blank to keep the existing value. - {{else}}Fill in the connection details. Audplexus will fan out post-download work to this destination.{{end}} - </p> +{{$logo := destLogo $dt}} + +<header class="dest-form-head"> + <div class="dest-form-head-mark"> + {{if $logo}}<img src="{{$logo}}" alt="">{{else}}<span>{{firstTwo .DestTypeLabel}}</span>{{end}} + </div> + <div class="dest-form-head-text"> + <div class="dest-form-eyebrow">{{.DestTypeLabel}}</div> + <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end}}</h2> + <p> + {{if $isEdit}}Update the connection details below. Leave the credential field blank to keep the existing value. + {{else}}Audplexus will fan out post-download work to this server once it's configured.{{end}} + </p> + </div> </header> {{if .FormError}} -<div class="info-box" role="alert" style="border-color:var(--error);margin-bottom:1rem" id="form-error-summary" tabindex="-1"> - <strong>Could not save:</strong> {{.FormError}} +<div class="alert err" role="alert" id="form-error-summary" tabindex="-1" style="margin-bottom:1rem"> + <span class="ico">!</span> + <div><strong>Could not save.</strong> {{.FormError}}</div> </div> {{end}} @@ -34,406 +44,393 @@ <h2 id="form-heading" style="margin:0">{{if $isEdit}}Edit {{.DestTypeLabel}} Des hx-target="#dest-modal-content" hx-swap="innerHTML" hx-headers='{"X-Dest-Modal":"1"}' - class="settings-form"> - {{if not $isEdit}}<input type="hidden" name="type" value="{{$dt}}">{{end}} + class="dest-form"> + {{if not $isEdit}}<input type="hidden" name="type" value="{{$dt}}">{{end}} - <div class="form-group"> - <label for="display_name">Display name <span class="muted" style="font-weight:normal">(optional)</span></label> - <input id="display_name" name="display_name" type="text" maxlength="128" - value="{{if $isEdit}}{{.Dest.DisplayName}}{{end}}" - placeholder="{{.DestTypeLabel}}" - aria-describedby="display_name_help"> - <small id="display_name_help" class="muted">Shown in the Settings list and per-destination logs. Defaults to <code>{{.DestTypeLabel}}</code>; set this only if you run multiple destinations of the same type (e.g. "Living room Plex" + "Bedroom Plex").</small> - </div> + {{/* ── Section 1: Identity & Connection ──────────────────────── */}} + <section class="dest-form-section"> + <header class="dest-form-section-head"> + <span class="dest-form-eyebrow">Connection</span> + <span class="dest-form-section-rule" aria-hidden="true"></span> + </header> - {{if ne $dt "plex"}} - <div class="form-group"> - <label for="url">{{.DestTypeLabel}} URL</label> - <input id="url" name="url" type="url" required - value="{{if $isEdit}}{{.Dest.URL}}{{end}}" - placeholder="http://server.lan:8096" - aria-describedby="url_help"> - <small id="url_help" class="muted">Internal URL Audplexus uses to reach the server. Use the LAN address; no public proxy required.</small> - </div> - {{end}} + <div class="form-group"> + <label for="display_name">Display name <span class="form-label-aside">optional</span></label> + <input id="display_name" name="display_name" type="text" maxlength="128" + value="{{if $isEdit}}{{.Dest.DisplayName}}{{end}}" + placeholder="{{.DestTypeLabel}}" + aria-describedby="display_name_help"> + <small id="display_name_help">Defaults to <code>{{.DestTypeLabel}}</code>. Override only when you run multiple destinations of the same type (e.g. <em>"Living room Plex"</em>).</small> + </div> - {{if eq $dt "plex"}} - {{$guidedStart := not $isEdit}} - <section id="plex-setup-flow"> - {{/* - Sign in with Plex — kicks off the plex.tv PIN flow. - Posts to /destinations/plex/pin/start which returns a - fragment containing an "Open plex.tv sign-in" button - (target=_blank) plus a hidden HTMX poller that re-fires - every 2s until plex.tv returns an authToken. On success - the poll response is a tiny <script> that writes the - token into #plex_token and clicks the Discover servers - button to chain into URL discovery. - */}} - <div class="form-group"> - <label>Sign in with Plex</label> - <div> + {{if eq $dt "plex"}} + {{$guidedStart := not $isEdit}} + <section id="plex-setup-flow" class="dest-guided"> + {{/* Plex sign-in step — kicks off the plex.tv PIN flow. + Posts to /destinations/plex/pin/start which returns a + fragment containing an "Open plex.tv sign-in" button + plus a hidden HTMX poller. */}} + <div class="dest-guided-step" id="plex-guided-step-1"> + <span class="dest-guided-num" aria-hidden="true">1</span> + <div class="dest-guided-body"> + <div class="dest-guided-row"> + <div class="dest-guided-text"> + <div class="dest-guided-title">Sign in with Plex</div> + <div class="dest-guided-desc">Approve in plex.tv and we fill the token automatically.</div> + </div> <button id="plex-signin-btn" type="button" class="btn btn-secondary" hx-post="/destinations/plex/pin/start" hx-target="#plex-pin-result" hx-swap="innerHTML" hx-disabled-elt="this" - hx-on::before-request="document.getElementById('plex-pin-result').setAttribute('aria-busy', 'true'); document.getElementById('plex-pin-result').textContent = 'Starting plex.tv sign-in…';" - hx-on::after-request="document.getElementById('plex-pin-result').setAttribute('aria-busy', 'false');"> - Sign in with Plex + hx-on::before-request="document.getElementById('plex-pin-result').setAttribute('aria-busy','true'); document.getElementById('plex-pin-result').textContent='Starting plex.tv sign-in…';" + hx-on::after-request="document.getElementById('plex-pin-result').setAttribute('aria-busy','false');"> + Sign in </button> - <div id="plex-pin-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false" style="margin-top:.5rem"></div> </div> - <small class="muted">Approve in plex.tv and Audplexus fills the token automatically, then loads your servers.</small> + <div id="plex-pin-result" class="dest-guided-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false"></div> </div> + </div> - <div id="plex-step-server" class="{{if $guidedStart}}is-hidden{{end}}"> - <div class="form-group"> - <label>Select Plex server</label> - <div> - <button id="plex-discover-servers-btn" type="button" class="btn btn-secondary is-hidden" - hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/plex/discover/servers{{else}}/destinations/plex/discover/servers{{end}}" - hx-include="closest form" - hx-target="#plex-server-discover-result" - hx-swap="innerHTML" - hx-disabled-elt="this" - hx-on::before-request="document.getElementById('plex-server-discover-result').setAttribute('aria-busy', 'true'); document.getElementById('plex-server-discover-result').textContent = 'Listing your Plex servers…';" - hx-on::after-request="document.getElementById('plex-server-discover-result').setAttribute('aria-busy', 'false');"> - Discover servers - </button> - <div id="plex-server-discover-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false" style="margin-top:.5rem"></div> + {{/* Discover servers — calls plex.tv /api/v2/resources, lists + the user's Plex servers, fills the URL field on pick. */}} + <div class="dest-guided-step{{if $guidedStart}} is-dim{{end}}" id="plex-guided-step-2"> + <span class="dest-guided-num" aria-hidden="true">2</span> + <div class="dest-guided-body"> + <div class="dest-guided-row"> + <div class="dest-guided-text"> + <div class="dest-guided-title">Select your Plex server</div> + <div class="dest-guided-desc">Lists every server linked to your account.</div> </div> - <small class="muted">After sign-in, this list auto-loads. Pick one server to continue.</small> + <button id="plex-discover-servers-btn" type="button" class="btn btn-secondary is-hidden" + hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/plex/discover/servers{{else}}/destinations/plex/discover/servers{{end}}" + hx-include="closest form" + hx-target="#plex-server-discover-result" + hx-swap="innerHTML" + hx-disabled-elt="this" + hx-on::before-request="document.getElementById('plex-server-discover-result').setAttribute('aria-busy','true'); document.getElementById('plex-server-discover-result').textContent='Listing your Plex servers…';" + hx-on::after-request="document.getElementById('plex-server-discover-result').setAttribute('aria-busy','false');"> + Discover + </button> </div> + <div id="plex-server-discover-result" class="dest-guided-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false"></div> </div> + </div> - {{/* - Discover servers — calls plex.tv /api/v2/resources, lists - the user's Plex servers, fills the URL field on pick. - hx-include posts the form values so the token (just - typed or just minted via PIN) flows through. The id - "plex-discover-servers-btn" is the click target of the - auto-chain from the PIN poll completion script. - */}} - <div id="plex-step-sections" class="{{if $guidedStart}}is-hidden{{end}}"> - <div class="form-group"> - <label>Select audiobook section</label> - <div> - <button id="plex-discover-sections-btn" type="button" class="btn btn-secondary is-hidden" - hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/plex/discover/sections{{else}}/destinations/plex/discover/sections{{end}}" - hx-include="closest form" - hx-target="#plex-section-discover-result" - hx-swap="innerHTML" - hx-disabled-elt="this" - hx-on::before-request="document.getElementById('plex-section-discover-result').setAttribute('aria-busy', 'true'); document.getElementById('plex-section-discover-result').textContent = 'Listing library sections…';" - hx-on::after-request="document.getElementById('plex-section-discover-result').setAttribute('aria-busy', 'false');"> - Discover sections - </button> - <div id="plex-section-discover-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false" style="margin-top:.5rem"></div> + {{/* Discover sections — calls the configured Plex server's + /library/sections, lists all sections, fills section_id + on pick. */}} + <div class="dest-guided-step{{if $guidedStart}} is-dim{{end}}" id="plex-guided-step-3"> + <span class="dest-guided-num" aria-hidden="true">3</span> + <div class="dest-guided-body"> + <div class="dest-guided-row"> + <div class="dest-guided-text"> + <div class="dest-guided-title">Pick the audiobook library</div> + <div class="dest-guided-desc">Choose the Plex section that holds your audiobooks.</div> </div> - <small class="muted">Choose the Plex library section that holds your audiobooks.</small> + <button id="plex-discover-sections-btn" type="button" class="btn btn-secondary is-hidden" + hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/plex/discover/sections{{else}}/destinations/plex/discover/sections{{end}}" + hx-include="closest form" + hx-target="#plex-section-discover-result" + hx-swap="innerHTML" + hx-disabled-elt="this" + hx-on::before-request="document.getElementById('plex-section-discover-result').setAttribute('aria-busy','true'); document.getElementById('plex-section-discover-result').textContent='Listing library sections…';" + hx-on::after-request="document.getElementById('plex-section-discover-result').setAttribute('aria-busy','false');"> + Discover + </button> </div> + <div id="plex-section-discover-result" class="dest-guided-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false"></div> </div> - {{/* - Discover sections — calls the configured Plex server's - /library/sections, lists all sections, fills section_id - on pick. We don't filter to type=artist because some - audiobook libraries are misconfigured as type=movie or - type=show and we don't want to hide them. - */}} - <details id="plex-manual-entry" style="margin:1rem 0;" {{if $isEdit}}open{{end}}> - <summary>Advanced</summary> - <div class="form-group" style="margin-top:.75rem"> + </div> + + <details id="plex-manual-entry" class="dest-form-disclosure" {{if $isEdit}}open{{end}}> + <summary> + <span class="dest-form-disclosure-label">Manual configuration</span> + <span class="dest-form-disclosure-hint">Skip the guided flow and paste values directly.</span> + </summary> + <div class="dest-form-disclosure-body"> + <div class="form-group"> <label for="url">Plex URL</label> <input id="url" name="url" type="url" required value="{{if $isEdit}}{{.Dest.URL}}{{end}}" placeholder="http://server.lan:32400" aria-describedby="url_help"> - <small id="url_help" class="muted">Manual override. Usually filled by server selection above.</small> + <small id="url_help">Usually filled by server selection above.</small> </div> - <div class="form-group" style="margin-top:.75rem"> + + <div class="form-group"> <label for="plex_token">Plex token</label> <input id="plex_token" name="plex_token" type="password" autocomplete="off" {{if not $isEdit}}required{{end}} aria-describedby="plex_token_help"> - <small id="plex_token_help" class="muted">{{if $isEdit}}Leave blank to keep the current token. {{end}}Long-lived authentication string for your Plex account. Sign in above to fill this automatically.</small> - <details style="margin-top:.4rem"> - <summary>Manual: how to find your Plex token</summary> - <ol style="margin:.5rem 0 0 1.25rem;padding:0"> - <li>Sign in to Plex web UI at <code>http://<your-plex>:32400/web</code>.</li> - <li>Play any audiobook (or open its detail page).</li> - <li>Click the … menu → <strong>Get Info</strong> → <strong>View XML</strong>.</li> - <li>The new tab's URL contains <code>?X-Plex-Token=<token></code> — copy that value.</li> - <li>Alternative: Plex web → Account picture → Account → Authorized Devices, or see <a href="https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/" target="_blank" rel="noopener noreferrer">Plex's official guide</a>.</li> - </ol> - </details> + <small id="plex_token_help">{{if $isEdit}}Leave blank to keep the current token. {{end}}Long-lived auth string for your Plex account. Sign-in above fills this automatically.</small> </div> <div class="form-group"> <label for="plex_section_id">Plex section ID</label> <input id="plex_section_id" name="plex_section_id" type="text" required value="{{if $isEdit}}{{.Dest.PlexSectionID}}{{end}}" + placeholder="3" aria-describedby="plex_section_id_help"> - <small id="plex_section_id_help" class="muted">Numeric ID of your audiobook library/section. Discover sections above fills this automatically.</small> - <details style="margin-top:.4rem"> - <summary>Manual: how to find your section ID</summary> - <ol style="margin:.5rem 0 0 1.25rem;padding:0"> - <li>In Plex web, click your audiobook library in the sidebar.</li> - <li>The URL bar shows <code>/source/<sectionId></code> — that number is your section ID.</li> - <li>API alternative: <code>curl 'http://<plex>:32400/library/sections?X-Plex-Token=<token>'</code> — pick the <code><Directory key="..."></code> with <code>type="artist"</code>.</li> - </ol> - </details> + <small id="plex_section_id_help">Numeric ID. Discover above fills this automatically.</small> </div> - </details> - <script> - (function () { - var tokenInput = document.getElementById('plex_token'); - var urlInput = document.getElementById('url'); - var stepServer = document.getElementById('plex-step-server'); - var stepSections = document.getElementById('plex-step-sections'); - var manualDetails = document.getElementById('plex-manual-entry'); - if (!tokenInput || !urlInput || !stepServer || !stepSections || !manualDetails) { - return; - } + </div> + </details> - function revealForProgress() { - if (manualDetails.open || tokenInput.value.trim() !== '') { - stepServer.classList.remove('is-hidden'); - } - if (manualDetails.open || urlInput.value.trim() !== '') { - stepSections.classList.remove('is-hidden'); - } - } + <script> + (function () { + var tokenInput = document.getElementById('plex_token'); + var urlInput = document.getElementById('url'); + var step1 = document.getElementById('plex-guided-step-1'); + var step2 = document.getElementById('plex-guided-step-2'); + var step3 = document.getElementById('plex-guided-step-3'); + var manualDetails = document.getElementById('plex-manual-entry'); + if (!tokenInput || !urlInput || !step1 || !step2 || !step3 || !manualDetails) { + return; + } - function autoLoadServers() { - var discoverServersBtn = document.getElementById('plex-discover-servers-btn'); - var serverResult = document.getElementById('plex-server-discover-result'); - if (!discoverServersBtn || !serverResult) { - return; - } - if (tokenInput.value.trim() === '') { - return; - } - if (serverResult.innerHTML.trim() === '') { - discoverServersBtn.click(); - } - } + function dim(el, isDim) { + if (!el) return; + if (isDim) el.classList.add('is-dim'); + else el.classList.remove('is-dim'); + } + function done(el, isDone) { + if (!el) return; + if (isDone) el.classList.add('is-done'); + else el.classList.remove('is-done'); + } - function autoLoadSections() { - var discoverSectionsBtn = document.getElementById('plex-discover-sections-btn'); - var sectionResult = document.getElementById('plex-section-discover-result'); - if (!discoverSectionsBtn || !sectionResult) { - return; - } - if (urlInput.value.trim() === '') { - return; - } - if (sectionResult.innerHTML.trim() === '') { - discoverSectionsBtn.click(); - } - } + function refreshState() { + var hasToken = tokenInput.value.trim() !== ''; + var hasURL = urlInput.value.trim() !== ''; + var manualOpen = manualDetails.open; + done(step1, hasToken); + dim(step2, !hasToken && !manualOpen); + done(step2, hasURL); + dim(step3, !hasURL && !manualOpen); - function syncFlow() { - revealForProgress(); - autoLoadServers(); - autoLoadSections(); + if (manualOpen || hasToken) { + var btn = document.getElementById('plex-discover-servers-btn'); + var rs = document.getElementById('plex-server-discover-result'); + if (btn && rs && rs.innerHTML.trim() === '' && hasToken) btn.click(); + } + if (manualOpen || hasURL) { + var btn = document.getElementById('plex-discover-sections-btn'); + var rs = document.getElementById('plex-section-discover-result'); + if (btn && rs && rs.innerHTML.trim() === '' && hasURL) btn.click(); } + } + + tokenInput.addEventListener('input', refreshState); + tokenInput.addEventListener('change', refreshState); + urlInput.addEventListener('input', refreshState); + urlInput.addEventListener('change', refreshState); + manualDetails.addEventListener('toggle', refreshState); + document.body.addEventListener('htmx:afterSwap', refreshState); + refreshState(); + })(); + </script> + </section> + + {{else}} + {{/* ── Emby / Jellyfin / Audiobookshelf ────────────────── */}} + <div class="form-group"> + <label for="url">Server URL</label> + <input id="url" name="url" type="url" required + value="{{if $isEdit}}{{.Dest.URL}}{{end}}" + placeholder="http://server.lan:8096" + aria-describedby="url_help"> + <small id="url_help">Internal address Audplexus uses to reach the server. Use the LAN URL; no public proxy required.</small> + </div> + + <div class="form-group"> + <label for="api_key">API key {{if $isEdit}}<span class="form-label-aside">leave blank to keep</span>{{end}}</label> + <input id="api_key" name="api_key" type="password" autocomplete="off" + {{if not $isEdit}}required{{end}} + placeholder="{{if $isEdit}}•••••••••••••••••••••{{else}}Admin-scope key from {{.DestTypeLabel}}{{end}}" + aria-describedby="api_key_help"> + <small id="api_key_help">{{if eq $dt "abs"}}Admin-scope API key for the Audiobookshelf server.{{else}}{{.DestTypeLabel}} server API key.{{end}}</small> + </div> + {{end}} + </section> + + {{/* ── Section 2: Library (non-Plex) ─────────────────────────── */}} + {{if ne $dt "plex"}} + <section class="dest-form-section"> + <header class="dest-form-section-head"> + <span class="dest-form-eyebrow">Library</span> + <span class="dest-form-section-rule" aria-hidden="true"></span> + </header> - tokenInput.addEventListener('input', syncFlow); - tokenInput.addEventListener('change', syncFlow); - urlInput.addEventListener('input', syncFlow); - urlInput.addEventListener('change', syncFlow); - manualDetails.addEventListener('toggle', syncFlow); - document.body.addEventListener('htmx:afterSwap', syncFlow); - syncFlow(); - })(); - </script> - </section> - {{else}} + {{$discoverPath := ""}} + {{if eq $dt "abs"}}{{$discoverPath = "abs"}}{{end}} + {{if eq $dt "emby"}}{{$discoverPath = "emby"}}{{end}} + {{if eq $dt "jellyfin"}}{{$discoverPath = "jellyfin"}}{{end}} + + <div class="dest-guided-row dest-guided-row--standalone"> + <div class="dest-guided-text"> + <div class="dest-guided-title">Discover libraries</div> + <div class="dest-guided-desc">Asks {{.DestTypeLabel}} which libraries this key can see and fills the field below.</div> + </div> + <button type="button" class="btn btn-secondary" + hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/discover/{{$discoverPath}}{{else}}/destinations/discover/{{$discoverPath}}{{end}}" + hx-include="closest form" + hx-target="#{{$discoverPath}}-discover-result" + hx-swap="innerHTML" + hx-disabled-elt="this" + hx-on::before-request="document.getElementById('{{$discoverPath}}-discover-result').setAttribute('aria-busy','true'); document.getElementById('{{$discoverPath}}-discover-result').textContent='Listing libraries…';" + hx-on::after-request="document.getElementById('{{$discoverPath}}-discover-result').setAttribute('aria-busy','false');"> + Discover + </button> + </div> + <div id="{{$discoverPath}}-discover-result" class="dest-guided-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false"></div> + + <div class="form-group"> + <label for="library_id">Library ID</label> + <input id="library_id" name="library_id" type="text" required + value="{{if $isEdit}}{{.Dest.LibraryID}}{{end}}" + placeholder="{{if eq $dt "abs"}}00d44f08-df07-…{{else}}ItemId{{end}}" + aria-describedby="library_id_help"> + <small id="library_id_help">{{if eq $dt "abs"}}UUID of the audiobook library on the ABS server.{{else}}{{.DestTypeLabel}} ItemId of the audiobook library on the server.{{end}}</small> + </div> + </section> + {{end}} + + {{/* ── Section 3: Path translation (advanced) ───────────────── */}} + <section class="dest-form-section"> + <details class="dest-form-disclosure"> + <summary> + <span class="dest-form-disclosure-label">Path translation</span> + <span class="dest-form-disclosure-hint">Override per-destination paths when Audplexus and {{.DestTypeLabel}} mount the same files at different locations.</span> + </summary> + <div class="dest-form-disclosure-body"> <div class="form-group"> - <label for="api_key">API key</label> - <input id="api_key" name="api_key" type="password" autocomplete="off" - {{if not $isEdit}}required{{end}} - aria-describedby="api_key_help"> - <small id="api_key_help" class="muted">{{if $isEdit}}Leave blank to keep the current API key. {{end}}{{if eq $dt "abs"}}Admin-scope API key for the Audiobookshelf server.{{else}}{{.DestTypeLabel}} server API key.{{end}}</small> - <details style="margin-top:.4rem"> - <summary>How to generate this API key</summary> - {{if eq $dt "emby"}} - <ol style="margin:.5rem 0 0 1.25rem;padding:0"> - <li>Open Emby's web UI as an administrator.</li> - <li>Go to <strong>Settings</strong> (gear icon) → <strong>Server</strong> → <strong>Advanced</strong> → <strong>API Keys</strong>.</li> - <li>Click <strong>+ New API Key</strong>. Give it a name like "Audplexus".</li> - <li>Copy the generated key. Emby may show it only once depending on version.</li> - </ol> - {{else if eq $dt "jellyfin"}} - <ol style="margin:.5rem 0 0 1.25rem;padding:0"> - <li>Open Jellyfin's web UI as an administrator.</li> - <li>Go to <strong>Dashboard</strong> → <strong>API Keys</strong> (under Advanced).</li> - <li>Click <strong>+</strong>. Give it an app name like "Audplexus".</li> - <li>Copy the generated key. Jellyfin shows it once at creation.</li> - </ol> - {{else if eq $dt "abs"}} - <ol style="margin:.5rem 0 0 1.25rem;padding:0"> - <li>Open Audiobookshelf as an administrator.</li> - <li>Go to <strong>Settings</strong> (gear icon) → <strong>Users</strong>.</li> - <li>Click your admin user.</li> - <li>Scroll to <strong>API Keys</strong> → <strong>Generate API Key</strong>. Required scope: admin.</li> - <li>The token is shown <em>once</em> at generation. Copy it immediately and paste here.</li> - </ol> - {{end}} - </details> + <label for="audiobook_path">Audplexus-side path</label> + <input id="audiobook_path" name="audiobook_path" type="text" + value="{{if $isEdit}}{{.Dest.AudiobookPath}}{{end}}" + placeholder="/audiobooks" + aria-describedby="audiobook_path_help"> + <small id="audiobook_path_help">Path Audplexus writes to. Leave blank to use the global Audiobooks path.</small> </div> - {{if eq $dt "abs"}} - {{/* - Audiobookshelf can list its own libraries via - GET /api/libraries. The Discover button posts the - URL + token to the server, which renders an HTML - fragment containing a <select> — picking one fills - the library_id input below via the option's inline - onchange. Saves the user from copy-pasting a UUID - out of a curl response or a URL bar. - */}} <div class="form-group"> - <label>Discover libraries</label> - <div> - <button type="button" class="btn btn-secondary" - hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/discover/abs{{else}}/destinations/discover/abs{{end}}" - hx-include="closest form" - hx-target="#abs-discover-result" - hx-swap="innerHTML" - hx-disabled-elt="this" - hx-on::before-request="document.getElementById('abs-discover-result').setAttribute('aria-busy', 'true'); document.getElementById('abs-discover-result').textContent = 'Listing libraries…';" - hx-on::after-request="document.getElementById('abs-discover-result').setAttribute('aria-busy', 'false');"> - Discover libraries - </button> - <div id="abs-discover-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false" style="margin-top:.5rem"></div> - </div> - <small class="muted">Asks Audiobookshelf which libraries this token can see and fills the Library ID below. Or paste the UUID manually if you already have it.</small> + <label for="destination_path">{{.DestTypeLabel}}-side path</label> + <input id="destination_path" name="destination_path" type="text" + value="{{if $isEdit}}{{.Dest.DestinationPath}}{{end}}" + placeholder="/data/audiobooks" + aria-describedby="destination_path_help"> + <small id="destination_path_help">Path {{.DestTypeLabel}} sees the same files at. Used to translate scan triggers.</small> + </div> + </div> + </details> + </section> + + {{/* ── Section 4: Setup help (consolidated per-type guidance) ─ */}} + <section class="dest-form-section"> + <details class="dest-form-disclosure dest-form-help"> + <summary> + <span class="dest-form-disclosure-label">Setup help</span> + <span class="dest-form-disclosure-hint">Where to find tokens, API keys, and library IDs in {{.DestTypeLabel}}.</span> + </summary> + <div class="dest-form-disclosure-body"> + {{if eq $dt "plex"}} + <div class="dest-help-block"> + <div class="dest-help-eyebrow">Plex token</div> + <ol> + <li>Sign in to Plex web at <code>http://<your-plex>:32400/web</code>.</li> + <li>Play any audiobook (or open its detail page).</li> + <li>Click the <strong>⋯</strong> menu → <strong>Get Info</strong> → <strong>View XML</strong>.</li> + <li>The new tab's URL contains <code>?X-Plex-Token=…</code> — copy that value.</li> + <li>Or see <a href="https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/" target="_blank" rel="noopener noreferrer">Plex's official guide</a>.</li> + </ol> + </div> + <div class="dest-help-block"> + <div class="dest-help-eyebrow">Section ID</div> + <ol> + <li>In Plex web, click your audiobook library in the sidebar.</li> + <li>The URL bar shows <code>/source/<sectionId></code> — that number is your section ID.</li> + <li>API alternative: <code>curl 'http://<plex>:32400/library/sections?X-Plex-Token=…'</code>.</li> + </ol> </div> {{else if eq $dt "emby"}} - {{/* - Emby exposes its libraries via /emby/Library/MediaFolders - which returns Items with Id, Name, CollectionType. The - picker filters to CollectionType="audiobooks", falling - back to showing all libraries when none match so the - user can still pick a misconfigured-but-real library. - */}} - <div class="form-group"> - <label>Discover libraries</label> - <div> - <button type="button" class="btn btn-secondary" - hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/discover/emby{{else}}/destinations/discover/emby{{end}}" - hx-include="closest form" - hx-target="#emby-discover-result" - hx-swap="innerHTML" - hx-disabled-elt="this" - hx-on::before-request="document.getElementById('emby-discover-result').setAttribute('aria-busy', 'true'); document.getElementById('emby-discover-result').textContent = 'Listing libraries…';" - hx-on::after-request="document.getElementById('emby-discover-result').setAttribute('aria-busy', 'false');"> - Discover libraries - </button> - <div id="emby-discover-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false" style="margin-top:.5rem"></div> - </div> - <small class="muted">Asks Emby which libraries this key can see and fills the Library ID below. Or paste the ItemId manually if you already have it.</small> + <div class="dest-help-block"> + <div class="dest-help-eyebrow">API key</div> + <ol> + <li>Open Emby's web UI as an administrator.</li> + <li>Settings (gear) → <strong>Server</strong> → <strong>Advanced</strong> → <strong>API Keys</strong>.</li> + <li>Click <strong>+ New API Key</strong>. Name it <em>"Audplexus"</em>.</li> + <li>Copy the generated key — Emby may show it only once.</li> + </ol> + </div> + <div class="dest-help-block"> + <div class="dest-help-eyebrow">Library ID</div> + <ol> + <li><code>curl 'http://<emby>:8096/emby/Library/MediaFolders?api_key=…' | jq</code></li> + <li>Find the entry where <code>CollectionType</code> is <code>audiobooks</code>.</li> + <li>Copy its <code>ItemId</code>.</li> + </ol> </div> {{else if eq $dt "jellyfin"}} - {{/* - Jellyfin exposes its libraries via /Library/VirtualFolders - with Authorization: MediaBrowser Token=... — the picker - filters to CollectionType="books". - */}} - <div class="form-group"> - <label>Discover libraries</label> - <div> - <button type="button" class="btn btn-secondary" - hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/discover/jellyfin{{else}}/destinations/discover/jellyfin{{end}}" - hx-include="closest form" - hx-target="#jellyfin-discover-result" - hx-swap="innerHTML" - hx-disabled-elt="this" - hx-on::before-request="document.getElementById('jellyfin-discover-result').setAttribute('aria-busy', 'true'); document.getElementById('jellyfin-discover-result').textContent = 'Listing libraries…';" - hx-on::after-request="document.getElementById('jellyfin-discover-result').setAttribute('aria-busy', 'false');"> - Discover libraries - </button> - <div id="jellyfin-discover-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false" style="margin-top:.5rem"></div> - </div> - <small class="muted">Asks Jellyfin which libraries this key can see and fills the Library ID below. Or paste the ItemId manually if you already have it.</small> + <div class="dest-help-block"> + <div class="dest-help-eyebrow">API key</div> + <ol> + <li>Open Jellyfin's web UI as an administrator.</li> + <li>Dashboard → <strong>API Keys</strong> (under Advanced).</li> + <li>Click <strong>+</strong>. App name <em>"Audplexus"</em>.</li> + <li>Copy the generated key — Jellyfin shows it once at creation.</li> + </ol> </div> - {{end}} - <div class="form-group"> - <label for="library_id">Library ID</label> - <input id="library_id" name="library_id" type="text" required - value="{{if $isEdit}}{{.Dest.LibraryID}}{{end}}" - aria-describedby="library_id_help"> - <small id="library_id_help" class="muted">{{if eq $dt "abs"}}UUID of the audiobook library on the ABS server.{{else}}{{.DestTypeLabel}} ItemId of the audiobook library on the server.{{end}}</small> - <details style="margin-top:.4rem"> - <summary>How to find your library ID</summary> - {{if eq $dt "emby"}} - <ol style="margin:.5rem 0 0 1.25rem;padding:0"> - <li>In a terminal, run: <code>curl 'http://<emby>:8096/emby/Library/MediaFolders?api_key=<your_key>' | jq</code></li> - <li>Find the entry where <code>CollectionType</code> is <code>audiobooks</code>.</li> - <li>Copy its <code>ItemId</code> field. That's your library ID.</li> - </ol> - {{else if eq $dt "jellyfin"}} - <ol style="margin:.5rem 0 0 1.25rem;padding:0"> - <li>In a terminal, run: <code>curl -H 'Authorization: MediaBrowser Token="<your_key>"' 'http://<jellyfin>:8096/Library/VirtualFolders' | jq</code></li> - <li>Find the entry with <code>"CollectionType": "books"</code> and an audiobook path.</li> - <li>Copy its <code>ItemId</code> field. That's your library ID.</li> - <li>The legacy <code>X-Emby-Token</code> header still works on Jellyfin 10.x but is removed in 12.0 — use the <code>Authorization: MediaBrowser</code> scheme above.</li> - </ol> - {{else if eq $dt "abs"}} - <ol style="margin:.5rem 0 0 1.25rem;padding:0"> - <li>In a terminal, run: <code>curl -H 'Authorization: Bearer <your_key>' 'http://<abs>:80/api/libraries' | jq '.libraries[] | {id,name,mediaType}'</code></li> - <li>Find the entry where <code>mediaType</code> is <code>book</code> (your audiobook library).</li> - <li>Copy its <code>id</code> field (a UUID like <code>00d44f08-df07-...</code>). That's your library ID.</li> - <li>UI alternative: open Audiobookshelf web → click your audiobook library → the URL <code>/library/<id></code> contains it.</li> - </ol> - {{end}} - </details> + <div class="dest-help-block"> + <div class="dest-help-eyebrow">Library ID</div> + <ol> + <li><code>curl -H 'Authorization: MediaBrowser Token="…"' 'http://<jellyfin>:8096/Library/VirtualFolders' | jq</code></li> + <li>Find <code>"CollectionType": "books"</code> with an audiobook path.</li> + <li>Copy its <code>ItemId</code>.</li> + </ol> + </div> + {{else if eq $dt "abs"}} + <div class="dest-help-block"> + <div class="dest-help-eyebrow">API key</div> + <ol> + <li>Open Audiobookshelf as an administrator.</li> + <li>Settings (gear) → <strong>Users</strong> → click your admin user.</li> + <li>Scroll to <strong>API Keys</strong> → <strong>Generate API Key</strong>. Required scope: <code>admin</code>.</li> + <li>Token is shown once — copy it immediately.</li> + </ol> + </div> + <div class="dest-help-block"> + <div class="dest-help-eyebrow">Library ID</div> + <ol> + <li><code>curl -H 'Authorization: Bearer …' 'http://<abs>/api/libraries' | jq '.libraries[] | {id,name,mediaType}'</code></li> + <li>Find <code>mediaType</code> = <code>book</code>.</li> + <li>Copy its <code>id</code> (UUID).</li> + <li>Or: open ABS web → click your audiobook library → URL <code>/library/<id></code>.</li> + </ol> </div> {{end}} + </div> + </details> + </section> - <details style="margin:1rem 0;"> - <summary>Advanced: per-destination path translation</summary> - <div class="form-group" style="margin-top:.75rem"> - <label for="audiobook_path">Audplexus-side audiobook path</label> - <input id="audiobook_path" name="audiobook_path" type="text" - value="{{if $isEdit}}{{.Dest.AudiobookPath}}{{end}}" - placeholder="/audiobooks" - aria-describedby="audiobook_path_help"> - <small id="audiobook_path_help" class="muted">Path Audplexus writes to. Leave blank to use the global Audiobooks path.</small> - </div> - <div class="form-group"> - <label for="destination_path">{{.DestTypeLabel}}-side audiobook path</label> - <input id="destination_path" name="destination_path" type="text" - value="{{if $isEdit}}{{.Dest.DestinationPath}}{{end}}" - placeholder="/data/audiobooks" - aria-describedby="destination_path_help"> - <small id="destination_path_help" class="muted">Path {{.DestTypeLabel}} sees the same files at. Used to translate scan triggers.</small> - </div> - </details> - - {{/* - Test result container (must exist before HTMX swap so the - live region announcement fires reliably). role="status" - + aria-live="polite" announces the outcome to SR users - without interrupting them. aria-atomic="true" ensures - the full result is read on update, not just diffs. - */}} - <div id="test-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false" style="margin-top:.75rem"></div> + {{/* Test result lives just above the sticky bar so its outcome is + visible when the user looks at the actions. */}} + <div id="test-result" class="dest-form-test-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false"></div> - <div style="display:flex;gap:.75rem;margin-top:1rem;flex-wrap:wrap"> - <button type="button" class="btn btn-outline-ds" onclick="window.AudplexusDestModal && window.AudplexusDestModal.close()">Cancel</button> - <button type="button" class="btn btn-secondary" data-test-button - hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/test{{else}}/destinations/test{{end}}" - hx-include="closest form" - hx-target="#test-result" - hx-swap="innerHTML" - hx-on::before-request="document.getElementById('test-result').setAttribute('aria-busy', 'true'); document.getElementById('test-result').textContent = 'Testing connection…';" - hx-on::after-request="document.getElementById('test-result').setAttribute('aria-busy', 'false');"> - Test Connection - </button> - <button type="submit" class="btn btn-primary">{{if $isEdit}}Save Destination{{else}}Create Destination{{end}}</button> - </div> - </form> + <div class="dest-form-actions"> + <button type="button" class="btn btn-outline-ds" onclick="window.AudplexusDestModal && window.AudplexusDestModal.close()">Cancel</button> + <span class="spacer" aria-hidden="true"></span> + <button type="button" class="btn btn-secondary" data-test-button + hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/test{{else}}/destinations/test{{end}}" + hx-include="closest form" + hx-target="#test-result" + hx-swap="innerHTML" + hx-on::before-request="document.getElementById('test-result').setAttribute('aria-busy','true'); document.getElementById('test-result').textContent='Testing connection…';" + hx-on::after-request="document.getElementById('test-result').setAttribute('aria-busy','false');"> + Test connection + </button> + <button type="submit" class="btn btn-primary"> + {{if $isEdit}}Save changes{{else}}Create destination{{end}} + </button> + </div> +</form> {{end}} {{end}} From aef8677e606315f83edc5c122127c934a9b35f52 Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Wed, 20 May 2026 23:12:21 -0500 Subject: [PATCH 27/40] fix: Fetch purchase date for Audible library items The Audible API's `/library` endpoint requires the `relationships` response group to return the `purchase_date`. Without this, the 'Purchased' date would appear blank on the Library page. This change explicitly requests the `relationships` group when fetching the Audible library, ensuring the purchase date is available for display. --- internal/library/sync.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/library/sync.go b/internal/library/sync.go index b9eda40..911cc54 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -943,7 +943,14 @@ func phaseRunsInMode(mode SyncMode, phase SyncPhase) bool { func (s *SyncService) doAudibleSync(ctx context.Context, syncRecord *database.SyncHistory) (int, error) { syncLog.Info().Msg("starting audible library sync") - books, err := s.client.GetAllLibrary(ctx) + // Audible's /library endpoint only returns purchase_date when the + // `relationships` response group is requested — it lives on the + // user↔asset relationship payload, not the product itself. The SDK's + // DefaultResponseGroups omits it, so without this override the + // Library page renders Purchased as blank for every row. + libraryGroups := append([]string{}, audible.DefaultResponseGroups...) + libraryGroups = append(libraryGroups, "relationships") + books, err := s.client.GetAllLibrary(ctx, audible.WithResponseGroups(libraryGroups...)) if err != nil { syncLog.Error().Err(err).Msg("failed to fetch audible library") return 0, err From 0de6bddba108c882b88c425a2a7a0e2a05d1f09d Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Wed, 20 May 2026 23:20:13 -0500 Subject: [PATCH 28/40] feat: Enhance sync status display with last run and next schedule info Improve the library sync status panel to provide comprehensive information, even when no sync is actively running. This includes: - Displaying a summary of the last completed sync (time, books found/added, status, error). This data is sourced from in-memory state or a database fallback on cold start. - Projecting and displaying the next scheduled sync time based on configured settings, including handling disabled auto-sync or invalid schedules. - Introducing a new `formatRelative` template helper for human-friendly time strings. These changes ensure the sync panel is always informative, giving users better visibility into their library's synchronization history and future schedule. --- internal/web/server.go | 134 ++++++++++++++++++++++++ internal/web/static/style.css | 62 +++++++++++ internal/web/templates/sync_status.html | 69 +++++++++++- 3 files changed, 264 insertions(+), 1 deletion(-) diff --git a/internal/web/server.go b/internal/web/server.go index af9d50b..267f563 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -36,6 +36,7 @@ import ( "github.com/mstrhakr/audplexus/internal/mediaserver" "github.com/mstrhakr/audplexus/internal/organizer" audible "github.com/mstrhakr/go-audible" + "github.com/robfig/cron/v3" ) var webLog = logging.Component("web") @@ -237,6 +238,54 @@ func (s *Server) setupTemplates() { } return t.Format("Jan 2, 2006") }, + // formatRelative renders a human-friendly "5 minutes ago" / + // "2 hours ago" / "in 4 hours" string. Returns "" for zero so + // the template can skip rendering altogether. Bidirectional — + // past times read "X ago", future times read "in X". + "formatRelative": func(t time.Time) string { + if t.IsZero() { + return "" + } + d := time.Since(t) + future := d < 0 + if future { + d = -d + } + fmtUnit := func(n int, unit string) string { + if n == 1 { + if future { + return "in 1 " + unit + } + return "1 " + unit + " ago" + } + if future { + return fmt.Sprintf("in %d %ss", n, unit) + } + return fmt.Sprintf("%d %ss ago", n, unit) + } + switch { + case d < time.Minute: + if future { + return "in under a minute" + } + return "just now" + case d < time.Hour: + return fmtUnit(int(d/time.Minute), "minute") + case d < 24*time.Hour: + return fmtUnit(int(d/time.Hour), "hour") + case d < 7*24*time.Hour: + days := int(d / (24 * time.Hour)) + if !future && days == 1 { + return "yesterday" + } + if future && days == 1 { + return "tomorrow" + } + return fmtUnit(days, "day") + default: + return t.Format("Jan 2, 2006") + } + }, "statusBadge": func(status database.BookStatus) string { switch status { case database.BookStatusComplete: @@ -1953,6 +2002,17 @@ func (s *Server) triggerSync(c *gin.Context, mode library.SyncMode) { } // syncStatusData converts a SyncProgress into template data. +// +// When no sync is running, we also surface the last completed sync's +// summary (when it ran, how many books were found / added, status, +// error) so the panel doesn't look empty between runs. The data +// source has two layers: +// - in-memory: SyncService keeps the last completed progress in +// s.progress between runs, so as long as the process hasn't +// restarted we can read it for free. +// - DB fallback: on cold start the in-memory progress is zero-valued, +// so we hit sync_history.GetLastSync to recover the most recent +// row. Cheap (single indexed lookup) and only runs when needed. func (s *Server) syncStatusData(progress library.SyncProgress) gin.H { phases := progress.Phases if len(phases) == 0 { @@ -1974,6 +2034,80 @@ func (s *Server) syncStatusData(progress library.SyncProgress) gin.H { "Phases": phases, "CurrentPhase": string(progress.CurrentPhase), } + + // "Idle" panel: surface last-run summary so the user sees what + // happened on the previous sync. We treat the panel as idle when + // the service isn't currently running. + if !progress.Running { + var ( + lastCompletedAt time.Time + lastBooksFound int + lastBooksAdded int + lastStatus string + lastError string + ) + + if !progress.CompletedAt.IsZero() { + // In-memory state from the most recent run in this process. + lastCompletedAt = progress.CompletedAt + lastBooksFound = progress.BooksFound + lastBooksAdded = progress.BooksAdded + lastStatus = progress.Status + lastError = progress.Error + } else if row, err := s.db.GetLastSync(context.Background()); err == nil && row != nil { + // Cold-start fallback — read the most recent sync_history row. + if row.CompletedAt != nil { + lastCompletedAt = *row.CompletedAt + } else { + lastCompletedAt = row.StartedAt + } + lastBooksFound = row.BooksFound + lastBooksAdded = row.BooksAdded + lastStatus = row.Status + lastError = row.Error + } + + data["LastCompletedAt"] = lastCompletedAt + data["LastBooksFound"] = lastBooksFound + data["LastBooksAdded"] = lastBooksAdded + data["LastStatus"] = lastStatus + data["LastError"] = lastError + data["HasLastSync"] = !lastCompletedAt.IsZero() + + // Next auto-run: read the same cron string + enabled flag the + // scheduler is configured from, parse it here, and project the + // next fire time. Done in the web layer (rather than asking the + // scheduler) so the panel reflects the persisted intent rather + // than the in-process state — if the scheduler crashed and the + // app is showing the panel, we still tell the user when their + // library *should* refresh. + dbCtx := context.Background() + schedule, _ := s.db.GetSetting(dbCtx, "sync_schedule") + schedule = strings.TrimSpace(schedule) + enabledRaw, _ := s.db.GetSetting(dbCtx, "sync_enabled") + // sync_enabled defaults to true when unset, matching the rest + // of the app (s.settingBool's default is true at the readers). + enabled := true + if enabledRaw != "" { + enabled = enabledRaw == "true" + } + + data["SyncEnabled"] = enabled + data["SyncSchedule"] = schedule + + if enabled && schedule != "" { + if parsed, err := cron.ParseStandard(schedule); err == nil { + next := parsed.Next(time.Now()) + if !next.IsZero() { + data["NextSyncAt"] = next + data["HasNextSync"] = true + } + } else { + data["ScheduleInvalid"] = true + } + } + } + return data } diff --git a/internal/web/static/style.css b/internal/web/static/style.css index f1250d0..29dba80 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -599,6 +599,68 @@ h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); } color: var(--text-muted); } +/* Idle-state last-sync summary row */ +.sync-status-v2 .sync-last-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; + padding: 0.5rem 0.7rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 7px; + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.4; +} +.sync-status-v2 .sync-last-time { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--text); + font-weight: 500; +} +.sync-status-v2 .sync-last-time svg { + color: var(--text-muted); + flex-shrink: 0; +} +.sync-status-v2 .sync-last-sep { + color: var(--border); + user-select: none; +} +.sync-status-v2 .sync-last-stat b { + color: var(--text); + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.sync-status-v2 .sync-last-stat--accent b { color: var(--accent); } +.sync-status-v2 .sync-last-stat--error { + color: #ff9b9b; + max-width: 38ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* Next-run pill — forward-looking sibling of the last-run timestamp. + Uses plex-cyan to read as schedule/metadata rather than action. */ +.sync-status-v2 .sync-next-time { + color: var(--plex-cyan); + font-weight: 500; +} +.sync-status-v2 .sync-next-time svg { + color: var(--plex-cyan); + opacity: 0.85; +} +.sync-status-v2 .sync-next-time--off { + color: var(--text-muted); + font-style: italic; +} +.sync-status-v2 .sync-next-time--off svg { + color: var(--text-muted); + opacity: 0.7; +} + /* Refined sync phases grid (DesignSystem look) — applied alongside legacy .sync-phase rules. Stacked classes (.sync-phases.v2) keep backward compat with the existing horizontal flex layout used elsewhere. */ diff --git a/internal/web/templates/sync_status.html b/internal/web/templates/sync_status.html index df52f8a..47ecfc1 100644 --- a/internal/web/templates/sync_status.html +++ b/internal/web/templates/sync_status.html @@ -1,5 +1,8 @@ {{define "sync_status.html"}} <div class="sync-status-container sync-status-v2"> + {{/* Header reflects the live state: actively running, just failed, + partial, complete, or idle. When idle we surface last-sync info + below so the panel is never blank. */}} <div class="sync-head"> {{if .Running}} <span class="pulse-dot"></span> @@ -13,11 +16,75 @@ {{else if eq .Status "complete"}} <span class="ok-dot"></span> <strong>{{if .Message}}{{.Message}}{{else}}Last sync complete{{end}}</strong> + {{else if .HasLastSync}} + {{/* Idle but we have a previous run on record: tint the dot + to match the previous outcome so the user knows at a + glance whether their library is fresh. */}} + {{if eq .LastStatus "failed"}} + <span class="ok-dot" style="background:var(--error);box-shadow:0 0 0 3px rgba(244,67,54,.18)"></span> + <strong>Last sync failed</strong> + {{else if eq .LastStatus "partial"}} + <span class="ok-dot" style="background:var(--warning);box-shadow:0 0 0 3px rgba(255,152,0,.18)"></span> + <strong>Last sync finished with errors</strong> + {{else if eq .LastStatus "complete"}} + <span class="ok-dot"></span> + <strong>Library is up to date</strong> + {{else}} + <span class="ok-dot" style="background:var(--text-muted);box-shadow:none"></span> + <strong>No sync running</strong> + {{end}} {{else}} <span class="ok-dot" style="background:var(--text-muted);box-shadow:none"></span> - <strong>No sync running</strong> + <strong>No sync has run yet</strong> {{end}} </div> + + {{if not .Running}} + {{if or .HasLastSync .HasNextSync .ScheduleInvalid (not .SyncEnabled)}} + <div class="sync-last-summary"> + {{if .HasLastSync}} + <span class="sync-last-time" title="Last sync at {{formatDate .LastCompletedAt}}"> + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> + {{formatRelative .LastCompletedAt}} + </span> + <span class="sync-last-sep" aria-hidden="true">·</span> + <span class="sync-last-stat"><b>{{.LastBooksFound}}</b> {{if eq .LastBooksFound 1}}book{{else}}books{{end}} found</span> + {{if gt .LastBooksAdded 0}} + <span class="sync-last-sep" aria-hidden="true">·</span> + <span class="sync-last-stat sync-last-stat--accent"><b>+{{.LastBooksAdded}}</b> new</span> + {{end}} + {{if and (eq .LastStatus "failed") .LastError}} + <span class="sync-last-sep" aria-hidden="true">·</span> + <span class="sync-last-stat sync-last-stat--error" title="{{.LastError}}">{{.LastError}}</span> + {{end}} + {{end}} + + {{/* Forward-looking pill — sits to the right of the recap so + the user reads "last sync … / next sync …" in a single + glance. The schedule comes from the same setting the + scheduler reads, so it stays in lockstep with intent + rather than runtime. */}} + {{if .HasNextSync}} + {{if .HasLastSync}}<span class="sync-last-sep" aria-hidden="true">·</span>{{end}} + <span class="sync-last-time sync-next-time" title="Next sync at {{formatDate .NextSyncAt}} ({{.SyncSchedule}})"> + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 10"/><polyline points="9 16 13 12 9 8"/></svg> + next {{formatRelative .NextSyncAt}} + </span> + {{else if .ScheduleInvalid}} + {{if .HasLastSync}}<span class="sync-last-sep" aria-hidden="true">·</span>{{end}} + <span class="sync-last-stat sync-last-stat--error" title="Cron expression {{.SyncSchedule}} could not be parsed"> + Schedule misconfigured + </span> + {{else if not .SyncEnabled}} + {{if .HasLastSync}}<span class="sync-last-sep" aria-hidden="true">·</span>{{end}} + <span class="sync-last-time sync-next-time sync-next-time--off" title="Auto-sync is disabled in Settings"> + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg> + manual sync only + </span> + {{end}} + </div> + {{end}} + {{end}} {{if .Phases}} <div class="sync-phases v2"> {{range .Phases}} From 1f26713eb35b3955c5e2ca90cae24faed65263e3 Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Wed, 20 May 2026 23:25:34 -0500 Subject: [PATCH 29/40] feat: Unify and enhance error message display in web UI Introduces a `cleanError` template helper to standardize the rendering of error and detail strings across the dashboard and sync status pages. This helper strips embedded HTML, collapses whitespace, rewrites common HTTP codes, and truncates messages for consistent and user-friendly presentation. --- internal/web/server.go | 8 ++++++++ internal/web/templates/dashboard_downloads.html | 2 +- internal/web/templates/dashboard_summary.html | 2 +- internal/web/templates/sync_status.html | 5 +++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index 267f563..df93ea3 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -317,6 +317,14 @@ func (s *Server) setupTemplates() { } return template.HTML(htmlPolicy.Sanitize(raw)) }, + // cleanError is the shared render path for error strings shown in + // the UI. Strips embedded HTML, collapses whitespace, rewrites + // common HTTP codes into friendly hints, and truncates. The + // destinations test path runs errors through this before storage; + // dashboard fields (failed-download errors, sync errors, cached + // destination health detail) are raw, so templates pipe through + // this helper to keep the render uniform. + "cleanError": cleanErrorForDisplay, "hasSuffix": strings.HasSuffix, "coveragePct": func(part, total int) int { if total <= 0 { diff --git a/internal/web/templates/dashboard_downloads.html b/internal/web/templates/dashboard_downloads.html index eee4294..d2a5064 100644 --- a/internal/web/templates/dashboard_downloads.html +++ b/internal/web/templates/dashboard_downloads.html @@ -37,7 +37,7 @@ <tr id="failed-{{.ID}}"> <td>{{if $bookID}}<a href="/library/{{$bookID}}" class="book-detail-link" data-book-id="{{$bookID}}">{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}}</a>{{else}}{{if $title}}{{$title}}{{else}}{{.ASIN}}{{end}}{{end}}</td> <td class="cell-mono">{{if $bookID}}<a href="/library/{{$bookID}}" class="book-detail-link" data-book-id="{{$bookID}}">{{.ASIN}}</a>{{else}}{{.ASIN}}{{end}}</td> - <td class="error-text">{{.Error}}</td> + <td class="error-text">{{cleanError .Error}}</td> </tr> {{end}} </tbody> diff --git a/internal/web/templates/dashboard_summary.html b/internal/web/templates/dashboard_summary.html index 5fcb9a1..576edf9 100644 --- a/internal/web/templates/dashboard_summary.html +++ b/internal/web/templates/dashboard_summary.html @@ -70,7 +70,7 @@ </div> {{end}} {{if .HealthDetail}} - <div class="dest-row" style="color:#ff9b9b;font-size:.72rem">{{.HealthDetail}}</div> + <div class="dest-row" style="color:#ff9b9b;font-size:.72rem">{{cleanError .HealthDetail}}</div> {{end}} <div class="dest-actions"> <span class="dest-checked"> diff --git a/internal/web/templates/sync_status.html b/internal/web/templates/sync_status.html index 47ecfc1..db74586 100644 --- a/internal/web/templates/sync_status.html +++ b/internal/web/templates/sync_status.html @@ -9,7 +9,7 @@ <strong>{{if eq .Mode "quick"}}Quick{{else}}Full{{end}} sync in progress</strong> {{else if eq .Status "failed"}} <span class="ok-dot" style="background:var(--error);box-shadow:0 0 0 3px rgba(244,67,54,.18)"></span> - <strong>Sync failed{{if .Error}}: {{.Error}}{{end}}</strong> + <strong>Sync failed{{if .Error}}: {{cleanError .Error}}{{end}}</strong> {{else if eq .Status "partial"}} <span class="ok-dot" style="background:var(--warning);box-shadow:0 0 0 3px rgba(255,152,0,.18)"></span> <strong>Sync finished with errors</strong> @@ -55,7 +55,8 @@ {{end}} {{if and (eq .LastStatus "failed") .LastError}} <span class="sync-last-sep" aria-hidden="true">·</span> - <span class="sync-last-stat sync-last-stat--error" title="{{.LastError}}">{{.LastError}}</span> + {{$cleanLast := cleanError .LastError}} + <span class="sync-last-stat sync-last-stat--error" title="{{$cleanLast}}">{{$cleanLast}}</span> {{end}} {{end}} From 0c21038a52268e26b51a9869c3f900971e505f1c Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Wed, 20 May 2026 23:27:55 -0500 Subject: [PATCH 30/40] chore: Add temporary diagnostic log for Audible book dates Introduces a one-shot log entry for the first book found during an Audible sync to inspect its `purchase_date` and `release_date` fields. This is to verify if the API consistently populates these fields with the SDK's default response groups. --- internal/library/sync.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/library/sync.go b/internal/library/sync.go index 911cc54..17ca608 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -956,6 +956,19 @@ func (s *SyncService) doAudibleSync(ctx context.Context, syncRecord *database.Sy return 0, err } + // One-shot diagnostic: log the first book's purchase/release fields so + // we can see whether the API populates purchase_date at all with the + // SDK's default response groups. Remove once resolved. + if len(books) > 0 { + b0 := books[0] + syncLog.Info(). + Str("asin", b0.BestID()). + Str("title", b0.Title). + Str("purchase_date", b0.PurchaseDate). + Str("release_date", b0.ReleaseDate). + Msg("sync diagnostic: first-book date fields") + } + syncRecord.BooksFound = len(books) s.mu.Lock() s.progress.BooksFound = len(books) From ccc7e1909bde29824989affc20be43dabc8d8c1e Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Wed, 20 May 2026 23:33:41 -0500 Subject: [PATCH 31/40] fix: Robustly parse Audible purchase and release dates Audible's API is inconsistent with the date formats returned for `purchase_date` and `release_date`. Fields can come back as RFC3339Nano, RFC3339, or simple ISO date, and can occasionally be empty. This change introduces a new `parseAudibleDate` helper function that attempts to parse these fields using a prioritized list of known layouts, ensuring correct interpretation across all observed formats. The one-shot diagnostic log, previously added to investigate these date fields, is also removed as the issue has been understood and addressed. --- internal/library/sync.go | 48 ++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/internal/library/sync.go b/internal/library/sync.go index 17ca608..6c46053 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -956,18 +956,6 @@ func (s *SyncService) doAudibleSync(ctx context.Context, syncRecord *database.Sy return 0, err } - // One-shot diagnostic: log the first book's purchase/release fields so - // we can see whether the API populates purchase_date at all with the - // SDK's default response groups. Remove once resolved. - if len(books) > 0 { - b0 := books[0] - syncLog.Info(). - Str("asin", b0.BestID()). - Str("title", b0.Title). - Str("purchase_date", b0.PurchaseDate). - Str("release_date", b0.ReleaseDate). - Msg("sync diagnostic: first-book date fields") - } syncRecord.BooksFound = len(books) s.mu.Lock() @@ -1169,6 +1157,32 @@ func ucfirst(s string) string { return strings.ToUpper(s[:1]) + s[1:] } +// audibleDateLayouts is the priority-ordered list of layouts we accept +// when parsing Audible-supplied timestamps. RFC3339 nano covers the +// "2026-05-19T07:29:29.505Z" purchase_date shape; RFC3339 covers the +// no-fractional variant; the date-only layout covers release_date. +var audibleDateLayouts = []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02", +} + +// parseAudibleDate tolerates the multiple shapes Audible returns for +// purchase_date / release_date. Returns the zero time when the input is +// empty or in none of the known formats. +func parseAudibleDate(raw string) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{} + } + for _, layout := range audibleDateLayouts { + if t, err := time.Parse(layout, raw); err == nil { + return t + } + } + return time.Time{} +} + func convertBook(b audible.Book) database.Book { authors := make([]string, len(b.Authors)) for i, a := range b.Authors { @@ -1198,8 +1212,14 @@ func convertBook(b audible.Book) database.Book { coverURL = b.ProductImages.Image500 } - purchaseDate, _ := time.Parse("2006-01-02", b.PurchaseDate) - releaseDate, _ := time.Parse("2006-01-02", b.ReleaseDate) + // Audible is inconsistent across fields and accounts: purchase_date + // comes back as RFC3339 ("2026-05-19T07:29:29.505Z"), release_date + // as plain ISO date ("2023-08-23"), and occasionally either field + // is just empty. parseAudibleDate tries the formats we've seen in + // the wild in order; failures fall through to the zero time, which + // the UI already renders as a blank cell. + purchaseDate := parseAudibleDate(b.PurchaseDate) + releaseDate := parseAudibleDate(b.ReleaseDate) drmType := b.ContentDeliveryType if drmType == "" { From 941665aa39c6c593581db5cbd8f8f9278ec38e93 Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Wed, 20 May 2026 23:40:50 -0500 Subject: [PATCH 32/40] feat: Enhance diagnostics page with connection tests and log copy A new 'Connection Tests' panel provides a centralized overview of Audible API and configured destination health, complete with inline test buttons. The 'Live Logs' panel gains client-side functionality to copy individual log lines or all visible log messages, improving debuggability. For Plex destination editing, the guided stepper now pre-seeds its state from existing configuration, streamlining the editing experience. Applies the shared error message cleaner to destination health checks in the diagnostics comparison view for consistent, user-friendly error display. --- internal/web/destinations_handlers.go | 16 +++ internal/web/diagnostics_handlers.go | 42 +++++- internal/web/server.go | 1 + internal/web/static/style.css | 105 ++++++++++++++ internal/web/templates/diagnostics.html | 175 +++++++++++++++++++++--- 5 files changed, 318 insertions(+), 21 deletions(-) diff --git a/internal/web/destinations_handlers.go b/internal/web/destinations_handlers.go index 754c0c2..120735b 100644 --- a/internal/web/destinations_handlers.go +++ b/internal/web/destinations_handlers.go @@ -1123,6 +1123,22 @@ func (s *Server) handleDestinationEditForm(c *gin.Context) { data["DestType"] = string(row.Type) data["DestTypeLabel"] = destinationTypeLabel(row.Type) data["Dest"] = row + + // Seed the guided stepper's initial state for the Plex edit flow. + // The template can't easily peek at LastHealthCheckOK (it's *bool), + // so we project the booleans the JS state machine wants: + // - HasStoredCredential: token already saved → step 1 done. + // - HasStoredURL: server picked → step 2 done. + // - HasStoredSection: library chosen → step 3 done. + // - LastHealthFailed: most recent test bombed → step 3 red. + data["HasStoredCredential"] = strings.TrimSpace(row.PlexToken) != "" || strings.TrimSpace(row.APIKey) != "" + data["HasStoredURL"] = strings.TrimSpace(row.URL) != "" + data["HasStoredSection"] = strings.TrimSpace(row.PlexSectionID) != "" || strings.TrimSpace(row.LibraryID) != "" + data["LastHealthFailed"] = row.LastHealthCheckOK != nil && !*row.LastHealthCheckOK + if row.LastHealthCheckErr != "" { + data["LastHealthErr"] = cleanErrorForDisplay(row.LastHealthCheckErr) + } + c.HTML(http.StatusOK, "destination_form_body", data) } diff --git a/internal/web/diagnostics_handlers.go b/internal/web/diagnostics_handlers.go index 40625ae..0cc98fa 100644 --- a/internal/web/diagnostics_handlers.go +++ b/internal/web/diagnostics_handlers.go @@ -152,7 +152,11 @@ func (s *Server) handleDiagnosticsCompare(c *gin.Context) { FetchHealthy: inv.FetchErr == nil, } if inv.FetchErr != nil { - card.FetchError = inv.FetchErr.Error() + // Strip embedded HTML / map HTTP codes to friendly hints + // using the shared cleaner so the Drift Inbox card reads + // the same way the destination card and Connection-test + // list do for the same underlying failure. + card.FetchError = cleanErrorForDisplay(inv.FetchErr.Error()) } destCards = append(destCards, card) } @@ -972,6 +976,42 @@ func (s *Server) handleDiagnosticsEnv(c *gin.Context) { }) } +// handleDiagnosticsDestinations returns a JSON snapshot of every +// configured destination's connection state for the Connection-tests +// list on the Logs & Environment diagnostics tab. Read-only; per-row +// Test buttons hit the existing /destinations/:id/test endpoint. +// +// We reuse destinationSummaries (the same view-model the dashboard +// reads) so disabled / never-checked / failed states render identically +// across the app. Sensitive fields (api_key, plex_token) are not in +// the summary struct, so this can't leak credentials. +func (s *Server) handleDiagnosticsDestinations(c *gin.Context) { + ctx := c.Request.Context() + + // Coverage stat needs a books-complete count, but the diagnostics + // list doesn't render coverage — pass 0 so the helper skips that + // branch and we avoid a per-call books table scan. + summaries := s.destinationSummaries(ctx, 0) + + out := make([]gin.H, 0, len(summaries)) + for _, v := range summaries { + out = append(out, gin.H{ + "id": v.ID, + "display_name": v.DisplayName, + "type": v.Type, + "type_label": v.TypeLabel, + "enabled": v.Enabled, + "configured": v.Configured, + "url": v.URL, + "health": v.Health, + "health_detail": v.HealthDetail, + "last_error": v.LastError, + "last_checked_at": v.LastCheckedAt, + }) + } + c.JSON(http.StatusOK, gin.H{"destinations": out}) +} + // handleDiagnosticsLogsTail returns the most recent log lines from the // in-memory ring buffer (capped at 1024). Polled by the diagnostics // page every 2s while the Logs panel is visible. diff --git a/internal/web/server.go b/internal/web/server.go index df93ea3..8f4cfac 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -562,6 +562,7 @@ func (s *Server) setupRoutes() { // DS-style ops tab — read-only system state. JSON so the // browser can pretty-render in the diagnostics page. api.GET("/diagnostics/env", s.handleDiagnosticsEnv) + api.GET("/diagnostics/destinations", s.handleDiagnosticsDestinations) api.GET("/diagnostics/logs/tail", s.handleDiagnosticsLogsTail) api.POST("/downloads/redownload/:asin", s.handleRedownload) diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 29dba80..a1abc8b 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -3066,6 +3066,111 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.18); } +/* ── Diagnostics: Live logs row + copy affordance ─────────────────── + Three-column grid: monospace timestamp, message, copy button. The + button stays invisible until the row is hovered or keyboard-focused + so the stream reads cleanly during fast updates, then materializes + with the cursor for one-click copy. */ +.diag-log-row { + display: grid; + grid-template-columns: 80px 1fr 24px; + gap: 0.6rem; + align-items: start; + padding: 1px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + position: relative; +} +.diag-log-row .diag-log-ts { + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.diag-log-row .diag-log-msg { + white-space: pre-wrap; + word-break: break-word; + min-width: 0; +} +.diag-log-row .diag-log-copy { + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-muted); + border-radius: 5px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0; + transform: translateY(-1px); + transition: opacity 0.1s, color 0.1s, border-color 0.1s, background 0.1s; +} +.diag-log-row:hover .diag-log-copy, +.diag-log-row:focus-within .diag-log-copy, +.diag-log-copy:focus-visible { + opacity: 1; +} +.diag-log-copy:hover { + color: var(--text); + border-color: var(--text-muted); + background: var(--surface-2); +} +.diag-log-copy.is-ok { + opacity: 1; + color: var(--success); + border-color: var(--success); + background: rgba(76, 175, 80, 0.12); +} +.diag-log-copy.is-err { + opacity: 1; + color: var(--error); + border-color: var(--error); + background: rgba(244, 67, 54, 0.12); +} +.diag-log-copy svg { display: block; } + +/* Diagnostics: Connection tests list */ +#diag-conn-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.diag-conn-row { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.65rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 7px; + font-size: 0.82rem; + min-width: 0; +} +.diag-conn-row .diag-conn-name { + color: var(--text); + font-weight: 500; + flex-shrink: 0; + display: inline-flex; + align-items: baseline; + min-width: 0; +} +.diag-conn-row .diag-conn-detail { + margin-left: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 60%; + min-width: 0; + font-variant-numeric: tabular-nums; +} +.diag-conn-row .diag-conn-action { flex-shrink: 0; } +.diag-conn-row .diag-conn-action .btn-icon { + width: 26px; + height: 26px; + min-width: 26px; +} + .picker-list { display: flex; flex-direction: column; gap: 0.5rem; } .picker-list .picker { flex-direction: row; align-items: center; gap: 0.85rem; } .picker-list .picker .lg { flex: 1; } diff --git a/internal/web/templates/diagnostics.html b/internal/web/templates/diagnostics.html index e53f0d5..7b54c3b 100644 --- a/internal/web/templates/diagnostics.html +++ b/internal/web/templates/diagnostics.html @@ -51,6 +51,10 @@ <h2>Issue Inbox</h2> <option value="error">ERROR</option> <option value="debug">DEBUG</option> </select> + <button type="button" class="btn btn-ghost btn-small" onclick="diagCopyVisibleLogs(this)" title="Copy visible log lines (respects level filter)"> + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> + Copy + </button> <button type="button" class="btn btn-ghost btn-small" onclick="diagRefreshLogs(true)">Refresh</button> </span> </div> @@ -170,9 +174,15 @@ <h3 style="font-size:.95rem;font-weight:600;margin:0">Connection tests</h3> if (level !== 'all' && lvl !== level) return ''; var color = ({info:'#6ab6ff', warn:'#ffb547', error:'#ff7c7c', debug:'#9aa9b3'})[lvl] || 'var(--text-muted)'; var ts = new Date(e.time).toLocaleTimeString(); - return '<div style="display:grid;grid-template-columns:80px 1fr;gap:.6rem;padding:1px 0;border-bottom:1px solid rgba(255,255,255,.03)">' + - '<span style="color:var(--text-muted)">' + escDiag(ts) + '</span>' + - '<span style="color:' + color + ';white-space:pre-wrap;word-break:break-word">' + escDiag(e.line) + '</span>' + + // data-log-line carries the raw line so the per-row copy + // button can grab the unescaped value via dataset, and + // diagCopyVisibleLogs can gather every visible row in order. + return '<div class="diag-log-row" data-log-line="' + escDiag(e.line) + '" data-log-ts="' + escDiag(ts) + '">' + + '<span class="diag-log-ts">' + escDiag(ts) + '</span>' + + '<span class="diag-log-msg" style="color:' + color + '">' + escDiag(e.line) + '</span>' + + '<button type="button" class="diag-log-copy" title="Copy line" aria-label="Copy line" onclick="diagCopyRow(event, this)">' + + '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' + + '</button>' + '</div>'; }).filter(Boolean).join(''); stream.innerHTML = rows || '<div class="muted">No log lines in buffer yet.</div>'; @@ -181,6 +191,79 @@ <h3 style="font-size:.95rem;font-weight:600;margin:0">Connection tests</h3> document.getElementById('diag-log-stream').innerHTML = '<div class="muted">Log fetch failed: ' + escDiag(err.message) + '</div>'; }); } + +// diagCopyVisibleLogs grabs every .diag-log-row currently rendered +// (which is exactly what the user sees — the level filter runs at +// render time, not at copy time). Each line is prefixed with its +// timestamp so the pasted block stays useful out of context. +function diagCopyVisibleLogs(btn) { + var rows = document.querySelectorAll('#diag-log-stream .diag-log-row'); + if (!rows.length) { + diagFlashCopy(btn, 'Nothing to copy', false); + return; + } + var lines = []; + for (var i = 0; i < rows.length; i++) { + var r = rows[i]; + var ts = r.getAttribute('data-log-ts') || ''; + var line = r.getAttribute('data-log-line') || ''; + lines.push((ts ? ts + ' ' : '') + line); + } + diagWriteClipboard(lines.join('\n')).then(function(ok) { + diagFlashCopy(btn, ok ? ('Copied ' + lines.length + ' line' + (lines.length === 1 ? '' : 's')) : 'Copy failed', ok); + }); +} + +// diagCopyRow copies a single row's raw line. Stops propagation so the +// row's own click handlers don't fire. +function diagCopyRow(ev, btn) { + ev.stopPropagation(); + var row = btn.closest('.diag-log-row'); + if (!row) return; + var line = row.getAttribute('data-log-line') || ''; + diagWriteClipboard(line).then(function(ok) { + btn.classList.toggle('is-ok', ok); + btn.classList.toggle('is-err', !ok); + setTimeout(function() { + btn.classList.remove('is-ok'); + btn.classList.remove('is-err'); + }, 1200); + }); +} + +// diagWriteClipboard wraps navigator.clipboard with a textarea fallback +// for older browsers / non-secure contexts. Returns a promise<boolean>. +function diagWriteClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text).then(function() { return true; }, function() { return diagFallbackCopy(text); }); + } + return Promise.resolve(diagFallbackCopy(text)); +} +function diagFallbackCopy(text) { + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + var ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } catch (e) { + return false; + } +} +function diagFlashCopy(btn, label, ok) { + if (!btn) return; + var orig = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = (ok ? '✓ ' : '⚠ ') + escDiag(label); + setTimeout(function() { + btn.innerHTML = orig; + btn.disabled = false; + }, 1400); +} function diagDetectLevel(s) { var t = s.toLowerCase(); if (t.indexOf('"level":"error"') !== -1 || / err |\berror\b/.test(t)) return 'error'; @@ -189,31 +272,83 @@ <h3 style="font-size:.95rem;font-weight:600;margin:0">Connection tests</h3> return 'info'; } -// Connection tests — lists destinations + Audible. Test buttons hit the -// existing /destinations/:id/test endpoint (which already records health) -// and write the result inline. +// Connection tests — Audible auth + every configured destination. +// Both endpoints return JSON; the destination rows reuse the global +// data-test-button affordance so the spinner→check/X feedback from +// base.html fires here too. function diagLoadConnList() { var list = document.getElementById('diag-conn-list'); - fetch('/api/diagnostics/env').then(function(r) { return r.json(); }).then(function(env) { - // Pull destinations by hitting the dashboard summary JSON would be - // overkill; the dest list lives on /destinations itself but isn't - // a JSON endpoint. Easiest: fetch the destinations page and parse - // out the cards. Cheaper alternative: just show Audible here and - // let users use /destinations for per-dest tests. + Promise.all([ + fetch('/api/diagnostics/env').then(function(r) { return r.json(); }), + fetch('/api/diagnostics/destinations').then(function(r) { return r.json(); }).catch(function() { return { destinations: [] }; }) + ]).then(function(res) { + var env = res[0]; + var dests = (res[1] && res[1].destinations) || []; var rows = []; - rows.push(diagConnRow('Audible API', env.authenticated ? 'ok' : 'err', env.authenticated ? 'authenticated' : 'not connected', null)); + rows.push(diagConnRow({ + name: 'Audible API', + status: env.authenticated ? 'ok' : 'err', + detail: env.authenticated ? 'authenticated' : 'not connected' + })); + for (var i = 0; i < dests.length; i++) { + var d = dests[i]; + rows.push(diagConnRow(diagDestRowProps(d))); + } list.innerHTML = rows.join('') + '<a href="/destinations" class="muted" style="font-size:.78rem;margin-top:.4rem;display:inline-block">Manage destinations →</a>'; + // Activate the hx-post test buttons we just injected so the + // global data-test-button feedback (spinner → check/X) fires. + if (window.htmx) htmx.process(list); }).catch(function(err) { list.innerHTML = '<div class="muted">' + escDiag(err.message) + '</div>'; }); } -function diagConnRow(name, status, detail, testURL) { - var dotClass = status === 'ok' ? 'ok' : (status === 'err' ? 'err' : 'idle'); - var btn = testURL ? '<button type="button" class="btn btn-ghost btn-small" onclick="diagRunTest(\'' + testURL + '\', this)">Test</button>' : ''; - return '<div class="flex" style="padding:.4rem .55rem;background:var(--bg);border-radius:6px;border:1px solid var(--border);font-size:.82rem">' + - '<span>' + escDiag(name) + '</span>' + - '<span class="dot-status ' + dotClass + '" style="margin-left:auto">' + escDiag(detail) + '</span>' + - (btn ? '<span style="margin-left:.5rem">' + btn + '</span>' : '') + + +// diagDestRowProps maps a destination JSON record onto the row helper's +// status/detail/test-URL contract. +function diagDestRowProps(d) { + var name = d.display_name || d.type_label || d.type; + var status, detail; + if (!d.enabled) { + status = 'idle'; + detail = 'disabled'; + } else if (!d.configured) { + status = 'idle'; + detail = 'not configured'; + } else if (d.health === 'healthy') { + status = 'ok'; + detail = d.url ? d.url : 'healthy'; + } else if (d.health === 'failed') { + status = 'err'; + detail = d.health_detail || d.last_error || 'connection failed'; + } else if (d.health === 'never') { + status = 'idle'; + detail = 'never checked'; + } else { + status = 'idle'; + detail = d.health || 'unknown'; + } + return { + name: name, + subtitle: d.type_label || d.type, + status: status, + detail: detail, + testURL: d.enabled && d.configured ? '/destinations/' + encodeURIComponent(d.id) + '/test' : '' + }; +} + +function diagConnRow(opts) { + var dotClass = opts.status === 'ok' ? 'ok' : (opts.status === 'err' ? 'err' : 'idle'); + var subtitle = opts.subtitle ? '<span class="muted" style="font-size:.7rem;margin-left:.4rem;text-transform:uppercase;letter-spacing:.08em">' + escDiag(opts.subtitle) + '</span>' : ''; + var btn = opts.testURL + ? '<button type="button" class="btn btn-ghost btn-small btn-icon" data-test-button title="Test connection"' + + ' hx-post="' + escDiag(opts.testURL) + '" hx-swap="none">' + + '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>' + + '</button>' + : ''; + return '<div class="diag-conn-row">' + + '<span class="diag-conn-name">' + escDiag(opts.name) + subtitle + '</span>' + + '<span class="dot-status ' + dotClass + ' diag-conn-detail" title="' + escDiag(opts.detail) + '">' + escDiag(opts.detail) + '</span>' + + (btn ? '<span class="diag-conn-action">' + btn + '</span>' : '') + '</div>'; } function escDiag(s) { From 8fdd0f707149f5abb99e87f7c11e0052bcb99994 Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Wed, 20 May 2026 23:42:56 -0500 Subject: [PATCH 33/40] feat: Enhance Plex guided form feedback and edit experience --- internal/web/static/style.css | 48 ++++++ internal/web/templates/destinations_form.html | 156 ++++++++++++++---- 2 files changed, 172 insertions(+), 32 deletions(-) diff --git a/internal/web/static/style.css b/internal/web/static/style.css index a1abc8b..bc270ad 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -2890,6 +2890,54 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } font-family: var(--font-sans); font-size: 0.95rem; } + +/* Loading state — number plate becomes a small spinning ring while + the discover request is in flight. Overrides .is-done / .is-error + visually because plexStepLoading() clears those classes first. */ +.dest-guided-step.is-loading .dest-guided-num { + background: var(--surface-2); + border-color: var(--plex-cyan); + color: transparent; + box-shadow: 0 0 0 4px rgba(124, 201, 255, 0.14); +} +.dest-guided-step.is-loading .dest-guided-num::before { + content: ""; + position: absolute; + inset: 4px; + border-radius: 999px; + border: 2px solid currentColor; + border-top-color: var(--plex-cyan); + color: rgba(124, 201, 255, 0.25); + animation: dest-guided-spin 0.7s linear infinite; +} +@keyframes dest-guided-spin { + to { transform: rotate(360deg); } +} + +/* Error state — paints the step red so a 404/credential failure on + the saved library is obvious the moment the form opens. */ +.dest-guided-step.is-error { + border-color: rgba(244, 67, 54, 0.45); + background: rgba(244, 67, 54, 0.05); +} +.dest-guided-step.is-error .dest-guided-num { + background: var(--error); + border-color: var(--error); + color: transparent; + box-shadow: 0 0 0 4px rgba(244, 67, 54, 0.18); +} +.dest-guided-step.is-error .dest-guided-num::before { + content: "!"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-family: var(--font-sans); + font-weight: 700; + font-size: 0.9rem; +} .dest-guided-body { min-width: 0; flex: 1; } .dest-guided-row { display: flex; diff --git a/internal/web/templates/destinations_form.html b/internal/web/templates/destinations_form.html index d438629..da916b9 100644 --- a/internal/web/templates/destinations_form.html +++ b/internal/web/templates/destinations_form.html @@ -65,18 +65,27 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end {{if eq $dt "plex"}} {{$guidedStart := not $isEdit}} + {{/* On edit, seed step states from the saved row so a working + destination opens with steps 1 + 2 pre-checked and step 3 + showing the actual outcome of its last health check. */}} + {{$step1Done := and $isEdit .HasStoredCredential}} + {{$step2Done := and $isEdit .HasStoredURL}} + {{$step3Done := and $isEdit .HasStoredSection (not .LastHealthFailed)}} + {{$step3Error := and $isEdit .HasStoredSection .LastHealthFailed}} <section id="plex-setup-flow" class="dest-guided"> {{/* Plex sign-in step — kicks off the plex.tv PIN flow. Posts to /destinations/plex/pin/start which returns a fragment containing an "Open plex.tv sign-in" button plus a hidden HTMX poller. */}} - <div class="dest-guided-step" id="plex-guided-step-1"> + <div class="dest-guided-step{{if $step1Done}} is-done{{end}}" id="plex-guided-step-1"> <span class="dest-guided-num" aria-hidden="true">1</span> <div class="dest-guided-body"> <div class="dest-guided-row"> <div class="dest-guided-text"> <div class="dest-guided-title">Sign in with Plex</div> - <div class="dest-guided-desc">Approve in plex.tv and we fill the token automatically.</div> + <div class="dest-guided-desc"> + {{if $step1Done}}Token on file — re-sign in only if it has been revoked.{{else}}Approve in plex.tv and we fill the token automatically.{{end}} + </div> </div> <button id="plex-signin-btn" type="button" class="btn btn-secondary" hx-post="/destinations/plex/pin/start" @@ -85,7 +94,7 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end hx-disabled-elt="this" hx-on::before-request="document.getElementById('plex-pin-result').setAttribute('aria-busy','true'); document.getElementById('plex-pin-result').textContent='Starting plex.tv sign-in…';" hx-on::after-request="document.getElementById('plex-pin-result').setAttribute('aria-busy','false');"> - Sign in + {{if $step1Done}}Re-sign in{{else}}Sign in{{end}} </button> </div> <div id="plex-pin-result" class="dest-guided-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false"></div> @@ -94,23 +103,25 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end {{/* Discover servers — calls plex.tv /api/v2/resources, lists the user's Plex servers, fills the URL field on pick. */}} - <div class="dest-guided-step{{if $guidedStart}} is-dim{{end}}" id="plex-guided-step-2"> + <div class="dest-guided-step{{if $step2Done}} is-done{{else if and $guidedStart (not $step1Done)}} is-dim{{end}}" id="plex-guided-step-2"> <span class="dest-guided-num" aria-hidden="true">2</span> <div class="dest-guided-body"> <div class="dest-guided-row"> <div class="dest-guided-text"> <div class="dest-guided-title">Select your Plex server</div> - <div class="dest-guided-desc">Lists every server linked to your account.</div> + <div class="dest-guided-desc"> + {{if $step2Done}}Currently <code>{{.Dest.URL}}</code> — change with the button.{{else}}Lists every server linked to your account.{{end}} + </div> </div> - <button id="plex-discover-servers-btn" type="button" class="btn btn-secondary is-hidden" + <button id="plex-discover-servers-btn" type="button" class="btn btn-secondary{{if and (not $step1Done) $guidedStart}} is-hidden{{end}}" hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/plex/discover/servers{{else}}/destinations/plex/discover/servers{{end}}" hx-include="closest form" hx-target="#plex-server-discover-result" hx-swap="innerHTML" hx-disabled-elt="this" - hx-on::before-request="document.getElementById('plex-server-discover-result').setAttribute('aria-busy','true'); document.getElementById('plex-server-discover-result').textContent='Listing your Plex servers…';" - hx-on::after-request="document.getElementById('plex-server-discover-result').setAttribute('aria-busy','false');"> - Discover + hx-on::before-request="plexStepLoading('plex-guided-step-2', true); document.getElementById('plex-server-discover-result').setAttribute('aria-busy','true'); document.getElementById('plex-server-discover-result').textContent='Listing your Plex servers…';" + hx-on::after-request="plexStepLoading('plex-guided-step-2', false); document.getElementById('plex-server-discover-result').setAttribute('aria-busy','false');"> + {{if $step2Done}}Change server{{else}}Discover{{end}} </button> </div> <div id="plex-server-discover-result" class="dest-guided-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false"></div> @@ -120,26 +131,32 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end {{/* Discover sections — calls the configured Plex server's /library/sections, lists all sections, fills section_id on pick. */}} - <div class="dest-guided-step{{if $guidedStart}} is-dim{{end}}" id="plex-guided-step-3"> + <div class="dest-guided-step{{if $step3Done}} is-done{{else if $step3Error}} is-error{{else if and $guidedStart (not $step2Done)}} is-dim{{end}}" id="plex-guided-step-3"> <span class="dest-guided-num" aria-hidden="true">3</span> <div class="dest-guided-body"> <div class="dest-guided-row"> <div class="dest-guided-text"> <div class="dest-guided-title">Pick the audiobook library</div> - <div class="dest-guided-desc">Choose the Plex section that holds your audiobooks.</div> + <div class="dest-guided-desc"> + {{if $step3Error}}<span style="color:#ff9b9b">Saved section <code>{{.Dest.PlexSectionID}}</code> is no longer reachable on this server.</span> + {{else if $step3Done}}Saved section <code>{{.Dest.PlexSectionID}}</code>. Re-discover to change. + {{else}}Choose the Plex section that holds your audiobooks.{{end}} + </div> </div> - <button id="plex-discover-sections-btn" type="button" class="btn btn-secondary is-hidden" + <button id="plex-discover-sections-btn" type="button" class="btn btn-secondary{{if and (not $step2Done) $guidedStart}} is-hidden{{end}}" hx-post="{{if $isEdit}}/destinations/{{.Dest.ID}}/plex/discover/sections{{else}}/destinations/plex/discover/sections{{end}}" hx-include="closest form" hx-target="#plex-section-discover-result" hx-swap="innerHTML" hx-disabled-elt="this" - hx-on::before-request="document.getElementById('plex-section-discover-result').setAttribute('aria-busy','true'); document.getElementById('plex-section-discover-result').textContent='Listing library sections…';" - hx-on::after-request="document.getElementById('plex-section-discover-result').setAttribute('aria-busy','false');"> - Discover + hx-on::before-request="plexStepLoading('plex-guided-step-3', true); document.getElementById('plex-section-discover-result').setAttribute('aria-busy','true'); document.getElementById('plex-section-discover-result').textContent='Listing library sections…';" + hx-on::after-request="plexStepLoading('plex-guided-step-3', false); plexStepFromResponse('plex-guided-step-3', event); document.getElementById('plex-section-discover-result').setAttribute('aria-busy','false');"> + {{if $step3Error}}Re-discover{{else if $step3Done}}Change library{{else}}Discover{{end}} </button> </div> - <div id="plex-section-discover-result" class="dest-guided-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false"></div> + <div id="plex-section-discover-result" class="dest-guided-result" role="status" aria-live="polite" aria-atomic="true" aria-busy="false"> + {{if $step3Error}}<div class="alert err" style="font-size:.78rem;padding:.45rem .6rem;margin:.4rem 0 0"><span class="ico">!</span><div>{{if .LastHealthErr}}{{.LastHealthErr}}{{else}}Last check failed. Re-discover to recover.{{end}}</div></div>{{end}} + </div> </div> </div> @@ -178,9 +195,40 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end </details> <script> + // Toggle a loading spinner on a guided step's number plate. + // Exposed globally so the inline hx-on attributes can call it. + window.plexStepLoading = function(stepId, on) { + var el = document.getElementById(stepId); + if (!el) return; + el.classList.toggle('is-loading', !!on); + // While loading, clear the done/error halo so the spinner + // doesn't render under a green check or red ring. The + // post-request state machine restores done/error. + if (on) { + el.classList.remove('is-done'); + el.classList.remove('is-error'); + } + }; + // After a discover request completes, peek at the XHR status + // and surface failures by painting the step red. Success cases + // fall through to refreshState() which sets is-done from + // input values. + window.plexStepFromResponse = function(stepId, ev) { + var el = document.getElementById(stepId); + if (!el) return; + var ok = ev && ev.detail && ev.detail.successful; + if (!ok) { + el.classList.add('is-error'); + el.classList.remove('is-done'); + } else { + el.classList.remove('is-error'); + } + }; + (function () { var tokenInput = document.getElementById('plex_token'); var urlInput = document.getElementById('url'); + var sectionInput = document.getElementById('plex_section_id'); var step1 = document.getElementById('plex-guided-step-1'); var step2 = document.getElementById('plex-guided-step-2'); var step3 = document.getElementById('plex-guided-step-3'); @@ -189,35 +237,62 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end return; } + // Seeded server-side flags so we don't clobber an edit + // that opens with steps already complete. These read off + // the initial className the template assigned. + var seededStep1Done = step1.classList.contains('is-done'); + var seededStep2Done = step2.classList.contains('is-done'); + var seededStep3Done = step3.classList.contains('is-done'); + var seededStep3Err = step3.classList.contains('is-error'); + function dim(el, isDim) { if (!el) return; - if (isDim) el.classList.add('is-dim'); - else el.classList.remove('is-dim'); + el.classList.toggle('is-dim', !!isDim); } function done(el, isDone) { if (!el) return; - if (isDone) el.classList.add('is-done'); - else el.classList.remove('is-done'); + el.classList.toggle('is-done', !!isDone); } function refreshState() { - var hasToken = tokenInput.value.trim() !== ''; + var hasTokenInForm = tokenInput.value.trim() !== ''; var hasURL = urlInput.value.trim() !== ''; + var hasSectionInForm = sectionInput ? sectionInput.value.trim() !== '' : false; var manualOpen = manualDetails.open; - done(step1, hasToken); - dim(step2, !hasToken && !manualOpen); - done(step2, hasURL); - dim(step3, !hasURL && !manualOpen); - if (manualOpen || hasToken) { - var btn = document.getElementById('plex-discover-servers-btn'); + // Step 1: either a token is stored server-side OR the + // user is typing one in. Don't un-set done from the + // seeded value just because the password field is + // empty (it always is on edit). + done(step1, seededStep1Done || hasTokenInForm); + + // Step 2: dimmed only when nothing upstream has put us + // in a state to discover servers. + var step1IsDone = step1.classList.contains('is-done'); + dim(step2, !step1IsDone && !manualOpen); + done(step2, seededStep2Done || hasURL); + + // Step 3: as above; respect the seeded error state. + var step2IsDone = step2.classList.contains('is-done'); + dim(step3, !step2IsDone && !manualOpen); + if (!step3.classList.contains('is-error')) { + done(step3, seededStep3Done || (hasSectionInForm && step2IsDone)); + } + + // Auto-trigger discovery only when we have credentials + // in the form AND no prior result. Don't re-fire on + // edit-mode page open — we already know the answers. + if (manualOpen || hasTokenInForm) { + var serversBtn = document.getElementById('plex-discover-servers-btn'); var rs = document.getElementById('plex-server-discover-result'); - if (btn && rs && rs.innerHTML.trim() === '' && hasToken) btn.click(); + if (serversBtn && rs && rs.innerHTML.trim() === '' && hasTokenInForm) serversBtn.click(); } if (manualOpen || hasURL) { - var btn = document.getElementById('plex-discover-sections-btn'); - var rs = document.getElementById('plex-section-discover-result'); - if (btn && rs && rs.innerHTML.trim() === '' && hasURL) btn.click(); + var sectionsBtn = document.getElementById('plex-discover-sections-btn'); + var rs2 = document.getElementById('plex-section-discover-result'); + if (sectionsBtn && rs2 && rs2.innerHTML.trim() === '' && hasURL && !seededStep3Done && !seededStep3Err) { + sectionsBtn.click(); + } } } @@ -225,8 +300,25 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end tokenInput.addEventListener('change', refreshState); urlInput.addEventListener('input', refreshState); urlInput.addEventListener('change', refreshState); + if (sectionInput) { + sectionInput.addEventListener('input', refreshState); + sectionInput.addEventListener('change', refreshState); + } manualDetails.addEventListener('toggle', refreshState); - document.body.addEventListener('htmx:afterSwap', refreshState); + // When discovery returns and HTMX swaps the result in, + // refreshState reads the new input values and lights up + // subsequent steps. The XHR-status branching for failures + // is owned by plexStepFromResponse. + document.body.addEventListener('htmx:afterSwap', function(ev) { + // A successful section pick should clear any stale + // error halo we showed on open. + if (ev.target && ev.target.id === 'plex-section-discover-result' && + sectionInput && sectionInput.value.trim() !== '') { + seededStep3Err = false; + step3.classList.remove('is-error'); + } + refreshState(); + }); refreshState(); })(); </script> From d4620a53c27041771914642e5dd132ec2abedc0c Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Wed, 20 May 2026 23:49:35 -0500 Subject: [PATCH 34/40] feat: Improve Plex guided form's section selection validation The Plex guided setup's Step 3 (select library) could incorrectly display as "done" or healthy when: - The destination's health was never checked (`LastHealthCheckOK` was `nil`). - A previously saved Plex section ID no longer exists on the Plex server. This change prevents these misrepresentations by: - Requiring an *affirmative* "OK" result from the last health check for Step 3 to be considered done. - Introducing client-side logic to verify the saved section ID against the list of sections discovered from the Plex server. If the stored ID is stale or invalid, it's cleared, the step is marked with an error, and a message prompts the user to select a new library. This provides more accurate feedback and prevents users from saving destinations with invalid Plex section configurations. --- internal/web/destinations_handlers.go | 6 ++ internal/web/templates/destinations_form.html | 102 ++++++++++++++++-- 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/internal/web/destinations_handlers.go b/internal/web/destinations_handlers.go index 120735b..5d10c30 100644 --- a/internal/web/destinations_handlers.go +++ b/internal/web/destinations_handlers.go @@ -1134,6 +1134,12 @@ func (s *Server) handleDestinationEditForm(c *gin.Context) { data["HasStoredCredential"] = strings.TrimSpace(row.PlexToken) != "" || strings.TrimSpace(row.APIKey) != "" data["HasStoredURL"] = strings.TrimSpace(row.URL) != "" data["HasStoredSection"] = strings.TrimSpace(row.PlexSectionID) != "" || strings.TrimSpace(row.LibraryID) != "" + // Three-state health: nil = never checked, true = OK, false = failed. + // Step 3's "done" badge requires an affirmative healthy result — + // "never checked" must NOT count as healthy because the saved + // library_id might no longer exist on the server (e.g. user deleted + // the Plex section since last save). + data["LastHealthOK"] = row.LastHealthCheckOK != nil && *row.LastHealthCheckOK data["LastHealthFailed"] = row.LastHealthCheckOK != nil && !*row.LastHealthCheckOK if row.LastHealthCheckErr != "" { data["LastHealthErr"] = cleanErrorForDisplay(row.LastHealthCheckErr) diff --git a/internal/web/templates/destinations_form.html b/internal/web/templates/destinations_form.html index da916b9..4a54df0 100644 --- a/internal/web/templates/destinations_form.html +++ b/internal/web/templates/destinations_form.html @@ -65,12 +65,14 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end {{if eq $dt "plex"}} {{$guidedStart := not $isEdit}} - {{/* On edit, seed step states from the saved row so a working - destination opens with steps 1 + 2 pre-checked and step 3 - showing the actual outcome of its last health check. */}} + {{/* On edit, seed step states from the saved row. + Step 3 only counts as DONE when the most recent test was + affirmatively OK — a "never checked" state must not flip + green because the saved section might no longer exist on + the server (we'll re-verify after discovery). */}} {{$step1Done := and $isEdit .HasStoredCredential}} {{$step2Done := and $isEdit .HasStoredURL}} - {{$step3Done := and $isEdit .HasStoredSection (not .LastHealthFailed)}} + {{$step3Done := and $isEdit .HasStoredSection .LastHealthOK}} {{$step3Error := and $isEdit .HasStoredSection .LastHealthFailed}} <section id="plex-setup-flow" class="dest-guided"> {{/* Plex sign-in step — kicks off the plex.tv PIN flow. @@ -244,6 +246,12 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end var seededStep2Done = step2.classList.contains('is-done'); var seededStep3Done = step3.classList.contains('is-done'); var seededStep3Err = step3.classList.contains('is-error'); + // Step 3 only earns "done" via affirmative evidence: + // - server says LastHealthOK (seededStep3Done = true), OR + // - the user just picked from the dropdown, OR + // - verifySavedSection() found the stored ID in the picker. + // A bare value in #plex_section_id is no longer enough. + var step3SelectionVerified = seededStep3Done; function dim(el, isDim) { if (!el) return; @@ -273,10 +281,13 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end done(step2, seededStep2Done || hasURL); // Step 3: as above; respect the seeded error state. + // Done only when the picker has affirmed the value — + // either via the server's last-health-OK seed, a + // post-discovery membership check, or a fresh user pick. var step2IsDone = step2.classList.contains('is-done'); dim(step3, !step2IsDone && !manualOpen); if (!step3.classList.contains('is-error')) { - done(step3, seededStep3Done || (hasSectionInForm && step2IsDone)); + done(step3, step3SelectionVerified && hasSectionInForm); } // Auto-trigger discovery only when we have credentials @@ -305,20 +316,89 @@ <h2 id="form-heading">{{if $isEdit}}Edit destination{{else}}Add destination{{end sectionInput.addEventListener('change', refreshState); } manualDetails.addEventListener('toggle', refreshState); + // When the discover-sections fragment lands, verify the + // saved section ID actually appears in the picker. The + // Plex section we stored (e.g. id=5) may have been deleted + // on the server since — we don't want to confirm step 3 + // green for a section that no longer exists. + function verifySavedSection() { + var picker = document.getElementById('plex_section_picker'); + if (!picker || !sectionInput) return; + var savedID = sectionInput.value.trim(); + if (savedID === '') return; + var found = false; + for (var i = 0; i < picker.options.length; i++) { + if (picker.options[i].value === savedID) { found = true; break; } + } + if (found) { + // Discovery confirms the saved ID is real. Now + // we can trust step 3 as done. + step3SelectionVerified = true; + step3.classList.remove('is-error'); + } else { + // Saved ID is stale. Clear the stored value so + // step 3 reverts to "needs a pick", paint it red, + // and surface a one-line explanation above the + // picker. + sectionInput.value = ''; + seededStep3Done = false; + step3SelectionVerified = false; + step3.classList.add('is-error'); + var rs = document.getElementById('plex-section-discover-result'); + if (rs && !rs.querySelector('[data-stale-section]')) { + var note = document.createElement('div'); + note.className = 'alert err'; + note.setAttribute('data-stale-section', '1'); + note.style.cssText = 'font-size:.78rem;padding:.45rem .6rem;margin:0 0 .4rem'; + note.innerHTML = '<span class="ico">!</span><div>Saved section <code>' + + savedID + '</code> no longer exists on this Plex server. Pick a new library below.</div>'; + rs.insertBefore(note, rs.firstChild); + } + } + } + // When discovery returns and HTMX swaps the result in, // refreshState reads the new input values and lights up // subsequent steps. The XHR-status branching for failures // is owned by plexStepFromResponse. document.body.addEventListener('htmx:afterSwap', function(ev) { - // A successful section pick should clear any stale - // error halo we showed on open. - if (ev.target && ev.target.id === 'plex-section-discover-result' && - sectionInput && sectionInput.value.trim() !== '') { - seededStep3Err = false; - step3.classList.remove('is-error'); + if (ev.target && ev.target.id === 'plex-section-discover-result') { + // The new fragment may or may not contain the + // picker (errors render an alert instead). Verify + // before assuming success. + verifySavedSection(); + // A user-driven pick clears any stale error. + if (sectionInput && sectionInput.value.trim() !== '' && + step3.classList.contains('is-error') === false) { + seededStep3Err = false; + } } refreshState(); }); + + // The picker fires `change` on #plex_section_id when the + // user makes a selection. Use that as the affirmative + // signal to clear the error halo and refresh state. + if (sectionInput) { + sectionInput.addEventListener('change', function() { + if (sectionInput.value.trim() !== '') { + // User picked from the dropdown (or typed a + // valid value into the manual field). Either + // way, treat it as affirmative selection. + step3SelectionVerified = true; + seededStep3Err = false; + step3.classList.remove('is-error'); + var rs = document.getElementById('plex-section-discover-result'); + if (rs) { + var stale = rs.querySelector('[data-stale-section]'); + if (stale) stale.remove(); + } + } else { + step3SelectionVerified = false; + } + }); + } + refreshState(); })(); </script> From 9b58dfe5a3bf76fcf89b7894e19d34d884b05d1f Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Thu, 21 May 2026 00:06:45 -0500 Subject: [PATCH 35/40] feat: Introduce shared error cleaner for consistent UI display This change centralizes error message sanitization into a new `errs.CleanForDisplay` helper. It transforms raw backend errors (which often embed HTML or verbose upstream responses) into concise, user-friendly strings. Capabilities include stripping HTML, collapsing whitespace, providing hints for common HTTP status codes, and truncating very long messages. The cleaner is applied to sync progress messages and destination health checks. This guarantees consistent, plain-text error display across the UI, preventing layout issues (e.g., from raw HTML). It also refactors existing error cleanup in the web package to use this shared utility. Additionally, CSS for live log rows is updated to make the log copy button float over messages without reserving layout space, improving the visual stability of the diagnostics panel. --- internal/errs/display.go | 71 +++++++++++++++++++++++++++ internal/errs/display_test.go | 55 +++++++++++++++++++++ internal/library/sync.go | 22 +++++++-- internal/web/destinations_handlers.go | 64 +++--------------------- internal/web/diagnostics_handlers.go | 12 ++++- internal/web/static/style.css | 60 ++++++++++++++++------ 6 files changed, 205 insertions(+), 79 deletions(-) create mode 100644 internal/errs/display.go create mode 100644 internal/errs/display_test.go diff --git a/internal/errs/display.go b/internal/errs/display.go new file mode 100644 index 0000000..3bbe07e --- /dev/null +++ b/internal/errs/display.go @@ -0,0 +1,71 @@ +// Package errs holds helpers for turning raw backend error strings — +// often containing HTML response bodies from upstream media servers — +// into something safe and compact for the UI to show. Centralized here +// so every code path (web handlers, sync phase messages, dashboard +// recap, drift inbox) renders the same wording for the same failure. +package errs + +import ( + "regexp" + "strings" +) + +// errTagRe matches an HTML tag for stripping. The bodies our backends +// embed in error strings are typically tiny upstream-server error pages +// (Plex / Emby / Jellyfin / ABS), so a regex is cheaper than spinning +// up html/parser and we don't need element-level fidelity here. +var errTagRe = regexp.MustCompile(`<[^>]*>`) + +// errSpaceRe collapses runs of whitespace (including the newlines left +// behind after stripping <head>/<body>/<title> blocks) into a single space. +var errSpaceRe = regexp.MustCompile(`\s+`) + +// errHTTPCodeRe extracts the numeric status code from a "<verb> returned +// <code>: <body>" error string. The first capture is the code itself. +var errHTTPCodeRe = regexp.MustCompile(`returned (\d{3})[:\s]`) + +// CleanForDisplay turns a raw backend error message — which may embed +// an HTML error page from Plex/Emby/Jellyfin/ABS — into something that +// fits on one line in a UI badge. It: +// - strips HTML tags +// - collapses whitespace +// - rewrites common HTTP status codes into friendly hints +// - truncates very long messages so a 2KB upstream body doesn't +// blow out the card layout +// +// The raw error is still logged at the original emission site, so we +// don't lose forensic detail. +func CleanForDisplay(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + + var hint string + if m := errHTTPCodeRe.FindStringSubmatch(s); m != nil { + switch m[1] { + case "401": + hint = "Authentication failed — check the token or API key." + case "403": + hint = "Authentication accepted but the account is not authorized for this resource." + case "404": + hint = "Server reachable but the configured library/section was not found." + case "500", "502", "503", "504": + hint = "Upstream server error — the media server returned " + m[1] + "." + } + } + + s = errTagRe.ReplaceAllString(s, " ") + s = errSpaceRe.ReplaceAllString(s, " ") + s = strings.TrimSpace(s) + + const maxLen = 200 + if len(s) > maxLen { + s = strings.TrimSpace(s[:maxLen]) + "…" + } + + if hint != "" { + return hint + } + return s +} diff --git a/internal/errs/display_test.go b/internal/errs/display_test.go new file mode 100644 index 0000000..936d2ec --- /dev/null +++ b/internal/errs/display_test.go @@ -0,0 +1,55 @@ +package errs + +import "testing" + +func TestCleanForDisplay(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "plex 404 with html body", + in: `plex section items returned 404: <html><head><title>Not Found

    404 Not Found

    `, + want: "Server reachable but the configured library/section was not found.", + }, + { + name: "401 auth failure", + in: "emby items returned 401: Access token is required.", + want: "Authentication failed — check the token or API key.", + }, + { + name: "503 upstream", + in: "abs /api/libraries returned 503: service unavailable", + want: "Upstream server error — the media server returned 503.", + }, + { + name: "non-http error keeps text", + in: "dial tcp 10.0.0.5:32400: connect: connection refused", + want: "dial tcp 10.0.0.5:32400: connect: connection refused", + }, + { + name: "html only, no http code", + in: `

    oops

    `, + want: "oops", + }, + { + name: "embedded h1 collapses", + in: `plex scan returned 404:

    404 Not Found

    `, + want: "Server reachable but the configured library/section was not found.", + }, + { + name: "empty", + in: " ", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := CleanForDisplay(tc.in) + if got != tc.want { + t.Fatalf("CleanForDisplay(%q)\n got: %q\n want: %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/internal/library/sync.go b/internal/library/sync.go index 6c46053..2c4a0c2 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -11,6 +11,7 @@ import ( "time" "github.com/mstrhakr/audplexus/internal/database" + "github.com/mstrhakr/audplexus/internal/errs" "github.com/mstrhakr/audplexus/internal/logging" audible "github.com/mstrhakr/go-audible" ) @@ -211,8 +212,18 @@ func NewSyncService(db database.Database, client *audible.Client, libraryDir str // subPhaseFnFor returns a SubPhaseFn closure that upserts a named SubPhaseStatus into the named // phase and emits a progress event. Safe to call concurrently from multiple goroutines. +// +// Sanitizes the incoming message via errs.CleanForDisplay before storing +// it on the SubPhaseStatus. Destination backends (Plex/Emby/…) wrap raw +// HTTP response bodies into their error strings — those bodies can +// contain HTML markup like

    404 Not Found

    , which when rendered +// by the SSE client into innerHTML blew out the row height. Cleaning +// at the chokepoint guarantees every sub-phase message shown in the +// UI is plain text and matches the wording used by the dashboard +// destination card for the same underlying failure. func (s *SyncService) subPhaseFnFor(phase SyncPhase) SubPhaseFn { return func(id, label, status, message string, current, total int) { + cleaned := errs.CleanForDisplay(message) s.mu.Lock() defer s.mu.Unlock() for i := range s.progress.Phases { @@ -222,7 +233,7 @@ func (s *SyncService) subPhaseFnFor(phase SyncPhase) SubPhaseFn { if (*sub)[j].ID == id { (*sub)[j].Label = label (*sub)[j].Status = status - (*sub)[j].Message = message + (*sub)[j].Message = cleaned (*sub)[j].Current = current (*sub)[j].Total = total if total > 0 { @@ -240,7 +251,7 @@ func (s *SyncService) subPhaseFnFor(phase SyncPhase) SubPhaseFn { ID: id, Label: label, Status: status, - Message: message, + Message: cleaned, Current: current, Total: total, Indeterminate: total == 0 && status == "running", @@ -732,6 +743,7 @@ func (s *SyncService) buildPhases(mode SyncMode, prev []PhaseStatus) []PhaseStat } func (s *SyncService) setPhase(phase SyncPhase, status, message string) { + cleaned := errs.CleanForDisplay(message) s.mu.Lock() defer s.mu.Unlock() s.progress.CurrentPhase = phase @@ -739,7 +751,7 @@ func (s *SyncService) setPhase(phase SyncPhase, status, message string) { if s.progress.Phases[i].Name == phase { now := time.Now() s.progress.Phases[i].Status = status - s.progress.Phases[i].Message = message + s.progress.Phases[i].Message = cleaned if status == "running" { s.progress.Phases[i].StartedAt = now s.progress.Phases[i].EndedAt = time.Time{} @@ -768,7 +780,7 @@ func (s *SyncService) setPhase(phase SyncPhase, status, message string) { s.progress.Phases[i].EndedAt = now } if status == "failed" { - s.progress.Phases[i].Error = message + s.progress.Phases[i].Error = cleaned s.progress.Phases[i].Indeterminate = false setPhaseProgress(&s.progress.Phases[i], s.progress.Phases[i].Current, s.progress.Phases[i].Total, false, status) } @@ -788,7 +800,7 @@ func (s *SyncService) setPhase(phase SyncPhase, status, message string) { // Update the top-level message for i := range s.progress.Phases { if s.progress.Phases[i].Name == phase { - s.progress.Message = s.progress.Phases[i].Label + ": " + message + s.progress.Message = s.progress.Phases[i].Label + ": " + cleaned break } } diff --git a/internal/web/destinations_handlers.go b/internal/web/destinations_handlers.go index 5d10c30..825a3df 100644 --- a/internal/web/destinations_handlers.go +++ b/internal/web/destinations_handlers.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" neturl "net/url" - "regexp" "strconv" "strings" "time" @@ -15,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/mstrhakr/audplexus/internal/database" + "github.com/mstrhakr/audplexus/internal/errs" "github.com/mstrhakr/audplexus/internal/mediaserver" ) @@ -928,64 +928,12 @@ func htmlEscape(s string) string { return r.Replace(s) } -// errTagRe matches an HTML tag for stripping. The bodies our backends -// embed in error strings are typically tiny upstream-server error pages -// (Plex / Emby / Jellyfin / ABS), so a regex is cheaper than spinning -// up html/parser and we don't need element-level fidelity here. -var errTagRe = regexp.MustCompile(`<[^>]*>`) - -// errSpaceRe collapses runs of whitespace (including the newlines left -// behind after stripping // blocks) into a single space. -var errSpaceRe = regexp.MustCompile(`\s+`) - -// errHTTPCodeRe extracts the numeric status code from a "<verb> returned -// <code>: <body>" error string. The first capture is the code itself. -var errHTTPCodeRe = regexp.MustCompile(`returned (\d{3})[:\s]`) - -// cleanErrorForDisplay turns a raw backend error message — which may -// embed an HTML error page from Plex/Emby/Jellyfin/ABS — into something -// that fits on one line in a UI badge. It: -// - strips HTML tags -// - collapses whitespace -// - rewrites common HTTP status codes into friendly hints -// - truncates very long messages so a 2KB upstream body doesn't -// blow out the card layout -// -// The raw error is still logged at recordDestinationHealth's caller -// chain (debug log), so we don't lose forensic detail. +// cleanErrorForDisplay is a thin wrapper around errs.CleanForDisplay +// kept here so existing callers in the web package keep compiling. The +// underlying helper lives in internal/errs so the library/sync package +// can share the same sanitization for phase messages. func cleanErrorForDisplay(raw string) string { - s := strings.TrimSpace(raw) - if s == "" { - return "" - } - - var hint string - if m := errHTTPCodeRe.FindStringSubmatch(s); m != nil { - switch m[1] { - case "401": - hint = "Authentication failed — check the token or API key." - case "403": - hint = "Authentication accepted but the account is not authorized for this resource." - case "404": - hint = "Server reachable but the configured library/section was not found." - case "500", "502", "503", "504": - hint = "Upstream server error — the media server returned " + m[1] + "." - } - } - - s = errTagRe.ReplaceAllString(s, " ") - s = errSpaceRe.ReplaceAllString(s, " ") - s = strings.TrimSpace(s) - - const maxLen = 200 - if len(s) > maxLen { - s = strings.TrimSpace(s[:maxLen]) + "…" - } - - if hint != "" { - return hint - } - return s + return errs.CleanForDisplay(raw) } // writeSensitiveHTML emits an HTML fragment that may carry secrets diff --git a/internal/web/diagnostics_handlers.go b/internal/web/diagnostics_handlers.go index 0cc98fa..4e082fb 100644 --- a/internal/web/diagnostics_handlers.go +++ b/internal/web/diagnostics_handlers.go @@ -18,6 +18,7 @@ import ( "github.com/gin-gonic/gin" "github.com/mstrhakr/audplexus/internal/database" + "github.com/mstrhakr/audplexus/internal/errs" "github.com/mstrhakr/audplexus/internal/library" "github.com/mstrhakr/audplexus/internal/logging" "github.com/mstrhakr/audplexus/internal/mediaserver" @@ -995,6 +996,13 @@ func (s *Server) handleDiagnosticsDestinations(c *gin.Context) { out := make([]gin.H, 0, len(summaries)) for _, v := range summaries { + // HealthDetail is written live from err.Error() in + // buildDestinationSummary, so it can still carry raw HTML + // response bodies. LastError gets cleaned at write time in + // recordDestinationHealth, but rows persisted before that + // fix may still hold raw text — clean both at the JSON + // boundary so the Connection-tests list always renders the + // same friendly wording as the rest of the app. out = append(out, gin.H{ "id": v.ID, "display_name": v.DisplayName, @@ -1004,8 +1012,8 @@ func (s *Server) handleDiagnosticsDestinations(c *gin.Context) { "configured": v.Configured, "url": v.URL, "health": v.Health, - "health_detail": v.HealthDetail, - "last_error": v.LastError, + "health_detail": errs.CleanForDisplay(v.HealthDetail), + "last_error": errs.CleanForDisplay(v.LastError), "last_checked_at": v.LastCheckedAt, }) } diff --git a/internal/web/static/style.css b/internal/web/static/style.css index bc270ad..e39590b 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -3115,13 +3115,15 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } } /* ── Diagnostics: Live logs row + copy affordance ─────────────────── - Three-column grid: monospace timestamp, message, copy button. The - button stays invisible until the row is hovered or keyboard-focused - so the stream reads cleanly during fast updates, then materializes - with the cursor for one-click copy. */ + Layout stays as a clean 2-column grid (timestamp · message). The + copy button is absolute-positioned on the right edge so it never + reserves layout space — it materializes only when the row is hovered + or keyboard-focused. A short gradient feather behind it bleeds the + message into surface so long lines don't render visibly under the + icon. */ .diag-log-row { display: grid; - grid-template-columns: 80px 1fr 24px; + grid-template-columns: 80px 1fr; gap: 0.6rem; align-items: start; padding: 1px 0; @@ -3137,7 +3139,27 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } word-break: break-word; min-width: 0; } + +/* Gradient feather under the floating button — only visible on hover. + Fades the last ~60px of the row to the panel surface so the copy + icon never sits visually on top of message text. */ +.diag-log-row::after { + content: ""; + position: absolute; + inset: 0 0 0 auto; + width: 64px; + background: linear-gradient(to right, transparent, var(--surface) 70%); + pointer-events: none; + opacity: 0; + transition: opacity 0.12s ease; +} +.diag-log-row:hover::after, +.diag-log-row:focus-within::after { opacity: 1; } + .diag-log-row .diag-log-copy { + position: absolute; + top: 1px; + right: 4px; width: 22px; height: 22px; padding: 0; @@ -3149,32 +3171,42 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } display: inline-flex; align-items: center; justify-content: center; - flex-shrink: 0; opacity: 0; - transform: translateY(-1px); - transition: opacity 0.1s, color 0.1s, border-color 0.1s, background 0.1s; + transform: translateX(4px); + transition: opacity 0.12s ease, transform 0.12s ease, + color 0.12s, border-color 0.12s, background 0.12s; + z-index: 1; } .diag-log-row:hover .diag-log-copy, .diag-log-row:focus-within .diag-log-copy, .diag-log-copy:focus-visible { opacity: 1; + transform: translateX(0); } .diag-log-copy:hover { - color: var(--text); - border-color: var(--text-muted); + color: var(--plex-cyan); + border-color: var(--plex-cyan); background: var(--surface-2); } -.diag-log-copy.is-ok { +.diag-log-copy:active { transform: translateX(0) scale(0.94); } + +/* Transient feedback after the JS copies. is-ok / is-err are sticky + for ~1.2s so the user catches the outcome even if their cursor has + already left the row. */ +.diag-log-copy.is-ok, +.diag-log-copy.is-err { opacity: 1; + transform: translateX(0); +} +.diag-log-copy.is-ok { color: var(--success); border-color: var(--success); - background: rgba(76, 175, 80, 0.12); + background: rgba(76, 175, 80, 0.14); } .diag-log-copy.is-err { - opacity: 1; color: var(--error); border-color: var(--error); - background: rgba(244, 67, 54, 0.12); + background: rgba(244, 67, 54, 0.14); } .diag-log-copy svg { display: block; } From 51c7cda5098a667c75d6296c5787fab1baa98a1d Mon Sep 17 00:00:00 2001 From: wutname1 <jeremy12@gmail.com> Date: Thu, 21 May 2026 00:38:07 -0500 Subject: [PATCH 36/40] feat: Introduce book presence column in library view Replaces the simple 'On Disk' indicator with a comprehensive 'Presence' column in the library table. This new column shows whether a book is available locally and its synchronization state (synced, syncing, failed, pending) with each configured external destination. It offers an at-a-glance overview of book distribution and sync health. --- internal/web/server.go | 105 ++++++++++++++++++++++ internal/web/templates/library_row.html | 25 +++++- internal/web/templates/library_table.html | 4 +- 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index 8f4cfac..58c7b21 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1025,6 +1025,7 @@ func (s *Server) handleLibrary(c *gin.Context) { "Filter": filter, "Page": "library", "BookActions": buildLibraryBookActions(books, s.settingBool(ctx, library.SettingKeyAutoQueueNewBooks, false)), + "BookPresence": s.computeBookPresence(ctx, books), "StatusCounts": counts, "ActiveStatus": statusStr, "PageNum": pageNum, @@ -1739,6 +1740,110 @@ func (s *Server) bookDestinationStatuses(ctx context.Context, bookID int64) []bo return out } +// bookPresenceChip is one cell in the Library page's Presence column. +// One chip per "place this book could live": local disk + each enabled +// destination. Lit chips indicate the book is present; dimmed chips +// indicate the book hasn't landed there yet. Failed sync gets a red +// halo, in-flight sync gets a cyan halo. +type bookPresenceChip struct { + Kind string // "disk" | "destination" + Type string // "plex" | "emby" | "jellyfin" | "abs" | "" for disk + LogoURL string // empty for disk (template renders an HDD svg) + DisplayName string // tooltip + State string // "present" | "synced" | "pending" | "syncing" | "failed" + Lit bool +} + +// computeBookPresence projects the per-book disk + destination state +// into a slice of chips ready for the template. Returns a map keyed by +// book ID. Issues one GetBookDestinations per book — fine at page sizes +// in the tens; if libraries get larger we can add a bulk DB call later. +func (s *Server) computeBookPresence(ctx context.Context, books []database.Book) map[int64][]bookPresenceChip { + out := make(map[int64][]bookPresenceChip, len(books)) + if len(books) == 0 { + return out + } + dests, err := s.db.ListLibraryDestinations(ctx) + if err != nil { + dests = nil + } + // Pre-filter to enabled destinations; build a stable ordering so the + // chips don't shuffle between renders. + enabled := make([]database.LibraryDestination, 0, len(dests)) + for _, d := range dests { + if d.Enabled { + enabled = append(enabled, d) + } + } + + for i := range books { + b := &books[i] + chips := make([]bookPresenceChip, 0, 1+len(enabled)) + hasDisk := strings.TrimSpace(b.FilePath) != "" + diskState := "pending" + if hasDisk { + diskState = "present" + } + chips = append(chips, bookPresenceChip{ + Kind: "disk", + DisplayName: "Local audiobooks directory", + State: diskState, + Lit: hasDisk, + }) + + if len(enabled) > 0 { + syncs, _ := s.db.GetBookDestinations(ctx, b.ID) + byDestID := make(map[string]database.BookDestination, len(syncs)) + for _, ss := range syncs { + byDestID[ss.DestinationID] = ss + } + for _, d := range enabled { + chip := bookPresenceChip{ + Kind: "destination", + Type: string(d.Type), + LogoURL: destinationLogoPath(string(d.Type)), + DisplayName: d.DisplayName, + State: "pending", + } + if sync, ok := byDestID[d.ID]; ok { + switch sync.SyncState { + case database.BookDestSyncSynced: + chip.State, chip.Lit = "synced", true + case database.BookDestSyncSyncing: + chip.State = "syncing" + case database.BookDestSyncFailed: + chip.State = "failed" + case database.BookDestSyncOrphaned: + chip.State = "orphaned" + case database.BookDestSyncRemovedFromDestination: + chip.State = "removed" + } + } + chips = append(chips, chip) + } + } + out[b.ID] = chips + } + return out +} + +// destinationLogoPath mirrors the destLogo template func so the Go-side +// presence builder can reuse the same URL mapping without going through +// the template layer. +func destinationLogoPath(t string) string { + switch strings.ToLower(strings.TrimSpace(t)) { + case "plex": + return "/static/plex.png" + case "emby": + return "/static/emby.png" + case "jellyfin": + return "/static/jellyfin.png" + case "abs", "audiobookshelf": + return "/static/audiobookshelf.png" + } + return "" +} + // sidebarData is the per-render snapshot used by the sidebar in base.html: // the badge counts, version, and uptime string. Computed cheaply per full- // page render (a handful of LIMIT 1 / len() queries) — for HTMX fragment diff --git a/internal/web/templates/library_row.html b/internal/web/templates/library_row.html index 62f0224..30b1553 100644 --- a/internal/web/templates/library_row.html +++ b/internal/web/templates/library_row.html @@ -23,9 +23,28 @@ <td class="cell-mono">{{formatDuration .Book.Duration}}</td> <td class="cell-mono">{{formatDate .Book.PurchaseDate}}</td> <td><span class="badge {{statusBadge .Book.Status}}" id="status-book-{{.Book.ID}}">{{.Book.Status}}</span></td> - <td> - {{if .Book.FilePath}}<span class="dest-chip abs" title="On disk">A</span> - {{else}}<span class="cell-mono">—</span>{{end}} + <td class="col-presence"> + {{if .Presence}} + <div class="presence-cell" role="list" aria-label="Presence"> + {{range .Presence}} + {{if eq .Kind "disk"}} + <span class="presence-chip presence-chip--disk{{if .Lit}} is-lit{{end}}" + role="listitem" + title="{{if .Lit}}{{.DisplayName}}{{else}}Not on disk yet{{end}}"> + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v6c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 11v6c0 1.66 4 3 9 3s9-1.34 9-3v-6"/></svg> + </span> + {{else}} + <span class="presence-chip presence-chip--dest{{if .Lit}} is-lit{{end}} is-{{.State}}" + role="listitem" + title="{{.DisplayName}} — {{.State}}"> + {{if .LogoURL}}<img src="{{.LogoURL}}" alt="">{{else}}<span class="presence-chip-fallback">{{firstTwo .Type}}</span>{{end}} + </span> + {{end}} + {{end}} + </div> + {{else}} + <span class="cell-mono">—</span> + {{end}} </td> <td class="col-actions"> <div class="table-actions" id="book-actions-{{.Book.ID}}" data-book-id="{{.Book.ID}}" data-asin="{{.Book.ASIN}}"> diff --git a/internal/web/templates/library_table.html b/internal/web/templates/library_table.html index 2a61145..d9fca97 100644 --- a/internal/web/templates/library_table.html +++ b/internal/web/templates/library_table.html @@ -22,13 +22,13 @@ <th class="sortable {{if eq $sort "status"}}sorted{{end}}"> <a href="?sort=status&dir={{if and (eq $sort "status") (eq $dir "asc")}}desc{{else}}asc{{end}}{{if $search}}&search={{$search}}{{end}}{{if $status}}&status={{$status}}{{end}}">Status <span class="sort-ind">{{if eq $sort "status"}}{{if eq $dir "asc"}}↑{{else}}↓{{end}}{{else}}↕{{end}}</span></a> </th> - <th>On Disk</th> + <th title="Where this book lives: local disk and each enabled destination">Presence</th> <th class="col-actions">Actions</th> </tr> </thead> <tbody> {{range .Books}} - {{template "library_row.html" (dict "Book" . "BookAction" (index $.BookActions .ID))}} + {{template "library_row.html" (dict "Book" . "BookAction" (index $.BookActions .ID) "Presence" (index $.BookPresence .ID))}} {{else}} <tr><td colspan="9" class="empty">No books match these filters.</td></tr> {{end}} From 2dd141a98c486e2b50c83768e982379b26b3239a Mon Sep 17 00:00:00 2001 From: Wutname1 <jeremy12@gmail.com> Date: Thu, 21 May 2026 00:42:12 -0500 Subject: [PATCH 37/40] fix: Promote book destination sync_state to synced on reconcile match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Books matched against ABS during reconcile kept sync_state='pending' forever, so the UI badge said "Pending" on every book even though server_item_id was populated. Reconcile proves the destination has the item — promote pending→synced (preserving failed/orphaned which carry signal). --- internal/mediaserver/destination_ids.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/mediaserver/destination_ids.go b/internal/mediaserver/destination_ids.go index c50fe43..d7fe4a3 100644 --- a/internal/mediaserver/destination_ids.go +++ b/internal/mediaserver/destination_ids.go @@ -3,6 +3,7 @@ package mediaserver import ( "context" "strings" + "time" "github.com/mstrhakr/audplexus/internal/database" ) @@ -38,5 +39,13 @@ func upsertBookDestinationItem(ctx context.Context, db database.Database, bookID } bd.ServerItemID = strings.TrimSpace(serverItemID) bd.ServerItemTitle = strings.TrimSpace(serverItemTitle) + // Reconcile proved the destination has this item. Promote pending→synced + // so the UI badge flips from "Pending" to "Synced" without waiting for + // a sync attempt. Don't clobber failed/orphaned — those carry signal. + if bd.ServerItemID != "" && (bd.SyncState == "" || bd.SyncState == database.BookDestSyncPending) { + bd.SyncState = database.BookDestSyncSynced + now := time.Now().UTC() + bd.LastSucceededAt = &now + } return db.UpsertBookDestination(ctx, bd) } \ No newline at end of file From ec2dcfcf826a3978fdb8cc739d32b5fca0e7a8b8 Mon Sep 17 00:00:00 2001 From: Wutname1 <jeremy12@gmail.com> Date: Thu, 21 May 2026 00:42:22 -0500 Subject: [PATCH 38/40] fix: Broaden ABS reconcile matching to all books and fallback ASIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile previously filtered local books to status=complete, so books in any other status would never get matched against ABS items by ASIN even when ABS plainly had them. Drop the filter — match all books. Also add a folder-path ASIN fallback for ABS items whose metadata.asin is empty: audplexus's download layout embeds the ASIN in folder names (e.g. "Title B0CJS1PS67 [us]"), so we can recover the match when ABS's own metadata extraction missed it. Log unmatched and no-ASIN counts at reconcile end so future drift is visible instead of silently skipped. --- internal/mediaserver/abs.go | 73 ++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/internal/mediaserver/abs.go b/internal/mediaserver/abs.go index c24f891..0baf6af 100644 --- a/internal/mediaserver/abs.go +++ b/internal/mediaserver/abs.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "os" + "regexp" "strings" "time" @@ -179,10 +180,12 @@ func (a *ABSBackend) ReconcileLibrary(ctx context.Context, progressFn func(curre } msLog.Info().Int("abs_items", len(items)).Msg("abs: fetched library item list for reconcile") - completeStatus := database.BookStatusComplete - books, _, err := a.db.ListBooks(ctx, database.BookFilter{Status: &completeStatus, Limit: 100000}) + // Match all local books regardless of status — a book can exist in ABS + // before its local status reaches "complete" (e.g. user dragged files in + // manually, or status got stuck after a partial run). + books, _, err := a.db.ListBooks(ctx, database.BookFilter{Limit: 100000}) if err != nil { - return fmt.Errorf("list complete books: %w", err) + return fmt.Errorf("list books: %w", err) } booksByASIN := make(map[string]database.Book, len(books)) for _, b := range books { @@ -195,16 +198,31 @@ func (a *ABSBackend) ReconcileLibrary(ctx context.Context, progressFn func(curre progressFn(0, len(items)) } matched := 0 + noASIN := 0 + unmatched := 0 for i, it := range items { if ctx.Err() != nil { return ctx.Err() } asin := strings.ToUpper(strings.TrimSpace(it.ASIN)) if asin == "" { - continue + // ABS didn't extract an ASIN from metadata. Audplexus's own + // download layout puts the ASIN in the folder name (e.g. + // "Title B0CJS1PS67 [us]"), so try that as a fallback. + asin = extractASINFromPath(it.Path) + if asin == "" { + asin = extractASINFromPath(it.RelPath) + } + if asin == "" { + noASIN++ + msLog.Debug().Str("abs_id", it.ID).Str("title", it.Title).Str("path", it.RelPath).Msg("abs: item has no ASIN in metadata or path") + continue + } } book, ok := booksByASIN[asin] if !ok { + unmatched++ + msLog.Debug().Str("abs_id", it.ID).Str("asin", asin).Str("title", it.Title).Msg("abs: no local book matches this ASIN") continue } if a.destination != nil { @@ -218,17 +236,42 @@ func (a *ABSBackend) ReconcileLibrary(ctx context.Context, progressFn func(curre progressFn(i+1, len(items)) } } - msLog.Info().Int("matched", matched).Int("abs_items", len(items)).Int("local_books", len(books)).Msg("abs: reconcile complete") + msLog.Info(). + Int("matched", matched). + Int("abs_items", len(items)). + Int("local_books", len(books)). + Int("abs_no_asin", noASIN). + Int("abs_unmatched", unmatched). + Msg("abs: reconcile complete") return nil } +// asinPathRe matches a 10-char Audible ASIN (always starts with B0) as a +// whole token in a folder name. Audplexus's download layout produces folders +// like "Title B0CJS1PS67 [us]"; this lets us recover the ASIN when ABS's +// own metadata extraction missed it. +var asinPathRe = regexp.MustCompile(`\b(B0[A-Z0-9]{8})\b`) + +func extractASINFromPath(p string) string { + if p == "" { + return "" + } + m := asinPathRe.FindStringSubmatch(strings.ToUpper(p)) + if len(m) < 2 { + return "" + } + return m[1] +} + // absLibraryItem is a thin DTO of the fields ReconcileLibrary needs from // /api/libraries/{id}/items — keeps the parser tolerant to ABS's larger // response shape without forcing us to model every field. type absLibraryItem struct { - ID string - Title string - ASIN string + ID string + Title string + ASIN string + Path string + RelPath string } func (a *ABSBackend) listAllItems(ctx context.Context, baseURL, apiKey, libraryID string) ([]absLibraryItem, error) { @@ -264,8 +307,10 @@ func (a *ABSBackend) listAllItems(ctx context.Context, baseURL, apiKey, libraryI var r struct { Results []struct { - ID string `json:"id"` - Media struct { + ID string `json:"id"` + Path string `json:"path"` + RelPath string `json:"relPath"` + Media struct { Metadata struct { Title string `json:"title"` ASIN string `json:"asin"` @@ -282,9 +327,11 @@ func (a *ABSBackend) listAllItems(ctx context.Context, baseURL, apiKey, libraryI for _, hit := range r.Results { all = append(all, absLibraryItem{ - ID: hit.ID, - Title: hit.Media.Metadata.Title, - ASIN: hit.Media.Metadata.ASIN, + ID: hit.ID, + Title: hit.Media.Metadata.Title, + ASIN: hit.Media.Metadata.ASIN, + Path: hit.Path, + RelPath: hit.RelPath, }) } if len(r.Results) < pageSize { From b59039a5856a94445d73715ea1ea66f1d78258dc Mon Sep 17 00:00:00 2001 From: Wutname1 <jeremy12@gmail.com> Date: Thu, 21 May 2026 00:42:33 -0500 Subject: [PATCH 39/40] feat: Persist denied-entitlement books as unavailable instead of dropping Books that fail Audible's CanDownload check (e.g. once-Plus-catalog titles the user no longer has access to) were silently skipped during sync, leaving a gap between the Audible library count and what audplexus shows. Persist them as status=unavailable with a cleaned-up reason string so the UI can surface why they're missing. - New BookStatus 'unavailable' and books.unavailable_reason column (migration 007 for both sqlite and postgres). - On subsequent syncs, re-check unavailable books in case the user regained access (Plus title added back). - errs.CleanEntitlementReason distills Audible's verbose multi-clause license-denied error into a short user-facing reason (e.g. "No longer included with your Audible plan"). --- .../007_add_unavailable_reason.down.sql | 1 + .../007_add_unavailable_reason.up.sql | 1 + .../007_add_unavailable_reason.down.sql | 1 + .../007_add_unavailable_reason.up.sql | 1 + internal/database/models.go | 13 ++++-- internal/database/postgres.go | 22 +++++----- internal/database/sqlite.go | 22 +++++----- internal/errs/display.go | 38 +++++++++++++++++ internal/library/sync.go | 41 ++++++++++++++++--- 9 files changed, 109 insertions(+), 31 deletions(-) create mode 100644 internal/database/migrations/007_add_unavailable_reason.down.sql create mode 100644 internal/database/migrations/007_add_unavailable_reason.up.sql create mode 100644 internal/database/migrations_postgres/007_add_unavailable_reason.down.sql create mode 100644 internal/database/migrations_postgres/007_add_unavailable_reason.up.sql diff --git a/internal/database/migrations/007_add_unavailable_reason.down.sql b/internal/database/migrations/007_add_unavailable_reason.down.sql new file mode 100644 index 0000000..2f9471d --- /dev/null +++ b/internal/database/migrations/007_add_unavailable_reason.down.sql @@ -0,0 +1 @@ +ALTER TABLE books DROP COLUMN unavailable_reason; diff --git a/internal/database/migrations/007_add_unavailable_reason.up.sql b/internal/database/migrations/007_add_unavailable_reason.up.sql new file mode 100644 index 0000000..b9b9be7 --- /dev/null +++ b/internal/database/migrations/007_add_unavailable_reason.up.sql @@ -0,0 +1 @@ +ALTER TABLE books ADD COLUMN unavailable_reason TEXT NOT NULL DEFAULT ''; diff --git a/internal/database/migrations_postgres/007_add_unavailable_reason.down.sql b/internal/database/migrations_postgres/007_add_unavailable_reason.down.sql new file mode 100644 index 0000000..2f9471d --- /dev/null +++ b/internal/database/migrations_postgres/007_add_unavailable_reason.down.sql @@ -0,0 +1 @@ +ALTER TABLE books DROP COLUMN unavailable_reason; diff --git a/internal/database/migrations_postgres/007_add_unavailable_reason.up.sql b/internal/database/migrations_postgres/007_add_unavailable_reason.up.sql new file mode 100644 index 0000000..b9b9be7 --- /dev/null +++ b/internal/database/migrations_postgres/007_add_unavailable_reason.up.sql @@ -0,0 +1 @@ +ALTER TABLE books ADD COLUMN unavailable_reason TEXT NOT NULL DEFAULT ''; diff --git a/internal/database/models.go b/internal/database/models.go index 4715754..8aa8e13 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -23,9 +23,10 @@ type Book struct { PurchaseDate time.Time `json:"purchase_date"` ReleaseDate time.Time `json:"release_date"` DRMType string `json:"drm_type"` // "Adrm" or "Mpeg" - Status BookStatus `json:"status"` - FilePath string `json:"file_path"` - FileSize int64 `json:"file_size"` + Status BookStatus `json:"status"` + UnavailableReason string `json:"unavailable_reason,omitempty"` + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -42,6 +43,12 @@ const ( BookStatusComplete BookStatus = "complete" BookStatusFailed BookStatus = "failed" BookStatusSkipped BookStatus = "skipped" + // BookStatusUnavailable marks a book that is in the user's Audible + // library but not actually downloadable (e.g. license denied because it + // was once a Plus-catalog title that's no longer included). We keep the + // row so the UI can surface why it's missing instead of silently + // dropping it during sync. + BookStatusUnavailable BookStatus = "unavailable" ) // DownloadQueue represents a queued download job. diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 024a852..fa5df93 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -70,7 +70,7 @@ func (p *PostgresDB) GetBook(ctx context.Context, id int64) (*Book, error) { return p.scanBook(p.db.QueryRowContext(ctx, `SELECT id, asin, title, author, author_asin, narrator, publisher, description, duration, series, series_position, cover_url, purchase_date, release_date, - drm_type, status, file_path, file_size, + drm_type, status, unavailable_reason, file_path, file_size, created_at, updated_at FROM books WHERE id = $1`, id)) } @@ -79,7 +79,7 @@ func (p *PostgresDB) GetBookByASIN(ctx context.Context, asin string) (*Book, err return p.scanBook(p.db.QueryRowContext(ctx, `SELECT id, asin, title, author, author_asin, narrator, publisher, description, duration, series, series_position, cover_url, purchase_date, release_date, - drm_type, status, file_path, file_size, + drm_type, status, unavailable_reason, file_path, file_size, created_at, updated_at FROM books WHERE asin = $1`, asin)) } @@ -119,7 +119,7 @@ func (p *PostgresDB) ListBooks(ctx context.Context, filter BookFilter) ([]Book, query := `SELECT id, asin, title, author, author_asin, narrator, publisher, description, duration, series, series_position, cover_url, purchase_date, release_date, - drm_type, status, file_path, file_size, + drm_type, status, unavailable_reason, file_path, file_size, created_at, updated_at FROM books` + where + orderBy + limit + offset @@ -171,20 +171,20 @@ func (p *PostgresDB) UpsertBook(ctx context.Context, book *Book) error { err := p.db.QueryRowContext(ctx, `INSERT INTO books (asin, title, author, author_asin, narrator, publisher, description, duration, series, series_position, cover_url, purchase_date, release_date, - drm_type, status, file_path, file_size, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) + drm_type, status, unavailable_reason, file_path, file_size, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) ON CONFLICT(asin) DO UPDATE SET title=EXCLUDED.title, author=EXCLUDED.author, author_asin=EXCLUDED.author_asin, narrator=EXCLUDED.narrator, publisher=EXCLUDED.publisher, description=EXCLUDED.description, duration=EXCLUDED.duration, series=EXCLUDED.series, series_position=EXCLUDED.series_position, cover_url=EXCLUDED.cover_url, purchase_date=EXCLUDED.purchase_date, release_date=EXCLUDED.release_date, - drm_type=EXCLUDED.drm_type, status=EXCLUDED.status, file_path=EXCLUDED.file_path, - file_size=EXCLUDED.file_size, updated_at=EXCLUDED.updated_at + drm_type=EXCLUDED.drm_type, status=EXCLUDED.status, unavailable_reason=EXCLUDED.unavailable_reason, + file_path=EXCLUDED.file_path, file_size=EXCLUDED.file_size, updated_at=EXCLUDED.updated_at RETURNING id`, book.ASIN, book.Title, book.Author, book.AuthorASIN, book.Narrator, book.Publisher, book.Description, book.Duration, book.Series, book.SeriesPosition, book.CoverURL, - book.PurchaseDate, book.ReleaseDate, book.DRMType, book.Status, book.FilePath, - book.FileSize, book.CreatedAt, book.UpdatedAt).Scan(&book.ID) + book.PurchaseDate, book.ReleaseDate, book.DRMType, book.Status, book.UnavailableReason, + book.FilePath, book.FileSize, book.CreatedAt, book.UpdatedAt).Scan(&book.ID) if err != nil { return fmt.Errorf("upsert book: %w", err) } @@ -545,7 +545,7 @@ func (p *PostgresDB) scanBook(row *sql.Row) (*Book, error) { err := row.Scan(&b.ID, &b.ASIN, &b.Title, &b.Author, &b.AuthorASIN, &b.Narrator, &b.Publisher, &b.Description, &b.Duration, &b.Series, &b.SeriesPosition, &b.CoverURL, &b.PurchaseDate, &b.ReleaseDate, &b.DRMType, &b.Status, - &b.FilePath, &b.FileSize, + &b.UnavailableReason, &b.FilePath, &b.FileSize, &b.CreatedAt, &b.UpdatedAt) if err == sql.ErrNoRows { return nil, nil @@ -561,7 +561,7 @@ func (p *PostgresDB) scanBookRow(rows *sql.Rows) (*Book, error) { err := rows.Scan(&b.ID, &b.ASIN, &b.Title, &b.Author, &b.AuthorASIN, &b.Narrator, &b.Publisher, &b.Description, &b.Duration, &b.Series, &b.SeriesPosition, &b.CoverURL, &b.PurchaseDate, &b.ReleaseDate, &b.DRMType, &b.Status, - &b.FilePath, &b.FileSize, + &b.UnavailableReason, &b.FilePath, &b.FileSize, &b.CreatedAt, &b.UpdatedAt) if err != nil { return nil, fmt.Errorf("scan book row: %w", err) diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index 91830fe..889262d 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -75,7 +75,7 @@ func (s *SQLiteDB) GetBook(ctx context.Context, id int64) (*Book, error) { return s.scanBook(s.db.QueryRowContext(ctx, `SELECT id, asin, title, author, author_asin, narrator, publisher, description, duration, series, series_position, cover_url, purchase_date, release_date, - drm_type, status, file_path, file_size, + drm_type, status, unavailable_reason, file_path, file_size, created_at, updated_at FROM books WHERE id = ?`, id)) } @@ -84,7 +84,7 @@ func (s *SQLiteDB) GetBookByASIN(ctx context.Context, asin string) (*Book, error return s.scanBook(s.db.QueryRowContext(ctx, `SELECT id, asin, title, author, author_asin, narrator, publisher, description, duration, series, series_position, cover_url, purchase_date, release_date, - drm_type, status, file_path, file_size, + drm_type, status, unavailable_reason, file_path, file_size, created_at, updated_at FROM books WHERE asin = ?`, asin)) } @@ -121,7 +121,7 @@ func (s *SQLiteDB) ListBooks(ctx context.Context, filter BookFilter) ([]Book, in query := `SELECT id, asin, title, author, author_asin, narrator, publisher, description, duration, series, series_position, cover_url, purchase_date, release_date, - drm_type, status, file_path, file_size, + drm_type, status, unavailable_reason, file_path, file_size, created_at, updated_at FROM books` + where + orderBy + limit + offset @@ -173,19 +173,19 @@ func (s *SQLiteDB) UpsertBook(ctx context.Context, book *Book) error { result, err := s.db.ExecContext(ctx, `INSERT INTO books (asin, title, author, author_asin, narrator, publisher, description, duration, series, series_position, cover_url, purchase_date, release_date, - drm_type, status, file_path, file_size, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + drm_type, status, unavailable_reason, file_path, file_size, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(asin) DO UPDATE SET title=excluded.title, author=excluded.author, author_asin=excluded.author_asin, narrator=excluded.narrator, publisher=excluded.publisher, description=excluded.description, duration=excluded.duration, series=excluded.series, series_position=excluded.series_position, cover_url=excluded.cover_url, purchase_date=excluded.purchase_date, release_date=excluded.release_date, - drm_type=excluded.drm_type, status=excluded.status, file_path=excluded.file_path, - file_size=excluded.file_size, updated_at=excluded.updated_at`, + drm_type=excluded.drm_type, status=excluded.status, unavailable_reason=excluded.unavailable_reason, + file_path=excluded.file_path, file_size=excluded.file_size, updated_at=excluded.updated_at`, book.ASIN, book.Title, book.Author, book.AuthorASIN, book.Narrator, book.Publisher, book.Description, book.Duration, book.Series, book.SeriesPosition, book.CoverURL, - book.PurchaseDate, book.ReleaseDate, book.DRMType, book.Status, book.FilePath, - book.FileSize, book.CreatedAt, book.UpdatedAt) + book.PurchaseDate, book.ReleaseDate, book.DRMType, book.Status, book.UnavailableReason, + book.FilePath, book.FileSize, book.CreatedAt, book.UpdatedAt) if err != nil { return fmt.Errorf("upsert book: %w", err) } @@ -572,7 +572,7 @@ func (s *SQLiteDB) scanBook(row *sql.Row) (*Book, error) { err := row.Scan(&b.ID, &b.ASIN, &b.Title, &b.Author, &b.AuthorASIN, &b.Narrator, &b.Publisher, &b.Description, &b.Duration, &b.Series, &b.SeriesPosition, &b.CoverURL, &b.PurchaseDate, &b.ReleaseDate, &b.DRMType, &b.Status, - &b.FilePath, &b.FileSize, + &b.UnavailableReason, &b.FilePath, &b.FileSize, &b.CreatedAt, &b.UpdatedAt) if err == sql.ErrNoRows { return nil, nil @@ -588,7 +588,7 @@ func (s *SQLiteDB) scanBookRow(rows *sql.Rows) (*Book, error) { err := rows.Scan(&b.ID, &b.ASIN, &b.Title, &b.Author, &b.AuthorASIN, &b.Narrator, &b.Publisher, &b.Description, &b.Duration, &b.Series, &b.SeriesPosition, &b.CoverURL, &b.PurchaseDate, &b.ReleaseDate, &b.DRMType, &b.Status, - &b.FilePath, &b.FileSize, + &b.UnavailableReason, &b.FilePath, &b.FileSize, &b.CreatedAt, &b.UpdatedAt) if err != nil { return nil, fmt.Errorf("scan book row: %w", err) diff --git a/internal/errs/display.go b/internal/errs/display.go index 3bbe07e..232881c 100644 --- a/internal/errs/display.go +++ b/internal/errs/display.go @@ -69,3 +69,41 @@ func CleanForDisplay(raw string) string { } return s } + +// CleanEntitlementReason turns Audible's verbose license-denied error +// (a multi-clause string listing every eligibility check that failed, +// often 600+ chars) into a short user-facing reason. Examples of input: +// +// license denied for ASIN B0... (status=Denied): License not granted to +// customer ... for asin [B0...]; reasons: Customer is not part of any +// plans [RequesterEligibility/Membership] | Ownership: ... | ... +// +// The user just needs to know "Audible says you don't have access" plus +// a hint at WHY (no membership, ownership not found, AYCL ineligible). +// We keep the full raw message in logs, not in the UI. +func CleanEntitlementReason(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "Audible entitlement denied" + } + lower := strings.ToLower(s) + switch { + case strings.Contains(lower, "not part of any plans"), + strings.Contains(lower, "requestereligibility/membership"): + return "No longer included with your Audible plan" + case strings.Contains(lower, "no ownership"), + strings.Contains(lower, "requestereligibility/ownership"): + return "Audible reports no ownership of this title" + case strings.Contains(lower, "not eligible for aycl"), + strings.Contains(lower, "contenteligibility/aycl"): + return "No longer included with Audible Plus" + case strings.Contains(lower, "license denied"), + strings.Contains(lower, "license not granted"): + return "Audible denied the download license" + } + const maxLen = 160 + if len(s) > maxLen { + s = strings.TrimSpace(s[:maxLen]) + "…" + } + return s +} diff --git a/internal/library/sync.go b/internal/library/sync.go index 2c4a0c2..5152fdb 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -1029,14 +1029,25 @@ func (s *SyncService) doAudibleSync(ctx context.Context, syncRecord *database.Sy } // For brand-new books only: verify entitlement before adding to the DB. + // If denied, still record the book — just flag it as unavailable so the + // UI can show it under the "Unavailable" tab with the denial reason + // (e.g. once-Plus-catalog titles the user no longer has access to). if existing == nil { canDownload, cdErr := s.client.CanDownload(ctx, item) if cdErr != nil || !canDownload { - logMsg := "skipping new item after entitlement check" + reason := "Audible entitlement denied" if cdErr != nil { - logMsg = fmt.Sprintf("skipping new item after entitlement check: %v", cdErr) + reason = errs.CleanEntitlementReason(cdErr.Error()) + } + syncLog.Info().Str("asin", book.ASIN).Str("title", book.Title).Str("reason", reason).Msg("marking new item as unavailable: entitlement denied") + book.Status = database.BookStatusUnavailable + book.UnavailableReason = reason + if err := s.db.UpsertBook(ctx, &book); err != nil { + syncLog.Error().Err(err).Str("asin", book.ASIN).Msg("failed to upsert unavailable book") + } else { + keepASIN[book.ASIN] = struct{}{} + added++ } - syncLog.Info().Str("asin", book.ASIN).Str("title", book.Title).Msg(logMsg) scanned++ s.mu.Lock() s.progress.BooksScanned = scanned @@ -1051,16 +1062,34 @@ func (s *SyncService) doAudibleSync(ctx context.Context, syncRecord *database.Sy s.mu.Unlock() continue } + } else if existing.Status == database.BookStatusUnavailable { + // User may have regained access (Plus added title back). Re-check. + canDownload, cdErr := s.client.CanDownload(ctx, item) + if cdErr == nil && canDownload { + syncLog.Info().Str("asin", book.ASIN).Msg("previously-unavailable book is now accessible") + book.Status = database.BookStatusNew + book.UnavailableReason = "" + } else { + book.Status = database.BookStatusUnavailable + book.UnavailableReason = existing.UnavailableReason + if cdErr != nil { + book.UnavailableReason = errs.CleanEntitlementReason(cdErr.Error()) + } + } } keepASIN[book.ASIN] = struct{}{} - // Preserve status/file info for existing books + // Preserve status/file info for existing books — unless the + // unavailable-recheck above already decided a new status for this row. if existing != nil { - book.Status = existing.Status + if book.Status == "" { + book.Status = existing.Status + book.UnavailableReason = existing.UnavailableReason + } book.FilePath = existing.FilePath book.FileSize = existing.FileSize - syncLog.Debug().Str("asin", book.ASIN).Str("status", string(existing.Status)).Msg("book already exists, preserving state") + syncLog.Debug().Str("asin", book.ASIN).Str("status", string(book.Status)).Msg("book already exists, preserving state") } else { book.Status = database.BookStatusNew added++ From 6d696f6c20e05c03ae74daf4ea0021000c1390b3 Mon Sep 17 00:00:00 2001 From: Wutname1 <jeremy12@gmail.com> Date: Thu, 21 May 2026 00:42:55 -0500 Subject: [PATCH 40/40] feat: Surface unavailable books in library UI with dedicated tab - New "Unavailable" tab and dropdown option on the library page, with a count badge fed by libraryStatusCounts. - Warning-triangle icon next to the title in the library row, with the cleaned reason in its tooltip. - Inline warning notice in the book detail panel explaining why the title can't be downloaded. - Unavailable status uses the warning badge color so it's visually distinct from new/complete/failed. --- internal/web/server.go | 7 ++ internal/web/static/style.css | 99 ++++++++++++++++++- internal/web/templates/book_detail_panel.html | 7 ++ internal/web/templates/library.html | 2 + internal/web/templates/library_row.html | 6 ++ 5 files changed, 117 insertions(+), 4 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index 58c7b21..ed6f7eb 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -296,6 +296,8 @@ func (s *Server) setupTemplates() { return "badge-warning" case database.BookStatusQueued: return "badge-info" + case database.BookStatusUnavailable: + return "badge-warning" default: return "badge-neutral" } @@ -1059,6 +1061,7 @@ func (s *Server) libraryStatusCounts(ctx context.Context) map[string]int { "waiting": 0, "complete": 0, "failed": 0, + "unavailable": 0, } byStatus, err := s.db.CountBooksByStatus(ctx) @@ -1078,6 +1081,8 @@ func (s *Server) libraryStatusCounts(ctx context.Context) map[string]int { out["waiting"] += n case database.BookStatusDownloading, database.BookStatusDecrypting, database.BookStatusProcessing: out["in_progress"] += n + case database.BookStatusUnavailable: + out["unavailable"] += n } // Other statuses (skipped) contribute to "all" but not to any // filter tab; that matches the legacy behaviour where skipped @@ -3292,9 +3297,11 @@ func (s *Server) handleDeleteBookMedia(c *gin.Context) { msg += "; redownload queued immediately" } if c.Query("view") == "row" { + presence := s.computeBookPresence(ctx, []database.Book{*book}) c.HTML(http.StatusOK, "library_row.html", gin.H{ "Book": book, "BookAction": buildLibraryBookActions([]database.Book{*book}, autoQueueNew)[book.ID], + "Presence": presence[book.ID], }) return } diff --git a/internal/web/static/style.css b/internal/web/static/style.css index e39590b..ea686ff 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -1017,10 +1017,10 @@ h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); } font-size: 0.6rem; font-weight: 700; } -.dest-chip.plex { background: linear-gradient(135deg, #1ddbe9, #114e88); } -.dest-chip.abs { background: linear-gradient(135deg, #f4a261, #e76f51); } -.dest-chip.emby { background: linear-gradient(135deg, #52b54b, #154e15); } -.dest-chip.jellyfin { background: linear-gradient(135deg, #aa5cc3, #00a4dc); } +.dest-chip.plex { background: none; } +.dest-chip.abs { background: none; } +.dest-chip.emby { background: none; } +.dest-chip.jellyfin { background: none; } .dest-chip img { width: 100%; height: 100%; object-fit: contain; display: block; } @@ -3251,6 +3251,97 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } min-width: 26px; } +/* ── Library: Presence column ──────────────────────────────────────── + A horizontal strip of small chips per book — disk + each enabled + destination. Lit chips show where the book currently lives; dimmed + chips are pending. Failed sync rings the chip in red so problem rows + are scannable at a glance across the table. */ +.col-presence { white-space: nowrap; vertical-align: middle; } +.presence-cell { + display: inline-flex; + align-items: center; + gap: 4px; + flex-wrap: nowrap; +} +.presence-chip { + width: 22px; + height: 22px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface-2); + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + transition: opacity 0.12s, filter 0.12s, box-shadow 0.12s, border-color 0.12s; + opacity: 0.32; + filter: grayscale(1); + position: relative; +} +.presence-chip.is-lit { + opacity: 1; + filter: none; +} +.presence-chip img { + width: 16px; + height: 16px; + object-fit: contain; + display: block; +} +.presence-chip svg { color: var(--text-muted); } +.presence-chip.is-lit svg { color: var(--plex-cyan); } +.presence-chip-fallback { + font-family: var(--font-mono); + font-size: 0.55rem; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.04em; +} + +/* Disk chip — slightly distinct treatment from destination chips so + the eye reads "local thing" vs "remote thing" without a label. */ +.presence-chip--disk { + background: var(--bg); +} +.presence-chip--disk.is-lit { background: var(--surface-2); } +.presence-chip--disk.is-lit svg { color: var(--text); } + +/* In-flight: cyan ring with a gentle pulse. Always fully opaque so + the user notices motion even on rows they aren't hovering. */ +.presence-chip.is-syncing { + opacity: 1; + filter: none; + border-color: var(--plex-cyan); + box-shadow: 0 0 0 2px rgba(124, 201, 255, 0.18); + animation: presence-syncing-pulse 1.4s ease-in-out infinite; +} +@keyframes presence-syncing-pulse { + 0%, 100% { box-shadow: 0 0 0 2px rgba(124, 201, 255, 0.18); } + 50% { box-shadow: 0 0 0 3px rgba(124, 201, 255, 0.32); } +} + +/* Failed: red ring, always lit so the failure isn't easy to miss. */ +.presence-chip.is-failed { + opacity: 1; + filter: none; + border-color: var(--error); + box-shadow: 0 0 0 2px rgba(244, 67, 54, 0.22); +} + +/* Removed / orphaned: lit but a muted amber border — the book is known + to the destination but in a weird state. */ +.presence-chip.is-orphaned, +.presence-chip.is-removed { + opacity: 0.65; + filter: grayscale(0.5); + border-color: rgba(255, 152, 0, 0.5); +} + +.presence-chip:hover { + cursor: default; +} + .picker-list { display: flex; flex-direction: column; gap: 0.5rem; } .picker-list .picker { flex-direction: row; align-items: center; gap: 0.85rem; } .picker-list .picker .lg { flex: 1; } diff --git a/internal/web/templates/book_detail_panel.html b/internal/web/templates/book_detail_panel.html index e89ba6c..c497d11 100644 --- a/internal/web/templates/book_detail_panel.html +++ b/internal/web/templates/book_detail_panel.html @@ -84,6 +84,13 @@ <h2>{{.Book.Title}}</h2> {{if .Book.FilePath}}<span class="badge badge-success">On disk</span>{{end}} </div> + {{if eq (printf "%s" .Book.Status) "unavailable"}} + <div class="unavailable-notice" role="status" style="display:flex;gap:.5rem;align-items:flex-start;background:var(--color-warning-bg,#3a2e15);color:var(--color-warning-fg,#ffd479);border:1px solid var(--color-warning-border,#6b5320);padding:.6rem .75rem;border-radius:6px;margin:.75rem 0;font-size:.85rem;"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex:0 0 auto;margin-top:1px;"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> + <div><strong>Unavailable.</strong> {{if .Book.UnavailableReason}}{{.Book.UnavailableReason}}{{else}}Audible reports this title cannot be downloaded with your current account.{{end}}</div> + </div> + {{end}} + <dl class="meta-grid"> <dt>Author</dt><dd>{{.Book.Author}}</dd> <dt>Narrator</dt><dd>{{if .Book.Narrator}}{{.Book.Narrator}}{{else}}—{{end}}</dd> diff --git a/internal/web/templates/library.html b/internal/web/templates/library.html index 58910cc..795fa0d 100644 --- a/internal/web/templates/library.html +++ b/internal/web/templates/library.html @@ -26,6 +26,7 @@ <h1>Library</h1> <a href="/library?status=queued{{if $search}}&search={{$search}}{{end}}" class="{{if eq $active "queued"}}active{{end}}" role="tab">Waiting <span class="count">{{index .StatusCounts "waiting"}}</span></a> <a href="/library?status=complete{{if $search}}&search={{$search}}{{end}}" class="{{if eq $active "complete"}}active{{end}}" role="tab">Complete <span class="count">{{index .StatusCounts "complete"}}</span></a> <a href="/library?status=failed{{if $search}}&search={{$search}}{{end}}" class="{{if eq $active "failed"}}active{{end}}" role="tab">Failed <span class="count">{{index .StatusCounts "failed"}}</span></a> + <a href="/library?status=unavailable{{if $search}}&search={{$search}}{{end}}" class="{{if eq $active "unavailable"}}active{{end}}" role="tab" title="In your Audible library but not downloadable (e.g. removed from Plus)">Unavailable <span class="count">{{index .StatusCounts "unavailable"}}</span></a> </div> <div class="filter-bar"> @@ -47,6 +48,7 @@ <h1>Library</h1> <option value="downloading" {{if eq $active "downloading"}}selected{{end}}>Downloading</option> <option value="complete" {{if eq $active "complete"}}selected{{end}}>Complete</option> <option value="failed" {{if eq $active "failed"}}selected{{end}}>Failed</option> + <option value="unavailable" {{if eq $active "unavailable"}}selected{{end}}>Unavailable</option> </select> <select name="sort" aria-label="Sort by" hx-get="/library" hx-target="#book-table" hx-swap="innerHTML" diff --git a/internal/web/templates/library_row.html b/internal/web/templates/library_row.html index 30b1553..841a3a7 100644 --- a/internal/web/templates/library_row.html +++ b/internal/web/templates/library_row.html @@ -12,6 +12,12 @@ <td> <div class="cell-title"> <a href="/library/{{.Book.ID}}" class="book-detail-link" data-book-id="{{.Book.ID}}">{{.Book.Title}}</a> + {{if eq (printf "%s" .Book.Status) "unavailable"}} + <div class="unavailable-inline" role="status" style="display:inline-flex;gap:.35rem;align-items:center;color:#e0a14b;font-size:.78rem;margin-left:.4rem;vertical-align:middle;"> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex:0 0 auto;"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> + <span>{{if .Book.UnavailableReason}}{{.Book.UnavailableReason}}{{else}}Unavailable on Audible{{end}}</span> + </div> + {{end}} {{if .Book.Narrator}}<small>narr. {{.Book.Narrator}}</small>{{end}} </div> </td>