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
+1657
-415
No files matched your search
@@ -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 ("); pins must match the escaped form.
|
||||||
|
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
@@ -89,6 +232,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) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -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>
|
||||||
|
|||||||
+829
-403
File diff suppressed because it is too large.
Load diff
+22
-2
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user