feat(audiobooks): A2 — narrator metadata, audio-aware UX, player polish, bookmarks

A2a (metadata round): narrators TEXT[] + narrators_search (tags
pattern: accumulator + GIN pairs) + abridged bool on media_items;
scanner fills from the Libation/ABS sidecar (narrators verbatim,
abridged flag) with audio-payload promotion; editorial fields ride
the real columns while audio_metadata stays scan-derived (duration/
codec/chapters). Detail/edit/save paths carry the fields; the web
edit dialog grows an Audio section (Narrator(s) comma-input, Abridged
toggle, scanned duration read-only); search weights + narrator filter
(word_similarity over narrators_search) + narrator autocomplete added.

A2c (per-media-type wording): copy helpers keyed on format_group —
Listen Now, Mark Complete/Not Complete, Listening Progress, Your
Listening Stats, Time Listening, Time listened (pages tile swapped for
audio), Position H:MM:SS / total H:MM:SS on detail + progress pages
(duration parsed from audio_metadata). Dashboard: system collections
swap to Continue Listening / Recently Listened at DISPLAY time when
the audiobooks library is selected (stored names unchanged; restores
keep working); week card = Listening Time / Sessions / Books Touched;
cover-corner badges identify audiobook (headphones) / comic / manga
cards on mixed grids — books stay unbadged; hover action icon follows
the media type.

A2b (player): the scrubber binds value to the playhead with an explicit
scrub guard (the handle previously froze at its initial max); header
row = reader-style back arrow + Chapter n/total launcher; chapters
open in a floating top-down drawer (current chapter highlighted, page
never shifts); Skip/Speed rows share one label-left layout; honest
wall-clock session accounting (playing-only deltas, paused flush).

A2d (bookmarks): note-bearing time bookmarks ride the existing
media_bookmarks substrate (notes column + sync-aware service already
there) — audio locator convention audio:<seconds>, chapter_number
filled, auto-title '<chapter> — H:MM:SS', capture popover with optional
note, drawer with jump/edit-note/delete. Zero server changes needed.

Verified live on the Libation sample: narrators backfilled by rescan;
PUT edit persisted + overrides detected (narrators, abridged); force
rescan preserved the user edit; reset-to-scanned-of-defaults restored
truth; narrator filter/autocomplete scoped by library; dashboard
listsening wording + badges render; bookmark save with note deduped to
one row.
This commit is contained in:
John O'Keefe committed 2026-10-15 18:03:35 -04:00
1 parent 902cbe5915
commit 86f0af632e
38 files changed
+3069 -1552

No files matched your search

