Files
bookhoard/internal/handlers/progress.go
T
John O'Keefe 86f0af632e 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.
2026-10-15 18:03:35 -04:00

312 lines
9.4 KiB
Go

package handlers
import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
// getDeviceIcon returns an emoji icon for device type
func getDeviceIcon(deviceType string) string {
switch deviceType {
case "kobo":
return "📚"
case "koreader":
return "📖"
case "kindle":
return "📱"
default:
return "📚"
}
}
// GetUniversalProgress retrieves progress with all location references
func (h *Handler) GetUniversalProgress(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
mediaItemID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID")
}
progress, err := h.db.GetUniversalProgress(context.Background(), database.GetUniversalProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": mediaItemID,
"progress": nil,
})
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get progress")
}
response := map[string]interface{}{
"book_id": progress.MediaItemID.Bytes,
"format_group": progress.FormatGroup,
"universal_progress": map[string]interface{}{
"percentage": progress.Percentage.Float64,
},
"location_references": map[string]interface{}{
"percentage": progress.Percentage.Float64,
"page": int(progress.CurrentPage.Int32),
"total_pages": int(progress.TotalPages.Int32),
},
}
if progress.Epubcfi.Valid {
response["location_references"].(map[string]interface{})["epubcfi"] = progress.Epubcfi.String
}
if progress.Chapter.Valid {
response["location_references"].(map[string]interface{})["chapter"] = int(progress.Chapter.Int32)
}
if progress.ChapterProgress.Valid {
response["location_references"].(map[string]interface{})["chapter_progress"] = progress.ChapterProgress.Float64
}
if progress.CharacterOffset.Valid {
response["location_references"].(map[string]interface{})["character"] = progress.CharacterOffset.Int64
}
deviceSync := map[string]interface{}{}
if progress.LastSyncDevice.Valid {
deviceSync["device"] = progress.LastSyncDevice.String
}
if progress.LastSyncSource.Valid {
deviceSync["source"] = progress.LastSyncSource.String
}
if progress.LastSyncTimestamp.Valid {
deviceSync["last_sync"] = progress.LastSyncTimestamp.Time
}
if len(deviceSync) > 0 {
response["device_sync"] = deviceSync
}
return c.JSON(http.StatusOK, response)
}
// UpdateUniversalProgress updates progress with automatic conversion
// GetProgressHistory retrieves reading session history
func (h *Handler) GetProgressHistory(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
mediaItemID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID")
}
limit := 50
if l := c.QueryParam("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
history, err := h.db.GetReadingHistory(context.Background(), database.GetReadingHistoryParams{
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
Limit: int32(limit),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get history")
}
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": mediaItemID,
"sessions": history,
"count": len(history),
})
}
type ProgressWithMedia struct {
MediaItemID uuid.UUID `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
Percentage float64 `json:"percentage"`
CurrentPage int32 `json:"current_page"`
TotalPages int32 `json:"total_pages"`
LastReadAt time.Time `json:"last_read_at"`
Epubcfi string `json:"epubcfi"`
LastSyncDevice string `json:"last_sync_device"`
DeviceName string `json:"device_name,omitempty"`
DeviceType string `json:"device_type,omitempty"`
DeviceIcon string `json:"device_icon,omitempty"`
ProgressPercentage float64 `json:"-"`
EpubCFI string `json:"-"`
LastUpdated string `json:"-"`
FormatGroup string `json:"format_group"`
EstimatedPages int `json:"estimated_pages"`
// Audiobook whole-book duration (seconds) for H:M:S progress display
DurationSeconds float64 `json:"duration_seconds,omitempty"`
}
// audioDurationFrom pulls duration_seconds from the scan's audio_metadata
// JSONB (0 when the item isn't audio or hasn't been probed).
func audioDurationFrom(raw []byte) float64 {
if len(raw) == 0 {
return 0
}
var payload struct {
DurationSeconds float64 `json:"duration_seconds"`
}
if json.Unmarshal(raw, &payload) != nil {
return 0
}
return payload.DurationSeconds
}
// GetAllProgress retrieves all progress for a user with sync source info
func (h *Handler) GetAllProgress(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
progressList := []ProgressWithMedia{}
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
Limit: 1000, Offset: 0,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media items")
}
for _, mediaItem := range mediaItems {
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: mediaItem.ID,
UserID: user.ID,
})
if err != nil {
continue
}
coverPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
author := ""
if mediaItem.Author.Valid {
author = mediaItem.Author.String
}
epubcfi := ""
if progress.Epubcfi.Valid {
epubcfi = progress.Epubcfi.String
}
deviceName := ""
if progress.LastSyncDevice.Valid {
deviceName = progress.LastSyncDevice.String
}
lastUpdated := ""
if progress.LastReadAt.Valid {
lastUpdated = utils.FormatTimeInTimezone(progress.LastReadAt.Time, user.Timezone.String)
}
progressList = append(progressList, ProgressWithMedia{
MediaItemID: progress.MediaItemID.Bytes,
Title: mediaItem.Title,
Author: author,
CoverImagePath: coverPath,
Percentage: progress.Percentage.Float64,
CurrentPage: progress.CurrentPage.Int32,
TotalPages: progress.TotalPages.Int32,
LastReadAt: progress.LastReadAt.Time,
Epubcfi: epubcfi,
LastSyncDevice: deviceName,
ProgressPercentage: progress.Percentage.Float64 * 100,
EpubCFI: epubcfi,
LastUpdated: lastUpdated,
DeviceIcon: getDeviceIcon(deviceName),
FormatGroup: mediaItem.FormatGroup,
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"progress": progressList,
"total": len(progressList),
})
}
func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, error) {
user := MustGetAuthenticatedUser(c)
progressList := []ProgressWithMedia{}
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
Limit: 1000,
Offset: 0,
})
if err != nil {
return nil, err
}
for _, mediaItem := range mediaItems {
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: mediaItem.ID,
UserID: user.ID,
})
if err != nil {
continue
}
coverPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath)
author := ""
if mediaItem.Author.Valid {
author = mediaItem.Author.String
}
epubcfi := ""
if progress.Epubcfi.Valid {
epubcfi = progress.Epubcfi.String
}
deviceName := ""
if progress.LastSyncDevice.Valid {
deviceName = progress.LastSyncDevice.String
}
lastUpdated := ""
if progress.LastReadAt.Valid {
lastUpdated = utils.FormatTimeInTimezone(progress.LastReadAt.Time, user.Timezone.String)
}
progressList = append(progressList, ProgressWithMedia{
MediaItemID: progress.MediaItemID.Bytes,
Title: mediaItem.Title,
Author: author,
CoverImagePath: coverPath,
Percentage: progress.Percentage.Float64,
CurrentPage: progress.CurrentPage.Int32,
TotalPages: progress.TotalPages.Int32,
LastReadAt: progress.LastReadAt.Time,
Epubcfi: epubcfi,
LastSyncDevice: deviceName,
ProgressPercentage: progress.Percentage.Float64 * 100,
EpubCFI: epubcfi,
LastUpdated: lastUpdated,
DeviceIcon: getDeviceIcon(deviceName),
DeviceName: deviceName,
DeviceType: deviceName,
FormatGroup: mediaItem.FormatGroup,
// Audiobook "current position" needs whole-book duration
// (H:M:S display); parsed from the scanner's audio_metadata.
DurationSeconds: audioDurationFrom(mediaItem.AudioMetadata),
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
})
}
return progressList, nil
}