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: 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: // 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 }