* 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>
266 lines
9.3 KiB
Go
266 lines
9.3 KiB
Go
package sections
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
)
|
|
|
|
// SectionType enumerates the supported section types.
|
|
type SectionType string
|
|
|
|
const (
|
|
SectionContinueWatching SectionType = "continue_watching"
|
|
SectionRecentlyAdded SectionType = "recently_added"
|
|
SectionRecentlyReleased SectionType = "recently_released"
|
|
SectionWatchlist SectionType = "watchlist"
|
|
SectionFavorites SectionType = "favorites"
|
|
SectionGenre SectionType = "genre"
|
|
SectionCustomFilter SectionType = "custom_filter"
|
|
SectionRandom SectionType = "random"
|
|
SectionCollection SectionType = "collection"
|
|
|
|
SectionRecommendedForYou SectionType = "recommended_for_you"
|
|
SectionBecauseYouWatched SectionType = "because_you_watched"
|
|
SectionSimilarUsersLiked SectionType = "similar_users_liked"
|
|
SectionTasteMatch SectionType = "taste_match"
|
|
SectionNextUp SectionType = "next_up"
|
|
SectionNextInSeries SectionType = "next_in_series"
|
|
SectionHiddenGems SectionType = "hidden_gems"
|
|
SectionCriticallyAcclaimed SectionType = "critically_acclaimed"
|
|
SectionAwardWinners SectionType = "award_winners"
|
|
SectionForgottenFavorites SectionType = "forgotten_favorites"
|
|
SectionFormatShowcase SectionType = "format_showcase"
|
|
SectionEditorialSpotlight SectionType = "editorial_spotlight"
|
|
SectionSeasonalThemed SectionType = "seasonal_themed"
|
|
SectionMoodCollection SectionType = "mood_collection"
|
|
|
|
SectionTrendingOnServer SectionType = "trending_on_server"
|
|
SectionProfileActivityFeed SectionType = "profile_activity_feed"
|
|
SectionNewToLibrary SectionType = "new_to_library"
|
|
SectionMostWatched SectionType = "most_watched"
|
|
|
|
SectionTrendingDiscover SectionType = "trending_discover"
|
|
|
|
SectionAdminCuratedList SectionType = "admin_curated_list"
|
|
)
|
|
|
|
// ValidSectionTypes is the set of all valid section type values.
|
|
var ValidSectionTypes = map[SectionType]bool{
|
|
SectionContinueWatching: true,
|
|
SectionRecentlyAdded: true,
|
|
SectionRecentlyReleased: true,
|
|
SectionWatchlist: true,
|
|
SectionFavorites: true,
|
|
SectionGenre: true,
|
|
SectionCustomFilter: true,
|
|
SectionRandom: true,
|
|
SectionCollection: true,
|
|
SectionRecommendedForYou: true,
|
|
SectionBecauseYouWatched: true,
|
|
SectionSimilarUsersLiked: true,
|
|
SectionTasteMatch: true,
|
|
SectionNextUp: true,
|
|
SectionNextInSeries: true,
|
|
SectionHiddenGems: true,
|
|
SectionCriticallyAcclaimed: true,
|
|
SectionAwardWinners: true,
|
|
SectionForgottenFavorites: true,
|
|
SectionFormatShowcase: true,
|
|
SectionEditorialSpotlight: true,
|
|
SectionSeasonalThemed: true,
|
|
SectionMoodCollection: true,
|
|
SectionTrendingOnServer: true,
|
|
SectionProfileActivityFeed: true,
|
|
SectionNewToLibrary: true,
|
|
SectionMostWatched: true,
|
|
SectionTrendingDiscover: true,
|
|
SectionAdminCuratedList: true,
|
|
}
|
|
|
|
// PageSection is an admin-defined section stored in PostgreSQL.
|
|
type PageSection struct {
|
|
ID string `json:"id"`
|
|
Scope string `json:"scope"`
|
|
LibraryID *int `json:"library_id"`
|
|
Position int `json:"position"`
|
|
SectionType SectionType `json:"section_type"`
|
|
Title string `json:"title"`
|
|
Featured bool `json:"featured"`
|
|
ItemLimit int `json:"item_limit"`
|
|
Config json.RawMessage `json:"config"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// ProfileSectionOverride is a per-profile customization stored in the user store.
|
|
type ProfileSectionOverride struct {
|
|
ID string `json:"id"`
|
|
ProfileID string `json:"profile_id"`
|
|
Scope string `json:"scope"`
|
|
LibraryID string `json:"library_id,omitempty"`
|
|
SectionID string `json:"section_id,omitempty"`
|
|
Position *int `json:"position,omitempty"`
|
|
Hidden bool `json:"hidden"`
|
|
Removed bool `json:"removed"`
|
|
SectionType SectionType `json:"section_type,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Featured *bool `json:"featured,omitempty"`
|
|
ItemLimit *int `json:"item_limit,omitempty"`
|
|
Config json.RawMessage `json:"config,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
|
|
// IsUserAdded marks this override as a profile-built recipe instance
|
|
// rather than a customization of an admin section. When true, SectionID
|
|
// is empty and UserSectionType / UserConfig / UserTitle take precedence
|
|
// over the legacy SectionType / Config / Title fields.
|
|
IsUserAdded bool `json:"is_user_added,omitempty"`
|
|
UserSectionType SectionType `json:"user_section_type,omitempty"`
|
|
UserConfig json.RawMessage `json:"user_config,omitempty"`
|
|
UserTitle string `json:"user_title,omitempty"`
|
|
}
|
|
|
|
// ResolvedSection is the merged result of admin section + profile override.
|
|
type ResolvedSection struct {
|
|
ID string `json:"id"`
|
|
SectionType SectionType `json:"section_type"`
|
|
Title string `json:"title"`
|
|
Featured bool `json:"featured"`
|
|
ItemLimit int `json:"item_limit"`
|
|
Config json.RawMessage `json:"config"`
|
|
Position int `json:"position"`
|
|
IsCustom bool `json:"is_custom"`
|
|
Customized bool `json:"customized"`
|
|
Hidden bool `json:"hidden,omitempty"`
|
|
}
|
|
|
|
// FilterConfig represents the rule-group filter structure.
|
|
type FilterConfig struct {
|
|
Match string `json:"match"`
|
|
Groups []FilterGroup `json:"groups"`
|
|
Sort string `json:"sort,omitempty"`
|
|
Order string `json:"order,omitempty"`
|
|
}
|
|
|
|
// FilterGroup is a group of filter rules joined by AND or OR.
|
|
type FilterGroup struct {
|
|
Match string `json:"match"`
|
|
Rules []FilterRule `json:"rules"`
|
|
}
|
|
|
|
// FilterRule is a single filter condition.
|
|
type FilterRule struct {
|
|
Field string `json:"field"`
|
|
Op string `json:"op"`
|
|
Value any `json:"value"`
|
|
}
|
|
|
|
// SectionConfigFilters holds the optional type and library filters from section config.
|
|
type SectionConfigFilters struct {
|
|
FilterType string `json:"filter_type"`
|
|
FilterLibraryID *int `json:"filter_library_id"`
|
|
FilterLibraryIDs []int `json:"filter_library_ids"`
|
|
}
|
|
|
|
// SectionCollectionConfig holds the selected collection reference.
|
|
// A section may reference either a library collection (admin-managed) or a
|
|
// user collection (personal, profile-scoped). Only one field should be set.
|
|
type SectionCollectionConfig struct {
|
|
LibraryCollectionID string `json:"library_collection_id,omitempty"`
|
|
UserCollectionID string `json:"user_collection_id,omitempty"`
|
|
}
|
|
|
|
// ParseConfigFilters extracts filter_type and filter_library_id from config JSON.
|
|
func ParseConfigFilters(config json.RawMessage) SectionConfigFilters {
|
|
var f SectionConfigFilters
|
|
if len(config) > 0 {
|
|
_ = json.Unmarshal(config, &f)
|
|
}
|
|
if len(f.FilterLibraryIDs) == 0 && f.FilterLibraryID == nil {
|
|
def, err := ParseQueryDefinition(config)
|
|
if err == nil {
|
|
switch def.MediaScope {
|
|
case "movie", "series", "audiobook":
|
|
f.FilterType = def.MediaScope
|
|
}
|
|
if len(def.LibraryIDs) > 0 {
|
|
f.FilterLibraryIDs = append([]int(nil), def.LibraryIDs...)
|
|
}
|
|
}
|
|
}
|
|
return f
|
|
}
|
|
|
|
// LibraryIDs returns the effective library filter IDs, supporting both the
|
|
// legacy single-library field and the newer multi-library field.
|
|
func (f SectionConfigFilters) LibraryIDs() []int {
|
|
ids := make([]int, 0, len(f.FilterLibraryIDs)+1)
|
|
seen := make(map[int]struct{}, len(f.FilterLibraryIDs)+1)
|
|
|
|
for _, id := range f.FilterLibraryIDs {
|
|
if id <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
if f.FilterLibraryID != nil && *f.FilterLibraryID > 0 {
|
|
if _, ok := seen[*f.FilterLibraryID]; !ok {
|
|
ids = append(ids, *f.FilterLibraryID)
|
|
}
|
|
}
|
|
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// ParseCollectionConfig extracts library_collection_id from config JSON.
|
|
func ParseCollectionConfig(config json.RawMessage) SectionCollectionConfig {
|
|
var c SectionCollectionConfig
|
|
if len(config) > 0 {
|
|
_ = json.Unmarshal(config, &c)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func ParseQueryDefinition(config json.RawMessage) (catalog.QueryDefinition, error) {
|
|
if len(config) == 0 {
|
|
return catalog.QueryDefinition{}.Normalize(), nil
|
|
}
|
|
|
|
var legacy struct {
|
|
FilterType string `json:"filter_type"`
|
|
FilterLibraryID *int `json:"filter_library_id"`
|
|
FilterLibraryIDs []int `json:"filter_library_ids"`
|
|
Sort json.RawMessage `json:"sort"`
|
|
Order string `json:"order"`
|
|
}
|
|
if err := json.Unmarshal(config, &legacy); err != nil {
|
|
return catalog.QueryDefinition{}, err
|
|
}
|
|
|
|
if legacy.FilterType != "" || legacy.FilterLibraryID != nil || len(legacy.FilterLibraryIDs) > 0 || legacy.Order != "" || isLegacySortConfig(legacy.Sort) {
|
|
return catalog.NormalizeLegacySectionFilter(config)
|
|
}
|
|
|
|
var def catalog.QueryDefinition
|
|
if err := json.Unmarshal(config, &def); err != nil {
|
|
return catalog.QueryDefinition{}, err
|
|
}
|
|
def = def.Normalize()
|
|
return def, def.Validate()
|
|
}
|
|
|
|
func isLegacySortConfig(raw json.RawMessage) bool {
|
|
return len(raw) > 0 && raw[0] == '"'
|
|
}
|