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

+27 -4
View File
@@ -469,9 +469,22 @@ func registerFrontendRoutes(cfg *Config) {
// Get only visible sections for the dashboard display
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
// Active library type drives the per-type wording (A2c): system
// collection titles and stat labels swap on audiobooks scope; the
// "All libraries" view keeps generic wording.
activeLibType := ""
if !libRes.IsAll {
for _, ld := range libRes.Libraries {
if ld.ID == libraryID {
activeLibType = ld.TypeValue
break
}
}
}
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
allSectionsData := handlers.BuildSections(allSections, libraryID)
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID, activeLibType))
allSectionsData := handlers.BuildSections(allSections, libraryID, activeLibType)
// "This week" widget: the user's rolling 7-day reading pulse for
// the CURRENTLY SELECTED library (the same resolution the sections
@@ -502,7 +515,7 @@ func registerFrontendRoutes(cfg *Config) {
}
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, errorMsg).Render(c.Request().Context(), &buf)
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -870,6 +883,15 @@ func registerFrontendRoutes(cfg *Config) {
return echo.NewHTTPError(http.StatusInternalServerError, weekErr.Error())
}
var buf bytes.Buffer
widgetLibType := ""
if !libRes.IsAll {
for _, ld := range libRes.Libraries {
if ld.ID == libRes.LibraryID {
widgetLibType = ld.TypeValue
break
}
}
}
err = templates.DashboardWeekWidget(templates.WeekStatsData{
WeekMinutes: weekSvc.WeekMinutes,
WeekPages: weekSvc.WeekPages,
@@ -879,7 +901,7 @@ func registerFrontendRoutes(cfg *Config) {
ActiveToday: weekSvc.ActiveToday,
CurrentStreak: weekSvc.CurrentStreak,
LibraryID: libRes.LibraryID,
}).Render(c.Request().Context(), &buf)
}, widgetLibType).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -1672,6 +1694,7 @@ func registerFrontendRoutes(cfg *Config) {
pgUserID, mediaUUID, user.Timezone); serr == nil && svcStats.SessionCount > 0 {
bookStats = &handlers.BookStatsData{
TotalMinutes: svcStats.TotalMinutes,
TotalSeconds: svcStats.TotalSeconds,
TotalPages: svcStats.TotalPages,
SessionCount: svcStats.SessionCount,
MaxProgress: svcStats.MaxProgress,
+1
View File
@@ -197,6 +197,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
TypeValue: lib.TypeName,
MediaCount: countMap[libUUID.String()],
Offline: offline,
}