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.*
|
||||
|
||||
@@ -43,6 +43,18 @@ type BookInfo struct {
|
||||
// it empty rather than pay a join).
|
||||
ViewerPercentage *float64 `json:"viewer_percentage"`
|
||||
LibraryName string `json:"library_name"`
|
||||
// Episode-collections extras (plan item 2.3): populated only by the
|
||||
// collection-detail adapter. IsEpisode renders the episode-row
|
||||
// variant in the grid; FeedTitle disambiguates same-named episodes;
|
||||
// DownloadState gates the play affordance. Hidden=true means the
|
||||
// LEFT JOIN missed under RLS (unsubscribed episode, or a
|
||||
// family-lens hidden book) — the row renders as an "unavailable"
|
||||
// hint instead of silently vanishing. Adapters that don't load
|
||||
// visibility leave Hidden false (cards stay fully rendered).
|
||||
IsEpisode bool `json:"is_episode,omitempty"`
|
||||
FeedTitle string `json:"feed_title,omitempty"`
|
||||
DownloadState string `json:"download_state,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
|
||||
@@ -151,8 +163,7 @@ func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashbo
|
||||
ID: uuid.UUID(mi.ID.Bytes),
|
||||
Title: mi.Title,
|
||||
Author: utils.PgTextString(mi.Author),
|
||||
LibraryID: mi.LibraryID,
|
||||
CoverPath: mi.CoverImagePath,
|
||||
LibraryID: mi.LibraryID, CoverPath: mi.CoverImagePath,
|
||||
FormatGroup: mi.FormatGroup,
|
||||
LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()],
|
||||
}))
|
||||
@@ -180,6 +191,39 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
|
||||
return cards
|
||||
}
|
||||
|
||||
// BookCardsFromCollectionDetailed builds cards from the
|
||||
// GetCollectionItemsDetailed rows (plan item 2.3): the collection's
|
||||
// full membership with per-row visibility. RLS-invisible rows
|
||||
// (unsubscribed episode, family-lens hidden book) become Hidden cards
|
||||
// — the grid renders them as "unavailable" hint rows; episode rows
|
||||
// carry feed title + download state for the play affordance. Unknown
|
||||
// library types resolve no badge; hidden rows need none anyway.
|
||||
func BookCardsFromCollectionDetailed(rows []database.GetCollectionItemsDetailedRow, libTypes map[string]string) []BookInfo {
|
||||
cards := make([]BookInfo, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if !r.Visible {
|
||||
cards = append(cards, BookInfo{
|
||||
MediaItemID: uuid.UUID(r.MediaItemID.Bytes).String(),
|
||||
Hidden: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
isEpisode := r.Source.String == "podcast_episode"
|
||||
card := buildBookCard(cardSource{
|
||||
ID: uuid.UUID(r.MediaItemID.Bytes),
|
||||
Title: utils.PgTextString(r.Title),
|
||||
Author: utils.PgTextString(r.Author),
|
||||
CoverPath: r.CoverImagePath,
|
||||
FormatGroup: r.FormatGroup.String,
|
||||
})
|
||||
card.IsEpisode = isEpisode
|
||||
card.FeedTitle = r.FeedTitle.String
|
||||
card.DownloadState = r.DownloadState.String
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
// BookCardsFromUnified builds cards from search-unified rows. Those
|
||||
// rows carry the library type name as a SQL column, so this adapter
|
||||
// needs no LibraryTypeMap query at all — and the viewer's own
|
||||
|
||||
@@ -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