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
+22
-2
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user