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

+22
View File
@@ -93,6 +93,12 @@ type MediaMetadata struct {
// Audiobook fields (media_items.audio_metadata JSONB — only the
// audio paths read it; see media_scanner_audio.go)
AudioMeta *AudioMetadataPayload
// Narrators/abridged land in real media_items columns (A2a): the
// editorial surface of audiobooks (edit form, filters, search),
// while AudioMeta stays scan-derived.
Narrators []string
Abridged bool
}
type HashInfo struct {
@@ -941,6 +947,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Contributors: metadata.Contributors,
ContributorsSearch: contributorsSearch,
Narrators: metadata.Narrators,
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
Abridged: metadata.Abridged,
Tags: metadata.Tags,
TagsSearch: tagsSearch,
AddedByAdminID: s.adminID,
@@ -1095,6 +1104,8 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Authors []string `json:"authors"`
Narrators []string `json:"narrators"`
Abridged *bool `json:"abridged"`
Series json.RawMessage `json:"series"`
Genres []string `json:"genres"`
Tags []string `json:"tags"`
@@ -1173,6 +1184,14 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
if sidecar.ASIN != nil {
metadata.ASIN = strings.TrimSpace(*sidecar.ASIN)
}
// Audiobook editorial facts land in the real columns (A2a):
// narrators verbatim (display), abridged as the Libation/Audible flag.
if len(sidecar.Narrators) > 0 {
metadata.Narrators = sidecar.Narrators
}
if sidecar.Abridged != nil {
metadata.Abridged = *sidecar.Abridged
}
if sidecar.Language != nil {
metadata.Language = strings.TrimSpace(*sidecar.Language)
}
@@ -3191,6 +3210,9 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Narrators: metadata.Narrators,
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
Abridged: metadata.Abridged,
Tags: metadata.Tags,
TagsSearch: tagsSearch,
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
+15 -3
View File
@@ -42,6 +42,7 @@ type AudioMetadataPayload struct {
CodecProfile string `json:"codec_profile,omitempty"`
BitRate int64 `json:"bit_rate,omitempty"`
ASIN string `json:"asin,omitempty"`
Abridged *bool `json:"abridged,omitempty"`
Chapters []AudioChapter `json:"chapters,omitempty"`
}
@@ -182,14 +183,24 @@ func (s *MediaScanner) enrichAudioMetadata(path string, metadata *MediaMetadata)
if payload.ASIN == "" {
payload.ASIN = metadata.ASIN
}
// Promote the editorial facts (A2a columns) when the sidecar's audio
// payload carries them and the common-field parse left them empty.
if len(metadata.Narrators) == 0 && len(payload.Narrators) > 0 {
metadata.Narrators = payload.Narrators
}
if payload.Abridged != nil {
metadata.Abridged = *payload.Abridged
}
}
// parseSidecarAudioFields extracts the audio-only fields from a
// Libation/Audiobookshelf metadata.json into the payload (narrators and
// the curated chapter list).
// Libation/Audiobookshelf metadata.json into the payload (narrators,
// abridged and the curated chapter list).
func parseSidecarAudioFields(path string, data []byte) *AudioMetadataPayload {
var sidecar struct {
Narrators []string `json:"narrators"`
Abridged *bool `json:"abridged"`
ASIN *string `json:"asin"`
Chapters []struct {
Start float64 `json:"start"`
@@ -201,11 +212,12 @@ func parseSidecarAudioFields(path string, data []byte) *AudioMetadataPayload {
if err := json.Unmarshal(data, &sidecar); err != nil {
return nil
}
if len(sidecar.Narrators) == 0 && len(sidecar.Chapters) == 0 && sidecar.ASIN == nil {
if len(sidecar.Narrators) == 0 && len(sidecar.Chapters) == 0 && sidecar.ASIN == nil && sidecar.Abridged == nil {
return nil
}
payload := &AudioMetadataPayload{
Narrators: sidecar.Narrators,
Abridged: sidecar.Abridged,
}
if sidecar.ASIN != nil {
payload.ASIN = strings.TrimSpace(*sidecar.ASIN)
+19
View File
@@ -29,6 +29,7 @@ type SearchParams struct {
GenreFilter string
PublisherFilter string
TagsFilter string
NarratorFilter string
LanguageFilter string
YearMin int
YearMax int
@@ -76,6 +77,7 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
@@ -174,6 +176,23 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
}
return fieldValues, nil
case "narrator":
results, err := s.db.SearchNarratorValues(ctx, database.SearchNarratorValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "series":
results, err := s.db.SearchSeriesValues(ctx, database.SearchSeriesValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
+2
View File
@@ -163,6 +163,7 @@ type WeekSummaryStats struct {
type BookStats struct {
MediaItemID string `json:"media_item_id"`
TotalMinutes int `json:"total_minutes"`
TotalSeconds int `json:"total_seconds"` // exact wall-clock (audio "Time listened"; minutes lose the remainder)
TotalPages int `json:"total_pages"`
SessionCount int `json:"session_count"`
MaxProgress float64 `json:"max_progress"`
@@ -542,6 +543,7 @@ func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, m
}
for i, sess := range sessions {
if sess.TimeSpentSeconds.Valid {
out.TotalSeconds += int(sess.TimeSpentSeconds.Int32)
out.TotalMinutes += int(sess.TimeSpentSeconds.Int32) / 60
}
if sess.PagesRead.Valid {