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

+22 -2
View File
@@ -9,10 +9,15 @@ import { showToast } from "./toast";
Alpine.store("bookPicker", {
isOpen: false,
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() {
this.isOpen = true;
this.selectedBooks.clear();
this.scope = "books";
this.loadBooks();
},
@@ -21,6 +26,15 @@ Alpine.store("bookPicker", {
this.selectedBooks.clear();
},
switchScope(scope: "books" | "episodes") {
this.scope = scope;
if (scope === "episodes") {
this.loadEpisodes();
} else {
this.loadBooks();
}
},
toggleBook(bookId: string) {
if (this.selectedBooks.has(bookId)) {
this.selectedBooks.delete(bookId);
@@ -47,6 +61,12 @@ Alpine.store("bookPicker", {
window.htmx.trigger(grid, "loadBooks");
},
async loadEpisodes() {
const grid = document.getElementById("episode-picker-grid");
if (!grid) return;
window.htmx.trigger(grid, "loadEpisodes");
},
clearFilters() {
const filterDiv = document.getElementById("book-picker-filters");
if (!filterDiv) return;
@@ -88,11 +108,11 @@ Alpine.store("bookPicker", {
});
if (response.ok) {
showToast(`Added ${this.selectedBooks.size} books to collection`, "success");
showToast(`Added ${this.selectedBooks.size} items to collection`, "success");
this.close();
location.reload();
} else {
showToast("Failed to add books", "error");
showToast("Failed to add items", "error");
}
} catch (error) {
console.error("Failed to add books:", error);