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 // blocks) into a single space. +var errSpaceRe = regexp.MustCompile(`\s+`) + +// errHTTPCodeRe extracts the numeric status code from a "<verb> returned +// <code>: <body>" error string. The first capture is the code itself. +var errHTTPCodeRe = regexp.MustCompile(`returned (\d{3})[:\s]`) + +// CleanForDisplay turns a raw backend error message — which may embed +// an HTML error page from Plex/Emby/Jellyfin/ABS — into something that +// fits on one line in a UI badge. It: +// - strips HTML tags +// - collapses whitespace +// - rewrites common HTTP status codes into friendly hints +// - truncates very long messages so a 2KB upstream body doesn't +// blow out the card layout +// +// The raw error is still logged at the original emission site, so we +// don't lose forensic detail. +func CleanForDisplay(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + + var hint string + if m := errHTTPCodeRe.FindStringSubmatch(s); m != nil { + switch m[1] { + case "401": + hint = "Authentication failed — check the token or API key." + case "403": + hint = "Authentication accepted but the account is not authorized for this resource." + case "404": + hint = "Server reachable but the configured library/section was not found." + case "500", "502", "503", "504": + hint = "Upstream server error — the media server returned " + m[1] + "." + } + } + + s = errTagRe.ReplaceAllString(s, " ") + s = errSpaceRe.ReplaceAllString(s, " ") + s = strings.TrimSpace(s) + + const maxLen = 200 + if len(s) > maxLen { + s = strings.TrimSpace(s[:maxLen]) + "…" + } + + if hint != "" { + return hint + } + return s +} + +// 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: <html><head><title>Not Found

404 Not Found

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

oops

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

404 Not Found

