fix(audiobooks): emit IDs on authors/series and stable genres/tags arrays

3rd-party ABS clients (Plappa, AudioBookShelfFully) require id on every
authors[] and series[] entry to encode filter selections; missing IDs
made author/series chips dead-end. Also ensures genres and tags are
always non-nil arrays so clients that branch on .length don't crash.

Tags is empty for now (silo has no item-tag concept); shape is stable so
future tag work won't break clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 07:11:07 +02:00
co-authored by Claude Opus 4.7
parent f9aafba336
commit c0e9229cf0
3 changed files with 296 additions and 28 deletions
+190 -27
View File
@@ -194,8 +194,25 @@ func (h *Handler) handleItemCover(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
// Redirect to silo's native image endpoint rather than proxying bytes.
http.Redirect(w, r, item.PosterPath, http.StatusFound)
target := item.PosterPath
// Raw silo paths (e.g. "local/audiobooks/.../original.webp") need to
// be resolved into a real URL via the CoverResolver before redirect;
// otherwise the client follows a relative path that doesn't exist on
// the ABS listener.
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
if h.deps.CoverResolver != nil {
if resolved := h.deps.CoverResolver(r.Context(), target, "card"); resolved != "" {
target = resolved
} else {
http.NotFound(w, r)
return
}
} else {
http.NotFound(w, r)
return
}
}
http.Redirect(w, r, target, http.StatusFound)
}
// handleAuthorImage — GET /abs/api/authors/{id}/image (unauthenticated)
@@ -207,48 +224,180 @@ func (h *Handler) handleAuthorImage(w http.ResponseWriter, _ *http.Request) {
}
// handleLibraryAuthors — GET /abs/api/libraries/{id}/authors
// Stubbed: silo does not yet maintain a normalised author table.
// Lists audiobook authors aggregated from item_people kind=7, including
// per-author book counts. Returns the canonical ABS paged envelope to
// match the continuum-plugin-audiobooks shape verbatim.
func (h *Handler) handleLibraryAuthors(w http.ResponseWriter, r *http.Request) {
if _, ok := h.resolveLibrary(w, r); !ok {
lib, ok := h.resolveLibrary(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, map[string]any{"authors": []any{}})
limit, page := readPagedQuery(r, 50)
authors, err := h.deps.MediaStore.ListLibraryAuthors(r.Context(), lib.ID, limit)
if err != nil {
http.Error(w, "list authors: "+err.Error(), http.StatusInternalServerError)
return
}
libID := audiobookLibraryID(lib)
results := make([]map[string]any, 0, len(authors))
for _, a := range authors {
results = append(results, map[string]any{
"id": a.ID,
"name": a.Name,
"numBooks": a.NumBooks,
"libraryId": libID,
})
}
writeJSON(w, http.StatusOK, pagedEnvelope(results, len(results), limit, page, "name", false, "", false, ""))
}
// handleLibrarySeries — GET /abs/api/libraries/{id}/series
// Stubbed: silo does not yet maintain a normalised series table.
// Lists audiobook series. Single-book series are filtered out by the
// store query since they're not useful as series. Returns the canonical
// ABS paged envelope; addedAt is 0 because the v1 catalog has no series
// added-at column (real ABS clients tolerate the placeholder).
func (h *Handler) handleLibrarySeries(w http.ResponseWriter, r *http.Request) {
if _, ok := h.resolveLibrary(w, r); !ok {
lib, ok := h.resolveLibrary(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}, "total": 0})
limit, page := readPagedQuery(r, 25)
series, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, limit)
if err != nil {
http.Error(w, "list series: "+err.Error(), http.StatusInternalServerError)
return
}
libID := audiobookLibraryID(lib)
results := make([]map[string]any, 0, len(series))
for _, s := range series {
results = append(results, map[string]any{
"id": s.ID,
"name": s.Name,
"numBooks": s.NumBooks,
"libraryId": libID,
"addedAt": 0,
})
}
writeJSON(w, http.StatusOK, pagedEnvelope(results, len(results), limit, page, "name", false, "", false, ""))
}
// handleLibrarySearch — GET /abs/api/libraries/{id}/search
// Stubbed: returns empty results for all categories.
// handleLibrarySearch — GET /abs/api/libraries/{id}/search?q=…&limit=…
// Returns matching books grouped under "book", with empty arrays for the
// other ABS-standard buckets (podcast, series, authors, tags). Bucket
// names match continuum-plugin-audiobooks exactly: note "authors" plural,
// not "author" — ABS mobile clients key off the plural form and a
// singular bucket is silently dropped.
func (h *Handler) handleLibrarySearch(w http.ResponseWriter, r *http.Request) {
if _, ok := h.resolveLibrary(w, r); !ok {
lib, ok := h.resolveLibrary(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"book": []any{},
q := strings.TrimSpace(r.URL.Query().Get("q"))
limit := 12
if n, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && n > 0 && n <= 50 {
limit = n
}
empty := map[string]any{
"book": []any{},
"podcast": []any{},
"author": []any{},
"series": []any{},
"tags": []any{},
})
"series": []any{},
"authors": []any{},
"tags": []any{},
}
if q == "" {
writeJSON(w, http.StatusOK, empty)
return
}
items, err := h.deps.MediaStore.SearchAudiobooks(r.Context(), lib.ID, q, limit)
if err != nil {
http.Error(w, "search: "+err.Error(), http.StatusInternalServerError)
return
}
baseURL := h.absBaseURL(r)
books := make([]map[string]any, 0, len(items))
for _, it := range items {
books = append(books, map[string]any{
"libraryItem": siloItemToLibraryItem(it, lib, baseURL),
"matchKey": "title",
"matchText": it.Title,
})
}
out := empty
out["book"] = books
writeJSON(w, http.StatusOK, out)
}
// handlePersonalized — GET /abs/api/libraries/{id}/personalized
// Stubbed: returns an empty shelf list. Continue Listening is served via
// /me/items-in-progress; this endpoint feeds the home-tab "shelves".
// A full implementation would build Recently Added, Continue Listening,
// Newest Authors, etc. from silo's catalog + progress tables.
//
// Emits the canonical six-shelf Home tab payload that ABS mobile clients
// expect: continue-listening, continue-series, newest, recent-series,
// discover, listen-again. Shelves we don't yet populate (continue-series,
// listen-again) ship with empty entities/total — the client iterates the
// shelf list by id and skips empties cleanly, but it crashes on a missing
// shelf id. Matches continuum-plugin-audiobooks/handlePersonalized layout.
func (h *Handler) handlePersonalized(w http.ResponseWriter, r *http.Request) {
if _, ok := h.resolveLibrary(w, r); !ok {
lib, ok := h.resolveLibrary(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, []any{})
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
baseURL := h.absBaseURL(r)
const shelfLimit = 10
shelves := []map[string]any{
{"id": "continue-listening", "label": "Continue Listening", "labelStringKey": "LabelContinueListening", "type": "book", "entities": []any{}, "total": 0},
{"id": "continue-series", "label": "Continue Series", "labelStringKey": "LabelContinueSeries", "type": "book", "entities": []any{}, "total": 0},
{"id": "newest", "label": "Newest", "labelStringKey": "LabelNewest", "type": "book", "entities": []any{}, "total": 0},
{"id": "recent-series", "label": "Recent Series", "labelStringKey": "LabelRecentSeries", "type": "series", "entities": []any{}, "total": 0},
{"id": "discover", "label": "Discover", "labelStringKey": "LabelDiscover", "type": "book", "entities": []any{}, "total": 0},
{"id": "listen-again", "label": "Listen Again", "labelStringKey": "LabelListenAgain", "type": "book", "entities": []any{}, "total": 0},
}
if items, err := h.deps.MediaStore.ListContinueListening(r.Context(), a.UserID, a.ProfileID, lib.ID, shelfLimit); err == nil && len(items) > 0 {
shelves[0]["entities"] = minifiedSlice(items, lib, baseURL)
shelves[0]["total"] = len(items)
}
if items, err := h.deps.MediaStore.ListRecentlyAdded(r.Context(), lib.ID, shelfLimit); err == nil && len(items) > 0 {
shelves[2]["entities"] = minifiedSlice(items, lib, baseURL)
shelves[2]["total"] = len(items)
}
libID := audiobookLibraryID(lib)
if series, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, shelfLimit); err == nil && len(series) > 0 {
recent := make([]map[string]any, 0, len(series))
for _, s := range series {
recent = append(recent, map[string]any{
"id": s.ID,
"name": s.Name,
"numBooks": s.NumBooks,
"libraryId": libID,
"books": []any{},
})
}
shelves[3]["entities"] = recent
shelves[3]["total"] = len(recent)
}
if items, err := h.deps.MediaStore.ListDiscover(r.Context(), lib.ID, shelfLimit); err == nil && len(items) > 0 {
shelves[4]["entities"] = minifiedSlice(items, lib, baseURL)
shelves[4]["total"] = len(items)
}
writeJSON(w, http.StatusOK, shelves)
}
// minifiedSlice converts a batch of MediaItems into ABS Minified entries.
func minifiedSlice(items []*models.MediaItem, lib AudiobookLibrary, baseURL string) []MinifiedLibraryItem {
out := make([]MinifiedLibraryItem, 0, len(items))
for _, it := range items {
out = append(out, Minify(siloItemToLibraryItem(it, lib, baseURL)))
}
return out
}
// ---------------------------------------------------------------------------
@@ -311,10 +460,12 @@ func siloItemToLibraryItem(item *models.MediaItem, lib AudiobookLibrary, baseURL
// extension). Convert from the int field.
duration := float64(item.Runtime) // seconds
coverPath := item.PosterPath
if coverPath == "" {
coverPath = baseURL + "/abs/api/items/" + item.ContentID + "/cover"
}
// Always point coverPath at our /api/items/{id}/cover endpoint rather
// than the raw silo PosterPath. Storage paths like
// "local/audiobooks/.../original.webp" mean nothing to an ABS client;
// our cover handler resolves them via the CoverResolver before
// redirecting to the real URL.
coverPath := baseURL + "/api/items/" + item.ContentID + "/cover"
addedAtMs := int64(0)
if item.AddedAt != nil {
@@ -345,6 +496,10 @@ func siloItemToLibraryItem(item *models.MediaItem, lib AudiobookLibrary, baseURL
// Authors and narrators are sourced from item.People; series from Studios
// (silo stores the series name in Studios for audiobooks until a proper
// series table lands — see scanner Stage 2 notes).
//
// Strict 3rd-party clients (Plappa, AudioBookShelfFully) require id on
// every author/series entry and non-nil tags/genres arrays. We surface
// IDs from item_people.id (authors) and slugify(name) (series).
func siloItemToMetadata(item *models.MediaItem) Metadata {
authors := make([]AuthorObj, 0)
narrators := make([]string, 0)
@@ -364,6 +519,9 @@ func siloItemToMetadata(item *models.MediaItem) Metadata {
// Series: silo's audiobook scanner stores series name in the Studios
// field until a dedicated series table is added. Derive an ID by
// slugifying the name (same convention as the plugin's translate.go).
// Authoritative series IDs will replace these slugs when a series
// table lands; client-stored references survive the change because the
// slug is stable for a given name.
series := make([]SeriesObj, 0, len(item.Studios))
for _, s := range item.Studios {
s = strings.TrimSpace(s)
@@ -386,6 +544,10 @@ func siloItemToMetadata(item *models.MediaItem) Metadata {
genres = []string{}
}
// silo has no item-level tags concept today; emit an empty array so
// clients that branch on tags[] don't see a null and crash.
tags := []string{}
return Metadata{
Title: item.Title,
Authors: authors,
@@ -394,6 +556,7 @@ func siloItemToMetadata(item *models.MediaItem) Metadata {
Description: item.Overview,
PublishedYear: publishedYear,
Genres: genres,
Tags: tags,
}
}
@@ -0,0 +1,101 @@
package abs
import (
"encoding/json"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestSiloItemToMetadata_AuthorsHaveIDs(t *testing.T) {
item := &models.MediaItem{
Title: "Test Book",
People: []models.ItemPerson{
{Person: models.Person{ID: 42, Name: "Stephen King"}, Kind: models.PersonKindAuthor},
{Person: models.Person{ID: 43, Name: "Audie Murphy"}, Kind: models.PersonKindNarrator},
},
}
m := siloItemToMetadata(item)
if len(m.Authors) != 1 {
t.Fatalf("authors len = %d, want 1", len(m.Authors))
}
if m.Authors[0].ID != "42" {
t.Errorf("author ID = %q, want %q", m.Authors[0].ID, "42")
}
if m.Authors[0].Name != "Stephen King" {
t.Errorf("author Name = %q, want %q", m.Authors[0].Name, "Stephen King")
}
}
func TestSiloItemToMetadata_SeriesHaveSlugIDs(t *testing.T) {
item := &models.MediaItem{
Title: "Test Book",
Studios: []string{"The Dark Tower"},
}
m := siloItemToMetadata(item)
if len(m.Series) != 1 {
t.Fatalf("series len = %d, want 1", len(m.Series))
}
if m.Series[0].ID == "" {
t.Errorf("series ID is empty; want slugified name")
}
if m.Series[0].Name != "The Dark Tower" {
t.Errorf("series Name = %q, want %q", m.Series[0].Name, "The Dark Tower")
}
}
func TestSiloItemToMetadata_GenresEmptyArrayNotNil(t *testing.T) {
item := &models.MediaItem{Title: "Test Book"} // Genres nil
m := siloItemToMetadata(item)
if m.Genres == nil {
t.Errorf("Genres is nil; want empty slice")
}
if len(m.Genres) != 0 {
t.Errorf("Genres len = %d, want 0", len(m.Genres))
}
}
func TestSiloItemToMetadata_TagsEmptyArrayNotNil(t *testing.T) {
item := &models.MediaItem{Title: "Test Book"}
m := siloItemToMetadata(item)
if m.Tags == nil {
t.Errorf("Tags is nil; want empty slice")
}
}
func TestSiloItemToMetadata_NarratorsListed(t *testing.T) {
item := &models.MediaItem{
Title: "Test Book",
People: []models.ItemPerson{
{Person: models.Person{ID: 1, Name: "Narrator One"}, Kind: models.PersonKindNarrator},
{Person: models.Person{ID: 2, Name: "Narrator Two"}, Kind: models.PersonKindNarrator},
},
}
m := siloItemToMetadata(item)
if len(m.Narrators) != 2 {
t.Fatalf("narrators len = %d, want 2", len(m.Narrators))
}
if m.Narrators[0] != "Narrator One" || m.Narrators[1] != "Narrator Two" {
t.Errorf("narrators = %v, want [Narrator One Narrator Two]", m.Narrators)
}
}
// TestSiloItemToMetadata_JSONKeysAlwaysPresent guards the omitempty fix:
// 3rd-party clients branch on the presence of "genres" and "tags" keys
// even when the values are empty arrays. Removing omitempty from those
// fields means the keys serialize even when the slice is empty.
func TestSiloItemToMetadata_JSONKeysAlwaysPresent(t *testing.T) {
item := &models.MediaItem{Title: "Test Book"} // no genres, no tags, no people
m := siloItemToMetadata(item)
out, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal: %v", err)
}
s := string(out)
for _, key := range []string{`"genres":`, `"tags":`, `"authors":`, `"series":`, `"narrators":`} {
if !strings.Contains(s, key) {
t.Errorf("JSON missing required key %s; got %s", key, s)
}
}
}
+5 -1
View File
@@ -90,6 +90,9 @@ type AudioTrack struct {
// Metadata is the book-level metadata block. Authors / Narrators / Series
// match the ABS spec: arrays of references (or strings for Narrators).
// Genres and Tags intentionally do NOT use omitempty — strict 3rd-party
// clients (Plappa, AudioBookShelfFully) branch on these keys being present
// (even if empty), and dropping the key sends them into degraded mode.
type Metadata struct {
Title string `json:"title"`
Authors []AuthorObj `json:"authors"`
@@ -99,7 +102,8 @@ type Metadata struct {
PublishedYear string `json:"publishedYear,omitempty"`
ISBN string `json:"isbn,omitempty"`
Publisher string `json:"publisher,omitempty"`
Genres []string `json:"genres,omitempty"`
Genres []string `json:"genres"`
Tags []string `json:"tags"`
}
// LibraryItemMedia carries the bulk of the audiobook metadata.