* 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>
42 lines
1.4 KiB
Go
42 lines
1.4 KiB
Go
package sections
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/sections/recipes"
|
|
)
|
|
|
|
// TestRecipeDispatchEquivalence calls each registered Recipe and asserts that the resulting
|
|
// ResolvedItems match what the existing FetchOne path would produce for the same inputs.
|
|
// This guards against regressions during the registry refactor.
|
|
func TestRecipeDispatchEquivalence(t *testing.T) {
|
|
// This test is exercised against a stub Fetcher (no DB) — it verifies the wiring,
|
|
// not the query logic. Real catalog comparisons live in fetcher_*_test.go.
|
|
f := &Fetcher{} // empty fetcher; FetchOne will return zero-value items for non-DB types
|
|
InstallRecipeDelegate(f)
|
|
|
|
rc := recipes.ResolverContext{
|
|
Ctx: context.Background(),
|
|
ItemLimit: 10,
|
|
}
|
|
|
|
// Each type that has a no-DB-required short-circuit in FetchOne (continue_watching with no
|
|
// store, next_up / next_in_series with no repo) should resolve without error.
|
|
for _, typ := range []string{"continue_watching", "next_up", "next_in_series"} {
|
|
rec, ok := recipes.Get(typ)
|
|
if !ok {
|
|
t.Fatalf("recipe %q missing", typ)
|
|
}
|
|
got, err := rec.Resolve(rc)
|
|
if err != nil {
|
|
t.Errorf("Resolve(%s): %v", typ, err)
|
|
continue
|
|
}
|
|
if got.Items == nil {
|
|
// Both old and new paths return an empty slice (not nil) when StoreProvider is missing.
|
|
t.Errorf("%s: items is nil; expected empty slice", typ)
|
|
}
|
|
}
|
|
}
|