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
@@ -262,11 +262,25 @@ type Querier interface {
|
||||
// rename can't spawn duplicates on the next scan.
|
||||
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
|
||||
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
|
||||
// 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).
|
||||
GetCollectionEpisodesOrdered(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionEpisodesOrderedRow, error)
|
||||
// Get collection items
|
||||
// A2c: position-first ordering (playlist reading order); un-positioned
|
||||
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
||||
// which walked a playlist backwards.
|
||||
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
|
||||
// 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).
|
||||
GetCollectionItemsDetailed(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsDetailedRow, error)
|
||||
// The excluded filter lives here (was: callers filtered ci.excluded in
|
||||
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
||||
// returns the real row so neither exists).
|
||||
@@ -762,6 +776,14 @@ type Querier interface {
|
||||
RevokeOpdsToken(ctx context.Context, token string) error
|
||||
RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error
|
||||
SearchAuthorValues(ctx context.Context, arg SearchAuthorValuesParams) ([]SearchAuthorValuesRow, error)
|
||||
// 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.
|
||||
SearchEpisodesForPicker(ctx context.Context, q_ string) ([]SearchEpisodesForPickerRow, error)
|
||||
SearchGenreValues(ctx context.Context, arg SearchGenreValuesParams) ([]SearchGenreValuesRow, error)
|
||||
SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error)
|
||||
// Search Media Items queries
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.*
|
||||
|
||||
Reference in new issue
Block a user