feat(podcasts): episode collections — picker scope, hint rows, the chain (item 2.3)
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).
This commit is contained in:
1 parent
a90e7debfd
commit
adb93cba9e
11 files changed
+1636
-394
No files matched your search
@@ -331,11 +331,17 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
|
||||
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
|
||||
}
|
||||
} else {
|
||||
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID)
|
||||
// Episode collections (plan item 2.3): the detailed query
|
||||
// keeps RLS-invisible rows as Hidden cards (unsubscribed
|
||||
// episodes render an "unavailable" hint) and tags episode
|
||||
// rows with feed title + download state. Same position
|
||||
// ordering; the legacy INNER-JOIN shape remains on the
|
||||
// library-scoped branch above.
|
||||
collItems, collErr := cfg.Queries.GetCollectionItemsDetailed(c.Request().Context(), collUUID)
|
||||
if collErr != nil {
|
||||
collItems = nil
|
||||
}
|
||||
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
|
||||
books = handlers.BookCardsFromCollectionDetailed(collItems, libTypes)
|
||||
}
|
||||
}
|
||||
return books, isSmart, ""
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -166,6 +167,17 @@ func registerReaderRoutes(cfg *Config) {
|
||||
// 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
|
||||
@@ -250,3 +262,59 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -41,6 +41,24 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
||||
if !ok {
|
||||
return c.String(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
// Episode picker scope (plan item 2.3): ?scope=episodes&show_checkbox=true
|
||||
// swaps the book cards for YOUR subscriptions' episodes — visibility
|
||||
// is the RLS policy's job (D20), the Go side adds nothing. The books
|
||||
// branch keeps its podcasts exclusion; this is the only door in.
|
||||
if c.QueryParam("scope") == "episodes" && c.QueryParam("show_checkbox") == "true" {
|
||||
eps, err := cfg.Queries.SearchEpisodesForPicker(c.Request().Context(), c.QueryParam("q"))
|
||||
if err != nil {
|
||||
log.Printf("Episode picker search error: %v", err)
|
||||
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := templates.EpisodePickerGrid(eps).Render(c.Request().Context(), &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}
|
||||
|
||||
// Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
|
||||
// — the HTMX grid partial and the JSON endpoint can never disagree on
|
||||
// which facet params exist or how they parse. (This branch previously
|
||||
|
||||
Reference in new issue
Block a user