* 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>
103 lines
3.6 KiB
Go
103 lines
3.6 KiB
Go
package recipes
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// libStapleParams is the (empty) param shape for parameter-free library staples.
|
|
type libStapleParams struct{}
|
|
|
|
// libStaple wraps a delegated resolver func with Recipe metadata.
|
|
type libStaple struct {
|
|
typ string
|
|
displayName string
|
|
icon string
|
|
descShort string
|
|
cacheTTL time.Duration
|
|
presets []GalleryPreset
|
|
}
|
|
|
|
func (l *libStaple) Type() string { return l.typ }
|
|
func (l *libStaple) NewParams() any { return &libStapleParams{} }
|
|
func (l *libStaple) Validate(raw json.RawMessage) error {
|
|
if l.typ != "continue_watching" {
|
|
return nil // any JSON is acceptable; missing fields ignored
|
|
}
|
|
var params struct {
|
|
ContinueType string `json:"continue_type"`
|
|
}
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(params.ContinueType)) {
|
|
case "", "watching", "listening", "reading":
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("continue_type must be 'watching', 'listening', or 'reading'")
|
|
}
|
|
}
|
|
func (l *libStaple) DefaultCacheTTL() time.Duration { return l.cacheTTL }
|
|
|
|
func (l *libStaple) Definition() RecipeDefinition {
|
|
presets := l.presets
|
|
if presets == nil {
|
|
presets = []GalleryPreset{
|
|
{
|
|
Key: l.typ + "_default",
|
|
DisplayName: l.displayName,
|
|
Icon: l.icon,
|
|
DescriptionShort: l.descShort,
|
|
DefaultParams: json.RawMessage(`{}`),
|
|
},
|
|
}
|
|
}
|
|
return RecipeDefinition{
|
|
Type: l.typ,
|
|
Category: CategoryLibraryStaples,
|
|
Presets: presets,
|
|
}
|
|
}
|
|
|
|
// Resolve delegates to the bridge installed by package sections (see Task 1.8).
|
|
func (l *libStaple) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve(l.typ, rc)
|
|
}
|
|
|
|
func init() {
|
|
Register(&libStaple{typ: "recently_added", displayName: "Recently Added", icon: "🆕", descShort: "Latest additions to your library.", cacheTTL: 5 * time.Minute})
|
|
Register(&libStaple{typ: "recently_released", displayName: "New Releases", icon: "🎬", descShort: "Recently released titles.", cacheTTL: 30 * time.Minute})
|
|
Register(&libStaple{
|
|
typ: "continue_watching",
|
|
displayName: "Continue Watching",
|
|
icon: "▶️",
|
|
descShort: "Pick up where you left off.",
|
|
cacheTTL: time.Minute,
|
|
presets: []GalleryPreset{
|
|
{
|
|
Key: "continue_watching_default",
|
|
DisplayName: "Continue Watching",
|
|
Icon: "▶️",
|
|
DescriptionShort: "Pick up movies and episodes where you left off.",
|
|
DefaultParams: json.RawMessage(`{"continue_type":"watching"}`),
|
|
},
|
|
{
|
|
Key: "continue_listening_default",
|
|
DisplayName: "Continue Listening",
|
|
Icon: "🎧",
|
|
DescriptionShort: "Pick up audiobooks where you left off.",
|
|
DefaultParams: json.RawMessage(`{"continue_type":"listening"}`),
|
|
},
|
|
},
|
|
})
|
|
Register(&libStaple{typ: "next_up", displayName: "On Deck", icon: "📺", descShort: "Next episodes ready to watch.", cacheTTL: time.Minute})
|
|
Register(&libStaple{typ: "next_in_series", displayName: "Next in Series", icon: "📚", descShort: "The next audiobook in series you've finished.", cacheTTL: time.Minute})
|
|
Register(&libStaple{typ: "watchlist", displayName: "Watchlist", icon: "🔖", descShort: "Items you've saved to watch.", cacheTTL: time.Minute})
|
|
Register(&libStaple{typ: "favorites", displayName: "Favorites", icon: "⭐", descShort: "Your favorites.", cacheTTL: time.Minute})
|
|
Register(&libStaple{typ: "random", displayName: "Surprise Me", icon: "🎲", descShort: "A random selection from your library.", cacheTTL: 5 * time.Minute})
|
|
}
|