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

+177
View File
@@ -4480,6 +4480,55 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect
return i, err
}
const GetCollectionEpisodesOrdered = `-- name: GetCollectionEpisodesOrdered :many
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
`
type GetCollectionEpisodesOrderedRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
}
// 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).
func (q *Queries) GetCollectionEpisodesOrdered(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionEpisodesOrderedRow, error) {
rows, err := q.db.Query(ctx, GetCollectionEpisodesOrdered, collectionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetCollectionEpisodesOrderedRow{}
for rows.Next() {
var i GetCollectionEpisodesOrderedRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.FeedPosition,
&i.DatePublished,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCollectionItems = `-- name: GetCollectionItems :many
SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_id, ci.excluded, ci.position, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
FROM collection_items ci
@@ -4542,6 +4591,73 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU
return items, nil
}
const GetCollectionItemsDetailed = `-- name: GetCollectionItemsDetailed :many
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
`
type GetCollectionItemsDetailedRow struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Position pgtype.Int4 `db:"position" json:"position"`
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
Visible bool `db:"visible" json:"visible"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
FormatGroup pgtype.Text `db:"format_group" json:"format_group"`
Source pgtype.Text `db:"source" json:"source"`
DownloadState pgtype.Text `db:"download_state" json:"download_state"`
FeedTitle pgtype.Text `db:"feed_title" json:"feed_title"`
}
// 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).
func (q *Queries) GetCollectionItemsDetailed(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsDetailedRow, error) {
rows, err := q.db.Query(ctx, GetCollectionItemsDetailed, collectionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetCollectionItemsDetailedRow{}
for rows.Next() {
var i GetCollectionItemsDetailedRow
if err := rows.Scan(
&i.MediaItemID,
&i.Position,
&i.AddedAt,
&i.Visible,
&i.Title,
&i.Author,
&i.CoverImagePath,
&i.FormatGroup,
&i.Source,
&i.DownloadState,
&i.FeedTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, rp.percentage AS viewer_percentage FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
@@ -16478,6 +16594,67 @@ func (q *Queries) SearchAuthorValues(ctx context.Context, arg SearchAuthorValues
return items, nil
}
const SearchEpisodesForPicker = `-- name: SearchEpisodesForPicker :many
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 ($1::text = '' OR mi.title ILIKE '%' || $1::text || '%')
ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST
LIMIT 60
`
type SearchEpisodesForPickerRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FormatGroup string `db:"format_group" json:"format_group"`
DownloadState string `db:"download_state" json:"download_state"`
FeedTitle string `db:"feed_title" json:"feed_title"`
}
// 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.
func (q *Queries) SearchEpisodesForPicker(ctx context.Context, q_ string) ([]SearchEpisodesForPickerRow, error) {
rows, err := q.db.Query(ctx, SearchEpisodesForPicker, q_)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SearchEpisodesForPickerRow{}
for rows.Next() {
var i SearchEpisodesForPickerRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.CoverImagePath,
&i.LibraryID,
&i.FormatGroup,
&i.DownloadState,
&i.FeedTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const SearchGenreValues = `-- name: SearchGenreValues :many
SELECT
mi.genre as value,