Files
silo-server/migrations/sql/20260609222830_audiobook_library_home_redesign.sql
162e0cc449 feat(audiobooks): redesign audiobook library around resume and series progression (#116)
* feat(audiobooks): redesign audiobook library around resume and series progression

Audiobook libraries previously reused the video-shaped library page: a
backdrop carousel hero (audiobooks have square covers and no backdrops),
movie-style default sections, and a browse grid whose primary audiobook
axes (author, narrator, series) were buried as filters.

Backend:
- New next_in_series section type: surfaces the next unstarted book, by
  series_index, in series the profile has finished a book of, ordered by
  most recent finish. Registered as a library-staple recipe.
- New GET /api/v1/catalog/audiobook-groups endpoint: grouped browse by
  author/narrator/series with book count, total duration, per-profile
  progress counts, and poster URLs for cover stacks.
- Audiobook library defaults: continue-listening is featured (renders as
  the Now Listening hero) with next-in-series directly after it. A data
  migration upgrades existing audiobook libraries, skipping layouts where
  an admin already featured a section.

Frontend:
- NowListeningHero replaces HeroBanner for audiobook libraries: resume
  deck with chapter position, hours left, ambient color from the cover,
  and one-click resume; remaining in-progress books render as the
  Continue Listening row.
- Library tab gains Books/Series/Authors/Narrators browse axes persisted
  via the type param; selecting a group drops into the Books grid with
  the matching filter applied.
- "Recommended" tab is labeled "Home" for audiobook libraries; audiobook
  continue cards use square covers and hr/min time-left formatting.
- Shared audiobook chapter/file/duration helpers extracted to
  web/src/lib/audiobooks (deduplicated from AudiobookContent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audiobooks): address review feedback on library redesign

- Push library scoping into the next-in-series candidate SQL so finished
  series whose next book lives in another library can't consume the
  candidate limit and starve a library-scoped section (Codex P2).
- Paginate the audiobook groups fetch until the server-reported total is
  reached (500/page, 20-page bound) so client-side filtering sees the
  complete author/narrator/series list (Codex P2, CodeRabbit).
- Make the redesign migration rollback-safe: rows the Up touches carry
  config markers (featured_by_migration / seeded_by_migration) and the
  Down reverts only marked rows, leaving admin-set featured state and
  hand-created next_in_series sections alone (Codex P2, CodeRabbit).
- Gate NowListeningHero's detail-derived files/credits on the detail
  matching the deck item, so Resume can't start the new book with the
  previous book's files while keepPreviousData shows stale detail
  (Codex P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:28:53 -04:00

123 lines
3.9 KiB
SQL

-- +goose Up
-- +goose StatementBegin
-- Audiobook library Home redesign: the Continue Listening section becomes the
-- featured "Now Listening" resume hero, and a next_in_series row surfaces the
-- next unstarted book in series the profile has finished.
--
-- Rows touched here carry a marker key in config so the Down migration can
-- revert exactly what this migration changed and nothing else.
--
-- Mark the first continue-listening section of each audiobook library as
-- featured, but only when the admin has not already featured something else in
-- that library's layout.
WITH target AS (
SELECT DISTINCT ON (ps.library_id) ps.id
FROM page_sections ps
JOIN media_folders mf ON mf.id = ps.library_id
WHERE ps.scope = 'library'
AND lower(mf.type) IN ('audiobook', 'audiobooks')
AND ps.section_type = 'continue_watching'
AND ps.config->>'continue_type' = 'listening'
AND NOT ps.featured
AND NOT EXISTS (
SELECT 1 FROM page_sections f
WHERE f.scope = 'library'
AND f.library_id = ps.library_id
AND f.featured
)
ORDER BY ps.library_id, ps.position ASC
)
UPDATE page_sections ps
SET featured = TRUE,
config = ps.config || '{"featured_by_migration":"20260609222830"}'::jsonb,
updated_at = NOW()
FROM target t
WHERE ps.id = t.id;
-- +goose StatementEnd
-- +goose StatementBegin
-- Insert a next_in_series section into each audiobook library that lacks one,
-- directly after its continue-listening section (or at the end of the layout
-- when no continue-listening section exists).
WITH anchors AS (
SELECT DISTINCT ON (ps.library_id)
ps.library_id,
ps.position + 1 AS insert_position
FROM page_sections ps
JOIN media_folders mf ON mf.id = ps.library_id
WHERE ps.scope = 'library'
AND lower(mf.type) IN ('audiobook', 'audiobooks')
AND ps.section_type = 'continue_watching'
AND ps.config->>'continue_type' = 'listening'
ORDER BY ps.library_id, ps.position ASC
),
targets AS (
SELECT
mf.id AS library_id,
COALESCE(
a.insert_position,
(
SELECT COALESCE(MAX(p2.position) + 1, 0)
FROM page_sections p2
WHERE p2.scope = 'library' AND p2.library_id = mf.id
)
) AS insert_position
FROM media_folders mf
LEFT JOIN anchors a ON a.library_id = mf.id
WHERE lower(mf.type) IN ('audiobook', 'audiobooks')
AND NOT EXISTS (
SELECT 1 FROM page_sections e
WHERE e.scope = 'library'
AND e.library_id = mf.id
AND e.section_type = 'next_in_series'
)
),
shifted AS (
UPDATE page_sections ps
SET position = ps.position + 1,
updated_at = NOW()
FROM targets t
WHERE ps.scope = 'library'
AND ps.library_id = t.library_id
AND ps.position >= t.insert_position
RETURNING 1
)
INSERT INTO page_sections (
id, scope, library_id, position, section_type, title, featured,
item_limit, config, enabled, created_at, updated_at
)
SELECT
gen_random_uuid()::text,
'library',
t.library_id,
t.insert_position,
'next_in_series',
'Next in Your Series',
false,
20,
'{"seeded_by_migration":"20260609222830"}'::jsonb,
true,
NOW(),
NOW()
FROM targets t;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Revert only the rows the Up migration touched, identified by the marker
-- keys it wrote. Admin-created next_in_series sections and sections featured
-- before (or after) the upgrade are left alone.
DELETE FROM page_sections
WHERE scope = 'library'
AND section_type = 'next_in_series'
AND config->>'seeded_by_migration' = '20260609222830';
UPDATE page_sections
SET featured = FALSE,
config = config - 'featured_by_migration',
updated_at = NOW()
WHERE scope = 'library'
AND section_type = 'continue_watching'
AND config->>'featured_by_migration' = '20260609222830';
-- +goose StatementEnd