Skip to content

UI refresh + first-run onboarding + sync/destination fixes#10

Merged
mstrhakr merged 40 commits into
mstrhakr:masterfrom
Wutname1:master
May 26, 2026
Merged

UI refresh + first-run onboarding + sync/destination fixes#10
mstrhakr merged 40 commits into
mstrhakr:masterfrom
Wutname1:master

Conversation

@Wutname1

@Wutname1 Wutname1 commented May 21, 2026

Copy link
Copy Markdown
Contributor

This is a big one, While doing the UI refresh I kept hitting visible bugs that were lurking in the sync and matching paths. There are a few elements I am not fully happy with, but This PR is already large enough and can be taken care of in follow up passes.

Onboarding wizard, This can be re-ran at any time on the settings screen:
Wizard1

Dashboard:
Dashboard

Destinations split out from Settings screen:
destinations

Library, now with pagnation:
Library

Book Details:
BookDetails

Try it

CI builds are enabled on this branch — pull the latest image with:

docker pull ghcr.io/wutname1/audplexus:master

Bugs fixed along the way

Rather than ship a pretty UI on top of broken plumbing, I fixed some bugs along the way:

  • Denied-license books silently dropped during sync. Books that fail Audible's CanDownload check (e.g. once-Plus-catalog titles you no longer have access to) were continue'd past without ever hitting the DB, leaving a gap between your Audible library count and what audplexus showed. Now persisted as status=unavailable with a cleaned reason, surfaced under a new Unavailable tab with a warning triangle and inline explanation.
  • All books showed PENDING for Audiobookshelf even when matched. ABS reconcile correctly assigned server_item_id, but sync_state stayed pending forever — nothing promoted it. On my server, that meant 607 books all displayed "Pending" despite being fully synced. Reconcile now promotes pending → synced when a match lands.
  • ABS reconcile only matched status=complete books. Anything in new/queued/failed/etc. was invisible to the matcher even when ABS had it. Filter dropped — now matches every book by ASIN.
  • ABS items with empty metadata.asin were silently skipped. ABS's own audible match misses about 3% of items in my library. Added a folder-path ASIN fallback (Title B0CJS1PS67 [us] style) and counters in the reconcile log so future drift is visible instead of silent.
  • Audible purchase/release date parsing was fragile. Hardened the parser and removed a diagnostic log left in from earlier debugging.
  • /library issued 7 separate LIMIT 1 count queries to populate the status-tab badges. Collapsed to a single GROUP BY.
  • First-run onboarding gate hit the DB on every request to check the "onboarded" flag. Cached in an atomic.Bool.
  • Destinations toggle did a full-page reload instead of the HTMX card swap it was wired for. Also dropped a dead non-modal renderAuthPage branch and an HX-Trigger payload that hand-built JSON (now encoding/json).
  • Several stub Database implementations didn't satisfy CountBooksByStatus — added test coverage.

What's new

First-run onboarding. New users land on a guided wizard (devices → first sync → first destination) instead of a blank dashboard. The "onboarded" gate redirects out automatically once setup is done.

Design-system refresh across every page. New foundation primitives (chore(ui): foundation primitives for DS chrome) underpin a refreshed dashboard, library, pipeline, settings, destinations, diagnostics, and book-detail modal. Highlights:

  • Library — status tabs with live counts, search/sort/filter, paginated table, new "Presence" column showing a strip of small chips per book (disk + each enabled destination) so you can see at a glance where a book actually lives.
  • Pipeline — completed-window filter so finished items don't pile up forever.
  • Sidebar — icons, status badges, footer with version info.
  • Book detail — restyled modal, extracted as a partial so the dashboard can reuse it.
  • Settings — DS chrome, scroll-spy table of contents, responsive form layout.
  • Diagnostics — connection tests, log copy, logs & env tab.
  • Destinations — split to its own page, collapsed New/Edit/Delete into a single modal flow, brand logos, health display with test-connection feedback, redesigned create/edit/delete forms.

Plex guided destination form. Step-by-step Plex setup with section-selection validation and live edit experience instead of a flat form.

Manual book metadata re-sync. Per-book button that re-fetches from audnexus and updates fields in place without touching the file.

Shared error display helper (internal/errs). Strips HTML, collapses whitespace, rewrites common HTTP status codes into hints, truncates noisy upstream bodies. Used everywhere errors surface — destinations health, sync phases, dashboard recap, drift inbox — so the same failure always reads the same way. Extended with CleanEntitlementReason for Audible's verbose multi-clause license-denied messages.

