Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
2b6cf71
chore(ui): foundation primitives for DS chrome
Wutname1 May 20, 2026
7d73e17
feat(setup): first-run onboarding wizard
Wutname1 May 20, 2026
d54feac
feat(ui): dashboard refresh
Wutname1 May 20, 2026
08178b3
feat(ui): library refresh with status tabs and pagination
Wutname1 May 20, 2026
489a4c6
feat(destinations): split into its own page + modal-able add flow
Wutname1 May 20, 2026
d02d683
feat(diagnostics): logs & env tab + settings scroll-spy TOC
Wutname1 May 20, 2026
8a33a15
feat(ui): pipeline refresh with completed-window filter
Wutname1 May 20, 2026
30d5015
feat(ui): sidebar icons, badges, and footer
Wutname1 May 20, 2026
205e50b
feat(ui): book detail modal restyle
Wutname1 May 20, 2026
9fac111
feat(ui): destinations delete confirm page refresh
Wutname1 May 20, 2026
df5764e
refactor(destinations): collapse New/Edit/Delete to a single modal flow
Wutname1 May 20, 2026
f2bd804
feat(ui): settings restyle to DS chrome
Wutname1 May 20, 2026
0e0ca8d
fix(destinations): use encoding/json for HX-Trigger payloads
Wutname1 May 20, 2026
6ca8919
refactor(destinations): drop dead non-modal renderAuthPage branches
Wutname1 May 20, 2026
75d7f6e
fix(destinations): toggle uses HTMX card swap, drop dead anchor
Wutname1 May 20, 2026
49f3f01
test(web): coverage for paginator, completed-window filter, first-run…
Wutname1 May 20, 2026
7febaf2
perf(library): one GROUP BY instead of 7 LIMIT-1 counts for tab badges
Wutname1 May 20, 2026
a56969a
perf(setup): cache onboarded flag in atomic.Bool, drop per-request DB…
Wutname1 May 20, 2026
7c1662f
test(database): satisfy CountBooksByStatus on remaining stub DBs
Wutname1 May 20, 2026
edb68ed
More base styles
Wutname1 May 20, 2026
e32abc0
feat: Add manual book metadata re-sync and UI
Wutname1 May 20, 2026
273b76c
Cleanup
Wutname1 May 20, 2026
9c102aa
feat: Extract and reuse book detail modal, add to dashboard
Wutname1 May 20, 2026
6904c8c
feat: Enhance destination health display and test connection feedback
Wutname1 May 20, 2026
604cf7f
feat: Add destination logos for supported media servers
Wutname1 May 21, 2026
99cf09e
feat: Redesign destination create, edit, and delete forms
Wutname1 May 21, 2026
aef8677
fix: Fetch purchase date for Audible library items
Wutname1 May 21, 2026
0de6bdd
feat: Enhance sync status display with last run and next schedule info
Wutname1 May 21, 2026
1f26713
feat: Unify and enhance error message display in web UI
Wutname1 May 21, 2026
0c21038
chore: Add temporary diagnostic log for Audible book dates
Wutname1 May 21, 2026
ccc7e19
fix: Robustly parse Audible purchase and release dates
Wutname1 May 21, 2026
941665a
feat: Enhance diagnostics page with connection tests and log copy
Wutname1 May 21, 2026
8fdd0f7
feat: Enhance Plex guided form feedback and edit experience
Wutname1 May 21, 2026
d4620a5
feat: Improve Plex guided form's section selection validation
Wutname1 May 21, 2026
9b58dfe
feat: Introduce shared error cleaner for consistent UI display
Wutname1 May 21, 2026
51c7cda
feat: Introduce book presence column in library view
Wutname1 May 21, 2026
2dd141a
fix: Promote book destination sync_state to synced on reconcile match
Wutname1 May 21, 2026
ec2dcfc
fix: Broaden ABS reconcile matching to all books and fallback ASIN
Wutname1 May 21, 2026
b59039a
feat: Persist denied-entitlement books as unavailable instead of drop…
Wutname1 May 21, 2026
6d696f6
feat: Surface unavailable books in library UI with dedicated tab
Wutname1 May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions internal/database/books_count_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
5 changes: 5 additions & 0 deletions internal/database/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE books DROP COLUMN unavailable_reason;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE books ADD COLUMN unavailable_reason TEXT NOT NULL DEFAULT '';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE books DROP COLUMN unavailable_reason;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE books ADD COLUMN unavailable_reason TEXT NOT NULL DEFAULT '';
13 changes: 10 additions & 3 deletions internal/database/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand All @@ -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.
Expand Down
43 changes: 32 additions & 11 deletions internal/database/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -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))
}
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
43 changes: 32 additions & 11 deletions internal/database/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -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))
}
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions internal/database/testutil_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions internal/database/testutil_stub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading