package handlers 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"` // User-specific data Rating *database.MediaRatings `json:"rating,omitempty"` Review *database.MediaReviews `json:"review,omitempty"` // raw markdown Collections []database.Collections `json:"collections"` ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"` // The viewer's own reading-history card for this book (nil when the // user has no sessions here); rendered as the "Your Reading Stats" // section of the book page. BookStats *BookStatsData `json:"book_stats,omitempty"` // Conflict data (if exists) ActiveConflict *ConflictDetailResponse `json:"active_conflict,omitempty"` // Computed counts NotesCount int `json:"notes_count"` HighlightsCount int `json:"highlights_count"` // Deleted-annotation history (tombstoned rows, newest first) — the book // page's "recently deleted" list with restore/permanent-delete actions. DeletedAnnotations []DeletedAnnotationResponse `json:"deleted_annotations"` } // 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 Finished bool 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) }