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:
John O'Keefe committed 2026-10-30 16:18:29 -04:00
1 parent a90e7debfd
commit adb93cba9e
11 files changed
+1636 -394

No files matched your search

+56
View File
@@ -932,6 +932,26 @@ ORDER BY
mi.title ASC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: SearchEpisodesForPicker :many
-- Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
-- The podcasts exclusion in SearchMediaItemsUnified stays (books
-- surfaces never list podcasts — plan §9); THIS query is the episode
-- picker's dedicated scope. Visibility is the engine's job (D20): the
-- media_items policy drops episodes from unsubscribed feeds and the
-- podcast_feeds join is governed the same way — non-subscribers see
-- nothing here without any Go-side clause.
SELECT mi.id, mi.title, mi.author, mi.cover_image_path, mi.library_id,
mi.format_group, mi.download_state,
pf.title AS feed_title
FROM media_items mi
JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
WHERE mi.source = 'podcast_episode'
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.arg('q')::text = '' OR mi.title ILIKE '%' || sqlc.arg('q')::text || '%')
ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST
LIMIT 60;
-- name: SearchAuthorValues :many
SELECT
mi.author as value,
@@ -3576,6 +3596,42 @@ WHERE ci.collection_id = $1
AND mi.missing_scan_count = 0
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
-- name: GetCollectionItemsDetailed :many
-- Episode-collections detail (plan item 2.3): the collection's full
-- membership for the SSR detail page, with per-row visibility. LEFT
-- JOIN + RLS: rows whose media is invisible to the requester
-- (unsubscribed episode — or a family-lens hidden book) survive with
-- NULL media columns and visible=false, so the page can render an
-- "unavailable" hint instead of silently vanishing them. Rows visible
-- but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
SELECT ci.media_item_id, ci.position, ci.added_at,
(mi.id IS NOT NULL)::bool AS visible,
mi.title, mi.author, mi.cover_image_path, mi.format_group,
mi.source, mi.download_state,
pf.title AS feed_title
FROM collection_items ci
LEFT JOIN media_items mi ON ci.media_item_id = mi.id
LEFT JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
WHERE ci.collection_id = $1
AND (mi.id IS NULL OR (mi.archived_at IS NULL AND mi.missing_scan_count = 0))
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
-- name: GetCollectionEpisodesOrdered :many
-- The collection queue's downloaded episodes in collection order
-- (plan item 2.3 "Play collection as sequence"): position-first, then
-- added_at. INNER JOIN — the chain steps only through episodes the
-- requester can see (RLS drops unsubscribed feeds) and that are
-- downloaded (invariant 6: un-downloaded episodes are never tracked).
SELECT mi.id, mi.title, mi.feed_position, mi.date_published
FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
AND mi.source = 'podcast_episode'
AND mi.download_state = 'downloaded'
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
-- Get collections for book
-- name: GetCollectionsForBook :many
SELECT c.*