"Newest episodes from my selected podcasts" is now buildable in the UI, end to end — the user could not find ANY podcast-related filter surface because none existed: the New Collection modal could only create book-scope smart collections, and the engine's episodes scope (item 3.1) had no authoring door. The scope door: - The create modal's Smart card gains a Rules-scope segmented control (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart means "match books by rules". Choosing Episodes seeds an EMPTY episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the collection is born episode-scoped: the editor opens with the episode vocabulary (feed/downloaded/played/published date) and membership runs the episode engine from the first rule. - ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay invalid) — the seed's legal shape. The root arrives at depth 1 by call convention, hence the depth>1 exemption. - The composers define the empty-tree semantics: zero children matches NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring EvaluateSmartCollection's zero-enabled-rules convention. Without the guard the composed query degrades to the bare scope head and a fresh collection would swallow every episode in scope — pinned by TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0) and TestEmptyRootTreeSeedsEpisodesScope. The curated chain order (plan §10 R2, user need: the briefing plays NEWEST-first): - view_settings.chain_order ("native"/"newest"/"oldest"/"reverse") stored on the collection — curated content, not a player-side knob. - PUT /api/collections/:id/view-settings merges the key into the stored JSONB (a blind write would clobber the playlist toggles). - buildSmartCollectionSequenceContext routes OrderEpisodes through handlers.CollectionChainOrder; the detail header's Play-as-Sequence gains the order select next to it (PlayCollectionButton), which PUTs and reloads. Pinned by chain_follows_the_curated_order (reverse flips the chain indexes and hops). Caught by the new test, a latent clobber fixed: UpdateCollection treated a NIL view_settings payload as "write NULL" — any edit that carried no settings dropped every stored key (chain_order, playlist toggles from API clients, imported provenance). Nil now means untouched, the same convention filterTree already had. Composer guards also cover ComposedCount, so the builder's live count reads the same semantics. Tests: collectionChainOrder unit matrix, empty-root parse unit pin, two integration pins (seed matches nothing; curated order drives the chain and survives whole-map edits).
414 lines
14 KiB
Go
414 lines
14 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
|
|
}
|
|
|
|
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,
|
|
MatchMode: coll.RuleMatchMode,
|
|
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
|
|
}
|