Detail page: - collapse Chapters section behind header toggle (73-chapter pages no longer push content below the fold) - square cover frames throughout (audiobook covers are Audible-style 1:1, not 2:3 book portrait) - narrator picker dropdown when multiple narrations of the same book exist - clickable genre badges (route to /audiobooks?genre=X) - reordered so credits/rails sit above the chapter list - Play-from-Start button forces remount via playToken counter (was a no-op when player was already at position 0) - regression test for the Play-from-Start fix Mini bar / Now Listening: - mini bar respects --app-sidebar-offset so it stops getting covered by the desktop sidebar - Now Listening adds overflow scroll + a labeled "Back to player" button so controls are never inaccessible on short viewports Library page: - infinite scroll (replaces Previous/Next pagination) - genre filter chip with X-to-clear Scanner: - 8-worker parallel reconcile (env SILO_AUDIOBOOK_SCAN_WORKERS to override) - file-path-first dedup so cleaned titles don't collapse separate narrations - title cleanup at write time (strips "Read by X" / "(unabridged)" suffixes; original tag preserved in original_title) - audiobook_series upsert from tag-derived series_name/series_position - secondary dedup check (author + narrator + year + duration ±0.5% + title-prefix) so two folders of the same book attach to one row Backend detail handler: - new fetchAlsoByAuthor, fetchInSeries (with series row > 1 entry guard), fetchSimilar (embedding-first with shared-genre fallback), fetchOtherNarrations (regex-strips narrator suffix to group siblings) - audiobookDetailResponse gained also_by_author, in_series, similar_audiobooks, other_narrations fields - list endpoint accepts a genre query param Embeddings: - BuildEmbeddingText branches on item.Type == "audiobook" to use author/narrator credits instead of cast/director/writer - mediaTypeLabel helper centralizes movie / "TV series" / audiobook - ListEmbeddingTextCandidates SQL mirrors the Go branching exactly - ItemsNeedingEmbedding and TotalMediaItemCount loosen status='matched' gate to also include audiobooks (which don't go through TMDB match) - FindSimilar gains a mediaType filter so cross-type results never appear - callers in similar.go and personal.go pass the source item's type Collections: - MediaAudiobook MediaKind + audiobook(s) case in templateEligibleForLibrary (stops offering broken movie/TV templates to audiobook libraries) - useAddItemToCollection hook (user + admin/library endpoints) - AddToCollectionDialog wired into audiobook detail and movie/series ActionBar overflow menu - ManualCollectionItemsEditor gained a search-and-add panel with debounced live results - QueryDefinition.media_scope, QuerySortRelevanceScope, ALL_MEDIA_SCOPES extended to include "audiobook" - CatalogFilterBar gained an Audiobooks media scope option - parseCatalogMediaScope (backend) accepts "audiobook" Migrations: - 145_audiobook_series: per-book series_name/series_index with a best-effort title-pattern backfill for the existing corpus - 146_audiobook_title_cleanup: strips narrator suffix / (unabridged) noise from existing titles, preserving raw in original_title Scripts: - scripts/dedup_audiobooks.py: one-shot merge for "Title" vs "Title: Subtitle" duplicates, file-path-stable, dry-run by default Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
440 lines
12 KiB
Go
440 lines
12 KiB
Go
package catalog
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
catalogGroupMatchPattern = regexp.MustCompile(`^groups\[(\d+)\]\[match\]$`)
|
|
catalogGroupRulePattern = regexp.MustCompile(`^groups\[(\d+)\]\[rules\]\[(\d+)\]\[(field|op|value)\]$`)
|
|
catalogGroupRuleValuePattern = regexp.MustCompile(`^groups\[(\d+)\]\[rules\]\[(\d+)\]\[value\]\[(\d+)\]$`)
|
|
)
|
|
|
|
type catalogOverlay struct {
|
|
searchQuery string
|
|
namePrefix string
|
|
query QueryDefinition
|
|
hasExplicitSort bool
|
|
}
|
|
|
|
type catalogGroupBuilder struct {
|
|
match string
|
|
rules map[int]*catalogRuleBuilder
|
|
}
|
|
|
|
type catalogRuleBuilder struct {
|
|
field string
|
|
op string
|
|
values []any
|
|
indexedValues map[int]any
|
|
}
|
|
|
|
// ParseCatalogRequest converts catalog URL params into a normalized request.
|
|
func ParseCatalogRequest(values url.Values) (CatalogRequest, error) {
|
|
req := CatalogRequest{
|
|
Source: CatalogSource(strings.ToLower(strings.TrimSpace(values.Get("source")))),
|
|
Limit: 20,
|
|
}
|
|
if req.Source == "" {
|
|
req.Source = CatalogSourceQuery
|
|
}
|
|
|
|
if limit := ParseIntParam(values.Get("limit")); limit > 0 {
|
|
req.Limit = limit
|
|
}
|
|
if req.Limit > 100 {
|
|
req.Limit = 100
|
|
}
|
|
req.Offset = max(ParseIntParam(values.Get("offset")), 0)
|
|
req.SkipTotal = parseCatalogSkipTotal(values.Get("include_total"))
|
|
|
|
if raw := strings.TrimSpace(values.Get("snapshot")); raw != "" {
|
|
t, err := time.Parse(time.RFC3339Nano, raw)
|
|
if err != nil {
|
|
return CatalogRequest{}, fmt.Errorf("invalid snapshot timestamp: %w", err)
|
|
}
|
|
req.SnapshotAt = &t
|
|
}
|
|
|
|
switch req.Source {
|
|
case CatalogSourceQuery:
|
|
overlay, err := parseCatalogOverlay(values)
|
|
if err != nil {
|
|
return CatalogRequest{}, err
|
|
}
|
|
req.NamePrefix = overlay.namePrefix
|
|
req.SearchQuery = overlay.searchQuery
|
|
req.Query = overlay.query
|
|
req.UseSourceOrder = false
|
|
if !overlay.hasExplicitSort {
|
|
req.Query.Sort = defaultCatalogQuerySort(req.SearchQuery)
|
|
}
|
|
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
|
|
overlay, err := parseCatalogOverlay(values)
|
|
if err != nil {
|
|
return CatalogRequest{}, err
|
|
}
|
|
req.NamePrefix = overlay.namePrefix
|
|
req.SearchQuery = overlay.searchQuery
|
|
req.Query = overlay.query
|
|
req.UseSourceOrder = !overlay.hasExplicitSort
|
|
case CatalogSourcePerson:
|
|
req.PersonID = ParseInt64Param(values.Get("person_id"))
|
|
if req.PersonID <= 0 {
|
|
return CatalogRequest{}, fmt.Errorf("person_id is required")
|
|
}
|
|
overlay, err := parseCatalogOverlay(values)
|
|
if err != nil {
|
|
return CatalogRequest{}, err
|
|
}
|
|
req.NamePrefix = overlay.namePrefix
|
|
req.SearchQuery = overlay.searchQuery
|
|
req.Query = overlay.query
|
|
req.UseSourceOrder = !overlay.hasExplicitSort
|
|
case CatalogSourceSection:
|
|
scope := strings.ToLower(strings.TrimSpace(values.Get("scope")))
|
|
if scope == "" {
|
|
scope = "library"
|
|
}
|
|
if scope != "home" && scope != "library" {
|
|
return CatalogRequest{}, fmt.Errorf("scope must be 'home' or 'library'")
|
|
}
|
|
req.Scope = scope
|
|
req.SectionID = strings.TrimSpace(values.Get("section_id"))
|
|
if req.SectionID == "" {
|
|
return CatalogRequest{}, fmt.Errorf("section_id is required")
|
|
}
|
|
if scope == "library" {
|
|
libraryID := ParseIntParam(values.Get("library_id"))
|
|
if libraryID <= 0 {
|
|
return CatalogRequest{}, fmt.Errorf("library_id is required for library sections")
|
|
}
|
|
req.LibraryID = libraryID
|
|
}
|
|
if hasCatalogOverlayParams(values, scope == "library") {
|
|
return CatalogRequest{}, fmt.Errorf("source %q does not allow overlay params", req.Source)
|
|
}
|
|
req.UseSourceOrder = true
|
|
case CatalogSourceLibraryCollection, CatalogSourceUserCollection:
|
|
req.CollectionID = strings.TrimSpace(values.Get("collection_id"))
|
|
if req.CollectionID == "" {
|
|
return CatalogRequest{}, fmt.Errorf("collection_id is required")
|
|
}
|
|
if hasCatalogOverlayParams(values, false) {
|
|
return CatalogRequest{}, fmt.Errorf("source %q does not allow overlay params", req.Source)
|
|
}
|
|
req.UseSourceOrder = true
|
|
default:
|
|
return CatalogRequest{}, fmt.Errorf("unsupported catalog source %q", req.Source)
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
func parseCatalogSkipTotal(raw string) bool {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return false
|
|
}
|
|
includeTotal, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return !includeTotal
|
|
}
|
|
|
|
func parseCatalogOverlay(values url.Values) (catalogOverlay, error) {
|
|
def := QueryDefinition{
|
|
Match: normalizeCatalogMatch(values.Get("match")),
|
|
Groups: []QueryGroup{},
|
|
}
|
|
|
|
searchQuery := strings.TrimSpace(values.Get("q"))
|
|
if libraryID := ParseIntParam(values.Get("library_id")); libraryID > 0 {
|
|
def.LibraryIDs = []int{libraryID}
|
|
}
|
|
if mediaScope := parseCatalogMediaScope(values.Get("type")); mediaScope != "" {
|
|
def.MediaScope = mediaScope
|
|
}
|
|
|
|
implicitRules := make([]QueryRule, 0, 4)
|
|
if genre := strings.TrimSpace(values.Get("genre")); genre != "" {
|
|
implicitRules = append(implicitRules, QueryRule{Field: "genre", Op: "contains", Value: genre})
|
|
}
|
|
if status := strings.TrimSpace(values.Get("status")); status != "" {
|
|
implicitRules = append(implicitRules, QueryRule{Field: "status", Op: "is", Value: status})
|
|
}
|
|
|
|
yearMin := ParseIntParam(values.Get("year_min"))
|
|
yearMax := ParseIntParam(values.Get("year_max"))
|
|
switch {
|
|
case yearMin > 0 && yearMax > 0:
|
|
implicitRules = append(implicitRules, QueryRule{Field: "year", Op: "between", Value: []any{yearMin, yearMax}})
|
|
case yearMin > 0:
|
|
implicitRules = append(implicitRules, QueryRule{Field: "year", Op: "gte", Value: yearMin})
|
|
case yearMax > 0:
|
|
implicitRules = append(implicitRules, QueryRule{Field: "year", Op: "lte", Value: yearMax})
|
|
}
|
|
|
|
if len(implicitRules) > 0 {
|
|
def.Groups = append(def.Groups, QueryGroup{
|
|
Match: "all",
|
|
Rules: implicitRules,
|
|
})
|
|
}
|
|
|
|
if ratings := ParseContentRatings(values.Get("content_rating")); len(ratings) == 1 {
|
|
def.Groups = append(def.Groups, QueryGroup{
|
|
Match: "all",
|
|
Rules: []QueryRule{{Field: "content_rating", Op: "is", Value: ratings[0]}},
|
|
})
|
|
} else if len(ratings) > 1 {
|
|
rules := make([]QueryRule, 0, len(ratings))
|
|
for _, rating := range ratings {
|
|
rules = append(rules, QueryRule{Field: "content_rating", Op: "is", Value: rating})
|
|
}
|
|
def.Groups = append(def.Groups, QueryGroup{Match: "any", Rules: rules})
|
|
}
|
|
|
|
explicitGroups, err := parseCatalogGroups(values)
|
|
if err != nil {
|
|
return catalogOverlay{}, err
|
|
}
|
|
def.Groups = append(def.Groups, explicitGroups...)
|
|
|
|
hasExplicitSort := values.Get("sort") != "" || values.Get("order") != ""
|
|
if hasExplicitSort {
|
|
def.Sort = normalizeExplicitCatalogSort(values.Get("sort"), values.Get("order"))
|
|
}
|
|
|
|
return catalogOverlay{
|
|
searchQuery: searchQuery,
|
|
namePrefix: strings.TrimSpace(values.Get("name_prefix")),
|
|
query: def,
|
|
hasExplicitSort: hasExplicitSort,
|
|
}, nil
|
|
}
|
|
|
|
func parseCatalogGroups(values url.Values) ([]QueryGroup, error) {
|
|
groupBuilders := map[int]*catalogGroupBuilder{}
|
|
|
|
for key, rawValues := range values {
|
|
if matches := catalogGroupMatchPattern.FindStringSubmatch(key); matches != nil {
|
|
groupIdx, _ := strconv.Atoi(matches[1])
|
|
builder := ensureCatalogGroupBuilder(groupBuilders, groupIdx)
|
|
if len(rawValues) > 0 {
|
|
builder.match = rawValues[0]
|
|
}
|
|
continue
|
|
}
|
|
|
|
if matches := catalogGroupRulePattern.FindStringSubmatch(key); matches != nil {
|
|
groupIdx, _ := strconv.Atoi(matches[1])
|
|
ruleIdx, _ := strconv.Atoi(matches[2])
|
|
fieldName := matches[3]
|
|
rule := ensureCatalogRuleBuilder(groupBuilders, groupIdx, ruleIdx)
|
|
switch fieldName {
|
|
case "field":
|
|
if len(rawValues) > 0 {
|
|
rule.field = rawValues[0]
|
|
}
|
|
case "op":
|
|
if len(rawValues) > 0 {
|
|
rule.op = rawValues[0]
|
|
}
|
|
case "value":
|
|
rule.values = make([]any, 0, len(rawValues))
|
|
for _, value := range rawValues {
|
|
rule.values = append(rule.values, parseCatalogScalar(value))
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if matches := catalogGroupRuleValuePattern.FindStringSubmatch(key); matches != nil {
|
|
groupIdx, _ := strconv.Atoi(matches[1])
|
|
ruleIdx, _ := strconv.Atoi(matches[2])
|
|
valueIdx, _ := strconv.Atoi(matches[3])
|
|
rule := ensureCatalogRuleBuilder(groupBuilders, groupIdx, ruleIdx)
|
|
if len(rawValues) == 0 {
|
|
continue
|
|
}
|
|
if rule.indexedValues == nil {
|
|
rule.indexedValues = map[int]any{}
|
|
}
|
|
rule.indexedValues[valueIdx] = parseCatalogScalar(rawValues[0])
|
|
}
|
|
}
|
|
|
|
if len(groupBuilders) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
groupIndexes := make([]int, 0, len(groupBuilders))
|
|
for idx := range groupBuilders {
|
|
groupIndexes = append(groupIndexes, idx)
|
|
}
|
|
slices.Sort(groupIndexes)
|
|
|
|
groups := make([]QueryGroup, 0, len(groupIndexes))
|
|
for _, groupIdx := range groupIndexes {
|
|
builder := groupBuilders[groupIdx]
|
|
ruleIndexes := make([]int, 0, len(builder.rules))
|
|
for idx := range builder.rules {
|
|
ruleIndexes = append(ruleIndexes, idx)
|
|
}
|
|
slices.Sort(ruleIndexes)
|
|
|
|
rules := make([]QueryRule, 0, len(ruleIndexes))
|
|
for _, ruleIdx := range ruleIndexes {
|
|
ruleBuilder := builder.rules[ruleIdx]
|
|
rules = append(rules, QueryRule{
|
|
Field: ruleBuilder.field,
|
|
Op: ruleBuilder.op,
|
|
Value: ruleBuilder.value(),
|
|
})
|
|
}
|
|
|
|
groups = append(groups, QueryGroup{
|
|
Match: normalizeCatalogMatch(builder.match),
|
|
Rules: rules,
|
|
})
|
|
}
|
|
|
|
return groups, nil
|
|
}
|
|
|
|
func ensureCatalogGroupBuilder(groups map[int]*catalogGroupBuilder, groupIdx int) *catalogGroupBuilder {
|
|
builder := groups[groupIdx]
|
|
if builder == nil {
|
|
builder = &catalogGroupBuilder{rules: map[int]*catalogRuleBuilder{}}
|
|
groups[groupIdx] = builder
|
|
}
|
|
return builder
|
|
}
|
|
|
|
func ensureCatalogRuleBuilder(groups map[int]*catalogGroupBuilder, groupIdx, ruleIdx int) *catalogRuleBuilder {
|
|
group := ensureCatalogGroupBuilder(groups, groupIdx)
|
|
rule := group.rules[ruleIdx]
|
|
if rule == nil {
|
|
rule = &catalogRuleBuilder{}
|
|
group.rules[ruleIdx] = rule
|
|
}
|
|
return rule
|
|
}
|
|
|
|
func (r *catalogRuleBuilder) value() any {
|
|
if len(r.indexedValues) > 0 {
|
|
indexes := make([]int, 0, len(r.indexedValues))
|
|
for idx := range r.indexedValues {
|
|
indexes = append(indexes, idx)
|
|
}
|
|
slices.Sort(indexes)
|
|
|
|
values := make([]any, 0, len(indexes))
|
|
for _, idx := range indexes {
|
|
values = append(values, r.indexedValues[idx])
|
|
}
|
|
return values
|
|
}
|
|
|
|
if len(r.values) == 1 {
|
|
return r.values[0]
|
|
}
|
|
if len(r.values) > 1 {
|
|
return r.values
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasCatalogOverlayParams(values url.Values, ignoreLibraryID bool) bool {
|
|
for key, rawValues := range values {
|
|
if len(rawValues) == 0 {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(key, "groups[") {
|
|
return true
|
|
}
|
|
|
|
switch key {
|
|
case "source", "scope", "section_id", "collection_id", "title", "limit", "offset", "snapshot":
|
|
continue
|
|
case "library_id":
|
|
if ignoreLibraryID {
|
|
continue
|
|
}
|
|
return true
|
|
case "q", "name_prefix", "match", "type", "genre", "year_min", "year_max", "content_rating", "status", "sort", "order":
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func normalizeCatalogMatch(raw string) string {
|
|
if strings.EqualFold(strings.TrimSpace(raw), "any") {
|
|
return "any"
|
|
}
|
|
return "all"
|
|
}
|
|
|
|
func parseCatalogMediaScope(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "movie", "series", "episode", "audiobook":
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func defaultCatalogQuerySort(searchQuery string) QuerySort {
|
|
if strings.TrimSpace(searchQuery) != "" {
|
|
return QuerySort{Field: "relevance", Order: "desc"}
|
|
}
|
|
return QuerySort{Field: "title", Order: "asc"}
|
|
}
|
|
|
|
func normalizeExplicitCatalogSort(field, order string) QuerySort {
|
|
normalizedField := strings.ToLower(strings.TrimSpace(field))
|
|
normalizedOrder := strings.ToLower(strings.TrimSpace(order))
|
|
|
|
if normalizedField == "relevance" {
|
|
if normalizedOrder == "" {
|
|
normalizedOrder = "desc"
|
|
}
|
|
return QuerySort{Field: normalizedField, Order: normalizedOrder}
|
|
}
|
|
|
|
normalized := NormalizeQuerySort(QuerySort{Field: normalizedField, Order: normalizedOrder})
|
|
if normalizedField == "" {
|
|
normalized.Field = ""
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func parseCatalogScalar(raw string) any {
|
|
trimmed := strings.TrimSpace(raw)
|
|
switch strings.ToLower(trimmed) {
|
|
case "true":
|
|
return true
|
|
case "false":
|
|
return false
|
|
}
|
|
|
|
if i, err := strconv.Atoi(trimmed); err == nil {
|
|
return i
|
|
}
|
|
if f, err := strconv.ParseFloat(trimmed, 64); err == nil {
|
|
return f
|
|
}
|
|
|
|
return trimmed
|
|
}
|