From 0c9185645b509f83ecf6cfc07a66fa31136d0713 Mon Sep 17 00:00:00 2001 From: wutname1 Date: Fri, 22 May 2026 06:43:58 +0000 Subject: [PATCH 01/20] fix: Correct column order in live-appended completed/failed download rows The SSE handler in base.html inserted dynamically-appended rows with ASIN in the first column and Title in the second, but the completed and failed table headers (in downloads.html and dashboard_downloads.html) order columns as Title, ASIN. Rows added on download completion appeared with swapped values until a full page refresh re-rendered them server-side. Reorder the inserted cells to match the header order, and run the values through escHTML to match the rest of the SSE-driven UI. --- internal/web/templates/base.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index edbc76b..ab2a26a 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -822,7 +822,7 @@

Add destination

if (empty) empty.remove(); var tr = document.createElement('tr'); var label = d.title || d.asin; - tr.innerHTML = '' + d.asin + '' + label + 'just now'; + tr.innerHTML = '' + escHTML(label) + '' + escHTML(d.asin) + 'just now'; cb.insertBefore(tr, cb.firstChild); adj('complete-count', 1); } @@ -837,7 +837,7 @@

Add destination

var tr = document.createElement('tr'); var label = d.title || d.asin; var errMsg = d.error || 'unknown error'; - tr.innerHTML = '' + d.asin + '' + errMsg + '' + label + ''; + tr.innerHTML = '' + escHTML(label) + '' + escHTML(d.asin) + '' + escHTML(errMsg) + ''; fb.insertBefore(tr, fb.firstChild); adj('failed-count', 1); } From 078f8d82d2b2c791e6cd6639790ddfc8ba14f407 Mon Sep 17 00:00:00 2001 From: wutname1 Date: Fri, 22 May 2026 07:06:34 +0000 Subject: [PATCH 02/20] feat: Search library by series and ASIN as well as title/author The library search input previously matched only the title and author columns, so a query like "Dungeon Crawler" returned only the books where the series name happened to also appear in the title. Extend the SQLite and PostgreSQL WHERE-clause builders to also LIKE match the series and asin columns. Series is stored as a plain string column on books so no schema changes are needed. --- internal/database/postgres.go | 6 +++--- internal/database/sqlite.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/database/postgres.go b/internal/database/postgres.go index fa5df93..509cca4 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -605,10 +605,10 @@ func buildBookWherePostgres(filter BookFilter) (string, []interface{}) { paramIdx++ } if filter.Search != "" { - clauses = append(clauses, fmt.Sprintf("(title ILIKE $%d OR author ILIKE $%d)", paramIdx, paramIdx+1)) + clauses = append(clauses, fmt.Sprintf("(title ILIKE $%d OR author ILIKE $%d OR series ILIKE $%d OR asin ILIKE $%d)", paramIdx, paramIdx+1, paramIdx+2, paramIdx+3)) search := "%" + filter.Search + "%" - args = append(args, search, search) - paramIdx += 2 + args = append(args, search, search, search, search) + paramIdx += 4 } if len(clauses) == 0 { diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index 889262d..b21caf4 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -646,9 +646,9 @@ func buildBookWhere(filter BookFilter) (string, []interface{}) { args = append(args, *filter.Status) } if filter.Search != "" { - clauses = append(clauses, "(title LIKE ? OR author LIKE ?)") + clauses = append(clauses, "(title LIKE ? OR author LIKE ? OR series LIKE ? OR asin LIKE ?)") search := "%" + filter.Search + "%" - args = append(args, search, search) + args = append(args, search, search, search, search) } if len(clauses) == 0 { From ca3fad33c451257cc8d0416ff676e1af04ea3204 Mon Sep 17 00:00:00 2001 From: wutname1 Date: Fri, 22 May 2026 07:06:42 +0000 Subject: [PATCH 03/20] fix: Use library total as destination coverage denominator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard and destinations page computed each destination's coverage % as item_count / books_with_status_complete, capped at 100. When a destination held more items than the DB tracked as complete — common when destination items linger across status transitions or when reconciliation hasn't refreshed the book records — the cap silently turned every card into "100%", hiding real coverage gaps (e.g. ABS at 636 items, Plex at 629, against ~650 in the library). Switch the denominator to the total book count so the metric reads as "fraction of the library present in this destination". The totalBooks count is far more stable than the complete count across sync transitions, so the cap rarely kicks in for legitimate state. --- internal/web/destinations_handlers.go | 5 ++--- internal/web/server.go | 27 +++++++++++++++------------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/internal/web/destinations_handlers.go b/internal/web/destinations_handlers.go index 825a3df..cca12d6 100644 --- a/internal/web/destinations_handlers.go +++ b/internal/web/destinations_handlers.go @@ -122,10 +122,9 @@ func destinationConfigured(d *database.LibraryDestination) bool { // (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}) + _, libraryTotal, _ := s.db.ListBooks(ctx, database.BookFilter{Limit: 1}) - dests := s.destinationSummaries(ctx, completeBooks) + dests := s.destinationSummaries(ctx, libraryTotal) healthy := 0 for _, d := range dests { diff --git a/internal/web/server.go b/internal/web/server.go index ed6f7eb..526af67 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -702,7 +702,11 @@ func (s *Server) getDashboardSummaryData(ctx context.Context) gin.H { // Per-destination summary cards. Replaces the legacy // single-active-backend stat cards. Empty slice means "no // destinations configured yet" — template shows a CTA. - "DestinationSummaries": s.destinationSummaries(ctx, completeBooks), + // Coverage % uses totalBooks (Audible library size) as the + // denominator so it represents "fraction of the library present + // in this destination" — stable across status transitions and + // matches the user's mental model. + "DestinationSummaries": s.destinationSummaries(ctx, totalBooks), } } @@ -750,7 +754,7 @@ type destinationSummaryView struct { // // Per a11y-lead: badges are role="status" so SR users hear destination // state changes (Healthy → Failed) without per-tick count chatter. -func (s *Server) destinationSummaries(ctx context.Context, completeBooks int) []destinationSummaryView { +func (s *Server) destinationSummaries(ctx context.Context, libraryTotal int) []destinationSummaryView { rows, err := s.db.ListLibraryDestinations(ctx) if err != nil { webLog.Warn().Err(err).Msg("dashboard: list destinations failed") @@ -763,7 +767,7 @@ func (s *Server) destinationSummaries(ctx context.Context, completeBooks int) [] out := make([]destinationSummaryView, 0, len(rows)) for _, r := range rows { row := r - out = append(out, s.buildDestinationSummary(ctx, &row, completeBooks)) + out = append(out, s.buildDestinationSummary(ctx, &row, libraryTotal)) } return out } @@ -778,12 +782,11 @@ func (s *Server) singleDestinationSummary(ctx context.Context, id string) *desti 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) + // Coverage % uses the total library size as the denominator so the + // meter renders the same as it does on the full grid. Cheap + // LIMIT-1 count. + _, libraryTotal, _ := s.db.ListBooks(ctx, database.BookFilter{Limit: 1}) + v := s.buildDestinationSummary(ctx, row, libraryTotal) return &v } @@ -791,7 +794,7 @@ func (s *Server) singleDestinationSummary(ctx context.Context, id string) *desti // 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 { +func (s *Server) buildDestinationSummary(ctx context.Context, row *database.LibraryDestination, libraryTotal int) destinationSummaryView { hasCred := row.APIKey != "" || row.PlexToken != "" v := destinationSummaryView{ ID: row.ID, @@ -832,8 +835,8 @@ func (s *Server) buildDestinationSummary(ctx context.Context, row *database.Libr } v.ItemCount = count v.ItemCountSet = true - if completeBooks > 0 { - cov := int(math.Round((float64(count) / float64(completeBooks)) * 100)) + if libraryTotal > 0 { + cov := int(math.Round((float64(count) / float64(libraryTotal)) * 100)) if cov < 0 { cov = 0 } From 0ffa189223277e4c936da7ea7e921f1b80cf83fd Mon Sep 17 00:00:00 2001 From: wutname1 Date: Fri, 22 May 2026 07:06:53 +0000 Subject: [PATCH 04/20] feat: Add Metadata Repair sync phase to re-tag books missing ASIN Books downloaded under the historical Basic tag profile (or restored from older runs) ship without an ASIN atom embedded in the m4b, which makes Audiobookshelf's library-matcher unable to identify them and forces a manual re-download to fix. Introduce a new Metadata Repair phase that runs as part of the full sync pipeline (and can be triggered standalone via the existing /api/sync/phase/:phase endpoint): - ffprobes every complete book's file and compares the embedded ASIN atom to the DB record - when missing or stale, re-runs EmbedMetadata with the AudiobookRich profile regardless of the user's tag_profile setting, since the whole point of this job is to make ASIN matching work - enriches via Audnexus when available so genre/copyright/etc. match what the original download would have written; falls back to the DB-only field set when audnexus is unreachable - writes the new file to a sibling temp path and atomically renames over the original, so a crashed ffmpeg never corrupts the source file ffmpeg uses -c copy so the actual operation is a fast remux with no re-encode. Phase is skipped (not failed) when ffmpeg isn't available on the host. --- cmd/server/main.go | 5 + internal/audio/probe_tags.go | 37 +++++++ internal/library/metadata_repair.go | 164 ++++++++++++++++++++++++++++ internal/library/sync.go | 69 +++++++++++- 4 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 internal/audio/probe_tags.go create mode 100644 internal/library/metadata_repair.go diff --git a/cmd/server/main.go b/cmd/server/main.go index b84935e..1a715c5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -103,6 +103,11 @@ func main() { syncSvc := library.NewSyncService(db, audibleClient, cfg.Paths.Audiobooks) anClient := audnexus.NewClientWithRegion(region) log.Info().Str("region", region).Msg("audnexus client initialized with region") + // Wire optional dependencies for the Metadata Repair sync phase. + // Both are tolerated as nil at the SyncService boundary so tests + // and lightweight uses don't have to construct them. + syncSvc.SetFFmpeg(ffmpeg) + syncSvc.SetAudnexusClient(anClient) org := organizer.NewPlexOrganizer(db, ffmpeg, cfg.Paths.Audiobooks, cfg.Output.EmbedCover, cfg.Output.ChapterFile, cfg.Output.PlexMatchFile) // First-boot synthesis: if library_destinations is empty AND legacy diff --git a/internal/audio/probe_tags.go b/internal/audio/probe_tags.go new file mode 100644 index 0000000..7362625 --- /dev/null +++ b/internal/audio/probe_tags.go @@ -0,0 +1,37 @@ +package audio + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" +) + +// ProbeTags returns the format-level metadata tags from an audio file. +// Reads via ffprobe; the freeform iTunes atoms emitted by the +// AudiobookRich profile (asin, series, series-part) come back as plain +// string keys. +func (f *FFmpeg) ProbeTags(ctx context.Context, inputPath string) (map[string]string, error) { + cmd := exec.CommandContext(ctx, f.probePath, + "-v", "quiet", + "-show_entries", "format_tags", + "-of", "json", + inputPath, + ) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ffprobe tags: %w", err) + } + var resp struct { + Format struct { + Tags map[string]string `json:"tags"` + } `json:"format"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return nil, fmt.Errorf("decode ffprobe tags: %w", err) + } + if resp.Format.Tags == nil { + return map[string]string{}, nil + } + return resp.Format.Tags, nil +} diff --git a/internal/library/metadata_repair.go b/internal/library/metadata_repair.go new file mode 100644 index 0000000..8afcfe2 --- /dev/null +++ b/internal/library/metadata_repair.go @@ -0,0 +1,164 @@ +package library + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mstrhakr/audplexus/internal/audio" + "github.com/mstrhakr/audplexus/internal/audnexus" + "github.com/mstrhakr/audplexus/internal/database" +) + +// RepairBookMetadata scans every complete book and re-embeds metadata +// when the file's ASIN tag is missing or doesn't match the DB record. +// Forces TagProfileAudiobookRich so the asin/series/series-part iTunes +// atoms are written regardless of the user's tag profile setting — +// this phase exists specifically to make books matchable by +// ASIN-aware servers like Audiobookshelf. +// +// When an audnexus client is supplied, each repaired book is enriched +// before re-tagging so genre/copyright/description match the original +// download. With a nil audnexus client the repair falls back to the +// DB record alone — sufficient for the ASIN-match use case. +// +// Returns the number of files re-tagged. +func RepairBookMetadata(ctx context.Context, db database.Database, ff *audio.FFmpeg, an *audnexus.Client, onProgress func(processed, total int)) (int, error) { + if ff == nil { + return 0, fmt.Errorf("ffmpeg not available") + } + completeStatus := database.BookStatusComplete + books, _, err := db.ListBooks(ctx, database.BookFilter{Status: &completeStatus}) + if err != nil { + return 0, fmt.Errorf("list complete books: %w", err) + } + total := len(books) + repaired := 0 + skipped := 0 + failed := 0 + for i := range books { + select { + case <-ctx.Done(): + return repaired, ctx.Err() + default: + } + if onProgress != nil { + onProgress(i, total) + } + book := &books[i] + if book.FilePath == "" { + skipped++ + continue + } + if _, statErr := os.Stat(book.FilePath); statErr != nil { + skipped++ + continue + } + tags, err := ff.ProbeTags(ctx, book.FilePath) + if err != nil { + syncLog.Warn().Err(err).Str("path", book.FilePath).Msg("metadata_repair: probe failed") + failed++ + continue + } + if !metadataNeedsRepair(book, tags) { + continue + } + if err := retagBookFile(ctx, ff, an, book); err != nil { + syncLog.Warn().Err(err).Str("path", book.FilePath).Str("asin", book.ASIN).Msg("metadata_repair: retag failed") + failed++ + continue + } + repaired++ + syncLog.Info().Str("asin", book.ASIN).Str("title", book.Title).Msg("metadata_repair: re-tagged file") + } + if onProgress != nil { + onProgress(total, total) + } + syncLog.Info(). + Int("books_complete", total). + Int("repaired", repaired). + Int("skipped_missing_file", skipped). + Int("failed", failed). + Msg("metadata_repair: pass complete") + return repaired, nil +} + +// metadataNeedsRepair returns true when the file's ASIN tag doesn't +// match the DB record. Triggers a rich re-tag rather than touching +// only the ASIN atom, so series/series-part land at the same time. +func metadataNeedsRepair(book *database.Book, tags map[string]string) bool { + return strings.TrimSpace(tags["asin"]) != book.ASIN +} + +// retagBookFile re-runs EmbedMetadata over the existing file with the +// AudiobookRich profile. Writes to a sibling temp file and atomically +// replaces the original on success so a crashed ffmpeg leaves the +// original file intact. +func retagBookFile(ctx context.Context, ff *audio.FFmpeg, an *audnexus.Client, book *database.Book) error { + meta, err := buildRepairMetadata(ctx, an, book) + if err != nil { + return err + } + + dir := filepath.Dir(book.FilePath) + ext := filepath.Ext(book.FilePath) + tmp, err := os.CreateTemp(dir, ".audplexus-retag-*"+ext) + if err != nil { + return err + } + tmpPath := tmp.Name() + tmp.Close() + cleanup := true + defer func() { + if cleanup { + os.Remove(tmpPath) + } + }() + + if err := ff.EmbedMetadata(book.FilePath, tmpPath, meta); err != nil { + return err + } + if err := os.Rename(tmpPath, book.FilePath); err != nil { + return err + } + cleanup = false + return nil +} + +// buildRepairMetadata constructs the AudiobookRich metadata set for a +// book. Uses the audnexus enrichment path when available so the +// repaired file ends up with the same rich tag set the initial +// download would have produced; falls back to the DB record alone +// when audnexus is unavailable or the network lookup fails. +func buildRepairMetadata(ctx context.Context, an *audnexus.Client, book *database.Book) (audio.Metadata, error) { + if an != nil { + enriched, err := an.EnrichMetadata(ctx, book) + if err == nil && enriched != nil { + meta := enriched.ToAudioMetadata() + meta.Profile = audio.TagProfileAudiobookRich + return meta, nil + } + } + year := "" + if !book.ReleaseDate.IsZero() { + year = book.ReleaseDate.Format("2006") + } + return audio.Metadata{ + Title: book.Title, + Author: book.Author, + Narrator: book.Narrator, + Publisher: book.Publisher, + Language: book.Language, + Album: book.Title, + AlbumArtist: book.Author, + Year: year, + Comment: book.Description, + Track: book.SeriesPosition, + Series: book.Series, + SeriesPart: book.SeriesPosition, + ASIN: book.ASIN, + Profile: audio.TagProfileAudiobookRich, + }, nil +} diff --git a/internal/library/sync.go b/internal/library/sync.go index 5152fdb..c349e8c 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "github.com/mstrhakr/audplexus/internal/audio" + "github.com/mstrhakr/audplexus/internal/audnexus" "github.com/mstrhakr/audplexus/internal/database" "github.com/mstrhakr/audplexus/internal/errs" "github.com/mstrhakr/audplexus/internal/logging" @@ -41,11 +43,12 @@ const ( type SyncPhase string const ( - PhaseAudibleSync SyncPhase = "audible_sync" - PhaseFileScan SyncPhase = "file_scan" - PhasePlexSync SyncPhase = "plex_sync" - PhaseCollectionSync SyncPhase = "collection_sync" - PhaseDownloadQueue SyncPhase = "download_queue" + PhaseAudibleSync SyncPhase = "audible_sync" + PhaseFileScan SyncPhase = "file_scan" + PhaseMetadataRepair SyncPhase = "metadata_repair" + PhasePlexSync SyncPhase = "plex_sync" + PhaseCollectionSync SyncPhase = "collection_sync" + PhaseDownloadQueue SyncPhase = "download_queue" ) // DefaultFullPhases returns the standard set of sync phases in idle state. @@ -57,6 +60,7 @@ func DefaultFullPhases() []PhaseStatus { return []PhaseStatus{ {Name: PhaseAudibleSync, Label: "Audible Library", Status: "idle"}, {Name: PhaseFileScan, Label: "File System Scan", Status: "idle"}, + {Name: PhaseMetadataRepair, Label: "Metadata Repair", Status: "idle"}, {Name: PhasePlexSync, Label: "Library Scan", Status: "idle"}, {Name: PhaseCollectionSync, Label: "Collection Sync", Status: "idle"}, } @@ -175,6 +179,13 @@ type SyncService struct { libraryDir string + // ffmpeg and audnexus are optional dependencies used by the + // Metadata Repair phase. Set via SetFFmpeg / SetAudnexusClient + // after construction so callers that don't run repair (tests, + // embedded use) don't have to build them. + ffmpeg *audio.FFmpeg + audnexus *audnexus.Client + // Plex callback (set by web layer after construction) plexSyncFunc PlexSyncFunc plexReconcileFunc PlexReconcileFunc @@ -263,6 +274,19 @@ func (s *SyncService) subPhaseFnFor(phase SyncPhase) SubPhaseFn { } } +// SetFFmpeg wires the ffmpeg wrapper used by the Metadata Repair phase. +// Optional — when nil, the repair phase fails with a clear message. +func (s *SyncService) SetFFmpeg(ff *audio.FFmpeg) { + s.ffmpeg = ff +} + +// SetAudnexusClient wires the audnexus client used by Metadata Repair +// to enrich book metadata before re-tagging. Optional — when nil, +// repair falls back to DB-only fields. +func (s *SyncService) SetAudnexusClient(c *audnexus.Client) { + s.audnexus = c +} + // SetPlexSyncCallback registers the combined Plex sync function. func (s *SyncService) SetPlexSyncCallback(fn PlexSyncFunc) { s.plexSyncFunc = fn @@ -468,6 +492,16 @@ func (s *SyncService) RunPhase(ctx context.Context, phase SyncPhase) error { s.setPhase(phase, "complete", fmt.Sprintf("%d files reconciled", reconciled)) } + case PhaseMetadataRepair: + repaired, err := RepairBookMetadata(ctx, s.db, s.ffmpeg, s.audnexus, func(processed, total int) { + s.updatePhaseProgress(PhaseMetadataRepair, processed, total, false) + }) + if err != nil { + phaseErr = err + } else { + s.setPhase(phase, "complete", fmt.Sprintf("%d files re-tagged", repaired)) + } + case PhasePlexSync: if s.plexSyncFunc == nil { phaseErr = fmt.Errorf("media server not configured") @@ -619,6 +653,29 @@ func (s *SyncService) runSync(ctx context.Context, mode SyncMode) (int, error) { } } + // --- Phase 2b: Metadata Repair (full sync only) --- + // Probes every complete book and re-embeds AudiobookRich tags when + // the file's ASIN atom is missing or stale, so audiobook servers + // (Audiobookshelf in particular) can match by ID. Non-fatal: + // failures degrade to a "partial" sync but don't block the rest. + if mode == SyncModeFull { + if s.ffmpeg == nil { + s.setPhase(PhaseMetadataRepair, "skipped", "ffmpeg unavailable") + } else { + s.setPhase(PhaseMetadataRepair, "running", "Checking metadata on existing files...") + repaired, repairErr := RepairBookMetadata(ctx, s.db, s.ffmpeg, s.audnexus, func(processed, total int) { + s.updatePhaseProgress(PhaseMetadataRepair, processed, total, false) + }) + if repairErr != nil { + s.setPhase(PhaseMetadataRepair, "failed", repairErr.Error()) + syncLog.Warn().Err(repairErr).Msg("metadata repair phase failed") + } else { + s.setPhase(PhaseMetadataRepair, "complete", fmt.Sprintf("%d files re-tagged", repaired)) + syncLog.Info().Int("repaired", repaired).Msg("metadata repair complete") + } + } + } + // --- Phase 3: Library Scan (full sync only). Phase identifier kept as // PhasePlexSync for URL/JS back-compat, but user-visible messages are // backend-agnostic — works for Plex, Emby, Jellyfin, ABS. --- @@ -710,6 +767,7 @@ func (s *SyncService) buildPhases(mode SyncMode, prev []PhaseStatus) []PhaseStat return []PhaseStatus{ defaultPhase(PhaseAudibleSync, "Audible Library"), defaultPhase(PhaseFileScan, "File System Scan"), + defaultPhase(PhaseMetadataRepair, "Metadata Repair"), defaultPhase(PhasePlexSync, "Library Scan"), defaultPhase(PhaseCollectionSync, "Collection Sync"), } @@ -721,6 +779,7 @@ func (s *SyncService) buildPhases(mode SyncMode, prev []PhaseStatus) []PhaseStat label string }{ {name: PhaseFileScan, label: "File System Scan"}, + {name: PhaseMetadataRepair, label: "Metadata Repair"}, {name: PhasePlexSync, label: "Library Scan"}, {name: PhaseCollectionSync, label: "Collection Sync"}, } { From 4be08d11de552e1d276d76ee2dd9b7ecdae32a68 Mon Sep 17 00:00:00 2001 From: wutname1 Date: Fri, 22 May 2026 07:14:23 +0000 Subject: [PATCH 05/20] feat: Add library presence filters (disk + per-destination) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library page had no way to ask "which of my books aren't on Plex/ABS yet?" — presence was visible only as per-row chips, with no way to slice the table by it. Status tabs were the only filter dimension, and presence didn't fit there anyway since a book can be Complete and missing from a destination at the same time. Add three orthogonal presence dimensions to BookFilter: OnDisk *bool — file_path != '' PresentInDestinations []id — EXISTS row with sync_state in synced|syncing MissingFromDestinations []id — NOT EXISTS the above Wire them through: - BookFilter struct and both WHERE-clause builders (sqlite, postgres), using EXISTS subqueries that hit the (destination_id, sync_state) index on book_library_destinations. - handleLibrary URL params: ?on_disk=yes|no plus a dynamic ?presence_=in|out for each enabled destination. - Filter bar dropdowns: one for disk state, one per enabled destination, generated from the live destinations list so the UI tracks whatever the user has configured. - Status tabs stay status-only — presence is now a filter, not a tab, so it composes with status (e.g. "Complete AND missing from ABS") instead of being mutually exclusive. Also drop the surface-2 background from .presence-chip so the row chips inherit from the table cell instead of painting their own fill. --- .../database/books_presence_filter_test.go | 112 ++++++++++++++++++ internal/database/interface.go | 16 +++ internal/database/postgres.go | 17 +++ internal/database/sqlite.go | 18 +++ internal/web/server.go | 89 +++++++++++--- internal/web/static/style.css | 1 - internal/web/templates/library.html | 33 +++++- 7 files changed, 267 insertions(+), 19 deletions(-) create mode 100644 internal/database/books_presence_filter_test.go diff --git a/internal/database/books_presence_filter_test.go b/internal/database/books_presence_filter_test.go new file mode 100644 index 0000000..5338ca2 --- /dev/null +++ b/internal/database/books_presence_filter_test.go @@ -0,0 +1,112 @@ +package database + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +// TestListBooksPresenceFilters covers the presence-filter slices on +// BookFilter (OnDisk + PresentIn/MissingFrom destinations). Verifies +// the WHERE-clause builder constructs subqueries that intersect with +// book_library_destinations correctly. +func TestListBooksPresenceFilters(t *testing.T) { + db := newTestSQLite(t) + ctx := context.Background() + + // Two destinations: Plex + ABS. + plex := &LibraryDestination{ + ID: uuid.NewString(), DisplayName: "Plex", + Type: LibraryDestinationTypePlex, Enabled: true, + URL: "http://plex.lan", PlexToken: "tok", PlexSectionID: "1", + AudiobookPath: "/m", + } + abs := &LibraryDestination{ + ID: uuid.NewString(), DisplayName: "ABS", + Type: LibraryDestinationTypeABS, Enabled: true, + URL: "http://abs.lan", APIKey: "k", LibraryID: "lib", + AudiobookPath: "/m", + } + if err := db.CreateLibraryDestination(ctx, plex); err != nil { + t.Fatalf("create plex: %v", err) + } + if err := db.CreateLibraryDestination(ctx, abs); err != nil { + t.Fatalf("create abs: %v", err) + } + + // Three books: + // A: on disk, synced to plex, synced to abs + // B: on disk, synced to plex only + // C: not on disk, no destination sync rows + books := []*Book{ + {ASIN: "B0001", Title: "A", Author: "x", Status: BookStatusComplete, FilePath: "/m/a.m4b"}, + {ASIN: "B0002", Title: "B", Author: "x", Status: BookStatusComplete, FilePath: "/m/b.m4b"}, + {ASIN: "B0003", Title: "C", Author: "x", Status: BookStatusNew, FilePath: ""}, + } + for _, b := range books { + if err := db.UpsertBook(ctx, b); err != nil { + t.Fatalf("upsert %s: %v", b.ASIN, err) + } + } + bookA, _ := db.GetBookByASIN(ctx, "B0001") + bookB, _ := db.GetBookByASIN(ctx, "B0002") + for _, bd := range []*BookDestination{ + {BookID: bookA.ID, DestinationID: plex.ID, SyncState: BookDestSyncSynced}, + {BookID: bookA.ID, DestinationID: abs.ID, SyncState: BookDestSyncSynced}, + {BookID: bookB.ID, DestinationID: plex.ID, SyncState: BookDestSyncSynced}, + } { + if err := db.UpsertBookDestination(ctx, bd); err != nil { + t.Fatalf("upsert book-dest: %v", err) + } + } + + tests := []struct { + name string + filter BookFilter + wantASIN []string + }{ + {"on disk only", BookFilter{OnDisk: ptrBool(true)}, []string{"B0001", "B0002"}}, + {"not on disk", BookFilter{OnDisk: ptrBool(false)}, []string{"B0003"}}, + {"present in plex", BookFilter{PresentInDestinations: []string{plex.ID}}, []string{"B0001", "B0002"}}, + {"present in abs", BookFilter{PresentInDestinations: []string{abs.ID}}, []string{"B0001"}}, + {"missing from abs", BookFilter{MissingFromDestinations: []string{abs.ID}}, []string{"B0002", "B0003"}}, + {"in plex AND missing from abs", BookFilter{PresentInDestinations: []string{plex.ID}, MissingFromDestinations: []string{abs.ID}}, []string{"B0002"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, _, err := db.ListBooks(ctx, tc.filter) + if err != nil { + t.Fatalf("ListBooks: %v", err) + } + gotASIN := make([]string, 0, len(got)) + for _, b := range got { + gotASIN = append(gotASIN, b.ASIN) + } + if !sameStringSet(gotASIN, tc.wantASIN) { + t.Fatalf("got %v, want %v", gotASIN, tc.wantASIN) + } + }) + } +} + +func ptrBool(b bool) *bool { return &b } + +func sameStringSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + m := map[string]int{} + for _, s := range a { + m[s]++ + } + for _, s := range b { + m[s]-- + } + for _, n := range m { + if n != 0 { + return false + } + } + return true +} diff --git a/internal/database/interface.go b/internal/database/interface.go index 661e7f2..cf6e2fc 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -74,5 +74,21 @@ type BookFilter struct { SortDir string // "asc", "desc" Limit int Offset int + + // Presence filters. Each is independent and stacks with the others; + // an empty/nil value means "no filter on this dimension". + // + // OnDisk filters on whether books.file_path is non-empty. nil means + // either; true keeps only on-disk rows; false keeps only rows whose + // file is missing locally. + // + // PresentInDestinations keeps books that ARE currently synced to + // every listed destination (sync_state in synced|syncing). + // MissingFromDestinations keeps books NOT currently synced to any + // of the listed destinations. The two slices stack — a book is + // included only when it satisfies every per-destination predicate. + OnDisk *bool + PresentInDestinations []string + MissingFromDestinations []string } diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 509cca4..0b15c01 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -610,6 +610,23 @@ func buildBookWherePostgres(filter BookFilter) (string, []interface{}) { args = append(args, search, search, search, search) paramIdx += 4 } + if filter.OnDisk != nil { + if *filter.OnDisk { + clauses = append(clauses, "file_path != ''") + } else { + clauses = append(clauses, "(file_path IS NULL OR file_path = '')") + } + } + for _, destID := range filter.PresentInDestinations { + clauses = append(clauses, fmt.Sprintf("EXISTS (SELECT 1 FROM book_library_destinations bld WHERE bld.book_id = books.id AND bld.destination_id = $%d AND bld.sync_state IN ('synced','syncing'))", paramIdx)) + args = append(args, destID) + paramIdx++ + } + for _, destID := range filter.MissingFromDestinations { + clauses = append(clauses, fmt.Sprintf("NOT EXISTS (SELECT 1 FROM book_library_destinations bld WHERE bld.book_id = books.id AND bld.destination_id = $%d AND bld.sync_state IN ('synced','syncing'))", paramIdx)) + args = append(args, destID) + paramIdx++ + } if len(clauses) == 0 { return "", nil diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index b21caf4..5c8e268 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -650,6 +650,24 @@ func buildBookWhere(filter BookFilter) (string, []interface{}) { search := "%" + filter.Search + "%" args = append(args, search, search, search, search) } + if filter.OnDisk != nil { + if *filter.OnDisk { + clauses = append(clauses, "file_path != ''") + } else { + clauses = append(clauses, "(file_path IS NULL OR file_path = '')") + } + } + // "Present in destination" — there is a synced/syncing row for + // this (book, destination). The index on (destination_id, sync_state) + // keeps the subquery cheap. + for _, destID := range filter.PresentInDestinations { + clauses = append(clauses, "EXISTS (SELECT 1 FROM book_library_destinations bld WHERE bld.book_id = books.id AND bld.destination_id = ? AND bld.sync_state IN ('synced','syncing'))") + args = append(args, destID) + } + for _, destID := range filter.MissingFromDestinations { + clauses = append(clauses, "NOT EXISTS (SELECT 1 FROM book_library_destinations bld WHERE bld.book_id = books.id AND bld.destination_id = ? AND bld.sync_state IN ('synced','syncing'))") + args = append(args, destID) + } if len(clauses) == 0 { return "", nil diff --git a/internal/web/server.go b/internal/web/server.go index 526af67..4670122 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1004,6 +1004,34 @@ func (s *Server) handleLibrary(c *gin.Context) { filter.Status = &status } + // Presence filters. on_disk is a tri-state: missing/empty = any, + // "yes" = on disk, "no" = missing locally. Per-destination filters + // arrive as presence_=in|out — iterating the raw query + // keeps the param list extensible as the user adds destinations. + switch c.Query("on_disk") { + case "yes": + v := true + filter.OnDisk = &v + case "no": + v := false + filter.OnDisk = &v + } + destFilterState := map[string]string{} + for key, vals := range c.Request.URL.Query() { + if !strings.HasPrefix(key, "presence_") || len(vals) == 0 { + continue + } + destID := strings.TrimPrefix(key, "presence_") + switch vals[0] { + case "in": + filter.PresentInDestinations = append(filter.PresentInDestinations, destID) + destFilterState[destID] = "in" + case "out": + filter.MissingFromDestinations = append(filter.MissingFromDestinations, destID) + destFilterState[destID] = "out" + } + } + books, total, err := s.db.ListBooks(ctx, filter) if err != nil { webLog.Error().Err(err).Msg("failed to list books") @@ -1025,20 +1053,22 @@ func (s *Server) handleLibrary(c *gin.Context) { } data := gin.H{ - "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), + "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, + "PresenceFilters": s.libraryPresenceFilterOpts(ctx, destFilterState), + "OnDiskFilter": c.Query("on_disk"), + "PageNum": pageNum, + "TotalPages": totalPages, + "PageSize": pageSize, + "PageFrom": from, + "PageTo": to, + "PageNums": paginationNumbers(pageNum, totalPages), } // For HTMX partial requests, render only the table body @@ -1049,6 +1079,37 @@ func (s *Server) handleLibrary(c *gin.Context) { c.HTML(http.StatusOK, "library.html", s.withSidebar(ctx, data)) } +// libraryPresenceFilterOption is one row in the per-destination presence +// dropdown — rendered as a + + {{range .PresenceFilters}} + + {{end}} From 5d4220e838cac4bb73e3e90dd1a8d7c41d2667d1 Mon Sep 17 00:00:00 2001 From: wutname1 Date: Fri, 22 May 2026 07:58:49 +0000 Subject: [PATCH 06/20] feat: Add File info block and collapsible description to book detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions to the book detail panel: 1. File info block (only when the book is on disk) New ProbeFile method on the FFmpeg wrapper that runs ffprobe with -show_format -show_streams -show_chapters in a single invocation and returns a FileProbe carrying container/codec technicals (format, codec, bitrate, sample rate, channels, channel layout, duration), chapter count, embedded-artwork flag, and the raw format-level tag map. handleBookDetail composes a bookFileInfoView from that probe plus os.Stat (size, modified time), the filesystem path, and the account's activation bytes (fetched from the audible client when authenticated — surfaced here because they're what was used to decrypt this file, useful for re-decryption diagnostics). The template renders it as a meta-grid mirroring the existing metadata stack, with the raw container tags tucked inside a
summary so encoder/major_brand/compatible_brands/asin etc. stay one click away. Container-identity tags bubble to the top of that list; everything else sorts alphabetically. Every field is independently optional — a degraded probe still renders size + modified date + path so the block degrades usefully instead of disappearing. 2. Collapsible description Long Audible descriptions clamp to 9.5rem with a fade-out gradient over the bottom 3rem; a "Show more" button toggles to "Show less". The script hides the toggle when the clamped content already fits (scrollHeight <= clientHeight) so short descriptions don't get a dangling affordance. Wires ffmpeg through web.NewServer so handlers can call ProbeFile directly — previously ffmpeg lived only inside DownloadManager and SyncService. --- cmd/server/main.go | 2 +- internal/audio/probe_tags.go | 109 +++++++++ internal/web/server.go | 224 ++++++++++++++++++ internal/web/static/style.css | 69 ++++++ internal/web/templates/book_detail_panel.html | 68 +++++- 5 files changed, 468 insertions(+), 4 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 1a715c5..94c69b3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -175,7 +175,7 @@ func main() { log.Info().Bool("enabled", cfg.Sync.Enabled).Str("schedule", cfg.Sync.Schedule).Msg("scheduler started") // Start web server - webServer := web.NewServer(db, syncSvc, dlMgr, anClient, org, audibleClient, credPath, cfg.Server.Port, cfg.Paths.Audiobooks, cfg.Paths.Downloads, cfg.Paths.Config) + webServer := web.NewServer(db, syncSvc, dlMgr, anClient, org, audibleClient, ffmpeg, credPath, cfg.Server.Port, cfg.Paths.Audiobooks, cfg.Paths.Downloads, cfg.Paths.Config) go func() { if err := webServer.Start(); err != nil { log.Fatal().Err(err).Msg("web server failed") diff --git a/internal/audio/probe_tags.go b/internal/audio/probe_tags.go index 7362625..720ad7f 100644 --- a/internal/audio/probe_tags.go +++ b/internal/audio/probe_tags.go @@ -35,3 +35,112 @@ func (f *FFmpeg) ProbeTags(ctx context.Context, inputPath string) (map[string]st } return resp.Format.Tags, nil } + +// FileProbe is the rich-probe snapshot used by the Book Details "File +// info" block: format, codec, container/stream technicals, chapter +// count, embedded artwork flag, and the raw tag map. Numeric fields +// stay as their ffprobe-reported strings so the UI can render "—" +// for absent values without a separate "set" flag per field. +type FileProbe struct { + FormatName string `json:"format_name"` + FormatLongName string `json:"format_long_name"` + DurationSec float64 `json:"duration_sec"` + BitRate string `json:"bit_rate"` + Size int64 `json:"size"` + NumStreams int `json:"num_streams"` + AudioCodec string `json:"audio_codec"` + AudioCodecLong string `json:"audio_codec_long"` + AudioBitRate string `json:"audio_bit_rate"` + SampleRate string `json:"sample_rate"` + Channels int `json:"channels"` + ChannelLayout string `json:"channel_layout"` + HasArtwork bool `json:"has_artwork"` + ChapterCount int `json:"chapter_count"` + Tags map[string]string `json:"tags"` +} + +// ProbeFile returns a FileProbe with everything the Book Details "File +// info" block surfaces: container/codec technicals, chapter count, +// embedded artwork flag, plus the raw format-level tags. One ffprobe +// invocation that asks for format+streams+chapters in JSON. +func (f *FFmpeg) ProbeFile(ctx context.Context, inputPath string) (*FileProbe, error) { + cmd := exec.CommandContext(ctx, f.probePath, + "-v", "quiet", + "-show_format", + "-show_streams", + "-show_chapters", + "-of", "json", + inputPath, + ) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ffprobe file: %w", err) + } + + var resp struct { + Format struct { + FormatName string `json:"format_name"` + FormatLongName string `json:"format_long_name"` + Duration string `json:"duration"` + Size string `json:"size"` + BitRate string `json:"bit_rate"` + NumStreams int `json:"nb_streams"` + Tags map[string]string `json:"tags"` + } `json:"format"` + Streams []struct { + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + CodecLongName string `json:"codec_long_name"` + BitRate string `json:"bit_rate"` + SampleRate string `json:"sample_rate"` + Channels int `json:"channels"` + ChannelLayout string `json:"channel_layout"` + Disposition map[string]int `json:"disposition"` + } `json:"streams"` + Chapters []struct { + ID int `json:"id"` + } `json:"chapters"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return nil, fmt.Errorf("decode ffprobe file: %w", err) + } + + probe := &FileProbe{ + FormatName: resp.Format.FormatName, + FormatLongName: resp.Format.FormatLongName, + BitRate: resp.Format.BitRate, + NumStreams: resp.Format.NumStreams, + ChapterCount: len(resp.Chapters), + Tags: resp.Format.Tags, + } + if probe.Tags == nil { + probe.Tags = map[string]string{} + } + if resp.Format.Duration != "" { + fmt.Sscanf(resp.Format.Duration, "%f", &probe.DurationSec) + } + if resp.Format.Size != "" { + fmt.Sscanf(resp.Format.Size, "%d", &probe.Size) + } + for _, st := range resp.Streams { + switch st.CodecType { + case "audio": + if probe.AudioCodec == "" { + probe.AudioCodec = st.CodecName + probe.AudioCodecLong = st.CodecLongName + probe.AudioBitRate = st.BitRate + probe.SampleRate = st.SampleRate + probe.Channels = st.Channels + probe.ChannelLayout = st.ChannelLayout + } + case "video": + // A video stream on an audiobook means embedded cover + // art. The attached_pic disposition makes that explicit. + if st.Disposition["attached_pic"] == 1 || !probe.HasArtwork { + probe.HasArtwork = true + } + } + } + return probe, nil +} + diff --git a/internal/web/server.go b/internal/web/server.go index 4670122..0016c35 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -56,6 +56,7 @@ type Server struct { audnexus *audnexus.Client organizer *organizer.PlexOrganizer audible *audible.Client + ffmpeg *audio.FFmpeg credPath string port int audiobooksPath string @@ -95,6 +96,7 @@ func NewServer( anClient *audnexus.Client, org *organizer.PlexOrganizer, audibleClient *audible.Client, + ffmpeg *audio.FFmpeg, credPath string, port int, audiobooksPath string, @@ -113,6 +115,7 @@ func NewServer( audnexus: anClient, organizer: org, audible: audibleClient, + ffmpeg: ffmpeg, credPath: credPath, port: port, audiobooksPath: audiobooksPath, @@ -1287,6 +1290,7 @@ func (s *Server) handleBookDetail(c *gin.Context) { } folderPath, files := buildBookFileDetails(book.FilePath) + fileInfo := s.buildBookFileInfo(ctx, book) data := gin.H{ "Book": book, @@ -1294,6 +1298,7 @@ func (s *Server) handleBookDetail(c *gin.Context) { "BookFolderPath": folderPath, "BookFiles": files, "BookFileCount": len(files), + "BookFileInfo": fileInfo, "BookAction": buildLibraryBookActions([]database.Book{*book}, s.settingBool(ctx, library.SettingKeyAutoQueueNewBooks, false))[book.ID], "BookDestinationStatuses": s.bookDestinationStatuses(ctx, book.ID), } @@ -1434,6 +1439,225 @@ func isAudioFile(path string) bool { } } +// bookFileInfoTag is one row of the raw-tag list on the File info +// block. Stays as a slice rather than a map so the template can render +// in a deterministic order — the ffprobe map iteration order is +// otherwise nondeterministic and shuffles between requests. +type bookFileInfoTag struct { + Key string + Value string +} + +// bookFileInfoView is the view-model for the "File info" block on +// Book Details. Every field is independently optional — the template +// uses zero values + an empty tag list to silently omit rows so a +// degraded probe (file moved, container quirks) still renders a +// useful partial card instead of a hard failure. +type bookFileInfoView struct { + Path string + Extension string + SizeBytes int64 + SizeLabel string + ModifiedAt string + Format string + Codec string + CodecLong string + BitRate string + SampleRate string + Channels string + ChapterCount int + HasArtwork bool + Duration string + ActivationBytes string + Tags []bookFileInfoTag + ProbeError string +} + +// buildBookFileInfo populates the File info block for a book that is +// known to be on disk. Returns nil for books with no FilePath so the +// template can skip the whole section with a single nil check. +// +// The function tolerates partial failures: a missing file still +// surfaces what's in the DB; a probe that errors leaves Format/etc +// blank but keeps the size/modified-time rows. Activation bytes are +// fetched from the audible client when available (account-scoped), +// since they're what was used to decrypt this file — handy for +// re-decryption. +func (s *Server) buildBookFileInfo(ctx context.Context, book *database.Book) *bookFileInfoView { + if book == nil || strings.TrimSpace(book.FilePath) == "" { + return nil + } + + v := &bookFileInfoView{ + Path: book.FilePath, + Extension: strings.TrimPrefix(strings.ToLower(filepath.Ext(book.FilePath)), "."), + } + + if info, err := os.Stat(book.FilePath); err == nil && !info.IsDir() { + v.SizeBytes = info.Size() + v.SizeLabel = humanFileSize(info.Size()) + v.ModifiedAt = info.ModTime().Format("2006-01-02 15:04") + } + + if s.ffmpeg != nil { + probeCtx, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + probe, err := s.ffmpeg.ProbeFile(probeCtx, book.FilePath) + if err != nil { + v.ProbeError = err.Error() + } else if probe != nil { + if probe.FormatLongName != "" { + v.Format = probe.FormatLongName + } else { + v.Format = probe.FormatName + } + v.Codec = probe.AudioCodec + v.CodecLong = probe.AudioCodecLong + v.BitRate = formatProbeBitRate(probe.AudioBitRate, probe.BitRate) + v.SampleRate = formatSampleRate(probe.SampleRate) + v.Channels = formatChannels(probe.Channels, probe.ChannelLayout) + v.ChapterCount = probe.ChapterCount + v.HasArtwork = probe.HasArtwork + if probe.DurationSec > 0 { + v.Duration = formatDurationSeconds(probe.DurationSec) + } + // Size from ffprobe wins when stat failed (rare; usually + // the same number). + if v.SizeBytes == 0 && probe.Size > 0 { + v.SizeBytes = probe.Size + v.SizeLabel = humanFileSize(probe.Size) + } + v.Tags = sortedProbeTags(probe.Tags) + } + } + + // Activation bytes are per-account, not per-book — but the field + // belongs on the file-info card because they're the secret that + // decrypted this particular file. Skip silently when the audible + // client isn't authenticated or the call errors. + if s.audible != nil && s.audible.IsAuthenticated() { + abCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if resp, err := s.audible.GetActivationBytes(abCtx); err == nil && resp != nil { + v.ActivationBytes = resp.ActivationBytes + } + } + + return v +} + +// formatProbeBitRate picks the most informative of two bit-rate +// candidates (audio stream first, container second) and converts +// bits/sec → "128 kb/s". ffprobe sometimes reports "0" or "" for +// the audio stream on lossy containers — fall back to the container. +func formatProbeBitRate(streamBPS, containerBPS string) string { + pick := streamBPS + if pick == "" || pick == "0" { + pick = containerBPS + } + if pick == "" || pick == "0" { + return "" + } + var bps int64 + if _, err := fmt.Sscanf(pick, "%d", &bps); err != nil || bps <= 0 { + return "" + } + return fmt.Sprintf("%d kb/s", bps/1000) +} + +func formatSampleRate(raw string) string { + if raw == "" { + return "" + } + var hz int64 + if _, err := fmt.Sscanf(raw, "%d", &hz); err != nil || hz <= 0 { + return "" + } + if hz%1000 == 0 { + return fmt.Sprintf("%d kHz", hz/1000) + } + return fmt.Sprintf("%.1f kHz", float64(hz)/1000.0) +} + +func formatChannels(n int, layout string) string { + if n <= 0 { + return "" + } + if layout != "" { + return fmt.Sprintf("%d (%s)", n, layout) + } + switch n { + case 1: + return "1 (mono)" + case 2: + return "2 (stereo)" + } + return fmt.Sprintf("%d", n) +} + +func formatDurationSeconds(sec float64) string { + total := int(sec) + h := total / 3600 + m := (total % 3600) / 60 + s := total % 60 + if h > 0 { + return fmt.Sprintf("%dh %dm %ds", h, m, s) + } + if m > 0 { + return fmt.Sprintf("%dm %ds", m, s) + } + return fmt.Sprintf("%ds", s) +} + +// sortedProbeTags returns the tag map as a slice. Container-level +// identity tags (encoder, major_brand, asin, series) bubble to the +// top so the most useful info renders first; the rest sort +// alphabetically. Empty values are dropped. +func sortedProbeTags(tags map[string]string) []bookFileInfoTag { + if len(tags) == 0 { + return nil + } + priority := map[string]int{ + "encoder": 1, + "major_brand": 2, + "minor_version": 3, + "compatible_brands": 4, + "asin": 5, + "series": 6, + "series-part": 7, + "title": 8, + "artist": 9, + "album_artist": 10, + "album": 11, + "date": 12, + "genre": 13, + "language": 14, + "media_type": 15, + } + out := make([]bookFileInfoTag, 0, len(tags)) + for k, val := range tags { + if strings.TrimSpace(val) == "" { + continue + } + out = append(out, bookFileInfoTag{Key: k, Value: val}) + } + sort.Slice(out, func(i, j int) bool { + pi, oki := priority[out[i].Key] + pj, okj := priority[out[j].Key] + switch { + case oki && okj: + return pi < pj + case oki: + return true + case okj: + return false + default: + return out[i].Key < out[j].Key + } + }) + return out +} + func humanFileSize(sizeBytes int64) string { units := []string{"B", "KiB", "MiB", "GiB", "TiB"} size := float64(sizeBytes) diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 71df556..d780bd7 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -2010,6 +2010,75 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } margin: 0.25rem 0; } +/* Collapsible description — clamps to ~9.5rem with a fade-out, then + the JS-driven toggle below flips the class to reveal the rest. */ +.book-description { + position: relative; + margin-top: 0.5rem; +} +.book-description-clamp { + max-height: 9.5rem; + overflow: hidden; + position: relative; +} +.book-description-clamp::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3rem; + pointer-events: none; + background: linear-gradient(to bottom, + rgba(22, 33, 62, 0) 0%, + var(--surface) 100%); +} +.book-description-toggle { + margin-top: 0.5rem; + background: transparent; + border: 0; + padding: 0; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.82rem; +} +.book-description-toggle:hover { text-decoration: underline; } + +/* File info — same meta-grid recipe as the top-of-card metadata, + but path/tag rows can be long so they break on word boundaries. */ +.book-file-info-grid { + margin-top: 0.5rem; +} +.book-file-info-path { + word-break: break-all; + font-size: 0.78rem; +} +.book-file-tags-details { + margin-top: 0.6rem; +} +.book-file-tags-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; + font-size: 0.82rem; +} +.book-file-tags-details[open] > summary { margin-bottom: 0.55rem; } +.book-file-tags-grid { + grid-template-columns: max-content 1fr; + gap: 0.2rem 0.75rem; + font-size: 0.78rem; +} +.book-file-tag-value { + word-break: break-word; + max-width: 100%; +} + /* Progress bar */ .progress-bar { height: 8px; diff --git a/internal/web/templates/book_detail_panel.html b/internal/web/templates/book_detail_panel.html index c497d11..dd4cf76 100644 --- a/internal/web/templates/book_detail_panel.html +++ b/internal/web/templates/book_detail_panel.html @@ -152,12 +152,74 @@

Unable to list files from disk right now.

{{end}} + {{with .BookFileInfo}} + +
+ {{if .Format}}
Format
{{.Format}}{{if .Extension}} (.{{.Extension}}){{end}}
{{end}} + {{if .Codec}}
Codec
{{if .CodecLong}}{{.CodecLong}} ({{.Codec}}){{else}}{{.Codec}}{{end}}
{{end}} + {{if .BitRate}}
Bitrate
{{.BitRate}}
{{end}} + {{if .SampleRate}}
Sample rate
{{.SampleRate}}
{{end}} + {{if .Channels}}
Channels
{{.Channels}}
{{end}} + {{if .Duration}}
Duration
{{.Duration}}
{{end}} + {{if gt .ChapterCount 0}}
Chapters
{{.ChapterCount}}
{{end}} +
Embedded artwork
{{if .HasArtwork}}Yes{{else}}No{{end}}
+ {{if .SizeLabel}}
File size
{{.SizeLabel}} ({{.SizeBytes}} bytes)
{{end}} + {{if .ModifiedAt}}
Modified
{{.ModifiedAt}}
{{end}} + {{if .ActivationBytes}}
Activation bytes
{{.ActivationBytes}}
{{end}} +
Path
{{.Path}}
+
+ {{if .ProbeError}} +

Probe error: {{.ProbeError}}

+ {{end}} + {{if .Tags}} +
+ Container tags ({{len .Tags}}) +
+ {{range .Tags}} +
{{.Key}}
+
{{.Value}}
+ {{end}} +
+
+ {{end}} + {{end}} + {{if .Book.Description}} -
- -
{{safeHTML .Book.Description}}
+ +
+
{{safeHTML .Book.Description}}
+
{{end}}
+ {{end}} From 162b01e7df323612568cd7e64ed089a41bf0fa11 Mon Sep 17 00:00:00 2001 From: wutname1 Date: Thu, 28 May 2026 04:29:59 +0000 Subject: [PATCH 07/20] fix: Dashboard auto-refresh, clickable New card, mobile nav + table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues touching the dashboard, sidebar, and library table that all show up the moment a user reaches for a phone or does a sync. 1. Dashboard didn't update after sync. The summary panel listened for an undefined "queue:last" event, so its only refresh path was the 12s poll — meaning the "Library Items" / "New" counters stayed stale for up to 12s after the user kicked a Quick Sync that surfaced new books. Add "sync:done from:body" to the dashboard summary and downloads hx-trigger lists, and have the SSE sync event handler in base.html fire that event whenever sync transitions out of the running state. The dashboard refetches immediately when sync ends instead of waiting for the next poll tick. 2. The "New" stat card on the dashboard was a non-interactive div even though "go look at my N new books" is the obvious next action. Wrap it in an anchor to /library?status=new with no text-decoration; subtle hover ring keeps the affordance visible without trumpeting it. 3. Mobile sidebar wasn't usable. The existing 768px breakpoint flipped the sidebar into a horizontal bar that wrapped six nav links and consumed half the screen. Replace with an off-canvas drawer: hamburger button in the topbar, body.sidebar-open slides the drawer in via transform, a backdrop closes on click, nav links auto-close on activation, Escape dismisses. 4. Library table wasn't usable on mobile either. Nine columns can't reflow on a phone-width viewport without horizontal scroll. At ≤768px, the table flips to a card layout: cover floats left, title/author/series stack as block lines, and the dense bits (duration, purchased, status, presence chips) pack into a single wrapping inline-flex row. Actions clear the float into a full-width footer with a top border. Named column classes (col-title, col-author, …) added to the row template so future column reorders don't break the layout. --- internal/web/static/style.css | 164 +++++++++++++++++- internal/web/templates/base.html | 53 +++++- internal/web/templates/dashboard.html | 4 +- internal/web/templates/dashboard_summary.html | 4 +- internal/web/templates/library_row.html | 12 +- 5 files changed, 216 insertions(+), 21 deletions(-) diff --git a/internal/web/static/style.css b/internal/web/static/style.css index d780bd7..2ad281d 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -466,6 +466,23 @@ h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; color: var(--text-muted); } .stat-grid .stat-card .delta.down { color: #ff7c7c; } .stat-grid .stat-card .delta.warn { color: #ffb547; } +/* Clickable variant — anchor styled as a card. No text decoration, + subtle hover lift so the affordance is visible without trumpeting it. */ +.stat-grid .stat-card.stat-card-link { + text-decoration: none; + color: inherit; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.stat-grid .stat-card.stat-card-link:hover { + border-color: var(--accent); + background: var(--surface-2); +} +.stat-grid .stat-card.stat-card-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* Filter tabs (Library) */ .tabs { display: flex; @@ -2624,30 +2641,159 @@ a.btn, a.btn:hover, a.btn:visited { text-decoration: none; } font-weight: 600; } +/* Hamburger toggle — hidden on desktop, shown alongside the topbar + breadcrumb on mobile. Styled like a ghost icon button so the affordance + reads as utility, not a primary action. */ +.nav-toggle { + display: none; + background: transparent; + border: 1px solid var(--border); + color: var(--text); + border-radius: 7px; + padding: 0.35rem 0.45rem; + cursor: pointer; + align-items: center; + justify-content: center; +} +.nav-toggle:hover { background: var(--surface-2); } +.nav-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Backdrop is always in the DOM so transitions are cheap; only made + interactive when the drawer is open. */ +.sidebar-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 40; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} +body.sidebar-open .sidebar-backdrop { + opacity: 1; + pointer-events: auto; +} + /* Responsive */ @media (max-width: 768px) { .sync-phases { gap: 0.25rem; } .sync-phase { flex-basis: 100%; } + + /* Sidebar becomes an off-canvas drawer. transform keeps the + animation on the compositor and avoids reflowing the page. */ .sidebar { - width: 100%; - position: relative; - flex-direction: row; - border-right: none; - border-bottom: 1px solid var(--border); + width: 260px; + transform: translateX(-100%); + transition: transform 0.22s ease; + z-index: 50; + flex-direction: column; + border-right: 1px solid var(--border); + border-bottom: none; } - .sidebar-header { display: none; } - .nav-links { display: flex; } - .nav-links li a { padding: 0.75rem; } + body.sidebar-open .sidebar { transform: translateX(0); } + .sidebar-header { display: flex; } + .nav-links { display: block; } + .nav-links li a { padding: 0.65rem 1rem; } + .main { margin-left: 0; width: 100%; } .content { margin-left: 0; padding: 1rem; } - .topbar { padding: 0 1rem; } + .topbar { padding: 0 1rem; gap: 0.65rem; } .topbar-search { min-width: 0; flex: 1; } + .nav-toggle { display: inline-flex; } + body { flex-direction: column; } .book-detail { flex-direction: column; } .stats-grid { grid-template-columns: repeat(2, 1fr); } .settings-page { max-width: 100%; } .auth-panel { padding: 1rem; border-radius: 10px; } .destination-type-grid { grid-template-columns: 1fr; } + + /* Library table → card view. Tables can't reflow on narrow viewports + without horizontal scroll or this CSS-flip; we choose the flip so + the row remains scannable on a phone. Named column classes + (col-title, col-author, …) on the row template keep this robust + against future column reorders. */ + .table-wrap { overflow-x: visible; } + #book-table .tbl, + #book-table .tbl tbody, + #book-table .tbl tr { + display: block; + width: 100%; + border: none; + } + #book-table .tbl thead { display: none; } + #book-table .tbl tr { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem 0.85rem 0.55rem; + margin-bottom: 0.6rem; + position: relative; + } + /* Clearfix for the floated cover. */ + #book-table .tbl tr::after { + content: ""; + display: block; + clear: both; + } + #book-table .tbl tr > td { + display: block; + padding: 0; + border: none; + text-align: left; + white-space: normal; + vertical-align: baseline; + } + + /* Cover floats left so subsequent cells wrap around it. */ + #book-table .tbl .col-cover { + float: left; + width: 60px; + margin: 0 0.7rem 0.4rem 0; + } + #book-table .tbl .col-cover img, + #book-table .tbl .col-cover .cover-thumb-placeholder { + width: 60px; + height: 90px; + display: block; + } + + /* Block lines for title / author / series. */ + #book-table .tbl .col-title { font-size: 0.93rem; line-height: 1.3; margin-bottom: 0.1rem; } + #book-table .tbl .col-author { color: var(--text-muted); font-size: 0.82rem; } + #book-table .tbl .col-series { font-size: 0.78rem; } + /* Skip the "—" placeholder cell so empty series doesn't leave a gap. */ + #book-table .tbl .col-series:has(.cell-mono:only-child) { display: none; } + + /* Metrics row: duration + purchased + status + presence, all + inline-flex so they pack into one wrapping line under the + block fields above. */ + #book-table .tbl .col-duration, + #book-table .tbl .col-purchased, + #book-table .tbl .col-status, + #book-table .tbl .col-presence { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0.25rem 0.55rem 0 0; + font-size: 0.78rem; + vertical-align: middle; + } + #book-table .tbl .col-presence { flex-wrap: wrap; } + + /* Actions clear the cover float and span full card width. */ + #book-table .tbl .col-actions { + clear: both; + margin-top: 0.55rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border); + text-align: left; + width: auto; + white-space: normal; + } + #book-table .tbl .col-actions .table-actions { justify-content: flex-start; } + + #book-table .table-pagination { display: block; } } /* ===================================================================== diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index ab2a26a..197b686 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -13,7 +13,8 @@ {{$sb := .Sidebar}} {{$isWizard := eq .Page "setup"}} {{if not $isWizard}} -