Files
silo-server/internal/audiobooks/abs/collapse.go
T
RXWatcherandClaude Opus 4.7 d73b1b1c6b feat(audiobooks): port ABS library + items browse
Stage 5 of the ABS-compat port. Adds:
  - GET /abs/api/me (and /api/me)
  - GET /abs/api/libraries (and /api/libraries)
  - GET /abs/api/libraries/{id}
  - GET /abs/api/libraries/{id}/items
  - GET /abs/api/items/{id}
  - GET /abs/api/items/{id}/similar
  - GET /abs/api/me/items-in-progress (Continue Listening)
  - Stub 200 endpoints for authors, series, search, personalized

Extends MediaStore interface: ListAudiobookLibraries and
LibraryID-filtered ListAudiobooks. Adds AudiobookLibrary struct.

Also ports collapse.go (CollapseBySeries) from the plugin — identical
logic, needed for collapseseries=1 query param support.

Author/series detail and filter-data population stubbed (empty lists);
ABS clients fall back to flat item browse when those endpoints return
empty rather than crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:41:21 +02:00

87 lines
2.7 KiB
Go

package abs
import "strings"
// CollapseBySeries folds a flat list of LibraryItems into a deduplicated
// list where every item belonging to a series is represented by a single
// entry carrying a CollapsedSeriesV1 block listing every book in that
// series. Items with no series pass through unchanged.
//
// The representative entry for a series is the first item in source
// order — same convention real ABS uses. The series itself is keyed by
// the first series.ID on each book (real ABS books rarely belong to
// multiple series; the spec defines this as taking the first when they
// do).
//
// Stable across multiple calls: input order determines output order, so
// pagination on top of this remains deterministic.
func CollapseBySeries(items []LibraryItem) []LibraryItem {
if len(items) == 0 {
return items
}
// Index series → representative slot (+ tracked ID list).
seriesSlot := make(map[string]int, len(items))
out := make([]LibraryItem, 0, len(items))
for _, it := range items {
series := primarySeries(it)
if series.ID == "" && series.Name == "" {
// Not part of a series — pass through.
out = append(out, it)
continue
}
key := seriesKey(series)
if slot, ok := seriesSlot[key]; ok {
out[slot].CollapsedSeries.NumBooks++
out[slot].CollapsedSeries.LibraryItemIDs = append(
out[slot].CollapsedSeries.LibraryItemIDs, it.ID)
continue
}
// First sighting — clone the item and attach a fresh
// CollapsedSeriesV1 with this id as the seed.
rep := it
rep.CollapsedSeries = &CollapsedSeriesV1{
ID: series.ID,
Name: series.Name,
NameIgnorePrefix: stripLeadingArticle(series.Name),
NumBooks: 1,
LibraryItemIDs: []string{it.ID},
}
seriesSlot[key] = len(out)
out = append(out, rep)
}
return out
}
// primarySeries returns the first series ref on a LibraryItem, or a
// zero-value SeriesObj when the book is unaffiliated.
func primarySeries(it LibraryItem) SeriesObj {
for _, s := range it.Media.Metadata.Series {
if s.ID != "" || s.Name != "" {
return s
}
}
return SeriesObj{}
}
// seriesKey prefers the id (stable identifier); falls back to the name
// when the id is empty (legacy / synthesised metadata).
func seriesKey(s SeriesObj) string {
if s.ID != "" {
return "id:" + s.ID
}
return "name:" + s.Name
}
// stripLeadingArticle produces the "ignore-prefix" sort label real ABS
// emits for series — "The Stormlight Archive" sorts under S, not T.
func stripLeadingArticle(name string) string {
lower := strings.ToLower(name)
for _, prefix := range []string{"the ", "a ", "an "} {
if strings.HasPrefix(lower, prefix) {
return strings.TrimSpace(name[len(prefix):])
}
}
return name
}