Wutname1 added 30 commits May 20, 2026 00:26
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.
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.
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.
- 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.
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.
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
  <div class="settings-section"> 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.
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.
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.
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 <dl> (Author/Narrator/Duration/Released/Purchased/
  Publisher/ASIN/Folder), per-destination sync list, files-on-disk
  <details>, 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.
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.
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 <a href> and
  <form method="POST"> + native confirm() to <button hx-get> that swaps
  the modal slot. Toggle stays as a plain POST form because it's a
  no-confirm one-click action.
- dashboard_summary.html: "Add destination" link in the section header
  and the empty-state CTA now open the modal too.
- base.html: AudplexusDestModal now listens for dest-created /
  dest-updated / dest-deleted on the same close-and-reload path.

Handler-side
- Deleted handleDestinationsNewPicker + handleDestinationsNewForm.
  Their modal counterparts (handleDestinationsModalPicker /
  handleDestinationsModalForm) were already doing the same work.
- handleDestinationsModalPicker / handleDestinationsModalForm: drop
  PickerAction / ModalMode plumbing; the partials don't read those
  any more.
- handleDestinationEditForm: returns destination_form_body directly
  (no withSidebar, no page chrome). Same partial as Add — $isEdit
  inside the template flips action + button labels when .Dest is set.
- handleDestinationUpdate: forks on success like handleDestinationsCreate
  — HX-Trigger dest-updated + modal success body. Error paths re-render
  destination_form_body with FormError set instead of crashing into
  renderAuthPage.
- handleDestinationDelete: split into two handlers. New GET
  /destinations/:id/delete returns the confirm body. POST stays as the
  actual delete, requires confirm=1 (the modal sets it), forks on
  success with HX-Trigger dest-deleted.

Routes
- /destinations/modal + /destinations/modal/form (add picker + form).
- /destinations/:id/edit  → GET form body (modal).
- /destinations/:id       → POST update.
- /destinations/:id/delete → GET confirm body, POST delete (with
  confirm=1).
- Legacy GET /destinations/new now 301s to /destinations so old
  bookmarks land somewhere useful.

CSS
- New .link-btn helper for <button>s that should look like inline
  links (used by the dashboard "Add destination" affordances now that
  they fire HTMX instead of navigating).
Brings the Settings page in line with the rest of the DS pass. Every
setting, form field, button, and JS hook is preserved — only the
chrome changed.

