collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it).
411 lines
14 KiB
Go
411 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
|
|
}
|
|
services.OrderEpisodes(ordered, "native",
|
|
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
|
|
}
|