diff --git a/internal/database/books_count_test.go b/internal/database/books_count_test.go new file mode 100644 index 0000000..27f838c --- /dev/null +++ b/internal/database/books_count_test.go @@ -0,0 +1,62 @@ +package database + +import ( + "context" + "testing" +) + +// TestCountBooksByStatus is the integration test for the GROUP BY +// query backing the library page's filter tabs. Seeds the table with +// a known status mix and asserts the returned counts. +func TestCountBooksByStatus(t *testing.T) { + db := newTestSQLite(t) + ctx := context.Background() + + // Empty table returns an empty map (not nil-vs-empty pedantry: the + // caller treats both the same, but be explicit). + got, err := db.CountBooksByStatus(ctx) + if err != nil { + t.Fatalf("CountBooksByStatus on empty table: %v", err) + } + if len(got) != 0 { + t.Fatalf("empty table: got %v, want empty map", got) + } + + // Seed: 3 new, 2 complete, 1 failed, 1 downloading. + seed := []struct { + asin string + status BookStatus + }{ + {"B0001", BookStatusNew}, + {"B0002", BookStatusNew}, + {"B0003", BookStatusNew}, + {"B0004", BookStatusComplete}, + {"B0005", BookStatusComplete}, + {"B0006", BookStatusFailed}, + {"B0007", BookStatusDownloading}, + } + for _, s := range seed { + if err := db.UpsertBook(ctx, &Book{ASIN: s.asin, Title: s.asin, Author: "x", Status: s.status}); err != nil { + t.Fatalf("UpsertBook %s: %v", s.asin, err) + } + } + + got, err = db.CountBooksByStatus(ctx) + if err != nil { + t.Fatalf("CountBooksByStatus after seed: %v", err) + } + want := map[BookStatus]int{ + BookStatusNew: 3, + BookStatusComplete: 2, + BookStatusFailed: 1, + BookStatusDownloading: 1, + } + if len(got) != len(want) { + t.Fatalf("status keys: got %d (%v), want %d (%v)", len(got), got, len(want), want) + } + for k, v := range want { + if got[k] != v { + t.Fatalf("status %s: got %d, want %d", k, got[k], v) + } + } +} diff --git a/internal/database/interface.go b/internal/database/interface.go index cc07fe0..661e7f2 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -14,6 +14,11 @@ type Database interface { GetBook(ctx context.Context, id int64) (*Book, error) GetBookByASIN(ctx context.Context, asin string) (*Book, error) ListBooks(ctx context.Context, filter BookFilter) ([]Book, int, error) + // CountBooksByStatus returns the row count of each book status in + // one query. Keys are the BookStatus values present in the table; + // statuses with zero books are omitted. Used by the library page's + // filter tabs so we don't fire 7 LIMIT-1 counts per page render. + CountBooksByStatus(ctx context.Context) (map[BookStatus]int, error) UpsertBook(ctx context.Context, book *Book) error UpdateBookStatus(ctx context.Context, id int64, status BookStatus) error DeleteBook(ctx context.Context, id int64) error 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 b5f9845..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 @@ -140,6 +140,27 @@ func (p *PostgresDB) ListBooks(ctx context.Context, filter BookFilter) ([]Book, return books, total, rows.Err() } +// CountBooksByStatus runs a single GROUP BY to return every status +// bucket's row count. The library page's filter tabs used to make 7 +// independent LIMIT-1 counts which was 7x what we actually needed. +func (p *PostgresDB) CountBooksByStatus(ctx context.Context) (map[BookStatus]int, error) { + rows, err := p.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM books GROUP BY status`) + if err != nil { + return nil, fmt.Errorf("count books by status: %w", err) + } + defer rows.Close() + out := map[BookStatus]int{} + for rows.Next() { + var status string + var n int + if err := rows.Scan(&status, &n); err != nil { + return nil, fmt.Errorf("scan status count: %w", err) + } + out[BookStatus(status)] = n + } + return out, rows.Err() +} + func (p *PostgresDB) UpsertBook(ctx context.Context, book *Book) error { now := time.Now() book.UpdatedAt = now @@ -150,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) } @@ -524,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 @@ -540,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 7c4f5e2..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 @@ -142,6 +142,27 @@ func (s *SQLiteDB) ListBooks(ctx context.Context, filter BookFilter) ([]Book, in return books, total, rows.Err() } +// CountBooksByStatus runs a single GROUP BY to return every status +// bucket's row count. The library page's filter tabs used to make 7 +// independent LIMIT-1 counts which was 7x what we actually needed. +func (s *SQLiteDB) CountBooksByStatus(ctx context.Context) (map[BookStatus]int, error) { + rows, err := s.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM books GROUP BY status`) + if err != nil { + return nil, fmt.Errorf("count books by status: %w", err) + } + defer rows.Close() + out := map[BookStatus]int{} + for rows.Next() { + var status string + var n int + if err := rows.Scan(&status, &n); err != nil { + return nil, fmt.Errorf("scan status count: %w", err) + } + out[BookStatus(status)] = n + } + return out, rows.Err() +} + func (s *SQLiteDB) UpsertBook(ctx context.Context, book *Book) error { now := time.Now() book.UpdatedAt = now @@ -152,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) } @@ -551,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 @@ -567,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/database/testutil_stub.go b/internal/database/testutil_stub.go index e3bf26e..73c2129 100644 --- a/internal/database/testutil_stub.go +++ b/internal/database/testutil_stub.go @@ -48,6 +48,7 @@ func (s *StubDB) GetBookByASIN(ctx context.Context, asin string) (*Book, error) func (s *StubDB) ListBooks(ctx context.Context, f BookFilter) ([]Book, int, error) { return nil, 0, nil } +func (s *StubDB) CountBooksByStatus(ctx context.Context) (map[BookStatus]int, error) { return nil, nil } func (s *StubDB) UpsertBook(ctx context.Context, b *Book) error { return nil } func (s *StubDB) UpdateBookStatus(ctx context.Context, id int64, st BookStatus) error { return nil } func (s *StubDB) DeleteBook(ctx context.Context, id int64) error { return nil } diff --git a/internal/database/testutil_stub_test.go b/internal/database/testutil_stub_test.go index 52327d0..e12af3e 100644 --- a/internal/database/testutil_stub_test.go +++ b/internal/database/testutil_stub_test.go @@ -47,6 +47,9 @@ func TestStubDB_AllNoopMethods(t *testing.T) { if rows, total, err := s.ListBooks(ctx, BookFilter{}); err != nil || rows != nil || total != 0 { t.Fatalf("ListBooks expected (nil,0,nil), got (%v,%d,%v)", rows, total, err) } + if counts, err := s.CountBooksByStatus(ctx); err != nil || counts != nil { + t.Fatalf("CountBooksByStatus expected (nil,nil), got (%v,%v)", counts, err) + } if err := s.UpsertBook(ctx, &Book{ASIN: "B000000001"}); err != nil { t.Fatalf("UpsertBook error: %v", err) } diff --git a/internal/errs/display.go b/internal/errs/display.go new file mode 100644 index 0000000..232881c --- /dev/null +++ b/internal/errs/display.go @@ -0,0 +1,109 @@ +// 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
//: " 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
+}
+
+// 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/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: 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/library_reconcile_test.go b/internal/library/library_reconcile_test.go
index 8fb90a4..07b17aa 100644
--- a/internal/library/library_reconcile_test.go
+++ b/internal/library/library_reconcile_test.go
@@ -488,6 +488,9 @@ func (m *reconcileMockDB) UpsertBook(ctx context.Context, book *database.Book) e
m.upserted = ©Book
return nil
}
+func (m *reconcileMockDB) CountBooksByStatus(ctx context.Context) (map[database.BookStatus]int, error) {
+ return nil, nil
+}
func (m *reconcileMockDB) UpdateBookStatus(ctx context.Context, id int64, status database.BookStatus) error {
return nil
}
diff --git a/internal/library/sync.go b/internal/library/sync.go
index b9eda40..5152fdb 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
}
}
@@ -943,12 +955,20 @@ 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
}
+
syncRecord.BooksFound = len(books)
s.mu.Lock()
s.progress.BooksFound = len(books)
@@ -1009,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
@@ -1031,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++
@@ -1149,6 +1198,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 {
@@ -1178,8 +1253,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 == "" {
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/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 {
diff --git a/internal/mediaserver/backend_contract_test.go b/internal/mediaserver/backend_contract_test.go
index 3786bb6..a6b62ec 100644
--- a/internal/mediaserver/backend_contract_test.go
+++ b/internal/mediaserver/backend_contract_test.go
@@ -90,6 +90,9 @@ func (s *settingsOnlyStubDB) GetBookByASIN(ctx context.Context, asin string) (*d
func (s *settingsOnlyStubDB) ListBooks(ctx context.Context, f database.BookFilter) ([]database.Book, int, error) {
return nil, 0, nil
}
+func (s *settingsOnlyStubDB) CountBooksByStatus(ctx context.Context) (map[database.BookStatus]int, error) {
+ return nil, nil
+}
func (s *settingsOnlyStubDB) UpsertBook(ctx context.Context, b *database.Book) error { return nil }
func (s *settingsOnlyStubDB) UpdateBookStatus(ctx context.Context, id int64, status database.BookStatus) error {
return nil
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
diff --git a/internal/mediaserver/destination_ids_test.go b/internal/mediaserver/destination_ids_test.go
index 81dc364..64b761b 100644
--- a/internal/mediaserver/destination_ids_test.go
+++ b/internal/mediaserver/destination_ids_test.go
@@ -44,6 +44,7 @@ func (d *destinationIDsStubDB) Reset(ctx context.Context) error { return nil }
func (d *destinationIDsStubDB) GetBook(ctx context.Context, id int64) (*database.Book, error) { return nil, nil }
func (d *destinationIDsStubDB) GetBookByASIN(ctx context.Context, asin string) (*database.Book, error) { return nil, nil }
func (d *destinationIDsStubDB) ListBooks(ctx context.Context, filter database.BookFilter) ([]database.Book, int, error) { return nil, 0, nil }
+func (d *destinationIDsStubDB) CountBooksByStatus(ctx context.Context) (map[database.BookStatus]int, error) { return nil, nil }
func (d *destinationIDsStubDB) UpsertBook(ctx context.Context, book *database.Book) error { return nil }
func (d *destinationIDsStubDB) UpdateBookStatus(ctx context.Context, id int64, status database.BookStatus) error { return nil }
func (d *destinationIDsStubDB) DeleteBook(ctx context.Context, id int64) error { return nil }
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..825a3df 100644
--- a/internal/web/destinations_handlers.go
+++ b/internal/web/destinations_handlers.go
@@ -14,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"
)
@@ -87,6 +88,22 @@ func summarizeHealth(d *database.LibraryDestination) string {
return "failed"
}
+// setDestModalTrigger sets the HX-Trigger header to fire `event` on
+// document.body with the given payload. Uses encoding/json so that
+// user-controlled fields (DisplayName especially) can't break out of
+// the JSON envelope or smuggle in sibling events.
+//
+// json.Marshal can't fail for map[string]map[string]string, so we
+// ignore the error — the alternative (skipping the header on error)
+// would silently break the modal's auto-close.
+func setDestModalTrigger(c *gin.Context, event string, payload map[string]string) {
+ if payload == nil {
+ payload = map[string]string{}
+ }
+ encoded, _ := json.Marshal(map[string]map[string]string{event: payload})
+ c.Header("HX-Trigger", string(encoded))
+}
+
func destinationConfigured(d *database.LibraryDestination) bool {
if strings.TrimSpace(d.URL) == "" {
return false
@@ -100,28 +117,55 @@ func destinationConfigured(d *database.LibraryDestination) bool {
return false
}
-// 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"
- c.HTML(http.StatusOK, "destinations_new.html", data)
+// 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", s.withSidebar(ctx, data))
+}
+
+// handleDestinationsModalPicker returns the type-picker body that opens
+// inside the global #dest-modal-content slot. There's no full-page
+// equivalent any more — every add flow uses the modal.
+func (s *Server) handleDestinationsModalPicker(c *gin.Context) {
+ c.HTML(http.StatusOK, "destination_picker_body", s.authBaseData(c.Request.Context()))
}
-// handleDestinationsNewForm renders the type-specific config form (step 2)
-// when the user submits the type picker.
-func (s *Server) handleDestinationsNewForm(c *gin.Context) {
+// handleDestinationsModalForm returns the type-specific form body that
+// swaps in after the user clicks a backend on the picker. Submit lands
+// on /destinations/create with the X-Dest-Modal header set so
+// handleDestinationsCreate emits HX-Trigger dest-created and the modal
+// auto-closes.
+func (s *Server) handleDestinationsModalForm(c *gin.Context) {
t := strings.ToLower(strings.TrimSpace(c.PostForm("type")))
if !validDestinationType(t) {
- s.renderAuthPage(c, http.StatusBadRequest, gin.H{"Error": "Pick a destination type."})
+ c.HTML(http.StatusBadRequest, "destination_picker_body", gin.H{
+ "Error": "Pick a destination type.",
+ })
return
}
data := s.authBaseData(c.Request.Context())
- 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, "destination_form_body", data)
}
// handleDestinationTest performs a live health check against the
@@ -183,7 +227,11 @@ func (s *Server) recordDestinationHealth(ctx context.Context, destID string, ok
now := time.Now().UTC()
row.LastHealthCheckAt = &now
row.LastHealthCheckOK = &ok
- row.LastHealthCheckErr = errMsg
+ // Strip embedded HTML / collapse whitespace before persisting so the
+ // dashboard's stored last_health_check_err is fit for direct render.
+ // The original raw err already flowed through the debug log path on
+ // the caller side, so we're not losing forensic detail here.
+ row.LastHealthCheckErr = cleanErrorForDisplay(errMsg)
if err := s.db.UpdateLibraryDestination(ctx, row); err != nil {
webLog.Debug().Err(err).Str("destination_id", destID).Msg("recordDestinationHealth: update failed")
}
@@ -872,7 +920,7 @@ func renderTestResult(c *gin.Context, ok bool, success, fail string) {
}
c.String(http.StatusOK,
``+
- `Failed. `+htmlEscape(fail)+``)
+ `Failed. `+htmlEscape(cleanErrorForDisplay(fail))+``)
}
func htmlEscape(s string) string {
@@ -880,6 +928,14 @@ func htmlEscape(s string) string {
return r.Replace(s)
}
+// 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 {
+ return errs.CleanForDisplay(raw)
+}
+
// 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
@@ -895,27 +951,68 @@ func writeSensitiveHTML(c *gin.Context, body string) {
c.String(http.StatusOK, body)
}
-// handleDestinationsCreate persists a new destination after the form submit.
+// handleDestinationsCreate persists a new destination after the form
+// submit. Invoked from the modal (HTMX, X-Dest-Modal=1 header) on the
+// happy path; non-modal callers (raw POST from curl/scripts) still
+// work and get a plain redirect.
+//
+// On modal success we set the HX-Trigger header so the global modal
+// helper closes the overlay and reloads the parent page. On modal
+// failure we re-render the form body with FormError set so the user
+// can correct and resubmit without losing the in-progress values
+// they've already typed.
func (s *Server) handleDestinationsCreate(c *gin.Context) {
+ modal := c.GetHeader("X-Dest-Modal") == "1"
+ ctx := c.Request.Context()
+
d, err := s.destinationFromForm(c, "")
if err != nil {
- s.renderAuthPage(c, http.StatusBadRequest, gin.H{"Error": err.Error()})
+ if modal {
+ t := c.PostForm("type")
+ c.HTML(http.StatusBadRequest, "destination_form_body", gin.H{
+ "DestType": t,
+ "DestTypeLabel": destinationTypeLabel(database.LibraryDestinationType(t)),
+ "FormError": err.Error(),
+ })
+ return
+ }
+ c.String(http.StatusBadRequest, 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.
- d.DisplayName = s.uniqueDisplayName(c.Request.Context(), d.DisplayName, "")
+ // destination stays plainly named "Plex".
+ d.DisplayName = s.uniqueDisplayName(ctx, 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 {
- s.renderAuthPage(c, http.StatusInternalServerError, gin.H{"Error": "Could not create destination: " + err.Error()})
+ if err := s.db.CreateLibraryDestination(ctx, d); err != nil {
+ if modal {
+ c.HTML(http.StatusInternalServerError, "destination_form_body", gin.H{
+ "DestType": string(d.Type),
+ "DestTypeLabel": destinationTypeLabel(d.Type),
+ "FormError": "Could not create destination: " + err.Error(),
+ })
+ return
+ }
+ c.String(http.StatusInternalServerError, "Could not create destination: "+err.Error())
+ return
+ }
+
+ 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.
+ setDestModalTrigger(c, "dest-created", map[string]string{"id": d.ID, "name": d.DisplayName})
+ c.HTML(http.StatusOK, "destination_form_body", gin.H{
+ "ModalSuccess": true,
+ "DestTypeLabel": destinationTypeLabel(d.Type),
+ "Dest": d,
+ })
return
}
- c.Redirect(http.StatusSeeOther, "/settings#library-destinations")
+ c.Redirect(http.StatusSeeOther, "/destinations")
}
// uniqueDisplayName appends " (2)", " (3)", … to candidate when another
@@ -955,36 +1052,72 @@ func (s *Server) uniqueDisplayName(ctx context.Context, candidate, excludeID str
return fmt.Sprintf("%s (%s)", candidate, uuid.NewString()[:8])
}
-// handleDestinationEditForm renders the per-destination edit form. Sensitive
-// values (PlexToken, APIKey) are NOT prefilled into the template — leaving
-// the field blank means "keep existing"; entering a new value rotates.
+// handleDestinationEditForm returns the edit form body that opens inside
+// the global #dest-modal-content slot. Same partial as the create flow
+// — $isEdit inside the template flips action + button labels when .Dest
+// is set. Sensitive values (PlexToken, APIKey) are NOT prefilled into
+// the template; leaving the field blank means "keep existing".
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."})
+ c.HTML(http.StatusNotFound, "destination_form_body", gin.H{
+ "FormError": "Destination not found.",
+ })
return
}
- data := s.authBaseData(c.Request.Context())
- data["Page"] = "destinations_edit"
+ data := s.authBaseData(ctx)
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)
+ 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) != ""
+ // 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)
+ }
+
+ c.HTML(http.StatusOK, "destination_form_body", data)
}
// handleDestinationUpdate persists an edit. Sensitive fields (PlexToken,
// APIKey) are only updated when the form provides a non-empty value.
+// Always invoked from the modal; on success we fire HX-Trigger
+// dest-updated so the page auto-closes the modal and reloads.
func (s *Server) handleDestinationUpdate(c *gin.Context) {
+ ctx := c.Request.Context()
id := c.Param("id")
- existing, err := s.db.GetLibraryDestination(c.Request.Context(), id)
+ existing, err := s.db.GetLibraryDestination(ctx, id)
if err != nil || existing == nil {
- s.renderAuthPage(c, http.StatusNotFound, gin.H{"Error": "Destination not found."})
+ c.HTML(http.StatusNotFound, "destination_form_body", gin.H{
+ "FormError": "Destination not found.",
+ })
return
}
updated, err := s.destinationFromForm(c, string(existing.Type))
if err != nil {
- s.renderAuthPage(c, http.StatusBadRequest, gin.H{"Error": err.Error()})
+ c.HTML(http.StatusBadRequest, "destination_form_body", gin.H{
+ "Dest": existing,
+ "DestType": string(existing.Type),
+ "DestTypeLabel": destinationTypeLabel(existing.Type),
+ "FormError": err.Error(),
+ })
return
}
@@ -998,7 +1131,7 @@ func (s *Server) handleDestinationUpdate(c *gin.Context) {
// Disambiguate display name on edit too — but exclude the current row
// so renaming a destination back to its existing value is a no-op
// instead of bumping it to "Plex (2)".
- updated.DisplayName = s.uniqueDisplayName(c.Request.Context(), updated.DisplayName, existing.ID)
+ updated.DisplayName = s.uniqueDisplayName(ctx, updated.DisplayName, existing.ID)
updated.ID = existing.ID
updated.Type = existing.Type
updated.Enabled = existing.Enabled
@@ -1007,64 +1140,142 @@ func (s *Server) handleDestinationUpdate(c *gin.Context) {
updated.LastHealthCheckOK = existing.LastHealthCheckOK
updated.LastHealthCheckErr = existing.LastHealthCheckErr
- if err := s.db.UpdateLibraryDestination(c.Request.Context(), updated); err != nil {
- s.renderAuthPage(c, http.StatusInternalServerError, gin.H{"Error": "Could not save: " + err.Error()})
+ if err := s.db.UpdateLibraryDestination(ctx, updated); err != nil {
+ c.HTML(http.StatusInternalServerError, "destination_form_body", gin.H{
+ "Dest": existing,
+ "DestType": string(existing.Type),
+ "DestTypeLabel": destinationTypeLabel(existing.Type),
+ "FormError": "Could not save: " + err.Error(),
+ })
return
}
- c.Redirect(http.StatusSeeOther, "/settings#library-destinations")
+
+ setDestModalTrigger(c, "dest-updated", map[string]string{"id": updated.ID, "name": updated.DisplayName})
+ c.HTML(http.StatusOK, "destination_form_body", gin.H{
+ "ModalSuccess": true,
+ "ModalSuccessVerb": "updated",
+ "DestTypeLabel": destinationTypeLabel(updated.Type),
+ "Dest": updated,
+ })
}
-// handleDestinationToggle flips the enabled flag.
+// handleDestinationToggle flips the enabled flag. HTMX callers get the
+// rebuilt card body back (outerHTML swap target); plain POSTs (curl,
+// scripts) get a redirect to /destinations.
func (s *Server) handleDestinationToggle(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."})
+ if c.GetHeader("HX-Request") == "true" {
+ c.String(http.StatusNotFound, "Destination not found.")
+ return
+ }
+ c.String(http.StatusNotFound, "Destination not found.")
return
}
d.Enabled = !d.Enabled
- if err := s.db.UpdateLibraryDestination(c.Request.Context(), d); err != nil {
- s.renderAuthPage(c, http.StatusInternalServerError, gin.H{"Error": "Could not toggle: " + err.Error()})
+ if err := s.db.UpdateLibraryDestination(ctx, d); err != nil {
+ if c.GetHeader("HX-Request") == "true" {
+ c.String(http.StatusInternalServerError, "Could not toggle: "+err.Error())
+ return
+ }
+ c.String(http.StatusInternalServerError, "Could not toggle: "+err.Error())
+ return
+ }
+
+ if c.GetHeader("HX-Request") == "true" {
+ v := s.singleDestinationSummary(ctx, id)
+ if v == nil {
+ c.String(http.StatusInternalServerError, "Could not reload destination view.")
+ return
+ }
+ c.HTML(http.StatusOK, "destination_card_body", gin.H{"Dest": v})
return
}
- c.Redirect(http.StatusSeeOther, "/settings#library-destinations")
+ c.Redirect(http.StatusSeeOther, "/destinations")
}
-// handleDestinationDelete is the only delete endpoint — POST-only, no
-// safe GET counterpart (destructive actions must not be GETs per RFC 9110
-// and WCAG semantics for destructive controls).
-//
-// Two-state behavior on the same path keeps the URL minimal:
-// - 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) {
+// handleDestinationDeleteModal returns the delete-confirm body that opens
+// inside the global #dest-modal-content slot. GET is safe here because
+// no mutation happens — the actual delete only fires when the user
+// clicks the confirm button, which POSTs to /destinations/:id/delete
+// with confirm=1.
+func (s *Server) handleDestinationDeleteModal(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."})
+ c.HTML(http.StatusNotFound, "destination_delete_body", gin.H{
+ "FormError": "Destination not found.",
+ })
return
}
-
- if c.PostForm("confirm") != "1" {
- // First POST: render the confirmation page.
- data := s.authBaseData(c.Request.Context())
- data["Page"] = "destinations_delete"
- data["Dest"] = destinationView{
+ c.HTML(http.StatusOK, "destination_delete_body", gin.H{
+ "Dest": destinationView{
ID: d.ID,
DisplayName: d.DisplayName,
Type: string(d.Type),
TypeLabel: destinationTypeLabel(d.Type),
- }
- c.HTML(http.StatusOK, "destinations_delete.html", data)
+ },
+ })
+}
+
+// handleDestinationDelete performs the actual delete. POST-only,
+// destructive actions must not be safe GETs (RFC 9110 + WCAG
+// link-purpose semantics for destructive controls). Requires
+// confirm=1 in the form body — the modal's delete button sets it.
+// Always invoked from the modal; fires HX-Trigger dest-deleted so the
+// modal auto-closes and the page reloads.
+func (s *Server) handleDestinationDelete(c *gin.Context) {
+ ctx := c.Request.Context()
+ id := c.Param("id")
+ d, err := s.db.GetLibraryDestination(ctx, id)
+ if err != nil || d == nil {
+ c.HTML(http.StatusNotFound, "destination_delete_body", gin.H{
+ "FormError": "Destination not found.",
+ })
+ return
+ }
+
+ if c.PostForm("confirm") != "1" {
+ // Defensive: missing confirm means the request didn't come from
+ // the modal's submit. Surface the prompt rather than acting.
+ c.HTML(http.StatusBadRequest, "destination_delete_body", gin.H{
+ "Dest": destinationView{
+ ID: d.ID,
+ DisplayName: d.DisplayName,
+ Type: string(d.Type),
+ TypeLabel: destinationTypeLabel(d.Type),
+ },
+ "FormError": "Confirmation required.",
+ })
return
}
- // Second POST with confirm=1: actually delete.
- if err := s.db.DeleteLibraryDestination(c.Request.Context(), id); err != nil {
- s.renderAuthPage(c, http.StatusInternalServerError, gin.H{"Error": "Could not delete: " + err.Error()})
+ if err := s.db.DeleteLibraryDestination(ctx, id); err != nil {
+ c.HTML(http.StatusInternalServerError, "destination_delete_body", gin.H{
+ "Dest": destinationView{
+ ID: d.ID,
+ DisplayName: d.DisplayName,
+ Type: string(d.Type),
+ TypeLabel: destinationTypeLabel(d.Type),
+ },
+ "FormError": "Could not delete: " + err.Error(),
+ })
return
}
- c.Redirect(http.StatusSeeOther, "/settings#library-destinations")
+
+ setDestModalTrigger(c, "dest-deleted", map[string]string{"id": d.ID})
+ c.HTML(http.StatusOK, "destination_delete_body", gin.H{
+ "ModalSuccess": true,
+ "Dest": destinationView{
+ ID: d.ID,
+ DisplayName: d.DisplayName,
+ Type: string(d.Type),
+ TypeLabel: destinationTypeLabel(d.Type),
+ },
+ })
}
func (s *Server) destinationFromForm(c *gin.Context, existingType string) (*database.LibraryDestination, error) {
diff --git a/internal/web/diagnostics_handlers.go b/internal/web/diagnostics_handlers.go
index e408442..4e082fb 100644
--- a/internal/web/diagnostics_handlers.go
+++ b/internal/web/diagnostics_handlers.go
@@ -10,13 +10,17 @@ 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/errs"
"github.com/mstrhakr/audplexus/internal/library"
+ "github.com/mstrhakr/audplexus/internal/logging"
"github.com/mstrhakr/audplexus/internal/mediaserver"
)
@@ -82,14 +86,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) {
@@ -148,7 +153,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)
}
@@ -930,3 +939,96 @@ 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(),
+ })
+}
+
+// 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 {
+ // 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,
+ "type": v.Type,
+ "type_label": v.TypeLabel,
+ "enabled": v.Enabled,
+ "configured": v.Configured,
+ "url": v.URL,
+ "health": v.Health,
+ "health_detail": errs.CleanForDisplay(v.HealthDetail),
+ "last_error": errs.CleanForDisplay(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.
+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/server.go b/internal/web/server.go
index 2d7ca3f..ed6f7eb 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -16,10 +16,12 @@ import (
"net/netip"
"os"
"path/filepath"
+ "runtime/debug"
"sort"
"strconv"
"strings"
"sync"
+ "sync/atomic"
"syscall"
"time"
@@ -34,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")
@@ -58,6 +61,17 @@ 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
+ // onboarded mirrors the "onboarded" setting so firstRunGate (which
+ // runs on every GET) can answer from memory instead of hitting the
+ // DB. Seeded from the DB once in NewServer and flipped whenever
+ // handleSetupFinish/Skip/Restart or handleFactoryReset write the
+ // underlying setting. atomic.Bool because reads are middleware and
+ // writes are spread across a handful of handler goroutines.
+ onboarded atomic.Bool
// 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 +118,14 @@ func NewServer(
audiobooksPath: audiobooksPath,
downloadsPath: downloadsPath,
configPath: configPath,
+ startedAt: time.Now(),
+ }
+
+ // Seed the onboarded flag from the DB once. firstRunGate (which
+ // runs on every GET) reads s.onboarded.Load() after this — no per-
+ // request DB hit for the flag check.
+ if v, err := db.GetSetting(context.Background(), settingKeyOnboarded); err == nil && (v == "true" || v == "1") {
+ s.onboarded.Store(true)
}
// Wire up the media-server sync callbacks. With multi-destination, the
@@ -216,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:
@@ -226,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"
}
@@ -233,6 +305,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{}
@@ -245,7 +319,51 @@ 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 {
+ 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])
+ },
+ // 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 {
+ 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")
@@ -266,7 +384,7 @@ func (s *Server) setupTemplates() {
base := template.Must(template.New("base").Funcs(funcMap).ParseFS(templateFS, "templates/base.html"))
// Parse all partial/fragment templates that may be referenced by page templates
- partials := []string{"templates/library_table.html", "templates/library_row.html", "templates/book_detail_panel.html", "templates/settings_saved.html", "templates/sync_status.html", "templates/dashboard_summary.html", "templates/dashboard_downloads.html"}
+ partials := []string{"templates/library_table.html", "templates/library_row.html", "templates/book_detail_panel.html", "templates/book_detail_modal.html", "templates/settings_saved.html", "templates/sync_status.html", "templates/dashboard_summary.html", "templates/dashboard_downloads.html", "templates/destinations_new.html", "templates/destinations_form.html", "templates/destinations_delete.html", "templates/destinations_card.html"}
baseWithPartials := template.Must(template.Must(base.Clone()).ParseFS(templateFS, partials...))
r := &multiRender{templates: make(map[string]*template.Template)}
@@ -304,6 +422,19 @@ func (s *Server) setupTemplates() {
r.templates[name] = partialSet
}
+ // Bare body fragments for the destination modal. New / Edit / Delete
+ // all swap into #dest-modal-content via HTMX — the three files
+ // each define just an inner *_body block, no page chrome. The
+ // multiRender's Instance() path uses the template name as the entry
+ // point, so "destination_picker_body" / "destination_form_body" /
+ // "destination_delete_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", "templates/destinations_delete.html", "templates/destinations_card.html")).Clone())
+ r.templates["destination_picker_body"] = destModalSet
+ r.templates["destination_form_body"] = destModalSet
+ r.templates["destination_delete_body"] = destModalSet
+ r.templates["destination_card_body"] = destModalSet
+
s.router.HTMLRender = r
}
@@ -317,6 +448,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 +462,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)
@@ -336,24 +481,34 @@ func (s *Server) setupRoutes() {
// destination affordance — see /destinations/plex/* below.
s.router.POST("/settings/tag-profile", s.handleTagProfileSelect)
- // 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/new", s.handleDestinationsNewPicker)
- s.router.POST("/destinations/new", s.handleDestinationsNewForm)
+ // Library destinations CRUD (multi-destination model). Modal-only —
+ // New / Edit / Delete all open inside the global #dest-modal-content
+ // overlay defined in base.html. Every handler returns a bare body
+ // partial (no page chrome) that HTMX swaps in, and mutating success
+ // fires an HX-Trigger event so the page auto-closes + reloads.
+ s.router.GET("/destinations", s.handleDestinations)
+ // Add: picker → type-specific form → POST /destinations/create.
+ s.router.GET("/destinations/modal", s.handleDestinationsModalPicker)
+ s.router.POST("/destinations/modal/form", s.handleDestinationsModalForm)
s.router.POST("/destinations/create", s.handleDestinationsCreate)
- // 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)
- s.router.POST("/destinations/:id/test", s.handleDestinationTest)
+ // Edit: hx-get the form body, POST update.
s.router.GET("/destinations/:id/edit", s.handleDestinationEditForm)
s.router.POST("/destinations/:id", s.handleDestinationUpdate)
- s.router.POST("/destinations/:id/toggle", s.handleDestinationToggle)
- // Delete is POST-only — destructive actions must not be safe GETs (per
- // RFC 9110 + WCAG link-purpose semantics for destructive controls).
- // The same endpoint serves both: confirm=1 actually deletes;
- // otherwise the confirmation page is rendered.
+ // Delete: hx-get the confirm body, POST with confirm=1 to delete.
+ // Destructive mutation stays POST-only (RFC 9110 + WCAG); the GET
+ // counterpart only renders the confirmation, no state changes.
+ s.router.GET("/destinations/:id/delete", s.handleDestinationDeleteModal)
s.router.POST("/destinations/:id/delete", s.handleDestinationDelete)
+ // Test connection + toggle enabled. Both still HTMX-fragment paths.
+ s.router.POST("/destinations/test", s.handleDestinationTest)
+ s.router.POST("/destinations/:id/test", s.handleDestinationTest)
+ s.router.POST("/destinations/:id/toggle", s.handleDestinationToggle)
+ // Legacy full-page entry points — old bookmarks and the wizard's
+ // "Add destination" link previously navigated here. Redirect to the
+ // Destinations page so users land somewhere useful instead of 404.
+ s.router.GET("/destinations/new", func(c *gin.Context) {
+ c.Redirect(http.StatusMovedPermanently, "/destinations")
+ })
// Discovery / sign-in endpoints — per-destination affordances that
// auto-populate the form. All HTMX-targeted, all render HTML fragments
@@ -406,11 +561,17 @@ 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/destinations", s.handleDiagnosticsDestinations)
+ api.GET("/diagnostics/logs/tail", s.handleDiagnosticsLogsTail)
api.POST("/downloads/redownload/:asin", s.handleRedownload)
// Per-book conversion between m4b and chapter-split mp3.
api.POST("/books/:id/convert", s.handleConvertBook)
api.POST("/books/:id/delete-media", s.handleDeleteBookMedia)
+ api.POST("/books/:id/resync-metadata", s.handleResyncMetadata)
}
}
@@ -500,7 +661,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 {
@@ -559,19 +720,28 @@ func mediaServerLabel(t mediaserver.Type) (string, string) {
// destinationSummaryView is the per-destination card rendered on the
// dashboard. Sensitive fields are intentionally absent; only display info.
type destinationSummaryView struct {
- ID string
- DisplayName string
- Type string
- TypeLabel string
- Enabled bool
- Configured bool
- ItemCount int
- ItemCountSet bool
- Coverage int
- CoverageSet bool
- Health string // "healthy" | "failed" | "never" | "not_configured"
- HealthDetail string // for failed: shorter human message
- LastCheckedAt *time.Time
+ ID string
+ DisplayName string
+ Type string
+ TypeLabel string
+ Enabled bool
+ Configured bool
+ HasCredential bool // API key for Emby/Jellyfin/ABS, token for Plex
+ URL string
+ PlexSectionID string // plex-only
+ LibraryID string // emby/jellyfin/abs
+ AudiobookPath string
+ DestinationPath 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
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
// destinationSummaries computes per-destination summary cards for the
@@ -593,55 +763,87 @@ func (s *Server) destinationSummaries(ctx context.Context, completeBooks int) []
out := make([]destinationSummaryView, 0, len(rows))
for _, r := range rows {
row := r
- v := destinationSummaryView{
- ID: row.ID,
- DisplayName: row.DisplayName,
- Type: string(row.Type),
- TypeLabel: destinationTypeLabel(row.Type),
- Enabled: row.Enabled,
- Configured: destinationConfigured(&row),
- Health: summarizeHealth(&row),
- LastCheckedAt: row.LastHealthCheckAt,
- }
-
- if !v.Enabled || !v.Configured {
- out = append(out, v)
- continue
- }
+ out = append(out, s.buildDestinationSummary(ctx, &row, completeBooks))
+ }
+ return out
+}
- // Construct a backend instance bound to this destination row so
- // LibraryItemCount uses the row's URL/api_key, not the legacy
- // settings table.
- backend, err := s.buildDestinationBackend(&row)
- if err != nil {
- v.HealthDetail = err.Error()
- out = append(out, v)
- continue
- }
+// singleDestinationSummary returns the same view-model the destinations
+// page builds for one row. Used by handleDestinationToggle /
+// handleDestinationTest to swap a single card after a mutation without
+// reloading the whole grid. Returns nil if the destination doesn't
+// exist or the lookup fails.
+func (s *Server) singleDestinationSummary(ctx context.Context, id string) *destinationSummaryView {
+ row, err := s.db.GetLibraryDestination(ctx, id)
+ if err != nil || row == nil {
+ return nil
+ }
+ // Coverage % is relative to the count of complete books; we need
+ // that number for the meter to render the same as it does on the
+ // full grid. Cheap LIMIT-1 count.
+ completeStatus := database.BookStatusComplete
+ _, completeBooks, _ := s.db.ListBooks(ctx, database.BookFilter{Status: &completeStatus, Limit: 1})
+ v := s.buildDestinationSummary(ctx, row, completeBooks)
+ return &v
+}
+
+// buildDestinationSummary is the per-row factory shared by the grid
+// builder and the single-row swap path. Probes LibraryItemCount (with
+// the 30s cache) only for enabled+configured destinations so toggling
+// a disabled row stays cheap.
+func (s *Server) buildDestinationSummary(ctx context.Context, row *database.LibraryDestination, completeBooks int) destinationSummaryView {
+ hasCred := row.APIKey != "" || row.PlexToken != ""
+ v := destinationSummaryView{
+ ID: row.ID,
+ DisplayName: row.DisplayName,
+ Type: string(row.Type),
+ TypeLabel: destinationTypeLabel(row.Type),
+ Enabled: row.Enabled,
+ Configured: destinationConfigured(row),
+ HasCredential: hasCred,
+ URL: row.URL,
+ PlexSectionID: row.PlexSectionID,
+ LibraryID: row.LibraryID,
+ AudiobookPath: row.AudiobookPath,
+ DestinationPath: row.DestinationPath,
+ Health: summarizeHealth(row),
+ LastError: row.LastHealthCheckErr,
+ LastCheckedAt: row.LastHealthCheckAt,
+ CreatedAt: row.CreatedAt,
+ UpdatedAt: row.UpdatedAt,
+ }
+
+ if !v.Enabled || !v.Configured {
+ return v
+ }
+
+ backend, err := s.buildDestinationBackend(row)
+ if err != nil {
+ v.HealthDetail = err.Error()
+ return v
+ }
- count, err := s.getCachedDestinationItemCount(ctx, row.ID, backend)
- if err != nil {
- webLog.Debug().Err(err).Str("destination_id", row.ID).Str("destination_name", row.DisplayName).Msg("dashboard: destination item count failed")
- v.Health = "failed"
- v.HealthDetail = err.Error()
- } else {
- v.ItemCount = count
- v.ItemCountSet = true
- if completeBooks > 0 {
- cov := int(math.Round((float64(count) / float64(completeBooks)) * 100))
- if cov < 0 {
- cov = 0
- }
- if cov > 100 {
- cov = 100
- }
- v.Coverage = cov
- v.CoverageSet = true
- }
+ count, err := s.getCachedDestinationItemCount(ctx, row.ID, backend)
+ if err != nil {
+ webLog.Debug().Err(err).Str("destination_id", row.ID).Str("destination_name", row.DisplayName).Msg("dashboard: destination item count failed")
+ v.Health = "failed"
+ v.HealthDetail = err.Error()
+ return v
+ }
+ v.ItemCount = count
+ v.ItemCountSet = true
+ if completeBooks > 0 {
+ cov := int(math.Round((float64(count) / float64(completeBooks)) * 100))
+ if cov < 0 {
+ cov = 0
}
- out = append(out, v)
+ if cov > 100 {
+ cov = 100
+ }
+ v.Coverage = cov
+ v.CoverageSet = true
}
- return out
+ return v
}
// buildDestinationBackend delegates to DestinationManager.BuildBackend
@@ -716,6 +918,7 @@ func (s *Server) getDashboardDownloadsData(ctx context.Context) gin.H {
"FailedDownloads": failedRecent,
"DoneDownloads": completeDownloads,
"DownloadTitles": s.getDownloadTitles(ctx, rowsForTitles),
+ "DownloadBookIDs": s.getDownloadBookIDs(ctx, rowsForTitles),
}
}
@@ -738,6 +941,25 @@ func (s *Server) getDownloadTitles(ctx context.Context, rows []database.Download
return titles
}
+// getDownloadBookIDs returns ASIN→book-ID for download queue rows whose
+// matching book row still exists. Used by the dashboard tables so the
+// title/ASIN cells can deep-link to /library/. Rows whose book has
+// been pruned simply get no entry (the template falls back to plain text).
+func (s *Server) getDownloadBookIDs(ctx context.Context, rows []database.DownloadQueue) map[string]int64 {
+ ids := make(map[string]int64)
+ for _, row := range rows {
+ if _, exists := ids[row.ASIN]; exists {
+ continue
+ }
+ book, err := s.db.GetBookByASIN(ctx, row.ASIN)
+ if err != nil || book == nil || book.ID == 0 {
+ continue
+ }
+ ids[row.ASIN] = book.ID
+ }
+ return ids
+}
+
// handleDashboardSummary renders only the dashboard summary block for HTMX polling.
func (s *Server) handleDashboardSummary(c *gin.Context) {
ctx := c.Request.Context()
@@ -756,14 +978,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 +1008,34 @@ 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)),
+ "BookPresence": s.computeBookPresence(ctx, books),
+ "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
@@ -788,7 +1043,85 @@ 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
+// 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.
+//
+// Drives off a single GROUP BY query — previously did 7 LIMIT-1 counts
+// which was 7x what was actually needed.
+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,
+ "unavailable": 0,
+ }
+
+ byStatus, err := s.db.CountBooksByStatus(ctx)
+ if err != nil {
+ return out
+ }
+ for st, n := range byStatus {
+ out["all"] += n
+ 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
+ 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
+ // was implicitly counted via the unfiltered total only.
+ }
+ 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 {
@@ -898,6 +1231,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" {
@@ -1050,7 +1384,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)
@@ -1060,29 +1400,109 @@ 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...)
- 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",
- })
+ // 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", s.withSidebar(ctx, gin.H{
+ "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
@@ -1146,8 +1566,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
@@ -1263,6 +1684,263 @@ 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
+}
+
+// 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
+// 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 {
@@ -1443,6 +2121,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 {
@@ -1464,6 +2153,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
}
@@ -2141,12 +2904,19 @@ 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() truncated the persisted onboarded flag along with
+ // every other setting. The in-memory mirror needs to follow so the
+ // firstRunGate sees first-run state on the next request.
+ s.onboarded.Store(false)
+
+ // 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.
@@ -2217,13 +2987,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.
@@ -2343,6 +3114,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!",
@@ -2398,6 +3178,82 @@ func (s *Server) handleRedownload(c *gin.Context) {
})
}
+// handleResyncMetadata re-fetches book metadata from audnexus and updates the
+// DB row in place. Cover URL, description, narrator, publisher, series, etc.
+// get refreshed without touching the local file. Status is unchanged.
+func (s *Server) handleResyncMetadata(c *gin.Context) {
+ ctx := c.Request.Context()
+ id, err := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64)
+ if err != nil || id <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book id"})
+ return
+ }
+
+ book, err := s.db.GetBook(ctx, id)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "book not found: " + err.Error()})
+ return
+ }
+
+ enriched, err := s.audnexus.EnrichMetadata(ctx, book)
+ if err != nil {
+ webLog.Warn().Err(err).Str("asin", book.ASIN).Msg("resync metadata: audnexus enrichment failed")
+ if c.GetHeader("HX-Request") == "true" {
+ c.HTML(http.StatusBadGateway, "settings_saved.html", gin.H{
+ "Message": "Could not refresh metadata: " + err.Error(),
+ })
+ return
+ }
+ c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
+ return
+ }
+
+ if v := enriched.Title(); v != "" {
+ book.Title = v
+ }
+ if v := enriched.Author(); v != "" {
+ book.Author = v
+ }
+ if v := enriched.Narrator(); v != "" {
+ book.Narrator = v
+ }
+ if v := enriched.Publisher(); v != "" {
+ book.Publisher = v
+ }
+ if v := enriched.Description(); v != "" {
+ book.Description = v
+ }
+ if v := enriched.CoverURL(); v != "" {
+ book.CoverURL = v
+ }
+ if v := enriched.Series(); v != "" {
+ book.Series = v
+ }
+ if v := enriched.SeriesPosition(); v != "" {
+ book.SeriesPosition = v
+ }
+ if v := enriched.Language(); v != "" {
+ book.Language = v
+ }
+
+ if err := s.db.UpsertBook(ctx, book); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save book: " + err.Error()})
+ return
+ }
+ webLog.Info().Str("asin", book.ASIN).Str("title", book.Title).Msg("book metadata resynced")
+
+ if c.GetHeader("HX-Request") == "true" {
+ c.HTML(http.StatusOK, "settings_saved.html", gin.H{
+ "Message": fmt.Sprintf("Metadata refreshed for '%s'", book.Title),
+ })
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": fmt.Sprintf("Metadata refreshed for '%s'", book.Title),
+ })
+}
+
// handleDeleteBookMedia deletes a book's local media and resets it to "new"
// without deleting the book metadata row.
func (s *Server) handleDeleteBookMedia(c *gin.Context) {
@@ -2441,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/setup_handlers.go b/internal/web/setup_handlers.go
new file mode 100644
index 0000000..194e8ec
--- /dev/null
+++ b/internal/web/setup_handlers.go
@@ -0,0 +1,179 @@
+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. Reads from s.onboarded (atomic.Bool) so firstRunGate can
+// answer for free on every GET — the DB-backed setting is the source
+// of truth, but it's only read once at boot in NewServer and on
+// targeted writes via setOnboarded.
+//
+// ctx kept in the signature for symmetry with the rest of the auth-
+// adjacent helpers, even though the atomic read doesn't need it.
+func (s *Server) isFirstRun(ctx context.Context) bool {
+ return !s.onboarded.Load()
+}
+
+// setOnboarded persists the onboarded flag to the DB and updates the
+// in-memory mirror in lockstep. value=true writes "1" and flips the
+// atomic on; value=false writes "" (empty string is treated as not-
+// onboarded by GetSetting → settingBool conventions) and flips it off.
+func (s *Server) setOnboarded(ctx context.Context, value bool) error {
+ stored := ""
+ if value {
+ stored = "1"
+ }
+ if err := s.db.SetSetting(ctx, settingKeyOnboarded, stored); err != nil {
+ return err
+ }
+ s.onboarded.Store(value)
+ return nil
+}
+
+// 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", s.withSidebar(ctx, 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.setOnboarded(ctx, true)
+
+ 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.setOnboarded(c.Request.Context(), true)
+ 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.setOnboarded(c.Request.Context(), false)
+ 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/static/audiobookshelf.png b/internal/web/static/audiobookshelf.png
new file mode 100644
index 0000000..24c827b
Binary files /dev/null and b/internal/web/static/audiobookshelf.png differ
diff --git a/internal/web/static/emby.png b/internal/web/static/emby.png
new file mode 100644
index 0000000..4807546
Binary files /dev/null and b/internal/web/static/emby.png differ
diff --git a/internal/web/static/jellyfin.png b/internal/web/static/jellyfin.png
new file mode 100644
index 0000000..a76e6e7
Binary files /dev/null and b/internal/web/static/jellyfin.png differ
diff --git a/internal/web/static/plex.png b/internal/web/static/plex.png
new file mode 100644
index 0000000..0786372
Binary files /dev/null and b/internal/web/static/plex.png differ
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
index 056c303..ea686ff 100644
--- a/internal/web/static/style.css
+++ b/internal/web/static/style.css
@@ -1,39 +1,100 @@
/* Audible Plex Downloader - UI Styles */
+@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500&display=swap");
:root {
+ /* Surfaces */
--bg: #1a1a2e;
--surface: #16213e;
--surface-2: #0f3460;
+ --border: #2a3a5c;
+
+ /* Brand */
--accent: #e94560;
--accent-hover: #d63851;
- --text: #eee;
- /* Bumped from #aaa to clear WCAG AA against destination cards on /settings
+ --plex-cyan: #7cc9ff;
+ --plex-aqua: #1DDBE9;
+ --plex-deep: #114E88;
+ --audible-teal: #67C4B1;
+ --audible-blue: #0F6191;
+
+ /* Foreground */
+ --text: #eeeeee;
+ --text-strong: #f5f8ff;
+ /* Bumped from DS #aaa to clear WCAG AA against destination cards on /settings
and other muted text contexts. axe flagged 21 nodes pre-fix. */
--text-muted: #c4c8d4;
- /* Status badge bgs darkened to clear WCAG AA (4.5:1) with #fff text.
+ --text-link: #78b7ff;
+ --text-link-hover: #9cc9ff;
+
+ /* Semantic — darkened from DS to clear WCAG AA (4.5:1) with #fff text.
Material 700/800 shades — same hue family, intentionally chosen so the
- brand reads identically. Original 500-shade tokens (#4caf50 / #f44336 /
- #2196f3 / #607d8b) failed AA against #fff badge text and cascaded
- 100+ contrast nodes on /library where every book row has a status badge. */
+ brand reads identically. */
--success: #2e7d32;
--warning: #ff9800;
--error: #c62828;
--info: #1565c0;
--neutral: #455a64;
- --border: #2a3a5c;
+ --inactive: #444444;
+ --type-tag-bg: #2a3a4f;
+ --type-tag-fg: #cfe1ff;
+
+ /* Geometry */
--radius: 8px;
+ --radius-sm: 4px;
+ --radius-pill: 999px;
+
+ /* Elevation */
+ --shadow-logo: 0 8px 18px rgba(0, 0, 0, 0.35);
+ --shadow-modal: 0 22px 48px rgba(0, 0, 0, 0.45);
+ --shadow-text: 0 1px 2px rgba(0, 0, 0, 0.4);
+
+ /* Sidebar gradient */
+ --sidebar-header-bg: linear-gradient(160deg, rgba(15, 52, 96, 0.55), rgba(22, 33, 62, 0.9));
+ --sidebar-header-glow: radial-gradient(circle, rgba(31, 200, 245, 0.28), rgba(31, 200, 245, 0));
+
+ /* Spacing */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+
+ /* Typography */
+ --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ --fs-eyebrow: 0.56rem;
+ --fs-xs: 0.70rem;
+ --fs-sm: 0.75rem;
+ --fs-base: 0.85rem;
+ --fs-md: 0.90rem;
+ --fs-lg: 1.00rem;
+ --fs-h1: 1.50rem;
+ --fs-h2: 1.20rem;
+ --fs-stat: 2.00rem;
+ --lh-tight: 1.05;
+ --lh-body: 1.6;
+ --tracking-eyebrow: 0.18em;
+ --tracking-th: 0.05em;
+ --tracking-tight: 0.01em;
+ --topbar-h: 56px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
background: var(--bg);
color: var(--text);
display: flex;
min-height: 100vh;
}
+/* Wizard pages (setup) render without sidebar; let onboard-shell flex-fill */
+body:has(> .onboard-shell) { display: block; }
+
/* Sidebar */
.sidebar {
width: 220px;
@@ -55,7 +116,7 @@ body {
align-items: center;
gap: 0.7rem;
overflow: hidden;
- background: linear-gradient(160deg, rgba(15, 52, 96, 0.55), rgba(22, 33, 62, 0.9));
+ background: var(--sidebar-header-bg);
}
.sidebar-header::before {
@@ -66,7 +127,7 @@ body {
right: -42px;
top: -46px;
border-radius: 999px;
- background: radial-gradient(circle, rgba(31, 200, 245, 0.28), rgba(31, 200, 245, 0));
+ background: var(--sidebar-header-glow);
pointer-events: none;
}
@@ -117,11 +178,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,798 +196,3220 @@ body {
color: var(--text);
}
-/* Main content */
-.content {
- margin-left: 220px;
- padding: 2rem;
- flex: 1;
- max-width: 1200px;
+.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;
}
-h1 { margin-bottom: 1.5rem; font-size: 1.5rem; }
-h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); }
-
-/* Stats grid */
-.stats-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
- gap: 1rem;
- margin-bottom: 1.5rem;
+.nav-icon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ opacity: 0.85;
}
-.stat-card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 1.25rem;
+.nav-badge {
+ margin-left: auto;
+ font-size: 0.65rem;
+ padding: 2px 7px;
+ border-radius: 999px;
+ background: var(--accent);
+ color: #fff;
+ font-weight: 700;
+ min-width: 20px;
text-align: center;
+ line-height: 1.3;
+ border: 1px solid rgba(233, 69, 96, 0.45);
+ font-family: var(--font-sans);
+ font-variant-numeric: tabular-nums;
}
-
-.stat-value {
- font-size: 2rem;
- font-weight: 700;
- color: var(--accent);
+.nav-badge.muted {
+ background: var(--surface-2);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+.nav-badge.danger {
+ background: var(--error);
+ color: #fff;
+ border-color: rgba(244, 67, 54, 0.45);
}
-.stat-label {
+/* 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);
- font-size: 0.85rem;
- margin-top: 0.25rem;
+ line-height: 1.3;
+ min-width: 0;
}
-
-/* Buttons */
-.btn {
- display: inline-block;
- padding: 0.5rem 1rem;
- border: none;
- border-radius: var(--radius);
- cursor: pointer;
- font-size: 0.9rem;
+.sidebar-footer .meta strong {
color: var(--text);
- transition: background 0.2s;
+ font-size: 0.76rem;
+ display: block;
}
-/* WCAG AA: white on --accent (#e94560) is ~3.21:1 — fails normal-text 4.5:1.
- Switch foreground to the deepest page bg (--bg #1a1a2e) which gives ~6.8:1
- against the brand red without changing the brand color itself. */
-.btn-primary { background: var(--accent); color: var(--bg); }
-.btn-primary:hover { background: var(--accent-hover); color: var(--bg); }
-.btn-secondary { background: var(--surface-2); border: 1px solid var(--border); }
-.btn-secondary:hover { background: var(--border); }
-.btn-danger { background: var(--error); }
-.btn-danger:hover { background: #c62828; }
-.btn-small { padding: 0.3rem 0.6rem; font-size: 0.8rem; }
+/* Main column (right of sidebar) */
+.main {
+ margin-left: 220px;
+ flex: 1;
+ width: calc(100% - 220px);
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
-.actions-bar {
+/* 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;
- margin-bottom: 0.5rem;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+ color: var(--text-muted);
}
-
-.sync-status-row {
- margin-bottom: 1.25rem;
+.topbar .crumbs .sep { opacity: 0.4; }
+.topbar .crumbs .current {
+ color: var(--text);
+ font-weight: 500;
}
-
-.queue-paused-banner {
- margin-bottom: 1rem;
- padding: 0.75rem 1rem;
- border: 1px solid #7f1d1d;
- border-radius: var(--radius);
- background: rgba(244, 67, 54, 0.12);
- color: #ffb3aa;
+.topbar .topbar-actions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
}
-
-/* Tables */
-.data-table {
- width: 100%;
- border-collapse: collapse;
+.topbar-search {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
background: var(--surface);
- border-radius: var(--radius);
- overflow: hidden;
-}
-
-.data-table th,
-.data-table td {
- padding: 0.75rem 1rem;
- text-align: left;
- border-bottom: 1px solid var(--border);
-}
-
-.data-table th {
- background: var(--surface-2);
+ 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;
- text-transform: uppercase;
- letter-spacing: 0.05em;
+ 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);
}
-.data-table tr:hover { background: rgba(255,255,255,0.03); }
-
-.cover-cell { width: 50px; }
-.cover-thumb { width: 40px; height: 60px; object-fit: cover; border-radius: 4px; }
-
-.empty { text-align: center; color: var(--text-muted); padding: 2rem; }
-.result-count { color: var(--text-muted); margin-bottom: 0.75rem; font-size: 0.9rem; }
-
-/* Badges */
-.badge {
- display: inline-block;
- padding: 0.2rem 0.5rem;
- border-radius: 4px;
- font-size: 0.75rem;
- font-weight: 600;
- text-transform: uppercase;
+/* Main content (below topbar) */
+.content {
+ padding: 1.5rem 1.75rem 3rem;
+ max-width: 1680px;
+ width: 100%;
}
-.badge-success { background: var(--success); color: #fff; }
-.badge-warning { background: var(--warning); color: #000; }
-.badge-error { background: var(--error); color: #fff; }
-.badge-info { background: var(--info); color: #fff; }
-.badge-neutral { background: var(--neutral); color: #fff; }
-.badge-inactive { background: #444; color: #ddd; } /* "never checked" — neutral, AA contrast */
-.badge-type { background: #2a3a4f; color: #cfe1ff; } /* subtle blue tag for type label */
+h1 { margin-bottom: 1.5rem; font-size: 1.5rem; }
+h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); }
-/* SR-only utility — visually hidden, accessible to screen readers. */
-.visually-hidden {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
-}
+/* ─────────────────────────────────────────────────────────
+ 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).
+ ───────────────────────────────────────────────────────── */
-.is-hidden {
- display: none !important;
-}
+.page { width: 100%; }
-/* Search bar */
-.search-bar {
+.page-header {
display: flex;
- gap: 0.75rem;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
margin-bottom: 1.5rem;
+ flex-wrap: wrap;
}
-
-.search-bar input,
-.search-bar select {
- padding: 0.5rem 0.75rem;
- background: var(--surface);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- font-size: 0.9rem;
-}
- .search-bar select,
- .form-group select {
- color-scheme: dark;
- }
- .search-bar select option,
- .form-group select option {
- background: var(--surface);
- color: var(--text);
- }
-.data-table td a {
- color: #78b7ff;
- text-decoration: none;
- font-weight: 500;
+.page-header h1 {
+ font-size: 1.6rem;
+ font-weight: 700;
+ line-height: 1.1;
+ margin: 0;
}
-.data-table td a:hover,
-.data-table td a:focus-visible {
- color: #9cc9ff;
- text-decoration: underline;
+.page-header .sub {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ margin-top: 0.25rem;
+ max-width: 620px;
}
-
-.table-actions {
+.page-header .actions {
display: flex;
+ gap: 0.5rem;
flex-wrap: wrap;
- gap: 0.35rem;
align-items: center;
}
-.library-row-default-actions,
-.library-row-cancel-action {
+.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;
- gap: 0.35rem;
align-items: center;
+ gap: 0.75rem;
}
-
-.search-bar input { flex: 1; }
-
-/* Book detail */
-.book-detail {
- display: flex;
- gap: 2rem;
+.section-h .count {
background: var(--surface);
border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 1.5rem;
+ border-radius: 999px;
+ padding: 1px 8px;
+ font-size: 0.7rem;
+ color: var(--text);
}
-
-.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; }
-.description { margin-top: 1.5rem; }
-.description p { line-height: 1.6; }
-.series { color: var(--accent); font-style: italic; }
-
-.book-files-details {
- margin-top: 0.6rem;
+.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; }
-.book-files-details > summary {
- display: inline-block;
- cursor: pointer;
- color: #9cc9ff;
- padding: 0.35rem 0.55rem;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: rgba(15, 52, 96, 0.35);
- user-select: none;
-}
-
-.book-files-details[open] > summary {
- margin-bottom: 0.55rem;
+/* 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;
}
-
-.book-files-table {
- width: 100%;
- border-collapse: collapse;
+.stat-grid .stat-card {
+ background: var(--surface);
border: 1px solid var(--border);
- border-radius: var(--radius);
- overflow: hidden;
- font-size: 0.86rem;
-}
-
-.book-files-table th,
-.book-files-table td {
- padding: 0.45rem 0.6rem;
- border-bottom: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 1rem 1.1rem;
text-align: left;
- vertical-align: top;
-}
-
-.book-files-table td:first-child {
- word-break: break-word;
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ position: relative;
+ overflow: hidden;
}
-
-.book-files-table th {
+.stat-grid .stat-card .label {
+ font-size: 0.72rem;
color: var(--text-muted);
- background: rgba(15, 52, 96, 0.45);
text-transform: uppercase;
- font-size: 0.72rem;
- letter-spacing: 0.04em;
-}
-
-.book-files-table tbody tr:last-child td {
- border-bottom: none;
-}
-
-.book-description-html p {
- margin: 0.6rem 0;
- line-height: 1.6;
+ letter-spacing: 0.07em;
+ font-weight: 600;
}
-
-.book-description-html ul,
-.book-description-html ol {
- margin: 0.7rem 0 0.7rem 1.2rem;
+.stat-grid .stat-card .value {
+ font-size: 1.7rem;
+ font-weight: 700;
+ color: var(--text);
+ line-height: 1.15;
+ letter-spacing: -0.01em;
}
-
-.book-description-html li {
- margin: 0.25rem 0;
+.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; }
-/* Progress bar */
-.progress-bar {
- height: 8px;
- background: var(--border);
- border-radius: 4px;
- overflow: hidden;
- margin-top: 0.5rem;
+/* Filter tabs (Library) */
+.tabs {
+ display: flex;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 1.25rem;
+ gap: 1rem;
+ flex-wrap: wrap;
}
-
-.progress-fill {
- height: 100%;
- background: var(--accent);
- transition: width 0.3s;
+.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;
}
-
-.progress-throbber {
- height: 8px;
- background: var(--border);
- border-radius: 4px;
- overflow: hidden;
- margin-top: 0.5rem;
- position: relative;
+.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;
}
-
-.progress-throbber-bar {
- position: absolute;
- height: 100%;
- width: 40%;
- background: var(--accent);
- border-radius: 4px;
- animation: throbber-slide 1.4s ease-in-out infinite;
+.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;
}
-
-@keyframes throbber-slide {
- 0% { left: -40%; }
- 100% { left: 100%; }
+.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);
}
-/* Download cards */
-.download-card {
+/* Filter bar card (Library) */
+.filter-bar {
background: var(--surface);
border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 1rem;
- margin-bottom: 0.75rem;
-}
-
-.dl-info {
- display: grid;
- grid-template-columns: 1fr auto 6rem;
- align-items: center;
+ border-radius: 10px;
+ padding: 0.75rem 0.9rem;
+ margin-bottom: 1rem;
+ display: flex;
gap: 0.5rem;
+ flex-wrap: wrap;
+ align-items: center;
}
-
-.dl-info strong {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- min-width: 0;
-}
-
-.dl-info .badge {
- justify-self: end;
- white-space: nowrap;
+.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);
}
-
-.dl-info-actions {
- justify-self: end;
- min-width: 0;
+.filter-bar .search-input input {
+ background: transparent;
+ border: 0;
+ outline: none;
+ color: var(--text);
+ flex: 1;
+ font-size: 0.85rem;
+ font-family: inherit;
}
-
-.dl-details {
- font-size: 0.8rem;
- color: var(--text-muted);
- margin-top: 0.25rem;
+.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;
}
-.pool-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
- gap: 0.75rem;
- margin-bottom: 1rem;
+/* Card primitive */
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 1rem 1.1rem;
}
-.pool-card {
+/* Sync status v2 — banner with phase pills */
+.sync-status-v2 {
background: var(--surface);
border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 0.75rem;
+ border-radius: 10px;
+ padding: 0.9rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ margin-bottom: 1.25rem;
}
-
-.pool-head {
+.sync-status-v2 .sync-head {
display: flex;
- justify-content: space-between;
align-items: center;
- margin-bottom: 0.35rem;
+ gap: 0.65rem;
+ font-size: 0.85rem;
}
-
-.pool-workers {
+.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);
- font-size: 0.8rem;
}
-.pool-stats {
+/* 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);
- font-size: 0.85rem;
- margin-bottom: 0.45rem;
+ line-height: 1.4;
}
-
-.pool-waiting-list {
- list-style: none;
+.sync-status-v2 .sync-last-time {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: var(--text);
+ font-weight: 500;
}
-
-.pool-waiting-list li {
- display: flex;
- justify-content: space-between;
- gap: 0.5rem;
- padding: 0.2rem 0;
- border-top: 1px solid rgba(255, 255, 255, 0.06);
- font-size: 0.82rem;
+.sync-status-v2 .sync-last-time svg {
+ color: var(--text-muted);
+ flex-shrink: 0;
}
-
-.pool-item-title {
+.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;
}
-
-.pool-item-asin,
-.pool-more,
-.pool-empty {
+/* 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-size: 0.78rem;
+ font-style: italic;
}
-
-.error-text { color: var(--error); font-size: 0.85rem; }
-
-/* Info box */
-.info-box {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 1rem;
+.sync-status-v2 .sync-next-time--off svg {
color: var(--text-muted);
+ opacity: 0.7;
}
-.info-box-compact {
- margin-bottom: 1rem;
+/* 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 */
+/* Destinations page — single column, full-width cards.
+ Dashboard summary keeps the compact grid via .destination-summary-list. */
+.dest-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+}
+.dest-grid.destination-summary-list {
+ 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: 1rem 1.15rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+ 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: 32px; height: 32px;
+ border-radius: 6px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.65rem;
+ 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); }
+.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);
+}
+.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 {
+ color: var(--text-muted);
+ font-size: 0.72rem;
+}
+
+/* Extended details block — visible on the Destinations page only.
+ Dashboard's .destination-summary-list hides it for the compact view. */
+.dest-card .dest-details {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 0.4rem 1.25rem;
+ margin: 0.25rem 0 0.15rem;
+ padding: 0.75rem 0;
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+ font-size: 0.8rem;
+}
+.dest-card .dest-details dt {
+ color: var(--text-muted);
+ font-weight: 500;
+}
+.dest-card .dest-details dd {
+ color: var(--text);
+ margin: 0;
+ min-width: 0;
+ word-break: break-word;
+}
+.dest-card .dest-details .cell-mono {
+ font-family: var(--font-mono);
+ font-size: 0.76rem;
+}
+.dest-card .dest-path-translation {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+.dest-card .dest-path-arrow {
+ color: var(--text-muted);
+ font-family: var(--font-sans);
+}
+.destination-summary-list .dest-card .dest-details { display: none; }
+
+/* Action bar — keep buttons on one row at desktop width; wrap gracefully. */
+.dest-card .dest-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-top: 0.15rem;
+}
+.dest-card .dest-actions-buttons {
+ margin-left: auto;
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+}
+.destination-summary-list .dest-card .dest-actions {
+ flex-wrap: wrap;
+}
+.destination-summary-list .dest-card .dest-actions-buttons {
+ gap: 0.3rem;
+}
+
+/* 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: 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;
+}
+.dest-chip:has(img) {
+ background: rgba(255, 255, 255, 0.06);
+ padding: 2px;
+}
+
+/* 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); }
+}
+
+/* ── DS form primitives ──────────────────────────────────────
+ Pulled from the DesignSystem mock. Used by the wizard, the
+ destinations modal, Settings, and anything else that wants the
+ standard label-input-hint stack with consistent spacing.
+ ──────────────────────────────────────────────────────────── */
+.input, .select, .textarea {
+ width: 100%;
+ padding: 0.55rem 0.7rem;
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ font-size: 0.85rem;
+ font-family: inherit;
+ outline: none;
+ color-scheme: dark;
+}
+.input:focus, .select:focus, .textarea:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.15);
+}
+.input::placeholder { color: var(--text-muted); }
+.textarea { resize: vertical; min-height: 5rem; }
+
+.checkbox {
+ width: 16px;
+ height: 16px;
+ appearance: none;
+ -webkit-appearance: none;
+ border: 1px solid var(--border);
+ background: var(--bg);
+ border-radius: 4px;
+ cursor: pointer;
+ position: relative;
+ flex-shrink: 0;
+}
+.checkbox:checked { background: var(--accent); border-color: var(--accent); }
+.checkbox:checked::after {
+ content: "✓";
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ font-size: 0.7rem;
+ font-weight: 700;
+}
+
+.toggle {
+ position: relative;
+ display: inline-block;
+ width: 28px;
+ height: 16px;
+ flex-shrink: 0;
+}
+.toggle input { opacity: 0; width: 0; height: 0; }
+.toggle-slider {
+ position: absolute;
+ inset: 0;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ cursor: pointer;
+ transition: 0.2s;
+}
+.toggle-slider::before {
+ content: "";
+ position: absolute;
+ height: 10px;
+ width: 10px;
+ left: 2px;
+ top: 2px;
+ background: var(--text-muted);
+ border-radius: 50%;
+ transition: 0.2s;
+}
+.toggle input:checked + .toggle-slider {
+ background: rgba(233, 69, 96, 0.28);
+ border-color: var(--accent);
+}
+.toggle input:checked + .toggle-slider::before {
+ transform: translateX(12px);
+ background: var(--accent);
+}
+
+.form-row {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ margin-bottom: 1.1rem;
+}
+.form-row > label {
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--text);
+}
+.form-row .hint {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ line-height: 1.45;
+}
+.form-row .hint code {
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+ background: var(--surface-2);
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-size: 0.72rem;
+ color: #7cc9ff;
+}
+.form-row.inline {
+ flex-direction: row;
+ align-items: center;
+ gap: 0.75rem;
+}
+.form-row.inline > label:first-child,
+.form-row.inline > .row-label {
+ flex: 1;
+ margin-bottom: 0;
+}
+.form-row.inline label.toggle,
+.form-row.inline span.toggle { flex: 0 0 auto; }
+label.form-row.inline { cursor: pointer; }
+label.form-row.inline:hover .toggle-slider { border-color: var(--accent); }
+
+/* .card-head — heading row inside .card cards. */
+.card-head {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ margin-bottom: 0.75rem;
+}
+.card-head h3 {
+ font-size: 0.95rem;
+ font-weight: 600;
+ margin: 0;
+}
+.card-head .right { margin-left: auto; display: flex; gap: 0.4rem; }
+
+/* Code block — for read-only paths and small command examples. */
+.code {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0.65rem 0.85rem;
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 0.78rem;
+ color: #7cc9ff;
+ line-height: 1.6;
+ overflow-x: auto;
+}
+
+/* Link button — semantic