Files
silo-server/internal/sections/types.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

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] == '"'
}