`, + want: "Server reachable but the configured library/section was not found.", + }, + { + name: "empty", + in: " ", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := CleanForDisplay(tc.in) + if got != tc.want { + t.Fatalf("CleanForDisplay(%q)\n got: %q\n want: %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/internal/library/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 +

Add destination

+ + + +
+ + + + {{end}} diff --git a/internal/web/templates/book_detail_modal.html b/internal/web/templates/book_detail_modal.html new file mode 100644 index 0000000..9254e51 --- /dev/null +++ b/internal/web/templates/book_detail_modal.html @@ -0,0 +1,180 @@ +{{define "book_detail_modal.html"}} +{{/* + Shared book-detail modal used by /library and /dashboard. + Includes the markup, the click-delegated open handler (any + .book-detail-link on the page opens the modal instead of navigating), + Escape-to-close, history.replaceState for ?book= deep links, and + auto-open when the page loads with ?book= already in the URL. + + CSS lives at the bottom — selector-scoped to .library-modal so adding + this partial to other pages doesn't bleed styles. +*/}} + + + + + +{{end}} diff --git a/internal/web/templates/book_detail_panel.html b/internal/web/templates/book_detail_panel.html index 18de439..c497d11 100644 --- a/internal/web/templates/book_detail_panel.html +++ b/internal/web/templates/book_detail_panel.html @@ -1,26 +1,131 @@ {{define "book_detail_panel.html"}}
{{if .Message}} -
{{.Message}}
+
+ +
{{.Message}}
+
{{end}} -
- {{if .Book.CoverURL}}Cover{{end}} + +
+ {{if .Book.CoverURL}} + Cover for {{.Book.Title}} + {{else}} + + {{end}} +
+ {{if eq (printf "%s" .Book.Status) "new"}} + + {{else if eq (printf "%s" .Book.Status) "failed"}} + + {{else if eq (printf "%s" .Book.Status) "complete"}} + + {{end}} + + {{if .Book.ID}} + + {{end}} + + {{if .BookAction.ShowConvert}} + + {{end}} + + {{if .BookAction.ShowDelete}} + + {{end}} + + {{if .Book.ASIN}} + + Open on Audible + + + {{end}} +
-
+ +

{{.Book.Title}}

- {{if .Book.Series}}

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

{{end}} -

Author: {{.Book.Author}}

-

Narrator: {{.Book.Narrator}}

-

Publisher: {{.Book.Publisher}}

-

Duration: {{formatDuration .Book.Duration}}

-

Purchased: {{formatDate .Book.PurchaseDate}}

-

Released: {{formatDate .Book.ReleaseDate}}

-

Status: {{.Book.Status}}

-

ASIN: {{.Book.ASIN}}

- {{if .BookFolderPath}}

Folder: {{.BookFolderPath}}

{{end}} + {{if .Book.Series}}
{{.Book.Series}} · Book {{.Book.SeriesPosition}}
{{end}} + +
+ {{.Book.Status}} + {{if .Book.FilePath}}On disk{{end}} +
+ + {{if eq (printf "%s" .Book.Status) "unavailable"}} +
+ +
Unavailable. {{if .Book.UnavailableReason}}{{.Book.UnavailableReason}}{{else}}Audible reports this title cannot be downloaded with your current account.{{end}}
+
+ {{end}} + +
+
Author
{{.Book.Author}}
+
Narrator
{{if .Book.Narrator}}{{.Book.Narrator}}{{else}}—{{end}}
+
Duration
{{formatDuration .Book.Duration}}
+
Released
{{formatDate .Book.ReleaseDate}}
+
Purchased
{{formatDate .Book.PurchaseDate}}
+
Publisher
{{if .Book.Publisher}}{{.Book.Publisher}}{{else}}—{{end}}
+
ASIN
{{.Book.ASIN}}
+ {{if .BookFolderPath}}
Folder
{{.BookFolderPath}}
{{end}} +
+ + {{if .BookDestinationStatuses}} + +
    + {{range .BookDestinationStatuses}} +
  • + {{$logo := destLogo .Type}}{{if $logo}}{{.TypeLabel}}{{else}}{{firstTwo .TypeLabel}}{{end}} + {{.DisplayName}} + + {{if eq .State "synced"}}{{.Label}} + {{else if eq .State "syncing"}}{{.Label}} + {{else if eq .State "failed"}}{{.Label}} + {{else if eq .State "orphaned"}}{{.Label}} + {{else if eq .State "removed"}}{{.Label}} + {{else}}{{.Label}}{{end}} + +
  • + {{end}} +
+ {{end}} + {{if gt .BookFileCount 0}} +
- Show files and details ({{.BookFileCount}}) + Show files and details @@ -44,38 +149,12 @@

{{.Book.Title}}

{{else if .Book.FilePath}} -

Files: Unable to list files from disk right now.

+

Unable to list files from disk right now.

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

Description

+
+
{{safeHTML .Book.Description}}
{{end}} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index efc2845..0628137 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -1,47 +1,71 @@ -{{define "content"}} -

Dashboard

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

Worker Pools

-
- -

Active Pipeline

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

Done ({{len .DoneDownloads}})

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

Failed ({{.FailedDL}})

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

Destinations

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

      - {{.DisplayName}} -

      -

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

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

- Last checked: -

+ {{if .HealthDetail}} +
{{cleanError .HealthDetail}}
{{end}} +
+ + {{if .LastCheckedAt}} + Last check + {{else}}Never checked{{end}} + + + + + +
{{end}} {{else}} -

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

+
+ No destinations configured. + + to fan out scans after each download. +
{{end}} {{if .LastSync}} -
- Last Sync: {{formatDate .LastSync.StartedAt}} — - {{.LastSync.BooksFound}} found, {{.LastSync.BooksAdded}} added - ({{.LastSync.Status}}) +
+ Last Sync: {{formatDate .LastSync.StartedAt}} — + {{.LastSync.BooksFound}} found, {{.LastSync.BooksAdded}} added ({{.LastSync.Status}})
{{end}} {{end}} diff --git a/internal/web/templates/destinations.html b/internal/web/templates/destinations.html new file mode 100644 index 0000000..82c05de --- /dev/null +++ b/internal/web/templates/destinations.html @@ -0,0 +1,39 @@ +{{define "content"}} +
+ + + {{if .Destinations}} +
+ {{range .Destinations}} + {{template "destination_card_body" (dict "Dest" .)}} + {{end}} +
+ {{else}} + + {{end}} +
+{{end}} diff --git a/internal/web/templates/destinations_card.html b/internal/web/templates/destinations_card.html new file mode 100644 index 0000000..5acc72e --- /dev/null +++ b/internal/web/templates/destinations_card.html @@ -0,0 +1,130 @@ +{{define "destination_card_body"}} +{{/* + One destination card. Rendered by: + - destinations.html (full-width stacked list; rich details visible) + - dashboard_summary.html (compact grid; .dest-details hidden via CSS) + - handleDestinationToggle (HTMX swap; returns just this card after + flipping Enabled so the page doesn't have to reload) + + The data shape matches destinationSummaryView so the same partial + handles every caller. .Dest is the single row; on the destinations + page that's $dest inside a range. +*/}} +{{with .Dest}} +
+ + +
+ {{$logo := destLogo .Type}}{{if $logo}}{{.TypeLabel}}{{else}}{{firstTwo .TypeLabel}}{{end}} +
+
{{.DisplayName}}
+
{{if .URL}}{{.URL}}{{else}}{{.TypeLabel}}{{end}}
+
+
+ {{if not .Enabled}}Disabled + {{else if eq .Health "healthy"}}Healthy + {{else if eq .Health "failed"}}Failed + {{else if eq .Health "never"}}Never checked + {{else}}Not configured{{end}} +
+
+ + {{if .ItemCountSet}} +
+ {{.ItemCount}} items on server + {{if .CoverageSet}} + + + + {{.Coverage}}% + of your library is present on this server + {{end}} +
+ {{end}} + + {{if and (eq .Health "failed") .LastError}} +
+ {{.LastError}} +
+ {{else if .HealthDetail}} +
{{.HealthDetail}}
+ {{end}} + + {{/* Extended details — full-width destinations page only. The + dashboard's .destination-summary-list ancestor hides this via + CSS so its compact card stays compact. */}} +
+
Type
{{.TypeLabel}}
+
Server URL
{{if .URL}}{{.URL}}{{else}}{{end}}
+ {{if eq .Type "plex"}} +
Plex token
{{if .HasCredential}}Set{{else}}Missing{{end}}
+
Section ID
{{if .PlexSectionID}}{{.PlexSectionID}}{{else}}{{end}}
+ {{else}} +
API key
{{if .HasCredential}}Set{{else}}Missing{{end}}
+
Library ID
{{if .LibraryID}}{{.LibraryID}}{{else}}{{end}}
+ {{end}} + {{if or .AudiobookPath .DestinationPath}} +
Path translation
+
+ {{if .AudiobookPath}}{{.AudiobookPath}}{{else}}(audiobooks){{end}} + + {{if .DestinationPath}}{{.DestinationPath}}{{else}}(same){{end}} +
+ {{end}} +
Last checked
+
+ {{if .LastCheckedAt}} + + {{else}}Never{{end}} +
+
Added
+
+ +
+ + {{if .LastCheckedAt}} + Last checked + {{else}} + Never checked + {{end}} + + + + + + + +
+
+{{end}} +{{end}} diff --git a/internal/web/templates/destinations_delete.html b/internal/web/templates/destinations_delete.html index 78d20e2..d839b2f 100644 --- a/internal/web/templates/destinations_delete.html +++ b/internal/web/templates/destinations_delete.html @@ -1,27 +1,78 @@ -{{define "content"}} -
-
-

Delete Library Destination

-
+{{define "destination_delete_body"}} +{{if .ModalSuccess}} +
+ +
+ Destination deleted. +
Refreshing…
+
+
+{{else}} +{{$logo := destLogo .Dest.Type}} -
-
-

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

-

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

-

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

+
+
+ {{if $logo}}{{else}}{{firstTwo .Dest.TypeLabel}}{{end}} +
+
+
Delete destination
+

{{.Dest.DisplayName}}

+

{{.Dest.TypeLabel}}{{if .Dest.URL}} · {{.Dest.URL}}{{end}}

+
+
-
- -
- Cancel - -
-
-
-
+
+ ! +
+ This removes the {{.Dest.TypeLabel}} destination from Audplexus. +
+ Your media files and the {{.Dest.TypeLabel}} server itself are not touched — + Audplexus will simply stop fanning out post-download work here. The + per-(book, destination) sync history for this destination is also deleted. + This cannot be undone. +
+
+ +
+ + +
+ + + Case-sensitive. Guards against accidental removal of a healthy destination. +
+ +
+ + + +
+
+ + +{{end}} {{end}} diff --git a/internal/web/templates/destinations_form.html b/internal/web/templates/destinations_form.html index 6e58e2f..4a54df0 100644 --- a/internal/web/templates/destinations_form.html +++ b/internal/web/templates/destinations_form.html @@ -1,429 +1,608 @@ -{{define "content"}} +{{define "destination_form_body"}} +{{if .ModalSuccess}} +
+ +
+ {{.DestTypeLabel}} destination {{if .ModalSuccessVerb}}{{.ModalSuccessVerb}}{{else}}saved{{end}}. +
Refreshing…
+
+
+{{else}} +{{/* + Destination create/edit form — rendered inside the global modal in + base.html. $isEdit (.Dest set) flips the form action between create + and update. Layout follows the DS aesthetic: typed identity header + with the brand logo, sectioned body with eyebrow dividers, sticky + action bar pinned to the bottom of the modal. +*/}} {{$isEdit := .Dest}} {{$dt := .DestType}} -
-
-

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

+{{$logo := destLogo $dt}} + +
+
+ {{if $logo}}{{else}}{{firstTwo .DestTypeLabel}}{{end}} +
+
+
{{.DestTypeLabel}}
+

{{if $isEdit}}Edit destination{{else}}Add destination{{end}}

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

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

Connection

-
- {{if not $isEdit}}{{end}} +{{if .FormError}} + +{{end}} -
- - - Shown in the Settings list and per-destination logs. Defaults to {{.DestTypeLabel}}; set this only if you run multiple destinations of the same type (e.g. "Living room Plex" + "Bedroom Plex"). -
+ + {{if not $isEdit}}{{end}} - {{if ne $dt "plex"}} -
- - - Internal URL Audplexus uses to reach the server. Use the LAN address; no public proxy required. -
- {{end}} + {{/* ── Section 1: Identity & Connection ──────────────────────── */}} +
+
+ Connection + +
- {{if eq $dt "plex"}} - {{$guidedStart := not $isEdit}} -
- {{/* - Sign in with Plex — kicks off the plex.tv PIN flow. - Posts to /destinations/plex/pin/start which returns a - fragment containing an "Open plex.tv sign-in" button - (target=_blank) plus a hidden HTMX poller that re-fires - every 2s until plex.tv returns an authToken. On success - the poll response is a tiny -
- {{else}} + refreshState(); + })(); + +
+ + {{else}} + {{/* ── Emby / Jellyfin / Audiobookshelf ────────────────── */}} +
+ + + Internal address Audplexus uses to reach the server. Use the LAN URL; no public proxy required. +
+ +
+ + + {{if eq $dt "abs"}}Admin-scope API key for the Audiobookshelf server.{{else}}{{.DestTypeLabel}} server API key.{{end}} +
+ {{end}} +
+ + {{/* ── Section 2: Library (non-Plex) ─────────────────────────── */}} + {{if ne $dt "plex"}} +
+
+ Library + +
+ + {{$discoverPath := ""}} + {{if eq $dt "abs"}}{{$discoverPath = "abs"}}{{end}} + {{if eq $dt "emby"}}{{$discoverPath = "emby"}}{{end}} + {{if eq $dt "jellyfin"}}{{$discoverPath = "jellyfin"}}{{end}} + +
+
+
Discover libraries
+
Asks {{.DestTypeLabel}} which libraries this key can see and fills the field below.
+
+ +
+
+ +
+ + + {{if eq $dt "abs"}}UUID of the audiobook library on the ABS server.{{else}}{{.DestTypeLabel}} ItemId of the audiobook library on the server.{{end}} +
+
+ {{end}} + + {{/* ── Section 3: Path translation (advanced) ───────────────── */}} +
+
+ + Path translation + Override per-destination paths when Audplexus and {{.DestTypeLabel}} mount the same files at different locations. + +
- - - {{if $isEdit}}Leave blank to keep the current API key. {{end}}{{if eq $dt "abs"}}Admin-scope API key for the Audiobookshelf server.{{else}}{{.DestTypeLabel}} server API key.{{end}} -
- How to generate this API key - {{if eq $dt "emby"}} -
    -
  1. Open Emby's web UI as an administrator.
  2. -
  3. Go to Settings (gear icon) → ServerAdvancedAPI Keys.
  4. -
  5. Click + New API Key. Give it a name like "Audplexus".
  6. -
  7. Copy the generated key. Emby may show it only once depending on version.
  8. -
- {{else if eq $dt "jellyfin"}} -
    -
  1. Open Jellyfin's web UI as an administrator.
  2. -
  3. Go to DashboardAPI Keys (under Advanced).
  4. -
  5. Click +. Give it an app name like "Audplexus".
  6. -
  7. Copy the generated key. Jellyfin shows it once at creation.
  8. -
- {{else if eq $dt "abs"}} -
    -
  1. Open Audiobookshelf as an administrator.
  2. -
  3. Go to Settings (gear icon) → Users.
  4. -
  5. Click your admin user.
  6. -
  7. Scroll to API KeysGenerate API Key. Required scope: admin.
  8. -
  9. The token is shown once at generation. Copy it immediately and paste here.
  10. -
- {{end}} -
+ + + Path Audplexus writes to. Leave blank to use the global Audiobooks path.
- {{if eq $dt "abs"}} - {{/* - Audiobookshelf can list its own libraries via - GET /api/libraries. The Discover button posts the - URL + token to the server, which renders an HTML - fragment containing a + Path {{.DestTypeLabel}} sees the same files at. Used to translate scan triggers. +
+
+ + + + {{/* ── Section 4: Setup help (consolidated per-type guidance) ─ */}} +
+
+ + Setup help + Where to find tokens, API keys, and library IDs in {{.DestTypeLabel}}. + +
+ {{if eq $dt "plex"}} +
+
Plex token
+
    +
  1. Sign in to Plex web at http://<your-plex>:32400/web.
  2. +
  3. Play any audiobook (or open its detail page).
  4. +
  5. Click the menu → Get InfoView XML.
  6. +
  7. The new tab's URL contains ?X-Plex-Token=… — copy that value.
  8. +
  9. Or see Plex's official guide.
  10. +
+
+
+
Section ID
+
    +
  1. In Plex web, click your audiobook library in the sidebar.
  2. +
  3. The URL bar shows /source/<sectionId> — that number is your section ID.
  4. +
  5. API alternative: curl 'http://<plex>:32400/library/sections?X-Plex-Token=…'.
  6. +
{{else if eq $dt "emby"}} - {{/* - Emby exposes its libraries via /emby/Library/MediaFolders - which returns Items with Id, Name, CollectionType. The - picker filters to CollectionType="audiobooks", falling - back to showing all libraries when none match so the - user can still pick a misconfigured-but-real library. - */}} -
- -
- -
-
- Asks Emby which libraries this key can see and fills the Library ID below. Or paste the ItemId manually if you already have it. +
+
API key
+
    +
  1. Open Emby's web UI as an administrator.
  2. +
  3. Settings (gear) → ServerAdvancedAPI Keys.
  4. +
  5. Click + New API Key. Name it "Audplexus".
  6. +
  7. Copy the generated key — Emby may show it only once.
  8. +
+
+
+
Library ID
+
    +
  1. curl 'http://<emby>:8096/emby/Library/MediaFolders?api_key=…' | jq
  2. +
  3. Find the entry where CollectionType is audiobooks.
  4. +
  5. Copy its ItemId.
  6. +
{{else if eq $dt "jellyfin"}} - {{/* - Jellyfin exposes its libraries via /Library/VirtualFolders - with Authorization: MediaBrowser Token=... — the picker - filters to CollectionType="books". - */}} -
- -
- -
-
- Asks Jellyfin which libraries this key can see and fills the Library ID below. Or paste the ItemId manually if you already have it. +
+
API key
+
    +
  1. Open Jellyfin's web UI as an administrator.
  2. +
  3. Dashboard → API Keys (under Advanced).
  4. +
  5. Click +. App name "Audplexus".
  6. +
  7. Copy the generated key — Jellyfin shows it once at creation.
  8. +
- {{end}} -
- - - {{if eq $dt "abs"}}UUID of the audiobook library on the ABS server.{{else}}{{.DestTypeLabel}} ItemId of the audiobook library on the server.{{end}} -
- How to find your library ID - {{if eq $dt "emby"}} -
    -
  1. In a terminal, run: curl 'http://<emby>:8096/emby/Library/MediaFolders?api_key=<your_key>' | jq
  2. -
  3. Find the entry where CollectionType is audiobooks.
  4. -
  5. Copy its ItemId field. That's your library ID.
  6. -
- {{else if eq $dt "jellyfin"}} -
    -
  1. In a terminal, run: curl -H 'Authorization: MediaBrowser Token="<your_key>"' 'http://<jellyfin>:8096/Library/VirtualFolders' | jq
  2. -
  3. Find the entry with "CollectionType": "books" and an audiobook path.
  4. -
  5. Copy its ItemId field. That's your library ID.
  6. -
  7. The legacy X-Emby-Token header still works on Jellyfin 10.x but is removed in 12.0 — use the Authorization: MediaBrowser scheme above.
  8. -
- {{else if eq $dt "abs"}} -
    -
  1. In a terminal, run: curl -H 'Authorization: Bearer <your_key>' 'http://<abs>:80/api/libraries' | jq '.libraries[] | {id,name,mediaType}'
  2. -
  3. Find the entry where mediaType is book (your audiobook library).
  4. -
  5. Copy its id field (a UUID like 00d44f08-df07-...). That's your library ID.
  6. -
  7. UI alternative: open Audiobookshelf web → click your audiobook library → the URL /library/<id> contains it.
  8. -
- {{end}} -
+
+
Library ID
+
    +
  1. curl -H 'Authorization: MediaBrowser Token="…"' 'http://<jellyfin>:8096/Library/VirtualFolders' | jq
  2. +
  3. Find "CollectionType": "books" with an audiobook path.
  4. +
  5. Copy its ItemId.
  6. +
- {{end}} - -
- Advanced: per-destination path translation -
- - - Path Audplexus writes to. Leave blank to use the global Audiobooks path. -
-
- - - Path {{.DestTypeLabel}} sees the same files at. Used to translate scan triggers. -
-
- - {{/* - Test result container (must exist before HTMX swap so the - live region announcement fires reliably). role="status" - + aria-live="polite" announces the outcome to SR users - without interrupting them. aria-atomic="true" ensures - the full result is read on update, not just diffs. - */}} -
- -
- Cancel - - + {{else if eq $dt "abs"}} +
+
API key
+
    +
  1. Open Audiobookshelf as an administrator.
  2. +
  3. Settings (gear) → Users → click your admin user.
  4. +
  5. Scroll to API KeysGenerate API Key. Required scope: admin.
  6. +
  7. Token is shown once — copy it immediately.
  8. +
- -
+
+
Library ID
+
    +
  1. curl -H 'Authorization: Bearer …' 'http://<abs>/api/libraries' | jq '.libraries[] | {id,name,mediaType}'
  2. +
  3. Find mediaType = book.
  4. +
  5. Copy its id (UUID).
  6. +
  7. Or: open ABS web → click your audiobook library → URL /library/<id>.
  8. +
+
+ {{end}} +
+ -
+ + {{/* Test result lives just above the sticky bar so its outcome is + visible when the user looks at the actions. */}} +
+ +
+ + + + +
+ +{{end}} {{end}} diff --git a/internal/web/templates/destinations_new.html b/internal/web/templates/destinations_new.html index c4b9487..7004587 100644 --- a/internal/web/templates/destinations_new.html +++ b/internal/web/templates/destinations_new.html @@ -1,30 +1,36 @@ -{{define "content"}} -
-
-

Add Library Destination

-

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

-
- -
-
-

Destination type

-
-
- Choose a destination type -

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

-
- - - - -
-
-
- Cancel - Pick a destination type above. -
-
-
-
-
+{{define "destination_picker_body"}} +{{/* + Type picker — shown inside the modal opened from the wizard, the + Destinations page, etc. There's no full-page entry point any more; + picker submits to /destinations/modal/form which returns the type- + specific form body that swaps into the same modal slot. +*/}} +
+

Add destination

+

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

+
+
+
+ Choose a destination type +

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

+
+ + + + +
+
+
{{end}} diff --git a/internal/web/templates/diagnostics.html b/internal/web/templates/diagnostics.html index fdf5e4f..7b54c3b 100644 --- a/internal/web/templates/diagnostics.html +++ b/internal/web/templates/diagnostics.html @@ -1,23 +1,393 @@ {{define "content"}} -

Diagnostics

-

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

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

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

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

Issue Inbox

-
-

Loading...

+

Issue Inbox

+
+

Loading...

+
+
+ + {{/* ── Tab 2: Logs & Environment ──────────────────────────── */}} +
+ + + + +{{end}} diff --git a/internal/web/templates/library.html b/internal/web/templates/library.html index 4746873..795fa0d 100644 --- a/internal/web/templates/library.html +++ b/internal/web/templates/library.html @@ -1,243 +1,92 @@ -{{define "content"}} -

Library

- -
-
- - -
- - - -
- -
- -
- - - -
- {{template "library_table.html" .}} -
- - - - - - -{{end}} +{{define "content"}} +
+ + + + + {{$active := .ActiveStatus}} + {{$search := .Filter.Search}} + + +
+
+ + +
+ + + +
+ +
+ {{template "library_table.html" .}} +
+ + {{template "book_detail_modal.html" .}} +
+ + +{{end}} diff --git a/internal/web/templates/library_row.html b/internal/web/templates/library_row.html index 2fba00f..841a3a7 100644 --- a/internal/web/templates/library_row.html +++ b/internal/web/templates/library_row.html @@ -1,45 +1,149 @@ {{define "library_row.html"}} - - {{if .Book.CoverURL}}{{end}} + + + {{if .Book.CoverURL}} + + {{else}} + + {{end}} + - {{.Book.Title}} - {{if .Book.Series}}
{{.Book.Series}} #{{.Book.SeriesPosition}}{{end}} +
+ {{.Book.Title}} + {{if eq (printf "%s" .Book.Status) "unavailable"}} +
+ + {{if .Book.UnavailableReason}}{{.Book.UnavailableReason}}{{else}}Unavailable on Audible{{end}} +
+ {{end}} + {{if .Book.Narrator}}narr. {{.Book.Narrator}}{{end}} +
{{.Book.Author}} - {{formatDuration .Book.Duration}} - {{.Book.Status}} + {{if .Book.Series}}{{.Book.Series}} #{{.Book.SeriesPosition}} + {{else}}{{end}} + + {{formatDuration .Book.Duration}} + {{formatDate .Book.PurchaseDate}} + {{.Book.Status}} + + {{if .Presence}} +
+ {{range .Presence}} + {{if eq .Kind "disk"}} + + + + {{else}} + + {{if .LogoURL}}{{else}}{{firstTwo .Type}}{{end}} + + {{end}} + {{end}} +
+ {{else}} + + {{end}} + +
- {{if eq (printf "%s" .Book.Status) "new"}} - - {{else if eq (printf "%s" .Book.Status) "failed"}} - - {{end}} + {{/* Primary actions, always visible. View opens the modal + via the .book-detail-link delegated handler on library.html. */}} + + + View + - {{if .BookAction.ShowConvert}} - - {{end}} + {{if .BookAction.ShowDelete}} + + {{end}} - {{if .BookAction.ShowDelete}} - - {{end}} + {{/* "More" dropdown — Queue/Retry, Re-download, Re-sync + metadata, Convert. Uses native
so no extra + JS is needed; clicking outside closes via a + library-page-level handler. */}} +
+ + + + +
- +
diff --git a/internal/web/templates/library_table.html b/internal/web/templates/library_table.html index 03855ff..d9fca97 100644 --- a/internal/web/templates/library_table.html +++ b/internal/web/templates/library_table.html @@ -1,22 +1,65 @@ -{{define "library_table.html"}} -

{{.Total}} books

- - - - - - - - - - - - - {{range .Books}} - {{template "library_row.html" (dict "Book" . "BookAction" (index $.BookActions .ID))}} - {{else}} - - {{end}} - -
CoverTitleAuthorDurationStatusActions
No books found
-{{end}} +{{define "library_table.html"}} +{{$search := .Filter.Search}} +{{$status := .ActiveStatus}} +{{$sort := .Filter.SortBy}} +{{$dir := .Filter.SortDir}} +
+ + + + + + + + + + + + + + + + {{range .Books}} + {{template "library_row.html" (dict "Book" . "BookAction" (index $.BookActions .ID) "Presence" (index $.BookPresence .ID))}} + {{else}} + + {{end}} + +
Cover + Title {{if eq $sort "title"}}{{if eq $dir "asc"}}↑{{else}}↓{{end}}{{else}}↕{{end}} + + Author {{if eq $sort "author"}}{{if eq $dir "asc"}}↑{{else}}↓{{end}}{{else}}↕{{end}} + SeriesDuration + Purchased {{if eq $sort "purchase_date"}}{{if eq $dir "asc"}}↑{{else}}↓{{end}}{{else}}↕{{end}} + + Status {{if eq $sort "status"}}{{if eq $dir "asc"}}↑{{else}}↓{{end}}{{else}}↕{{end}} + PresenceActions
No books match these filters.
+
+ + {{if eq .Total 0}}0{{else}}{{.PageFrom}}–{{.PageTo}}{{end}} of {{.Total}} + +
+ {{$base := printf "/library?sort=%s&dir=%s" $sort $dir}} + {{if $search}}{{$base = printf "%s&search=%s" $base $search}}{{end}} + {{if $status}}{{$base = printf "%s&status=%s" $base $status}}{{end}} + {{if le .PageNum 1}} + + {{else}} + + {{end}} + {{range .PageNums}} + {{if eq . "…"}} + + {{else}} + {{.}} + {{end}} + {{end}} + {{if ge .PageNum .TotalPages}} + + {{else}} + + {{end}} +
+
+
+{{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index 77e031f..0d8f6c3 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -1,423 +1,478 @@ {{define "content"}} -

Settings

- -
- -
-

Authentication & Integrations

- - {{if .Error}} -
- Error: {{.Error}} +
+