+5
View File
@@ -75,6 +75,8 @@ type BookInfo struct {
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
HasConflict bool `json:"has_conflict"`
// Media-type identifier for card badges/hover icons (A2c)
FormatGroup string `json:"format_group"`
}
type SectionData struct {
@@ -283,6 +285,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
FormatGroup: item.FormatGroup,
})
}
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
@@ -333,6 +336,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
FormatGroup: item.FormatGroup,
}
}
bookList = bookCards
@@ -349,6 +353,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
Title: book.Title,
Author: textToString(book.Author),
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
FormatGroup: book.FormatGroup,
})
}
}
+22 -22
View File
@@ -392,18 +392,18 @@ func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaIte
}
params := database.UpdateMediaHighlightForSyncParams{
ID: existing.ID,
SelectionText: existing.SelectionText,
StartPosition: existing.StartPosition,
EndPosition: existing.EndPosition,
Color: existing.Color,
NoteText: existing.NoteText,
PercentageStart: existing.PercentageStart,
PercentageEnd: existing.PercentageEnd,
EpubcfiStart: existing.EpubcfiStart,
EpubcfiEnd: existing.EpubcfiEnd,
ChapterReference: existing.ChapterReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
ID: existing.ID,
SelectionText: existing.SelectionText,
StartPosition: existing.StartPosition,
EndPosition: existing.EndPosition,
Color: existing.Color,
NoteText: existing.NoteText,
PercentageStart: existing.PercentageStart,
PercentageEnd: existing.PercentageEnd,
EpubcfiStart: existing.EpubcfiStart,
EpubcfiEnd: existing.EpubcfiEnd,
ChapterReference: existing.ChapterReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
UserID: userID,
@@ -439,17 +439,17 @@ func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItem
}
params := database.UpdateMediaBookmarkForSyncParams{
ID: existing.ID,
PageNumber: existing.PageNumber,
ChapterNumber: existing.ChapterNumber,
CfiPosition: existing.CfiPosition,
Title: existing.Title,
Position: existing.Position,
Notes: existing.Notes,
ID: existing.ID,
PageNumber: existing.PageNumber,
ChapterNumber: existing.ChapterNumber,
CfiPosition: existing.CfiPosition,
Title: existing.Title,
Position: existing.Position,
Notes: existing.Notes,
PercentageLocation: existing.PercentageLocation,
EpubcfiLocation: existing.EpubcfiLocation,
ChapterReference: existing.ChapterReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
EpubcfiLocation: existing.EpubcfiLocation,
ChapterReference: existing.ChapterReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
UserID: userID,
+30 -2
View File
@@ -64,7 +64,17 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
}
sectionData := BuildSections(sections, libraryID)
// Per-type wording: system-collection display swaps on audiobook scope.
libraryType := ""
if libraryID != "" {
if pgLibID, perr := uuid.Parse(libraryID); perr == nil {
if lib, lerr := h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: pgLibID, Valid: true}); lerr == nil {
libraryType = lib.TypeName
}
}
}
sectionData := BuildSections(sections, libraryID, libraryType)
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
@@ -168,10 +178,27 @@ func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
}
func BuildSections(sections []services.DashboardSection, currentLibraryID string) []SectionData {
// BuildSections maps dashboard service sections to card data. When the
// active library is audiobooks, system-collection titles/descriptions
// swap to the listening wording at display time — the stored names stay
// generic so restores and settings keep working across library types.
func BuildSections(sections []services.DashboardSection, currentLibraryID, libraryType string) []SectionData {
var result []SectionData
audioScope := libraryType == "audiobooks"
for _, ds := range sections {
if audioScope && ds.IsSystem {
switch ds.Title {
case "Continue Reading":
ds.Title = "Continue Listening"
ds.Description = "Audiobooks you're currently listening to (0 < progress < 1)"
case "Recently Read":
ds.Title = "Recently Listened"
ds.Description = "Audiobooks you've finished (progress >= 1)"
}
}
bookCards := make([]BookInfo, len(ds.Items))
for i, item := range ds.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
@@ -180,6 +207,7 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
FormatGroup: item.FormatGroup,
}
}
+22
View File
@@ -59,6 +59,9 @@ type CreateMediaItemRequest struct {
AlternateInfo string `json:"alternate_info"` // JSON string
ScanInformation string `json:"scan_information"`
Summary string `json:"summary"`
// Audio editorial facts (A2a)
Narrators []string `json:"narrators"`
Abridged bool `json:"abridged"`
}
// UpdateMediaItemRequest represents the request for updating a media item
@@ -98,6 +101,10 @@ type UpdateMediaItemRequest struct {
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
ScanInformation string `form:"scan_information" json:"scan_information"`
Summary string `form:"summary" json:"summary"`
// Audiobook editorial facts (A2a): narrators verbatim display order;
// abridged as the Libation/Audible flag.
Narrators []string `form:"narrators" json:"narrators"`
Abridged bool `form:"abridged" json:"abridged"`
}
// CreateMediaNoteRequest represents the request for creating a media note
@@ -897,6 +904,8 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
"updated_at": item.UpdatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
"format_group": item.FormatGroup,
"format_mimetype": textToString(item.FormatMimetype),
"narrators": item.Narrators,
"abridged": item.Abridged,
// NEW: Reading direction fields
"manga_type": textToString(item.MangaType),
"reading_direction": textToString(item.ReadingDirection),
@@ -1405,6 +1414,7 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
narratorsSearch := utils.NormalizeNarratorsSearch(req.Narrators)
// Validate and normalize ISBN
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
@@ -1454,6 +1464,9 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
TagsSearch: tagsSearch,
Narrators: req.Narrators,
NarratorsSearch: narratorsSearch,
Abridged: req.Abridged,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
@@ -1519,6 +1532,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
narratorsSearch := utils.NormalizeNarratorsSearch(req.Narrators)
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
if err != nil && req.ISBN != "" {
@@ -1572,6 +1586,9 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
TagsSearch: tagsSearch,
Narrators: req.Narrators,
NarratorsSearch: narratorsSearch,
Abridged: req.Abridged,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
@@ -2427,6 +2444,9 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
if tags := c.QueryParam("tags"); tags != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags)
}
if narrator := c.QueryParam("narrator"); narrator != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator)
}
if series := c.QueryParam("series"); series != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
}
@@ -2468,6 +2488,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
genreFilter := c.QueryParam("genre_filter")
publisherFilter := c.QueryParam("publisher_filter")
tagsFilter := c.QueryParam("tags_filter")
narratorFilter := c.QueryParam("narrator_filter")
languageFilter := c.QueryParam("language_filter")
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
@@ -2494,6 +2515,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
GenreFilter: genreFilter,
PublisherFilter: publisherFilter,
TagsFilter: tagsFilter,
NarratorFilter: narratorFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
+149 -1
View File
@@ -1,12 +1,22 @@
package handlers
import "bookhoard/internal/database"
import (
"encoding/json"
"fmt"
"bookhoard/internal/database"
)
// MediaDetail embeds database.MediaItems for complete book metadata
// No field duplication - template gets direct access to all database fields
type MediaDetail struct {
database.MediaItems // Embedded - ALL book fields available
// Audio editorial/tech surfaces (A2a): duration parsed out of the
// audio_metadata JSONB the scanner wrote (display-only).
audioDurationSeconds float64
audioParsed bool
// Absolute on-disk location (library folder + relative path), resolved by
// the page handler so the UI can show where the file lives.
FileLocation string `json:"file_location"`
@@ -37,6 +47,7 @@ type MediaDetail struct {
// BookStatsData is the per-book reading-stats card payload.
type BookStatsData struct {
TotalMinutes int
TotalSeconds int // exact wall-clock; audio "Time listened" displays from this
TotalPages int
SessionCount int
MaxProgress float64 // 0..1
@@ -44,3 +55,140 @@ type BookStatsData struct {
LastSession string
FirstSession string
}
// IsAudio reports whether the item's format group is the dedicated
// audiobook group (drives every "reading vs listening" wording swap).
func (md *MediaDetail) IsAudio() bool {
return md.FormatGroup == "audiobook"
}
// --- audio-aware copy helpers (A2c): one home per verb so web + future
// clients agree; format_group chooses the string ---
func (md *MediaDetail) ActionLabel() string {
if md.IsAudio() {
return "Listen Now"
}
return "Read Now"
}
func (md *MediaDetail) ActionIcon() string {
if md.IsAudio() {
return "headphones"
}
return "book-open"
}
func (md *MediaDetail) MarkCompleteLabel() string {
if md.IsAudio() {
return "Mark Complete"
}
return "Mark Read"
}
func (md *MediaDetail) MarkResetLabel() string {
if md.IsAudio() {
return "Mark Not Complete"
}
return "Mark Unread"
}
func (md *MediaDetail) ProgressHeader() string {
if md.IsAudio() {
return "Listening Progress"
}
return "Reading Progress"
}
func (md *MediaDetail) LastReadLabel() string {
if md.IsAudio() {
return "Last Listen"
}
return "Last Read"
}
func (md *MediaDetail) StatsHeader() string {
if md.IsAudio() {
return "Your Listening Stats"
}
return "Your Reading Stats"
}
func (md *MediaDetail) TimeStatLabel() string {
if md.IsAudio() {
return "Time Listening"
}
return "Time Reading"
}
// AudioDuration lazily parses duration_seconds out of audio_metadata.
func (md *MediaDetail) AudioDuration() float64 {
if !md.audioParsed {
md.audioParsed = true
var payload struct {
DurationSeconds float64 `json:"duration_seconds"`
}
if len(md.AudioMetadata) > 0 && json.Unmarshal(md.AudioMetadata, &payload) == nil {
md.audioDurationSeconds = payload.DurationSeconds
}
}
return md.audioDurationSeconds
}
// AudioDurationLabel renders H:MM:SS (or M:SS under an hour) for the
// audio surfaces — same shape the web player shows.
func (md *MediaDetail) AudioDurationLabel() string {
s := int(md.AudioDuration() + 0.5)
if s <= 0 {
return "—"
}
h := s / 3600
m := (s % 3600) / 60
sec := s % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
}
return fmt.Sprintf("%d:%02d", m, sec)
}
// AudioCurrentSeconds is the playhead in seconds: percentage of the
// whole-book duration (chapter-exact granularity lives in the player;
// the detail page shows shelf-level position).
func (md *MediaDetail) AudioCurrentSeconds() int {
dur := md.AudioDuration()
if dur <= 0 || md.ReadingProgress == nil || !md.ReadingProgress.Percentage.Valid {
return 0
}
return int(md.ReadingProgress.Percentage.Float64*dur + 0.5)
}
// AudioCurrentLabel is the playhead rendered H:MM:SS.
func (md *MediaDetail) AudioCurrentLabel() string {
s := md.AudioCurrentSeconds()
if s <= 0 {
return "0:00"
}
h := s / 3600
m := (s % 3600) / 60
sec := s % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
}
return fmt.Sprintf("%d:%02d", m, sec)
}
// AudioStatsLabel is "Time listened" for audio items (H:MM:SS), honoring
// the plan's swap of the Pages Synced tile.
func (md *MediaDetail) TimeListenedLabel() string {
s := 0
if md.BookStats != nil {
s = md.BookStats.TotalSeconds
}
h := s / 3600
m := (s % 3600) / 60
sec := s % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
}
return fmt.Sprintf("%d:%02d", m, sec)
}
+22 -1
View File
@@ -5,6 +5,7 @@ import (
wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
@@ -148,6 +149,23 @@ type ProgressWithMedia struct {
LastUpdated string `json:"-"`
FormatGroup string `json:"format_group"`
EstimatedPages int `json:"estimated_pages"`
// Audiobook whole-book duration (seconds) for H:M:S progress display
DurationSeconds float64 `json:"duration_seconds,omitempty"`
}
// audioDurationFrom pulls duration_seconds from the scan's audio_metadata
// JSONB (0 when the item isn't audio or hasn't been probed).
func audioDurationFrom(raw []byte) float64 {
if len(raw) == 0 {
return 0
}
var payload struct {
DurationSeconds float64 `json:"duration_seconds"`
}
if json.Unmarshal(raw, &payload) != nil {
return 0
}
return payload.DurationSeconds
}
// GetAllProgress retrieves all progress for a user with sync source info
@@ -282,7 +300,10 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
DeviceName: deviceName,
DeviceType: deviceName,
FormatGroup: mediaItem.FormatGroup,
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
// Audiobook "current position" needs whole-book duration
// (H:M:S display); parsed from the scanner's audio_metadata.
DurationSeconds: audioDurationFrom(mediaItem.AudioMetadata),
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
})
}
+1
View File
@@ -159,6 +159,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
FormatGroup: item.FormatGroup,
})
}