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

178 lines
5.3 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"context"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type SeriesHandler struct {
seriesService *services.SeriesService
}
func NewSeriesHandler(db *database.Queries) *SeriesHandler {
return &SeriesHandler{
seriesService: services.NewSeriesService(db),
}
}
func (h *SeriesHandler) GetSeries(c *echo.Context) error {
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}
}
limit := 20
if l := c.QueryParam("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 {
limit = v
if limit > 100 {
limit = 100
}
}
}
offset := 0
if o := c.QueryParam("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
offset = v
}
}
// Visibility scoping: an explicit library_id must be visible to the
// requester (403 otherwise); the unscoped aggregate covers only the
// requester's visible libraries.
visibleSet, visibleIDs, err := h.visibleLibrarySet(c)
if err != nil {
return err
}
if libraryID != "" && !visibleSet[libraryID] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
}
type SeriesResponse struct {
Name string `json:"name"`
LibraryID string `json:"library_id"`
BookCount int64 `json:"book_count"`
TotalInSeries int `json:"total_in_series"`
CoverPaths []string `json:"cover_paths"`
LastEntryAt string `json:"last_entry_at"`
}
response := make([]SeriesResponse, 0, len(seriesList))
for _, s := range seriesList {
response = append(response, SeriesResponse{
Name: s.Name,
LibraryID: s.LibraryID,
BookCount: s.BookCount,
TotalInSeries: s.TotalInSeries,
CoverPaths: s.CoverPaths,
LastEntryAt: s.LastEntryAt,
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"series": response,
"total": total,
"limit": limit,
"offset": offset,
})
}
// visibleLibrarySet loads the requester's visible libraries. Every series
// path funnels through this: hidden libraries contribute nothing, and an
// explicit library_id outside the set is a 403, not a silent pass.
func (h *SeriesHandler) visibleLibrarySet(c *echo.Context) (map[string]bool, []pgtype.UUID, error) {
user, ok := c.Get("user").(database.Users)
if !ok {
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
libraries, err := h.seriesService.VisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return nil, nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to check library access")
}
set := make(map[string]bool, len(libraries))
ids := make([]pgtype.UUID, 0, len(libraries))
for _, lib := range libraries {
if !lib.ID.Valid {
continue
}
libUUID, err := uuid.FromBytes(lib.ID.Bytes[0:16])
if err != nil {
continue
}
set[libUUID.String()] = true
ids = append(ids, lib.ID)
}
return set, ids, nil
}
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
seriesName := c.QueryParam("name")
if seriesName == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
}
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
visibleSet, _, err := h.visibleLibrarySet(c)
if err != nil {
return err
}
if !visibleSet[libraryID] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}, seriesName)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
}
bookCards := make([]BookInfo, 0, len(books))
for _, item := range books {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards = append(bookCards, BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
FormatGroup: item.FormatGroup,
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"name": seriesName,
"library_id": libraryID,
"books": bookCards,
"total": len(bookCards),
})
}
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
svc := services.NewSeriesService(db)
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset)
}