Files
bookhoard/internal/router/reader.go
T
John O'Keefe 81e54f89b2 fix(collections): the order select re-orders the PAGE — visible order = play order
The detail header's listening-order select persisted fine and the
reader chain honored it, but the page's card list never applied
chain_order (it kept the composed query's title order) — so flipping
the select changed nothing visible, and Play as Sequence launches from
the FIRST CARD ON THE PAGE, making the played start (and the perceived
order) disagree with the curated setting (2026-11-01 user report on
the smart podcast page).

- Smart episode collections: the page's match list re-orders through
  OrderEpisodeMedia (the same OrderEpisodes comparator the sequence
  producer uses — §10 R2's single source of order truth) before card
  building. Books-scope matches keep title order.
- Static episode playlists: the sequence now honors the curated order
  too — native keeps the manual position order (SQL), reverse flips
  it, oldest/newest re-key on publication date with feed position as
  tiebreak. Previously statics ignored chain_order entirely while the
  select rendered on their pages.

Pins: native page order (feed positions) and the reverse flip
re-ordering the SSR cards, asserted in the curated-order subtest
alongside the existing reader-chain pins. Podcast/playlist/audiobook/
RLS/dashboard/contract slices green.
2026-11-01 00:49:04 -04:00

432 lines
15 KiB
Go

package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/internal/utils"
"bookhoard/templates"
"bytes"
"encoding/json"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
func registerReaderRoutes(cfg *Config) {
e := cfg.Echo
// Create reader service and handler
readerService := services.NewReaderService(cfg.Queries, cfg.Worker)
cfg.ReaderHandler = handlers.NewReaderHandler(
cfg.Queries,
services.NewLibraryService(cfg.Queries),
readerService,
cfg.Worker,
)
jwtMiddleware := createJWTMiddleware(cfg)
// Reader page routes (SSR + API)
reader := e.Group("/readers", jwtMiddleware)
// Reader page (SSR)
reader.GET("/:mediaItemId", func(c *echo.Context) error {
// Get user
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
// Parse mediaItemID
mediaItemID := c.Param("mediaItemId")
parsedUUID, err := uuid.Parse(mediaItemID)
if err != nil {
return renderErrorPage(c, "Invalid media item ID", "invalid_id")
}
// Fetch media item (directly, no handler)
mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
if err != nil {
return renderErrorPage(c, "Media item not found", "not_found")
}
// Check library access (using pattern from opds.go)
userUUID, _ := uuid.Parse(user.ID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
return renderErrorPage(c, "Failed to check access", "access_error")
}
visible := false
for _, lib := range libraries {
if lib.ID == mediaItem.LibraryID {
visible = true
break
}
}
if !visible {
return renderErrorPage(c, "Access denied", "access_denied")
}
// Library type decides comic reader treatment: fixed-layout items
// (e.g. manga PDFs) shelved in comics/manga libraries are paged
// through like comics — bookmarks only, no highlights or search.
libraryWithType, err := cfg.Queries.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
if err != nil {
return renderErrorPage(c, "Failed to get library info", "library_error")
}
// Convert to template types. Reading state is deliberately NOT
// fetched or embedded: the reader pulls position, bookmarks, and
// annotations from the APIs at open time so the page can never
// carry (nor write back) a stale snapshot.
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16])
metadata := templates.ReaderMetadata{
MediaItemID: mediaUUID.String(),
Title: mediaItem.Title,
Author: utils.PgTextString(mediaItem.Author),
// Sidecar covers store a raw library-relative path; resolve to
// the cookie-authed /uploads URL (idempotent for items whose
// stored value is already a servable path).
CoverImagePath: utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath),
LibraryType: mediaItem.FormatGroup,
MimeType: utils.PgTextString(mediaItem.MimeType),
FilePath: mediaItem.FilePath,
TotalPages: int(mediaItem.PageCount.Int32),
ChapterCount: int(mediaItem.ChapterCount.Int32),
FormatGroup: mediaItem.FormatGroup,
MangaType: utils.PgTextString(mediaItem.MangaType),
ReadingDirection: utils.PgTextString(mediaItem.ReadingDirection),
LibraryID: libUUID.String(),
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
TotalCharacters: mediaItem.TotalCharacters.Int64,
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
KeepScreenAwake: user.KeepScreenAwake,
}
// Podcast chaining (plan D7): episodes render the audio reader
// with sequence context — the neighboring DOWNLOADED episodes in
// the caller's effective order (override applied). RLS already
// scoped the episode read; the subscription predicate covers the
// neighbors the same way. ?single=1 escapes chaining (the "play
// single episode" affordance).
if mediaItem.Source == "podcast_episode" && mediaItem.SourceFeedID.Valid && c.QueryParam("single") != "1" {
userPG := uuidToPGType(userUUID)
if seq := buildPodcastSequenceContext(c, cfg, &mediaItem, userPG, libraryWithType.TypeName); seq != nil {
metadata.PodcastSequence = seq
}
}
// Render template
var buf bytes.Buffer
// Audiobooks get the audio player page — the foliate document
// engine is deliberately not involved (handoff item 3 Batch A).
if mediaItem.FormatGroup == "audiobook" {
err = templates.AudioReader(user, metadata).Render(c.Request().Context(), &buf)
} else {
err = templates.Reader(user, metadata).Render(c.Request().Context(), &buf)
}
if err != nil {
return renderErrorPage(c, "Error rendering reader", "render_error")
}
return c.HTML(http.StatusOK, buf.String())
})
// Content serving (lazy-loaded pages)
reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage)
// Chapter metadata
reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters)
// Panel data (comics/manga)
reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels)
reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels)
// PDF outline/TOC
reader.GET("/:mediaItemId/outline", cfg.ReaderHandler.GetPDFOutline)
// PDF thumbnails (for mini-map)
reader.GET("/:mediaItemId/thumbnails/:pageNumber", cfg.ReaderHandler.GetPDFThumbnail)
// Reading speed
reader.GET("/:mediaItemId/reading-speed", cfg.ReaderHandler.GetReadingSpeed)
reader.POST("/:mediaItemId/reading-speed", cfg.ReaderHandler.UpdateReadingSpeed)
// Dictionary lookup
reader.GET("/dictionary/:word", cfg.ReaderHandler.LookupWord)
// Reader settings
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
}
// buildPodcastSequenceContext computes the episode chain payload (plan
// D7): effective order — the D5 override applied, feed-native fallback —
// DOWNLOADED episodes only (gaps never enter the queue), the current
// episode's index, and prev/next reader URLs. RLS scopes the episode
// reads; non-subscribers never reach this path in the first place.
func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, libraryType string) *templates.PodcastSequenceContext {
ctx := c.Request().Context()
// Queue override (plan item 2.3): ?queue=collection:<id> plays the
// episode inside an Up Next collection chain. Absent/malformed/
// not-owned ids fall back to the feed queue — the reader page must
// never hard-fail on a stale link.
if q := c.QueryParam("queue"); strings.HasPrefix(q, "collection:") {
if collID, err := uuid.Parse(strings.TrimPrefix(q, "collection:")); err == nil {
if seq := buildCollectionSequenceContext(c, cfg, item, userID, collID); seq != nil {
return seq
}
}
}
eps, err := cfg.Queries.ListPodcastEpisodes(ctx, item.SourceFeedID)
if err != nil || len(eps) == 0 {
return nil
}
feed, err := cfg.Queries.GetPodcastFeed(ctx, item.SourceFeedID)
if err != nil {
return nil
}
// Effective order override (D5): reader_settings KV.
order := "native"
if row, err := cfg.Queries.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
UserID: userID,
SettingKey: "podcast_order:" + uuid.UUID(item.SourceFeedID.Bytes).String(),
}); err == nil {
var v struct {
Order string `json:"order"`
}
if json.Unmarshal(row, &v) == nil && services.ValidPodcastEpisodeOrder(v.Order) {
order = v.Order
}
}
type seqEp struct {
id string
title string
date string
pos int32
}
ordered := make([]seqEp, 0, len(eps))
currentID := uuid.UUID(item.ID.Bytes).String()
for i := range eps {
ep := eps[i]
if ep.DownloadState != "downloaded" {
continue // un-downloaded episodes are skipped, not tracked (invariant 6)
}
date := ""
if ep.DatePublished.Valid {
date = ep.DatePublished.Time.Format("2006-01-02")
}
ordered = append(ordered, seqEp{
id: uuid.UUID(ep.ID.Bytes).String(),
title: ep.Title,
date: date,
pos: ep.FeedPosition.Int32,
})
}
// Effective listening order (order consolidation §0 item 5a).
services.OrderEpisodes(ordered, order,
func(e seqEp) int32 { return e.pos },
func(e seqEp) string { return e.date },
)
idx := -1
for i, ep := range ordered {
if ep.id == currentID {
idx = i
break
}
}
if idx < 0 {
return nil // current episode not downloaded (or not in sequence)
}
seq := &templates.PodcastSequenceContext{
FeedURL: "/podcasts/" + uuid.UUID(item.SourceFeedID.Bytes).String(),
FeedTitle: feed.Title,
// R2: this producer is the feed queue. The player's order
// control renders only for this source.
Source: "feed",
FeedID: uuid.UUID(item.SourceFeedID.Bytes).String(),
Order: order,
EpisodeIndex: idx + 1,
IndexTotal: len(ordered),
}
if idx > 0 {
seq.PrevURL = "/readers/" + ordered[idx-1].id
}
if idx < len(ordered)-1 {
seq.NextURL = "/readers/" + ordered[idx+1].id
seq.NextTitle = ordered[idx+1].title
}
return seq
}
// buildCollectionSequenceContext is the COLLECTION queue producer (plan
// item 2.3, §10 R2: same context shape, different owner). The queue is
// the collection's ordered downloaded episodes — the collection's
// order IS content (R1), so no feed-order override applies here and
// the player's order control stays hidden (source=collection).
// Ownership gates the chain: collections are user-scoped app state
// (no RLS on the table), and a foreign or deleted collection id falls
// back to the feed queue. Prev/next URLs carry ?queue=collection:<id>
// so the chain survives hops; backToDetail lands on the collection.
func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, collID uuid.UUID) *templates.PodcastSequenceContext {
ctx := c.Request().Context()
owns, err := cfg.CollectionHandler.CollectionService().UserOwnsCollection(ctx, collID, uuid.UUID(userID.Bytes))
if err != nil || !owns {
return nil
}
coll, err := cfg.Queries.GetCollection(ctx, pgtype.UUID{Bytes: collID, Valid: true})
if err != nil {
return nil
}
// Smart collections compute their membership live (R1: computed
// order, never written back) — evaluate the rules for THIS viewer
// and chain the downloaded episode matches in native feed order
// (R3: every listening order routes through OrderEpisodes).
if services.IsCollectionSmart(coll) {
return buildSmartCollectionSequenceContext(c, cfg, item, userID, coll)
}
// Downloaded episodes in collection order (SQL orders; RLS scopes
// the join — unsubscribed feeds drop out and the chain skips them).
eps, err := cfg.Queries.GetCollectionEpisodesOrdered(ctx, pgtype.UUID{Bytes: collID, Valid: true})
if err != nil || len(eps) == 0 {
return nil
}
// The curated listening order applies to static episode playlists
// too (§10 R2): native/"" keeps the manual position order (the SQL
// order above); reverse flips it; oldest/newest re-key on
// publication date with feed position as the tiebreak.
switch order := handlers.CollectionChainOrder(coll.ViewSettings); order {
case "reverse":
for i, j := 0, len(eps)-1; i < j; i, j = i+1, j-1 {
eps[i], eps[j] = eps[j], eps[i]
}
case "oldest", "newest":
services.OrderEpisodes(eps, order,
func(e database.GetCollectionEpisodesOrderedRow) int32 { return e.FeedPosition.Int32 },
func(e database.GetCollectionEpisodesOrderedRow) string {
if e.DatePublished.Valid {
return e.DatePublished.Time.Format("2006-01-02")
}
return ""
})
}
currentID := uuid.UUID(item.ID.Bytes).String()
idx := -1
for i, ep := range eps {
if uuid.UUID(ep.ID.Bytes).String() == currentID {
idx = i
break
}
}
if idx < 0 {
return nil // current episode not downloaded (or not in sequence)
}
queueParam := "?queue=collection:" + collID.String()
seq := &templates.PodcastSequenceContext{
FeedURL: "/collections/" + collID.String(),
FeedTitle: coll.Name,
Source: "collection",
EpisodeIndex: idx + 1,
IndexTotal: len(eps),
}
if idx > 0 {
seq.PrevURL = "/readers/" + uuid.UUID(eps[idx-1].ID.Bytes).String() + queueParam
}
if idx < len(eps)-1 {
seq.NextURL = "/readers/" + uuid.UUID(eps[idx+1].ID.Bytes).String() + queueParam
seq.NextTitle = eps[idx+1].Title
}
return seq
}
// buildSmartCollectionSequenceContext chains a SMART collection's live
// membership (plan item 2.5 acceptance: "Daily Fiction" → Play =
// today's episodes back-to-back). The rules evaluate for the requesting
// user (mode from the collection; family lens via the visible set);
// episode matches that are downloaded chain in native feed order.
// Books and un-downloaded matches never enter the queue (invariant 6).
func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, coll database.Collections) *templates.PodcastSequenceContext {
ctx := c.Request().Context()
colService := cfg.CollectionHandler.CollectionService()
visibleIDs, err := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if err != nil {
visibleIDs = nil
}
matches, err := colService.EvaluateSmartMembership(ctx, coll, services.SmartEvalOptions{
VisibleIDs: visibleIDs,
ViewerUserID: userID,
})
if err != nil {
return nil
}
type seqEp struct {
id string
title string
date string
pos int32
}
ordered := make([]seqEp, 0, len(matches))
currentID := uuid.UUID(item.ID.Bytes).String()
for _, m := range matches {
if m.Source != "podcast_episode" || m.DownloadState != "downloaded" {
continue
}
date := ""
if m.DatePublished.Valid {
date = m.DatePublished.Time.Format("2006-01-02")
}
ordered = append(ordered, seqEp{
id: uuid.UUID(m.ID.Bytes).String(),
title: m.Title,
date: date,
pos: m.FeedPosition.Int32,
})
}
if len(ordered) == 0 {
return nil
}
// Plan §10 R2: the collection's CURATED listening order (stored in
// view_settings.chain_order, set from the detail header) — not a
// player-side knob. "" / native / unknown all mean feed order.
services.OrderEpisodes(ordered, handlers.CollectionChainOrder(coll.ViewSettings),
func(e seqEp) int32 { return e.pos },
func(e seqEp) string { return e.date },
)
idx := -1
for i, ep := range ordered {
if ep.id == currentID {
idx = i
break
}
}
if idx < 0 {
return nil
}
collID := uuid.UUID(coll.ID.Bytes).String()
queueParam := "?queue=collection:" + collID
seq := &templates.PodcastSequenceContext{
FeedURL: "/collections/" + collID,
FeedTitle: coll.Name,
Source: "collection",
EpisodeIndex: idx + 1,
IndexTotal: len(ordered),
}
if idx > 0 {
seq.PrevURL = "/readers/" + ordered[idx-1].id + queueParam
}
if idx < len(ordered)-1 {
seq.NextURL = "/readers/" + ordered[idx+1].id + queueParam
seq.NextTitle = ordered[idx+1].title
}
return seq
}