Files
silo-server/internal/sections/defaults_test.go
T
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

441 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sections
import (
"encoding/json"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestDefaultHomeSectionsWithoutLibraries(t *testing.T) {
sections := DefaultHomeSections(nil)
// 1 continue-watching + 3 recipe-rich defaults (hidden_gems, trending, seasonal-auto-cycle)
if len(sections) != 4 {
t.Fatalf("expected 4 default home sections, got %d", len(sections))
}
if sections[0].SectionType != SectionContinueWatching {
t.Fatalf("expected continue watching, got %s", sections[0].SectionType)
}
assertContinueType(t, sections[0].Config, ContinueTypeWatching)
}
func TestDefaultHomeSectionsWithLibraries(t *testing.T) {
libraries := []*models.MediaFolder{
{ID: 7, Name: "Movies", Type: "movies", SortOrder: 1},
{ID: 9, Name: "Shows", Type: "series", SortOrder: 2},
}
got := DefaultHomeSections(libraries)
// 1 continue-watching + 2 libraries × 2 types + 3 recipe-rich defaults
if len(got) != 8 {
t.Fatalf("expected 8 default home sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
libraryID int
}{
{index: 0, id: "default-continue-watching", sectionType: SectionContinueWatching, title: "Continue Watching", position: 0},
{index: 1, id: "default-home-recently_added-library-7", sectionType: SectionRecentlyAdded, title: "Recently Added in Movies", position: 1, libraryID: 7},
{index: 2, id: "default-home-recently_released-library-7", sectionType: SectionRecentlyReleased, title: "Recently Released in Movies", position: 2, libraryID: 7},
{index: 3, id: "default-home-recently_added-library-9", sectionType: SectionRecentlyAdded, title: "Recently Added in Shows", position: 3, libraryID: 9},
{index: 4, id: "default-home-recently_released_episodes-library-9", sectionType: SectionCustomFilter, title: "Recently Released Episodes in Shows", position: 4, libraryID: 9},
}
for _, tt := range tests {
section := got[tt.index]
if section.ID != tt.id {
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
}
if section.SectionType != tt.sectionType {
t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType)
}
if section.Title != tt.title {
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
}
if section.Position != tt.position {
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
}
if tt.libraryID == 0 {
assertContinueType(t, section.Config, ContinueTypeWatching)
continue
}
libraryID, ok := ParseGeneratedHomeLibraryRecentConfig(section.Config)
if !ok {
t.Fatalf("section %d expected generated home config", tt.index)
}
if libraryID != tt.libraryID {
t.Fatalf("section %d config library id = %d, want %d", tt.index, libraryID, tt.libraryID)
}
}
assertQueryDefinition(t, got[4].Config, catalog.QueryDefinition{
LibraryIDs: []int{9},
MediaScope: "episode",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "release_date", Order: "desc"},
})
}
func TestDefaultHomeSectionsWithAudiobookLibrary(t *testing.T) {
libraries := []*models.MediaFolder{
{ID: 7, Name: "Movies", Type: "movies", SortOrder: 1},
{ID: 10, Name: "Books", Type: "audiobooks", SortOrder: 2},
}
got := DefaultHomeSections(libraries)
if len(got) != 9 {
t.Fatalf("expected 9 default home sections, got %d", len(got))
}
tests := []struct {
index int
id string
title string
position int
continueType ContinueType
}{
{index: 0, id: "default-continue-watching", title: "Continue Watching", position: 0, continueType: ContinueTypeWatching},
{index: 1, id: "default-continue-listening", title: "Continue Listening", position: 1, continueType: ContinueTypeListening},
}
for _, tt := range tests {
section := got[tt.index]
if section.ID != tt.id {
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
}
if section.Title != tt.title {
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
}
if section.Position != tt.position {
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
}
assertContinueType(t, section.Config, tt.continueType)
}
if got[2].Position != 2 || got[2].Title != "Recently Added in Movies" {
t.Fatalf("first generated library row = position %d title %q", got[2].Position, got[2].Title)
}
}
func TestDefaultLibrarySectionsForTypeMovies(t *testing.T) {
libraryID := 42
got := DefaultLibrarySectionsForType(&libraryID, "movies")
if len(got) != 6 {
t.Fatalf("expected 6 movie default sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
}{
{index: 0, id: "default-continue-watching", sectionType: SectionContinueWatching, title: "Continue Watching", position: 0},
{index: 1, id: "default-recently-added-movies", sectionType: SectionRecentlyAdded, title: "Recently Added Movies", position: 1},
{index: 2, id: "default-recently-released-movies", sectionType: SectionRecentlyReleased, title: "Recently Released Movies", position: 2},
{index: 3, id: "default-top-rated-movies", sectionType: SectionCustomFilter, title: "Top Rated Movies", position: 3},
{index: 4, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 4},
{index: 5, id: "default-random-movies", sectionType: SectionRandom, title: "Random Picks", position: 5},
}
for _, tt := range tests {
section := got[tt.index]
if section.ID != tt.id {
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
}
if section.SectionType != tt.sectionType {
t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType)
}
if section.Title != tt.title {
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
}
if section.Position != tt.position {
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
}
if section.Featured {
t.Fatalf("section %d featured = true, want false", tt.index)
}
}
assertContinueType(t, got[0].Config, ContinueTypeWatching)
assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{
MediaScope: "movie",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
assertQueryDefinition(t, got[3].Config, catalog.QueryDefinition{
MediaScope: "movie",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "rating_imdb", Order: "desc"},
})
assertEmptyJSON(t, got[4].Config)
assertQueryDefinition(t, got[5].Config, catalog.QueryDefinition{
MediaScope: "movie",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
}
func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) {
libraryID := 17
got := DefaultLibrarySectionsForType(&libraryID, "series")
if len(got) != 6 {
t.Fatalf("expected 6 series default sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
}{
{index: 0, id: "default-continue-watching", sectionType: SectionContinueWatching, title: "Continue Watching", position: 0},
{index: 1, id: "default-recently-added-tv", sectionType: SectionRecentlyAdded, title: "Recently Added TV", position: 1},
{index: 2, id: "default-recently-released-episodes", sectionType: SectionCustomFilter, title: "Recently Released Episodes", position: 2},
{index: 3, id: "default-top-rated-tv", sectionType: SectionCustomFilter, title: "Top Rated TV", position: 3},
{index: 4, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 4},
{index: 5, id: "default-random-tv", sectionType: SectionRandom, title: "Random Picks", position: 5},
}
for _, tt := range tests {
section := got[tt.index]
if section.ID != tt.id {
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
}
if section.SectionType != tt.sectionType {
t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType)
}
if section.Title != tt.title {
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
}
if section.Position != tt.position {
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
}
if section.Featured {
t.Fatalf("section %d featured = true, want false", tt.index)
}
}
assertContinueType(t, got[0].Config, ContinueTypeWatching)
assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{
MediaScope: "series",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
assertQueryDefinition(t, got[2].Config, catalog.QueryDefinition{
MediaScope: "episode",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "release_date", Order: "desc"},
})
assertQueryDefinition(t, got[3].Config, catalog.QueryDefinition{
MediaScope: "series",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "rating_imdb", Order: "desc"},
})
assertEmptyJSON(t, got[4].Config)
assertQueryDefinition(t, got[5].Config, catalog.QueryDefinition{
MediaScope: "series",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
}
func TestDefaultLibrarySectionsForTypeAudiobooks(t *testing.T) {
libraryID := 10
got := DefaultLibrarySectionsForType(&libraryID, "audiobooks")
if len(got) != 6 {
t.Fatalf("expected 6 audiobook default sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
featured bool
}{
{index: 0, id: "default-continue-listening", sectionType: SectionContinueWatching, title: "Continue Listening", position: 0, featured: true},
{index: 1, id: "default-next-in-series", sectionType: SectionNextInSeries, title: "Next in Your Series", position: 1},
{index: 2, id: "default-recently-added-audiobooks", sectionType: SectionRecentlyAdded, title: "Recently Added Audiobooks", position: 2},
{index: 3, id: "default-recently-released-audiobooks", sectionType: SectionRecentlyReleased, title: "Recently Released Audiobooks", position: 3},
{index: 4, id: "default-recommended-for-you", sectionType: SectionRecommendedForYou, title: "Recommended for You", position: 4},
{index: 5, id: "default-random-audiobooks", sectionType: SectionRandom, title: "Random Picks", position: 5},
}
for _, tt := range tests {
section := got[tt.index]
if section.ID != tt.id {
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
}
if section.SectionType != tt.sectionType {
t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType)
}
if section.Title != tt.title {
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
}
if section.Position != tt.position {
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
}
if section.Featured != tt.featured {
t.Fatalf("section %d featured = %v, want %v", tt.index, section.Featured, tt.featured)
}
}
assertContinueType(t, got[0].Config, ContinueTypeListening)
assertEmptyJSON(t, got[1].Config)
assertQueryDefinition(t, got[2].Config, catalog.QueryDefinition{
MediaScope: "audiobook",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
assertQueryDefinition(t, got[3].Config, catalog.QueryDefinition{
MediaScope: "audiobook",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
assertEmptyJSON(t, got[4].Config)
assertQueryDefinition(t, got[5].Config, catalog.QueryDefinition{
MediaScope: "audiobook",
Match: "all",
Groups: []catalog.QueryGroup{},
Sort: catalog.QuerySort{Field: "added_at", Order: "desc"},
})
}
func TestDefaultLibrarySectionsForTypeMixed(t *testing.T) {
libraryID := 99
got := DefaultLibrarySectionsForType(&libraryID, "mixed")
if len(got) != 3 {
t.Fatalf("expected 3 mixed default sections, got %d", len(got))
}
tests := []struct {
index int
id string
sectionType SectionType
title string
position int
}{
{index: 0, id: "default-continue-watching", sectionType: SectionContinueWatching, title: "Continue Watching", position: 0},
{index: 1, id: "default-recently-added", sectionType: SectionRecentlyAdded, title: "Recently Added", position: 1},
{index: 2, id: "default-recently-released", sectionType: SectionRecentlyReleased, title: "Recently Released", position: 2},
}
for _, tt := range tests {
section := got[tt.index]
if section.ID != tt.id {
t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id)
}
if section.SectionType != tt.sectionType {
t.Fatalf("section %d type = %q, want %q", tt.index, section.SectionType, tt.sectionType)
}
if section.Title != tt.title {
t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title)
}
if section.Position != tt.position {
t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position)
}
}
assertContinueType(t, got[0].Config, ContinueTypeWatching)
}
func assertQueryDefinition(t *testing.T, raw json.RawMessage, want catalog.QueryDefinition) {
t.Helper()
got, err := ParseQueryDefinition(raw)
if err != nil {
t.Fatalf("ParseQueryDefinition() error = %v", err)
}
got = got.Normalize()
want = want.Normalize()
if got.MediaScope != want.MediaScope {
t.Fatalf("media_scope = %q, want %q", got.MediaScope, want.MediaScope)
}
if got.Match != want.Match {
t.Fatalf("match = %q, want %q", got.Match, want.Match)
}
if got.Sort != want.Sort {
t.Fatalf("sort = %+v, want %+v", got.Sort, want.Sort)
}
if len(got.Groups) != len(want.Groups) {
t.Fatalf("groups len = %d, want %d", len(got.Groups), len(want.Groups))
}
for i := range want.Groups {
if got.Groups[i].Match != want.Groups[i].Match {
t.Fatalf("groups[%d].match = %q, want %q", i, got.Groups[i].Match, want.Groups[i].Match)
}
if len(got.Groups[i].Rules) != len(want.Groups[i].Rules) {
t.Fatalf("groups[%d].rules len = %d, want %d", i, len(got.Groups[i].Rules), len(want.Groups[i].Rules))
}
for j := range want.Groups[i].Rules {
if got.Groups[i].Rules[j] != want.Groups[i].Rules[j] {
t.Fatalf("groups[%d].rules[%d] = %+v, want %+v", i, j, got.Groups[i].Rules[j], want.Groups[i].Rules[j])
}
}
}
}
func assertEmptyJSON(t *testing.T, raw json.RawMessage) {
t.Helper()
if string(raw) != "{}" {
t.Fatalf("config = %s, want {}", string(raw))
}
}
func assertContinueType(t *testing.T, raw json.RawMessage, want ContinueType) {
t.Helper()
got, err := ParseContinueType(raw)
if err != nil {
t.Fatalf("ParseContinueType(%s): %v", string(raw), err)
}
if got != want {
t.Fatalf("continue_type = %q, want %q (config %s)", got, want, string(raw))
}
}
func TestHomeDefaultsIncludeRecipeRichSet(t *testing.T) {
defs := DefaultHomeSections(nil)
want := []SectionType{
SectionContinueWatching,
SectionHiddenGems,
SectionTrendingOnServer,
SectionSeasonalThemed,
}
for _, w := range want {
found := false
for _, d := range defs {
if d.SectionType == w {
found = true
break
}
}
if !found {
t.Errorf("default home sections missing %s", w)
}
}
}