D11 lifts: podcast episodes (media_items rows) join collections, and "Play collection as sequence" feeds the player from the collection's order. No new tables — collection_items already carries ordered membership (item 2.1 audit). PICKER: the collection modal gains a Books/Episodes tab pair. The episodes tab hits /api/media-items/search?scope=episodes — a dedicated picker query (SearchEpisodesForPicker) whose visibility is entirely RLS: the media_items subscription predicate drops unsubscribed feeds' episodes, and the podcast_feeds join is governed the same way. The books scope keeps its podcasts exclusion (plan §9) — this scope is the only door in. Pending episodes are pickable (plan ahead; the detail page shows them as not downloaded). Selection rides the existing bookPicker store — ids never collide across scopes, and AddBooks works unchanged on episode ids. DETAIL: the All-Libraries membership view switches to GetCollectionItemsDetailed — LEFT JOIN + per-row visibility. An unsubscribed episode (or family-lens hidden book) survives as a Hidden card rendered as an "Unavailable — remove entry" hint row instead of silently vanishing; visible episodes render feed title, download state, and a Play link. The library-scoped view keeps its existing dashboard-query behavior (the switcher's contract is untouched for books). CHAIN: ?queue=collection:<id> on any reader URL makes buildPodcastSequenceContext produce the COLLECTION context (§10 R2 same shape, source=collection): downloaded episodes in collection order (positions are content — R1; no feed-order override), prev/next URLs carrying the queue param so the chain survives hops, and backToDetail landing on the collection page. Ownership gates the chain — foreign/deleted ids fall back to the feed queue; the order control stays hidden (2.2's source gate). The header's "Play as Sequence" button starts from the first downloaded episode in list order. Pinned by TestPodcastEpisodeCollections (7 subtests): RLS-scoped picker + family-lens auditor case (subscribed episode in a hidden library must not reach the picker), books-scope exclusion intact, hint rows, chain carrying the queue param, start-from-first, foreign fallback, and reorder driving the chain (the R4 primitive works on episode ids unchanged).
321 lines
11 KiB
Go
321 lines
11 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
|
|
}
|
|
// 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
|
|
}
|