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
+1657 -415

No files matched your search

+205
View File
@@ -560,6 +560,211 @@ func TestPodcastPlayerOrderControl(t *testing.T) {
}) })
} }
// ---- item 2.3: episode collections — picker scope, hint rows, the
// collection chain (queue=collection:<id>) ----
func TestPodcastEpisodeCollections(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
lib, _ := createPodcastsLibrary(t, setup, "test EpiColl Podcasts")
marker := fmt.Sprintf("epicoll%d", time.Now().UnixNano()%100000)
// Feed A: the regular user SUBSCRIBES. Feed B: collected-but-UNSUBSCRIBED
// (the plan's hint-row case). Admin identity seeds both feeds' rows.
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "EpiColl Feed A " + marker,
})
require.NoError(t, err)
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "EpiColl Feed B " + marker,
})
require.NoError(t, err)
a0 := upsertTestEpisode(t, setup, feedA, marker+"-a0", "EpiColl A Zero", 1)
a1 := upsertTestEpisode(t, setup, feedA, marker+"-a1", "EpiColl A One", 2)
a3 := upsertTestEpisode(t, setup, feedA, marker+"-a3", "EpiColl A Three", 4)
a2 := upsertTestEpisode(t, setup, feedA, marker+"-a2", "EpiColl A Two Pending", 3)
b1 := upsertTestEpisode(t, setup, feedB, marker+"-b1", "EpiColl B One", 1)
adminScopeExec(t, setup, adminCtxValue(t, setup),
`UPDATE media_items SET download_state = 'downloaded', file_size = 1000
WHERE id IN ($1, $2, $3, $4) AND source = 'podcast_episode'`,
a0.ID, a1.ID, a3.ID, b1.ID) // a2 stays pending
subscribeRaw(t, setup, regUUID, feedA.ID)
// The collection (regular user's own) via the real API path.
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test Up Next `+marker+`","description":"episode collection test"}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
collID := coll.ID
// Add episodes through the same endpoint the picker submits —
// book_ids is the key for ANY media id. Includes the unsubscribed
// b1: the insert must succeed; the DETAIL page decides visibility.
code, body = podcastAuthed(t, setup, regToken, "POST", "/api/collections/"+collID+"/books",
`{"book_ids":["`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a1.ID.Bytes).String()+`","`+uuid.UUID(a3.ID.Bytes).String()+`","`+uuid.UUID(a2.ID.Bytes).String()+`","`+uuid.UUID(b1.ID.Bytes).String()+`"]}`)
require.Equal(t, http.StatusNoContent, code, "%s", string(body))
// Pin the collection order explicitly (added_at ties are possible —
// same-transaction inserts share NOW(); positions make it
// deterministic). Order: A3, A0, A1 (+ pending A2, invisible B1).
code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/items/order",
`{"item_ids":["`+uuid.UUID(a3.ID.Bytes).String()+`","`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a1.ID.Bytes).String()+`"]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
// Sequence config rides a data-json ATTRIBUTE — templ escapes the
// quotes (&#34;); pins must match the escaped form.
esc := func(s string) string { return strings.ReplaceAll(s, `"`, "&#34;") }
t.Run("picker_scope_episodes_rls_scoped", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true&scope=episodes", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
html := buf.String()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, html, "EpiColl A Zero", "subscribed feed's episodes appear")
assert.Contains(t, html, "EpiColl Feed A", "rows carry the feed title")
assert.Contains(t, html, "EpiColl A Two Pending", "pending episodes are pickable — the detail page shows them as not downloaded")
assert.Contains(t, html, "not downloaded", "pending picker rows carry the honest badge")
assert.NotContains(t, html, "EpiColl B One", "unsubscribed feed's episodes are RLS-dropped from the picker")
})
t.Run("picker_books_scope_still_excludes_podcasts", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
assert.NotContains(t, buf.String(), "EpiColl A Zero",
"the books scope keeps its podcasts exclusion (plan §9) — episodes enter ONLY through the episode scope")
})
t.Run("picker_respects_family_lens_hidden_libraries", func(t *testing.T) {
// Invariant-13 auditor case: a SUBSCRIBED episode in a
// family-HIDDEN library still drops — the episode predicate
// ANDs subscription with the family lens. (RLS auditor:
// membership-visible surfaces leak nothing from hidden libs.)
libH, _ := createPodcastsLibrary(t, setup, "test EpiColl Hidden")
feedH, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: libH.ID,
FeedUrl: "https://feed.invalid/" + marker + "-h.xml",
Title: "EpiColl Feed Hidden " + marker,
})
require.NoError(t, err)
upsertTestEpisode(t, setup, feedH, marker+"-h1", "EpiColl Hidden One", 1)
subscribeRaw(t, setup, regUUID, feedH.ID)
_, err = setup.DB.SetLibraryVisibility(seedCtx(), database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: regUUID, Valid: true},
LibraryID: libH.ID,
IsVisible: false,
})
require.NoError(t, err)
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true&scope=episodes", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
assert.NotContains(t, buf.String(), "EpiColl Hidden One",
"LEAK: a subscribed episode in a family-hidden library must not reach the picker")
})
t.Run("detail_renders_episode_rows_and_unsubscribed_hint", func(t *testing.T) {
// The membership view (All Libraries cookie) shows the full
// collection including hint rows; the library-scoped view keeps
// its existing INNER-JOIN behavior (that's the switcher's
// contract, unchanged for books).
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+collID, nil)
req.Header.Set("Authorization", "Bearer "+regToken)
req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"})
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
require.Equal(t, http.StatusOK, resp.StatusCode)
html := buf.String()
assert.Contains(t, html, "EpiColl A One", "subscribed downloaded episode renders")
assert.Contains(t, html, "EpiColl Feed A", "episode rows carry the feed title")
assert.Contains(t, html, "Play", "downloaded episodes get the play affordance")
assert.Contains(t, html, "Not downloaded", "pending episode renders the honest state")
assert.Contains(t, html, "Unavailable", "collected-but-unsubscribed episode renders the hint row (LEFT JOIN)")
assert.Contains(t, html, "Remove entry", "hint rows stay removable")
assert.Contains(t, html, "Play as Sequence", "header sequence-start button renders when something can play")
})
t.Run("collection_chain_carries_queue_param", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
// The collection's pinned order is A3 → A0 → A1; a1 is LAST:
// prev = A0 (second-to-last), no next.
assert.Contains(t, html, esc(`"source":"collection"`))
assert.Contains(t, html, esc(`"episode_index":3`))
assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
"prev hop carries the queue param — the chain survives")
assert.NotContains(t, html, esc(`"next_url"`), "end of the collection queue")
assert.NotContains(t, html, `id="player-order-select"`,
"the order control is FEED-mode only (§10 R2) — collection order is content")
})
t.Run("start_button_plays_from_first_in_order", func(t *testing.T) {
// The header button starts from the FIRST playable episode in
// collection order (a3) — and that page chains forward to a0.
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a3.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"episode_index":1`))
assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`))
})
t.Run("foreign_collection_falls_back_to_feed_queue", func(t *testing.T) {
foreign := uuid.New().String()
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+foreign, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"source":"feed"`), "not-owned collection id falls back to the feed queue")
assert.Contains(t, html, `id="player-order-select"`, "feed mode renders its order control")
})
t.Run("reorder_changes_the_chain", func(t *testing.T) {
// Reverse to A1 → A0 → A3: the same reader page now chains the
// other way — collection order IS the queue order (R1 content).
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/items/order",
`{"item_ids":["`+uuid.UUID(a1.ID.Bytes).String()+`","`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a3.ID.Bytes).String()+`"]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
code, body = podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"episode_index":1`))
assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`))
})
}
// ---- download worker: state machine, idempotency, file layout ---- // ---- download worker: state machine, idempotency, file layout ----
func TestPodcastDownloadWorker(t *testing.T) { func TestPodcastDownloadWorker(t *testing.T) {
+22
View File
@@ -262,11 +262,25 @@ type Querier interface {
// rename can't spawn duplicates on the next scan. // rename can't spawn duplicates on the next scan.
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error) GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (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 // Get collection items
// A2c: position-first ordering (playlist reading order); un-positioned // A2c: position-first ordering (playlist reading order); un-positioned
// legacy items keep added_at ASC after the positioned tail — NOT DESC, // legacy items keep added_at ASC after the positioned tail — NOT DESC,
// which walked a playlist backwards. // which walked a playlist backwards.
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error) 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 // The excluded filter lives here (was: callers filtered ci.excluded in
// Go over a 69-field row + a 45-field converter to MediaItems; embed // Go over a 69-field row + a 45-field converter to MediaItems; embed
// returns the real row so neither exists). // returns the real row so neither exists).
@@ -762,6 +776,14 @@ type Querier interface {
RevokeOpdsToken(ctx context.Context, token string) error RevokeOpdsToken(ctx context.Context, token string) error
RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error
SearchAuthorValues(ctx context.Context, arg SearchAuthorValuesParams) ([]SearchAuthorValuesRow, 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) SearchGenreValues(ctx context.Context, arg SearchGenreValuesParams) ([]SearchGenreValuesRow, error)
SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error) SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error)
// Search Media Items queries // Search Media Items queries
+177
View File
@@ -4480,6 +4480,55 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect
return i, err 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 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 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 FROM collection_items ci
@@ -4542,6 +4591,73 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU
return items, nil 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 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 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 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 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 const SearchGenreValues = `-- name: SearchGenreValues :many
SELECT SELECT
mi.genre as value, mi.genre as value,
+56
View File
@@ -932,6 +932,26 @@ ORDER BY
mi.title ASC mi.title ASC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); 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 -- name: SearchAuthorValues :many
SELECT SELECT
mi.author as value, mi.author as value,
@@ -3576,6 +3596,42 @@ WHERE ci.collection_id = $1
AND mi.missing_scan_count = 0 AND mi.missing_scan_count = 0
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC; 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 -- Get collections for book
-- name: GetCollectionsForBook :many -- name: GetCollectionsForBook :many
SELECT c.* SELECT c.*
+46 -2
View File
@@ -43,6 +43,18 @@ type BookInfo struct {
// it empty rather than pay a join). // it empty rather than pay a join).
ViewerPercentage *float64 `json:"viewer_percentage"` ViewerPercentage *float64 `json:"viewer_percentage"`
LibraryName string `json:"library_name"` 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 // LibraryTypeMap builds libraryID→library-type-name for badge resolution
@@ -151,8 +163,7 @@ func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashbo
ID: uuid.UUID(mi.ID.Bytes), ID: uuid.UUID(mi.ID.Bytes),
Title: mi.Title, Title: mi.Title,
Author: utils.PgTextString(mi.Author), Author: utils.PgTextString(mi.Author),
LibraryID: mi.LibraryID, LibraryID: mi.LibraryID, CoverPath: mi.CoverImagePath,
CoverPath: mi.CoverImagePath,
FormatGroup: mi.FormatGroup, FormatGroup: mi.FormatGroup,
LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()], LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()],
})) }))
@@ -180,6 +191,39 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
return cards 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 // BookCardsFromUnified builds cards from search-unified rows. Those
// rows carry the library type name as a SQL column, so this adapter // rows carry the library type name as a SQL column, so this adapter
// needs no LibraryTypeMap query at all — and the viewer's own // needs no LibraryTypeMap query at all — and the viewer's own
+8 -2
View File
@@ -331,11 +331,17 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes) books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
} }
} else { } 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 { if collErr != nil {
collItems = nil collItems = nil
} }
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes) books = handlers.BookCardsFromCollectionDetailed(collItems, libTypes)
} }
} }
return books, isSmart, "" return books, isSmart, ""
+68
View File
@@ -10,6 +10,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "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. // 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 { func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, libraryType string) *templates.PodcastSequenceContext {
ctx := c.Request().Context() 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) eps, err := cfg.Queries.ListPodcastEpisodes(ctx, item.SourceFeedID)
if err != nil || len(eps) == 0 { if err != nil || len(eps) == 0 {
return nil return nil
@@ -250,3 +262,59 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
} }
return seq 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
}
+18
View File
@@ -41,6 +41,24 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
if !ok { if !ok {
return c.String(http.StatusUnauthorized, "Unauthorized") 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) // Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
// — the HTMX grid partial and the JSON endpoint can never disagree on // — the HTMX grid partial and the JSON endpoint can never disagree on
// which facet params exist or how they parse. (This branch previously // which facet params exist or how they parse. (This branch previously
+206 -6
View File
@@ -3,6 +3,7 @@ package templates
import ( import (
"fmt" "fmt"
"bookhoard/internal/database"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
) )
@@ -14,6 +15,67 @@ func pluralizeBooks(n int) string {
return "books" return "books"
} }
// firstPlayableEpisodeURL returns the reader URL that starts the
// collection sequence from the first downloaded episode (plan item 2.3
// "Play collection as sequence" — the queue param makes the reader
// build the collection chain), or "" when nothing can play.
func firstPlayableEpisodeURL(collectionID string, books []handlers.BookInfo) string {
for _, b := range books {
if b.IsEpisode && !b.Hidden && b.DownloadState == "downloaded" {
return "/readers/" + b.MediaItemID + "?queue=collection:" + collectionID
}
}
return ""
}
// PlayCollectionButton renders the header's sequence-start button when
// the collection holds at least one downloaded episode.
templ PlayCollectionButton(collectionID string, books []handlers.BookInfo) {
if url := firstPlayableEpisodeURL(collectionID, books); url != "" {
<a
href={ url }
class="btn btn-primary"
title="Play the collection's episodes back-to-back in list order"
>
<span>▶</span>
<span>Play as Sequence</span>
</a>
}
}
// EpisodePickerGrid renders the episode scope of the collection picker
// (plan item 2.3): YOUR subscriptions' episodes (RLS scopes), compact
// rows with the shared bookPicker selection store. Checksum ✓ overlay
// mirrors BookPickerGrid so selection survives htmx swaps.
templ EpisodePickerGrid(eps []database.SearchEpisodesForPickerRow) {
for _, ep := range eps {
<div
class="relative cursor-pointer rounded-lg border p-3 flex items-center gap-3"
style="border-color: var(--border);"
@click={ "$store.bookPicker.toggleBook('" + ep.ID.String() + "')" }
>
<div class="w-9 h-9 rounded-md grid place-items-center shrink-0 text-lg" style="background-color: var(--surface-hover);">🎧</div>
<div class="min-w-0 flex-1">
<p class="text-sm truncate" style="color: var(--text-primary)">{ ep.Title }</p>
<p class="text-xs truncate" style="color: var(--text-secondary)">{ ep.FeedTitle }</p>
</div>
if ep.DownloadState != "downloaded" {
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" style="background-color: var(--surface-hover); color: var(--text-secondary)">not downloaded</span>
}
<div
x-show={ "$store.bookPicker.isSelected('" + ep.ID.String() + "')" }
class="absolute top-2 right-2 w-6 h-6 rounded-full flex items-center justify-center text-sm font-bold pointer-events-none"
style="background-color: var(--accent); color: var(--bg-primary);"
>
✓
</div>
</div>
}
if len(eps) == 0 {
<p class="text-sm text-center py-8" style="color: var(--text-secondary)">No episodes from your subscriptions match.</p>
}
}
// CollectionBooksGrid renders the content of #books-container: the // CollectionBooksGrid renders the content of #books-container: the
// collection's book cards, or the empty state. Shared by the collection // collection's book cards, or the empty state. Shared by the collection
// detail page and the /collections/:id/books-grid partial route (the // detail page and the /collections/:id/books-grid partial route (the
@@ -21,9 +83,90 @@ func pluralizeBooks(n int) string {
// templ renders, JS only swaps). // templ renders, JS only swaps).
templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) { templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) {
if len(books) == 0 { if len(books) == 0 {
<div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">No books in this collection yet.</div> <div id="empty-state" class="col-span-full card text-center py-16" style="color: var(--text-secondary)">Nothing in this collection yet.</div>
} }
for _, book := range books { for _, book := range books {
if book.Hidden {
// RLS-invisible membership (plan item 2.3): an
// unsubscribed episode — or a family-lens hidden
// book. The entry stays in the list (removable);
// its facts are withheld by the engine, not the UI.
<div class="card p-4 rounded-2xl opacity-75" data-media-id={ book.MediaItemID }>
<div class="flex gap-4 items-center">
<div class="w-10 h-10 rounded-lg grid place-items-center shrink-0 text-lg" style="background-color: var(--surface-hover); color: var(--text-secondary)">◌</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-lg" style="color: var(--text-secondary)">Unavailable</h3>
<p class="text-sm" style="color: var(--text-secondary)">Not subscribed (or not visible to you) — the feed's episodes leave the queue until you follow it again.</p>
</div>
if !collection.IsSystem && !collection.IsSmart {
<button
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
class="btn btn-secondary text-sm"
>
@Icon("trash", "h-4 w-4")
<span>Remove entry</span>
</button>
}
</div>
</div>
} else if book.IsEpisode {
// Episode card (plan item 2.3): play resumes the
// collection chain — ?queue=collection:<id> makes
// the reader feed prev/next from the collection's
// order (Play collection as sequence).
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-media-id={ book.MediaItemID }>
<div class="flex gap-4">
if !collection.IsSystem && !collection.IsSmart {
<div class="flex-shrink-0 pt-1">
<label class="flex items-center cursor-pointer p-2 -m-2">
<input
type="checkbox"
class="w-5 h-5"
:checked={ "selectedBooks.includes(" + JSONArg(book.MediaItemID) + ")" }
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
/>
</label>
<div class="flex flex-col items-center gap-0.5 mt-1">
<button type="button" class="icon-btn text-xs" title="Move earlier (playlist order)"
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", -1)" }>↑</button>
<button type="button" class="icon-btn text-xs" title="Move later (playlist order)"
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", 1)" }>↓</button>
</div>
</div>
}
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-lg mb-0.5 line-clamp-2" style="color: var(--text-primary)">
{ book.Title }
</h3>
<p class="text-sm line-clamp-1" style="color: var(--text-secondary)">
🎧 { book.FeedTitle }
</p>
if book.DownloadState == "downloaded" {
<a
href={ "/readers/" + book.MediaItemID + "?queue=collection:" + collection.ID }
class="btn btn-primary text-sm mt-2"
>
<span>▶</span>
<span>Play</span>
</a>
} else {
<span class="badge text-xs mt-2 inline-block" style="background-color: var(--surface-hover); color: var(--text-secondary)">Not downloaded</span>
}
</div>
</div>
if !collection.IsSystem && !collection.IsSmart {
<div class="mt-3 pt-3 border-t" style="border-color: var(--border);">
<button
@click={ "requestRemoveBook(" + JSONArg(book.MediaItemID) + ")" }
class="btn btn-secondary w-full"
>
@Icon("trash", "h-4 w-4")
<span>Remove from Collection</span>
</button>
</div>
}
</div>
} else {
<div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }> <div class="card p-4 rounded-2xl collection-book-card" data-title={ book.Title } data-author={ book.Author } data-media-id={ book.MediaItemID }>
<div class="flex gap-4"> <div class="flex gap-4">
if !collection.IsSystem && !collection.IsSmart { if !collection.IsSystem && !collection.IsSmart {
@@ -88,6 +231,7 @@ templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo)
} }
</div> </div>
} }
}
} }
templ Collection(user User, collections []CollectionData, errorMessage string) { templ Collection(user User, collections []CollectionData, errorMessage string) {
@@ -241,10 +385,11 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div> </div>
<div class="mb-6 flex flex-wrap justify-between items-center gap-4"> <div class="mb-6 flex flex-wrap justify-between items-center gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Books in this Collection</h2> <h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">Items in this Collection</h2>
<span x-show="selectedBooks.length > 0" x-text="selectedBooks.length + ' selected'" class="badge" style="display: none; background-color: var(--accent); color: var(--bg-primary);"></span> <span x-show="selectedBooks.length > 0" x-text="selectedBooks.length + ' selected'" class="badge" style="display: none; background-color: var(--accent); color: var(--bg-primary);"></span>
</div> </div>
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
@PlayCollectionButton(collection.ID, books)
<div class="flex-1 min-w-[200px] max-w-md"> <div class="flex-1 min-w-[200px] max-w-md">
<input <input
type="text" type="text"
@@ -277,7 +422,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
class="btn btn-primary" class="btn btn-primary"
> >
@Icon("plus", "h-4 w-4") @Icon("plus", "h-4 w-4")
<span>Add Books</span> <span>Add Items</span>
</button> </button>
} }
} }
@@ -313,7 +458,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
style="border-color: var(--border);" style="border-color: var(--border);"
> >
<h2 class="text-xl font-bold" style="color: var(--text-primary)"> <h2 class="text-xl font-bold" style="color: var(--text-primary)">
Add Books to Collection Add Items to Collection
</h2> </h2>
<button <button
@click="$store.bookPicker.close()" @click="$store.bookPicker.close()"
@@ -323,9 +468,30 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
@Icon("close", "h-5 w-5") @Icon("close", "h-5 w-5")
</button> </button>
</div> </div>
<!-- Scope tabs (plan item 2.3): books stay the default; the
episodes tab is YOUR subscriptions' episodes (RLS scopes).
Pure client-local state — the tab click triggers the
hidden grid's htmx load. -->
<div class="flex gap-2 px-6 pt-4">
<button
type="button"
@click="$store.bookPicker.switchScope('books')"
:class="$store.bookPicker.scope === 'episodes' ? 'btn btn-ghost' : 'btn btn-secondary'"
>
📚 Books
</button>
<button
type="button"
@click="$store.bookPicker.switchScope('episodes')"
:class="$store.bookPicker.scope === 'episodes' ? 'btn btn-secondary' : 'btn btn-ghost'"
>
🎧 Episodes
</button>
</div>
<div <div
class="p-4 border-b" class="p-4 border-b"
style="border-color: var(--border);" style="border-color: var(--border);"
x-show="$store.bookPicker.scope !== 'episodes'"
> >
<div id="book-picker-filters" class="flex flex-wrap gap-3 items-center"> <div id="book-picker-filters" class="flex flex-wrap gap-3 items-center">
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
@@ -383,12 +549,46 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
hx-include="#book-picker-filters" hx-include="#book-picker-filters"
> >
</div> </div>
<!-- Episode scope (plan item 2.3): one search field — the
episode list is feed-ordered, facets make no sense here.
RLS scopes to YOUR subscriptions server-side. -->
<div
class="p-4 border-b"
style="border-color: var(--border);"
x-show="$store.bookPicker.scope === 'episodes'"
x-cloak
>
<div id="episode-picker-filters" class="flex flex-wrap gap-3 items-center">
<div class="flex-1 min-w-[200px]">
<input
type="text"
name="q"
placeholder="Search episodes..."
class="input"
hx-get={ "/api/media-items/search?show_checkbox=true&scope=episodes&collection_id=" + collection.ID }
hx-target="#episode-picker-grid"
hx-trigger="keyup changed delay:300ms"
hx-include="#episode-picker-filters"
/>
</div>
</div>
</div>
<div
id="episode-picker-grid"
class="flex flex-col gap-2 p-4 max-h-96 overflow-y-auto"
x-show="$store.bookPicker.scope === 'episodes'"
x-cloak
hx-get={ "/api/media-items/search?show_checkbox=true&scope=episodes&collection_id=" + collection.ID }
hx-trigger="loadEpisodes"
hx-include="#episode-picker-filters"
>
</div>
<div <div
class="p-4 border-t flex justify-between items-center" class="p-4 border-t flex justify-between items-center"
style="border-color: var(--border);" style="border-color: var(--border);"
> >
<div class="text-sm" style="color: var(--text-secondary);"> <div class="text-sm" style="color: var(--text-secondary);">
<span x-text="$store.bookPicker.selectedCount"></span> books selected <span x-text="$store.bookPicker.selectedCount"></span> selected
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@@ -401,7 +601,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
@click="$store.bookPicker.submit()" @click="$store.bookPicker.submit()"
class="btn btn-primary" class="btn btn-primary"
> >
Add Selected Books Add Selected
</button> </button>
</div> </div>
</div> </div>
File diff suppressed because it is too large. Load diff
+22 -2
View File
@@ -9,10 +9,15 @@ import { showToast } from "./toast";
Alpine.store("bookPicker", { Alpine.store("bookPicker", {
isOpen: false, isOpen: false,
selectedBooks: new Set<string>(), selectedBooks: new Set<string>(),
// Picker scope (plan item 2.3): "books" (default) or "episodes" —
// YOUR subscriptions' episodes. Pure client-local tab state; the
// selection store is shared, ids never collide.
scope: "books",
open() { open() {
this.isOpen = true; this.isOpen = true;
this.selectedBooks.clear(); this.selectedBooks.clear();
this.scope = "books";
this.loadBooks(); this.loadBooks();
}, },
@@ -21,6 +26,15 @@ Alpine.store("bookPicker", {
this.selectedBooks.clear(); this.selectedBooks.clear();
}, },
switchScope(scope: "books" | "episodes") {
this.scope = scope;
if (scope === "episodes") {
this.loadEpisodes();
} else {
this.loadBooks();
}
},
toggleBook(bookId: string) { toggleBook(bookId: string) {
if (this.selectedBooks.has(bookId)) { if (this.selectedBooks.has(bookId)) {
this.selectedBooks.delete(bookId); this.selectedBooks.delete(bookId);
@@ -47,6 +61,12 @@ Alpine.store("bookPicker", {
window.htmx.trigger(grid, "loadBooks"); window.htmx.trigger(grid, "loadBooks");
}, },
async loadEpisodes() {
const grid = document.getElementById("episode-picker-grid");
if (!grid) return;
window.htmx.trigger(grid, "loadEpisodes");
},
clearFilters() { clearFilters() {
const filterDiv = document.getElementById("book-picker-filters"); const filterDiv = document.getElementById("book-picker-filters");
if (!filterDiv) return; if (!filterDiv) return;
@@ -88,11 +108,11 @@ Alpine.store("bookPicker", {
}); });
if (response.ok) { if (response.ok) {
showToast(`Added ${this.selectedBooks.size} books to collection`, "success"); showToast(`Added ${this.selectedBooks.size} items to collection`, "success");
this.close(); this.close();
location.reload(); location.reload();
} else { } else {
showToast("Failed to add books", "error"); showToast("Failed to add items", "error");
} }
} catch (error) { } catch (error) {
console.error("Failed to add books:", error); console.error("Failed to add books:", error);