feat(podcasts): episode collections — picker scope, hint rows, the chain (item 2.3)

D11 lifts: podcast episodes (media_items rows) join collections, and
"Play collection as sequence" feeds the player from the collection's
order. No new tables — collection_items already carries ordered
membership (item 2.1 audit).

PICKER: the collection modal gains a Books/Episodes tab pair. The
episodes tab hits /api/media-items/search?scope=episodes — a dedicated
picker query (SearchEpisodesForPicker) whose visibility is entirely
RLS: the media_items subscription predicate drops unsubscribed feeds'
episodes, and the podcast_feeds join is governed the same way. The
books scope keeps its podcasts exclusion (plan §9) — this scope is the
only door in. Pending episodes are pickable (plan ahead; the detail
page shows them as not downloaded). Selection rides the existing
bookPicker store — ids never collide across scopes, and AddBooks works
unchanged on episode ids.

DETAIL: the All-Libraries membership view switches to
GetCollectionItemsDetailed — LEFT JOIN + per-row visibility. An
unsubscribed episode (or family-lens hidden book) survives as a
Hidden card rendered as an "Unavailable — remove entry" hint row
instead of silently vanishing; visible episodes render feed title,
download state, and a Play link. The library-scoped view keeps its
existing dashboard-query behavior (the switcher's contract is
untouched for books).

CHAIN: ?queue=collection:<id> on any reader URL makes
buildPodcastSequenceContext produce the COLLECTION context (§10 R2
same shape, source=collection): downloaded episodes in collection
order (positions are content — R1; no feed-order override), prev/next
URLs carrying the queue param so the chain survives hops, and
backToDetail landing on the collection page. Ownership gates the chain
— foreign/deleted ids fall back to the feed queue; the order control
stays hidden (2.2's source gate). The header's "Play as Sequence"
button starts from the first downloaded episode in list order.

Pinned by TestPodcastEpisodeCollections (7 subtests): RLS-scoped
picker + family-lens auditor case (subscribed episode in a hidden
library must not reach the picker), books-scope exclusion intact,
hint rows, chain carrying the queue param, start-from-first, foreign
fallback, and reorder driving the chain (the R4 primitive works on
episode ids unchanged).
This commit is contained in:
John O'Keefe committed 2026-10-30 16:18:29 -04:00
1 parent a90e7debfd
commit adb93cba9e
11 files changed
+1636 -394

No files matched your search

+206 -6
View File
@@ -3,6 +3,7 @@ package templates
import (
"fmt"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
)
@@ -14,6 +15,67 @@ func pluralizeBooks(n int) string {
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
// collection's book cards, or the empty state. Shared by the collection
// 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 CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo) {
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 {
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="flex gap-4">
if !collection.IsSystem && !collection.IsSmart {
@@ -87,6 +230,7 @@ templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo)
</div>
}
</div>
}
}
}
@@ -241,10 +385,11 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
</div>
<div class="mb-6 flex flex-wrap justify-between items-center gap-4">
<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>
</div>
<div class="flex flex-wrap gap-2 items-center">
@PlayCollectionButton(collection.ID, books)
<div class="flex-1 min-w-[200px] max-w-md">
<input
type="text"
@@ -277,7 +422,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
class="btn btn-primary"
>
@Icon("plus", "h-4 w-4")
<span>Add Books</span>
<span>Add Items</span>
</button>
}
}
@@ -313,7 +458,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
style="border-color: var(--border);"
>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">
Add Books to Collection
Add Items to Collection
</h2>
<button
@click="$store.bookPicker.close()"
@@ -323,9 +468,30 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
@Icon("close", "h-5 w-5")
</button>
</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
class="p-4 border-b"
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 class="flex-1 min-w-[200px]">
@@ -383,12 +549,46 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
hx-include="#book-picker-filters"
>
</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
class="p-4 border-t flex justify-between items-center"
style="border-color: var(--border);"
>
<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 class="flex gap-2">
<button
@@ -401,7 +601,7 @@ templ CollectionDetail(user User, collection CollectionData, books []handlers.Bo
@click="$store.bookPicker.submit()"
class="btn btn-primary"
>
Add Selected Books
Add Selected
</button>
</div>
</div>
File diff suppressed because it is too large. Load diff