Structure
- Each section is now a .card with .card-head instead of the old
  .settings-section flat-with-h2 look. Section IDs (#section-auth,
  #section-system, #section-config, #section-devices, #section-danger)
  kept so the scroll-spy TOC still works.
- Auth & Integrations: two .card panels side-by-side via the new
  .auth-section grid — "Amazon / Audible" (region select + sign-in
  flow) and "Tag Profile". The legacy "Library Destinations" link-out
  card was removed; the sidebar already has the Destinations link, so
  it was redundant.
- System Information: collapsed to a single .card with a .meta-grid
  <dl> (same pattern as the book detail modal). All 4 paths preserved.
- Configuration: .form-row pattern throughout, .form-row.inline for
  the 5 toggles (sync_enabled, sync_auto_queue_new, embed_cover,
  chapter_file, plexmatch_file) using the DS .toggle component. The
  3 long template fields (author_dir_template, book_dir_template,
  file_name_template) span the full grid width. Save / Reorganize
  buttons + #save-status anchor kept.
- Registered Devices: .table-wrap + .tbl v2.
- Danger Zone: .card with an opening .alert err warning, danger-action
  rows preserved. btn-outline → btn-outline-ds for the Download Backup
  button.

Modals
- Restart and Factory Reset modals switched from the bespoke .modal-box
  to the global .modal-overlay + .modal-dialog chrome. Same IDs
  (#restart-modal, #reset-modal, #final-confirm, #reset-confirm-input,
  #restart-confirm-btn, #restart-modal-status) so the existing modal-
  driver JS keeps working without changes. Click-outside backdrop +
  × close button added.

CSS
- New DS form primitives added to style.css: .input / .select /
  .textarea, .checkbox, .toggle + .toggle-slider, .form-row +
  .form-row.inline + .form-row .hint, .card-head, .code block. These
  were already referenced by setup.html (the wizard) so they get
  proper styling there too as a bonus.
- Bottom scoped <style> block in settings.html went from ~450 lines to
  ~55. Everything that duplicated global rules was pruned; what
  remains is the Settings-only layout primitives (auth-section grid,
  settings-form-grid, danger-action rows, btn-inline used by
  settings_saved.html).
The dest-created / dest-updated / dest-deleted HX-Trigger headers
were built by string concatenation with user-controlled DisplayName
spliced directly into JSON:

  c.Header("HX-Trigger", `{"dest-created":{"id":"`+d.ID+`","name":"`+d.DisplayName+`"}}`)

A DisplayName containing `"` would break the JSON; a crafted value
like `Plex","extra-event":{"x":""` could inject sibling events.
HTMX parses the payload and dispatches by key, so impact is limited
to firing extra DOM events on document.body, but it's a real
correctness bug.

Routed all three call sites through a new setDestModalTrigger helper
that marshals via encoding/json. Event names stay server-controlled
literals; payload keys/values go through proper JSON encoding.
After the modal-only refactor (df5764e), the non-modal error branches
in handleDestinationsCreate were unreachable: every UI entry point
posts with X-Dest-Modal=1, and the renderAuthPage calls rendered the
old "Authentication & Integrations" settings page with an Error field
— a layout that no longer exists since destinations split out of
Settings.

Replaced both non-modal error branches with plain c.String() so
direct POSTs from curl/scripts still get a usable response. Modal
branches gained no behaviour, just dropped the now-dead ModalMode
data field and threaded c.Request.Context() once at the top instead
of repeating the call.

handleDestinationUpdate already used the right shape; only the
template-side ModalMode field was stale, removed in the same sweep.
Tests still green (e2e test exercises the non-modal happy-path
redirect which keeps working).
handleDestinationToggle used to redirect to /settings#library-
destinations on success — but the destinations split-out removed
that anchor when the inline destinations block was replaced with a
link-out button. Users clicking Enable/Disable were dropped on
/settings instead of bouncing back to where they came from.

Two changes:

- Extracted the dest-card markup into destination_card_body so the
  same partial renders the card on the full grid AND as the response
  to a single-card mutation. New singleDestinationSummary(ctx, id)
  helper builds the view-model for one row, sharing the existing
  buildDestinationSummary logic between bulk and single paths.
- handleDestinationToggle now branches on HX-Request: HTMX callers
  get the rebuilt card body back (button's hx-target is its own
  card; outerHTML swap); plain POSTs still get a redirect, now
  pointed at /destinations.

Side effect: clicking Enable/Disable is instant and in-place — no
full page reload, no jumping to a different route. Cards that flip
to Disabled lose their item-count/coverage probe (the helper skips
expensive backend calls for disabled rows) so the swap stays cheap.
… gate

Closes the gap noted in the post-DS review: ~1500 lines of new web
code shipped without unit tests on the pure helpers. Adds:

- TestPaginationNumbers — 7 cases covering small-total (no ellipsis),
  current near start/middle/end of large total, and the edge cases
  current=1 / current=total. Asserts exact slice equality via
  reflect.DeepEqual so a refactor that drops or reorders ellipsis
  markers fails loudly.
- TestParseCompletedWindow — 8 cases. Critical assertion: any
  unrecognized input (empty, gibberish, "1h", etc.) falls through to
  the 24h default. A casual click on /downloads?completed_window=foo
  must never silently surface all-time history.
- TestFilterCompletedByWindow — builds rows at known offsets relative
  to time.Now() and asserts which ASINs survive each window (24h /
  7d / 30d / all). Covers the legacy-row fallback path where
  CompletedAt is nil and UpdatedAt is used instead.
- TestFirstRunGate — table-driven coverage of the gate's
  path-allowlist + method-allowlist. Verifies that the 4 gated GETs
  (/, /library, /downloads, /destinations) 303 to /setup when
  onboarded != "1", and every other route (settings, diagnostics,
  /api/*, /static/*, all POSTs) passes through. Separate
  TestFirstRunGate_OnceOnboarded confirms the gate passes everything
  through once the flag is set.

Adds a tiny sameStringSet helper used by the filter test so the
order of survivor rows doesn't get baked into the assertion.
libraryStatusCounts was firing 7 LIMIT-1 COUNT(*) queries per library
page render — one per status the filter tabs care about. Replaced
with a single CountBooksByStatus that returns a map[BookStatus]int
from a single GROUP BY query.

- New CountBooksByStatus on the Database interface, implemented on
  SQLiteDB and PostgresDB with the same SELECT status, COUNT(*) FROM
  books GROUP BY status query. StubDB returns nil to satisfy the
  interface for unit tests that don't touch books.
- libraryStatusCounts in web/server.go simplified to a single
  switch-map; the "all" bucket sums every status' count so it
  matches the legacy total (which used a no-filter LIMIT-1 count).
  Skipped status contributes to "all" but no tab, matching legacy
  behaviour.
- New TestCountBooksByStatus in internal/database covers the empty
  table case and a seeded mix across 4 statuses. Stub's interface-
  completeness test also gained an assertion on the new method so
  adding a future status doesn't silently slip past the stub.
… read

firstRunGate runs on every GET. Each call was hitting GetSetting →
SQL query for the "onboarded" key — a value that flips maybe twice in
the app's lifetime (once on first finish, once if the user re-runs
the wizard). Cheap on SQLite but pure waste.

- Added Server.onboarded (atomic.Bool). Seeded from the DB exactly
  once in NewServer; flipped by the new setOnboarded(ctx, value)
  helper which persists to the DB and mirrors to memory in lockstep.
- isFirstRun now reads s.onboarded.Load() — no allocation, no
  syscall, no SQL. ctx kept in the signature for symmetry but the
  read doesn't need it.
- All four mutation sites updated:
    - handleSetupFinish    → setOnboarded(true)
    - handleSetupSkip      → setOnboarded(true)
    - handleSetupRestart   → setOnboarded(false)
    - handleFactoryReset   → s.onboarded.Store(false) (DB is wiped
                             by db.Reset, atomic mirrors that)
- TestSetOnboarded covers the round-trip: atomic and DB stay in
  sync across true→false→true transitions. TestFirstRunGate_
  OnceOnboarded updated to go through setOnboarded instead of
  poking the DB directly, since direct DB writes no longer affect
  what firstRunGate sees.
The Database-interface addition in 7febaf2 added CountBooksByStatus
on the production impls (SQLiteDB/PostgresDB) and the canonical
testutil_stub.go StubDB, but three additional test-only stub types
in other packages also implement the interface and broke the build:

- internal/mediaserver/backend_contract_test.go → settingsOnlyStubDB
- internal/mediaserver/destination_ids_test.go → destinationIDsStubDB
- internal/library/library_reconcile_test.go → reconcileMockDB

Each gets a no-op CountBooksByStatus returning (nil, nil) — these
stubs aren't exercised by code that reads book counts, so a nil map
is the right shape (libraryStatusCounts treats it as "no books").

Pre-existing Windows path-separator test failures (TestFileValidator_
ExtractASIN, TestRenderNamingTemplateCustomLayout) remain — those
are unrelated and predate the DS refresh.
Introduces a new POST `/api/books/:id/resync-metadata` endpoint and handler.
This allows users to manually refresh a book's metadata (e.g., title, author,
cover URL, description, series) from Audnexus without impacting local files or
download status.

A "Re-sync metadata" button is added to the book detail panel, triggering this
action via HTMX with an automatic page reload on completion.

Also includes minor UI refinements: adding SVG icons to various buttons in the
book detail panel and a reorganization of the settings navigation links and
their corresponding sections for better clarity.
```
The book detail modal (HTML, JS, and CSS) is extracted from `library.html` into
its own reusable partial `book_detail_modal.html`. This component is now
included in both `/library` and `/dashboard` pages.

Book links on the dashboard's tables (e.g., recent completions/failures)
now open details within this modal, providing a consistent user experience
and preventing full page reloads. The modal also supports `?book=`
deep linking and history manipulation.

New `.btn-icon` styles are introduced and applied to action buttons on
destination cards in the dashboard summary.
Introduce robust error message cleaning for `last_health_check_err` fields, stripping HTML, collapsing whitespace, providing hints for common HTTP errors, and truncating long messages. This ensures display-friendly output in UI badges and test results.

Also, implement a global HTMX-driven mechanism for `data-test-button`s that provides dynamic loading, success, and failure states with visual indicators (spinner, checkmark, cross). This replaces manual `hx-on` attributes for test buttons, centralizing the logic.

Finally, add styling for disabled destination cards, making their status visually distinct on the dashboard and detail pages.
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.
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.
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.
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.
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.
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.
Wutname1 added 10 commits May 20, 2026 23:33
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.
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.
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.
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.
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.
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).
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.
…ping

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").
- 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.
@Wutname1

Copy link
Copy Markdown
Contributor Author

How the Plex Destination edit looks with errors:

image

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@mstrhakr mstrhakr merged commit 004e810 into mstrhakr:master May 26, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants