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:
1 parent
902cbe5915
commit
86f0af632e
38 files changed
+3069
-1552
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user