Files
bookhoard/internal/handlers/dashboard.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

334 lines
10 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"context"
"log"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type DashboardHandler struct {
db *database.Queries
dashboardService *services.DashboardService
}
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
return &DashboardHandler{
db: db,
dashboardService: services.NewDashboardService(db),
}
}
func (h *DashboardHandler) GetSections(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
libraryID := c.QueryParam("library_id")
var libUUID pgtype.UUID
if libraryID != "" {
parsed, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
limit := 20
if prefs.ItemsPerSection.Valid {
limit = int(prefs.ItemsPerSection.Int32)
}
if limitStr := c.QueryParam("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
sections, err := h.dashboardService.GetDashboardSections(
c.Request().Context(),
userUUID,
libUUID,
limit,
prefs.CollectionOrder,
prefs.HiddenCollections,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
}
// Per-type wording: system-collection display swaps on audiobook scope.
libraryType := ""
if libraryID != "" {
if pgLibID, perr := uuid.Parse(libraryID); perr == nil {
if lib, lerr := h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: pgLibID, Valid: true}); lerr == nil {
libraryType = lib.TypeName
}
}
}
sectionData := BuildSections(sections, libraryID, libraryType)
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
}
func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
// nil (JSON null/absent) = the global All Libraries scope;
// empty string is a LOST value, not a scope — rejected loudly.
LibraryID *string `json:"library_id"`
HiddenCollections []string `json:"hidden_collections"`
CollectionOrder []string `json:"collection_order"`
ItemsPerSection int `json:"items_per_section"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
var libUUID pgtype.UUID
if req.LibraryID != nil {
if *req.LibraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
parsed, err := uuid.Parse(*req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
// Sanitize preferences (remove duplicates)
cleanHidden, cleanOrder := h.dashboardService.SanitizeDashboardPreferences(req.HiddenCollections, req.CollectionOrder)
req.HiddenCollections = cleanHidden
req.CollectionOrder = cleanOrder
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: libUUID,
HiddenCollections: req.HiddenCollections,
CollectionOrder: req.CollectionOrder,
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
}
return c.JSON(http.StatusOK, prefs)
}
func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
CollectionName string `form:"collection_name" json:"collection_name"`
ResetType string `form:"reset_type" json:"reset_type"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if req.CollectionName == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
}
if req.ResetType == "" {
req.ResetType = "full"
}
validCollections := map[string]bool{
"Continue Reading": true,
"Recently Added": true,
"Recently Read": true,
"Not Started": true,
"Continue Series": true,
}
if !validCollections[req.CollectionName] {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
}
if req.ResetType != "full" && req.ResetType != "keep_books" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "reset_type must be 'full' or 'keep_books'"})
}
err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName, req.ResetType)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
}
// Add redirect header for HTMX requests
if c.Request().Header.Get("HX-Request") == "true" {
c.Response().Header().Set("HX-Redirect", "/collections")
}
return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
}
// BuildSections maps dashboard service sections to card data. When the
// active library is audiobooks, system-collection titles/descriptions
// swap to the listening wording at display time — the stored names stay
// generic so restores and settings keep working across library types.
func BuildSections(sections []services.DashboardSection, currentLibraryID, libraryType string) []SectionData {
var result []SectionData
audioScope := libraryType == "audiobooks"
for _, ds := range sections {
if audioScope && ds.IsSystem {
switch ds.Title {
case "Continue Reading":
ds.Title = "Continue Listening"
ds.Description = "Audiobooks you're currently listening to (0 < progress < 1)"
case "Recently Read":
ds.Title = "Recently Listened"
ds.Description = "Audiobooks you've finished (progress >= 1)"
}
}
bookCards := make([]BookInfo, len(ds.Items))
for i, item := range ds.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
FormatGroup: item.FormatGroup,
}
}
result = append(result, SectionData{
ID: ds.CollectionName,
CollectionID: ds.CollectionID.String(),
IsSystem: ds.IsSystem,
Title: ds.Title,
Description: ds.Description,
Icon: ds.Icon,
Items: bookCards,
ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID),
Priority: ds.Priority,
})
}
return result
}
// activeConflictSet returns the set of media item IDs (as strings) that have an
// active (unresolved) progress sync conflict for the given user. A single query
// is issued; resolved conflicts are filtered out in memory.
func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool {
conflicts, err := db.ListSyncConflictsByUser(ctx, userID)
if err != nil {
return nil
}
set := make(map[string]bool, len(conflicts))
for _, c := range conflicts {
if c.ResolutionStatus.String == "unresolved" {
set[uuid.UUID(c.MediaItemID.Bytes).String()] = true
}
}
return set
}
// MarkActiveConflicts stamps HasConflict on each book whose media item has an
// active progress sync conflict for the user. It performs a single query
// regardless of how many books are passed.
func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo {
if len(books) == 0 {
return books
}
set := activeConflictSet(ctx, db, userID)
for i := range books {
if set[books[i].MediaItemID] {
books[i].HasConflict = true
}
}
return books
}
// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts,
// used by the dashboard which renders books grouped into sections.
func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData {
if len(sections) == 0 {
return sections
}
set := activeConflictSet(ctx, db, userID)
if len(set) == 0 {
return sections
}
for s := range sections {
for i := range sections[s].Items {
if set[sections[s].Items[i].MediaItemID] {
sections[s].Items[i].HasConflict = true
}
}
}
return sections
}
func getViewAllURL(collectionID string, libraryID string) string {
if collectionID != "" {
if libraryID != "" {
return "/collections/" + collectionID + "?library_id=" + libraryID
}
return "/collections/" + collectionID
}
return ""
}
func (h *DashboardHandler) GetPreferences(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
libraryID := c.QueryParam("library_id")
var libUUID pgtype.UUID
if libraryID != "" {
parsed, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
if err != nil {
// Return default preferences instead of 404 when none exist
log.Printf("GetDashboardPreferences failed: %v", err)
prefs = database.UserDashboardPreferences{
HiddenCollections: []string{},
CollectionOrder: []string{},
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
}
}
// Normalize nil slices to empty arrays for JSON
hiddenCollections := prefs.HiddenCollections
if hiddenCollections == nil {
hiddenCollections = []string{}
}
collectionOrder := prefs.CollectionOrder
if collectionOrder == nil {
collectionOrder = []string{}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"hidden_collections": hiddenCollections,
"collection_order": collectionOrder,
"items_per_section": prefs.ItemsPerSection.Int32,
})
}