Files
bookhoard/internal/router/reader.go
T
John O'Keefe e3675536aa feat(podcasts): reverse order — position DESC, date-agnostic; single order helper
The 'Oldest first' order sorts by PUB DATE, which breaks on Scribl's
migrated podiobooks feeds (messy/all-equal dates). New order value
'reverse' = feed position DESC with a date-DESC tiebreak — dates are
not involved, so nothing can break.

- services.OrderEpisodes: THE single order source of truth (§0 items
  3+5a). The four surfaces (sequence JSON, reader chain context, SSR
  page list) previously carried three near-identical handwritten
  comparators — all now route through one generic helper. The SSR page
  list gains a SortDate key (lexical 2006-01-02) so the pub-date
  tiebreak is actually chronological; the old comparator compared
  'Jan 2, 2006' display strings alphabetically (latent drift bug).
- Dropdown becomes four options: Feed order / Feed order (reversed) /
  Oldest first (pub date) / Newest first (pub date).
- Serial suggestion button renamed 'Reverse feed order' (sets reverse,
  not oldest) and its response out-of-band-swaps the order select
  (PodcastOrderSelectOOB — same mechanism as Download All's policy
  select), so the dropdown mirrors the server state immediately.
- Validation: oneof=native reverse oldest newest everywhere.
- Tests: reverse binds via the form wire; messy-dates sequence
  returns 3,2,1; existing reorder-safety stays green.
2026-10-30 09:15:03 -04:00

248 lines
8.6 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"
"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()
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,
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
}