The metadata editor's Comic/Manga section (Manga Type, Reading Direction, Age Rating, Story Arc, Imprint, Scan Info, Metadata Notes, B&W) rendered for every item — an audiobook showed a manga dropdown, which is nonsense. MediaDetail now carries LibraryTypeName (resolved via GetLibraryType in the /media/:uuid handler) and a ShowComicMetadata() gate: comics and manga libraries always show the section; other media only when the scanner recorded comic-style values (fixed-layout manga-style EPUBs get those scanner defaults deliberately — when present they stay editable).
212 lines
5.9 KiB
Go
212 lines
5.9 KiB
Go
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
|
|
|
|
// Display type of the owning library ("audiobooks", "comics", "ebooks",
|
|
// "manga"); "" when it could not be resolved. Drives media-type-aware
|
|
// UI gating (e.g. hiding the Comic/Manga editor section).
|
|
LibraryTypeName string
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// ShowComicMetadata reports whether the Comic/Manga section belongs on
|
|
// this item's edit modal: comics and manga libraries always; other media
|
|
// only when the scanner actually recorded comic-style values (fixed-layout
|
|
// manga-style EPUBs get scanner defaults).
|
|
func (md *MediaDetail) ShowComicMetadata() bool {
|
|
if md.LibraryTypeName == "comics" || md.LibraryTypeName == "manga" {
|
|
return true
|
|
}
|
|
return (md.MangaType.Valid && md.MangaType.String != "") ||
|
|
(md.ReadingDirection.Valid && md.ReadingDirection.String != "")
|
|
}
|
|
|
|
// 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)
|
|
}
|