feat(audiobooks): emit non-null imagePath for photo-bearing ABS authors (#316)

* fix(audiobooks): select photo_path in ABS author detail query

GetAuthorByID selected poster_path from the people table, but the
person-image column is photo_path, so every GET /api/authors/{id}
request failed with SQLSTATE 42703 and a 500. Since photo_path is
NOT NULL DEFAULT '', the nullable scan is also dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audiobooks): emit non-null imagePath for photo-bearing ABS authors

Author responses always emitted imagePath: null, so ABS clients never
fetched /api/authors/{id}/image even though it works and ~7% of
audiobook authors have a people.photo_path. Real ABS puts a server-local
filesystem path in imagePath and clients treat any non-null value as the
cue to fetch the image endpoint, so a synthetic ABS-shaped path is
emitted whenever the person has a photo.

The detail path reads the flag from Author.PosterPath. The list path
carries a new AuthorSummary.HasPhoto: the MV query LEFT JOINs people at
read time (photo presence stays current instead of stale until the next
REFRESH) and the live fallback selects photo_path alongside its existing
people join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-06 10:45:47 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 11eb771165
commit d08a4f232f
5 changed files with 61 additions and 26 deletions
@@ -68,16 +68,24 @@ func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
}
// authorObjectABS builds the real ABS Author.toOldJSON(+numBooks) shape
// (server/models/Author.js). silo does not track asin/description/imagePath/
// timestamps, so those are emitted as null/0 — nullable in real ABS, and a
// present key (not its value) is what keeps strict clients from crashing.
func authorObjectABS(id, name, libraryID string, numBooks int) map[string]any {
// (server/models/Author.js). silo does not track asin/description/timestamps,
// so those are emitted as null/0 — nullable in real ABS, and a present key
// (not its value) is what keeps strict clients from crashing. imagePath is
// a server-local filesystem path in real ABS; clients treat any non-null
// value as "this author has a photo" and fetch it via
// GET /api/authors/{id}/image rather than dereferencing the path, so a
// synthetic ABS-shaped path is emitted when silo has a photo for the person.
func authorObjectABS(id, name, libraryID string, numBooks int, hasPhoto bool) map[string]any {
var imagePath any
if hasPhoto {
imagePath = "/metadata/authors/" + id + ".jpg"
}
return map[string]any{
"id": id,
"asin": nil,
"name": name,
"description": nil,
"imagePath": nil,
"imagePath": imagePath,
"libraryId": libraryID,
"addedAt": 0,
"updatedAt": 0,
@@ -87,7 +95,7 @@ func authorObjectABS(id, name, libraryID string, numBooks int) map[string]any {
func authorToABS(a Author, lib AudiobookLibrary, baseURL string) map[string]any {
libID := audiobookLibraryID(lib)
obj := authorObjectABS(a.ID, a.Name, libID, len(a.Books))
obj := authorObjectABS(a.ID, a.Name, libID, len(a.Books), a.PosterPath != "")
// Author-detail books are full minified library items (not thin stubs) so
// any strict client decodes them with its LibraryItem model.
books := make([]MinifiedLibraryItem, 0, len(a.Books))
@@ -32,7 +32,7 @@ func (s *authorSeriesStubMediaStore) GetSeriesByName(_ context.Context, name str
func TestAuthor_Detail_ReturnsBooks(t *testing.T) {
media := &authorSeriesStubMediaStore{
author: Author{ID: "42", Name: "Brandon Sanderson", Books: []*models.MediaItem{
author: Author{ID: "42", Name: "Brandon Sanderson", PosterPath: "tmdb/people/42/profile", Books: []*models.MediaItem{
{ContentID: "book-1", Title: "Mistborn"},
{ContentID: "book-2", Title: "Stormlight"},
}},
@@ -56,6 +56,11 @@ func TestAuthor_Detail_ReturnsBooks(t *testing.T) {
t.Errorf("author object missing key %q", k)
}
}
// A photo-bearing author must emit a non-null imagePath — it is the
// client's cue to fetch /api/authors/{id}/image.
if got["imagePath"] == nil {
t.Errorf("imagePath = nil for author with a photo")
}
// Author items are real-ABS minified library items under libraryItems.
items, _ := got["libraryItems"].([]any)
if len(items) != 2 {
@@ -83,7 +88,7 @@ func (s *libAuthorsStub) ListLibraryAuthors(_ context.Context, _ int64, _, _ int
// paginated, paged { results, total, ... } when limit+page are present.
func TestLibraryAuthors_EnvelopeBranchesOnPagination(t *testing.T) {
media := &libAuthorsStub{authors: []AuthorSummary{
{ID: "1", Name: "Alpha", NumBooks: 2},
{ID: "1", Name: "Alpha", NumBooks: 2, HasPhoto: true},
{ID: "2", Name: "Beta", NumBooks: 1},
}}
h := New(Dependencies{MediaStore: media})
@@ -108,6 +113,16 @@ func TestLibraryAuthors_EnvelopeBranchesOnPagination(t *testing.T) {
if len(authors) != 2 {
t.Errorf("authors len = %d, want 2", len(authors))
}
if len(authors) == 2 {
a0, _ := authors[0].(map[string]any)
a1, _ := authors[1].(map[string]any)
if a0["imagePath"] == nil {
t.Errorf("imagePath = nil for list author with a photo")
}
if a1["imagePath"] != nil {
t.Errorf("imagePath = %v for list author without a photo, want null", a1["imagePath"])
}
}
if a0, _ := authors[0].(map[string]any); a0 != nil {
if _, ok := a0["asin"]; !ok {
t.Errorf("author object missing 'asin' (thin shape regression)")
+4
View File
@@ -93,6 +93,10 @@ type AuthorSummary struct {
ID string
Name string
NumBooks int
// HasPhoto reports whether the author's people row carries a photo, so
// list responses can emit a non-null imagePath (the client's cue to
// fetch /api/authors/{id}/image).
HasPhoto bool
}
// SeriesSummary is an aggregated series entry for /libraries/{id}/series.
+1 -1
View File
@@ -384,7 +384,7 @@ func (h *Handler) handleLibraryAuthors(w http.ResponseWriter, r *http.Request) {
libID := audiobookLibraryID(lib)
results := make([]map[string]any, 0, len(pageAuthors))
for _, a := range pageAuthors {
results = append(results, authorObjectABS(a.ID, a.Name, libID, a.NumBooks))
results = append(results, authorObjectABS(a.ID, a.Name, libID, a.NumBooks, a.HasPhoto))
}
// Real ABS LibraryController.getAuthors branches on isPaginated =
// (limit present & numeric) && (page present & numeric): paged envelope
+25 -17
View File
@@ -764,15 +764,20 @@ func (s *ABSMediaStore) ListLibraryAuthors(ctx context.Context, libraryID int64,
var orderBy string
switch sortBy {
case "addedAt":
orderBy = "added_at " + dir + ", person_id"
orderBy = "c.added_at " + dir + ", c.person_id"
case "numBooks":
orderBy = "num_books " + dir + ", LOWER(name)"
orderBy = "c.num_books " + dir + ", LOWER(c.name)"
default: // name
orderBy = "LOWER(name) " + dir
orderBy = "LOWER(c.name) " + dir
}
dataSQL := `SELECT person_id, name, num_books, added_at FROM abs_audiobook_author_counts
WHERE library_id = $1 ORDER BY ` + orderBy
// The MV carries no photo column; join people at read time so photo
// presence is always current instead of stale until the next REFRESH.
dataSQL := `SELECT c.person_id, c.name, c.num_books, c.added_at,
COALESCE(p.photo_path, '') <> ''
FROM abs_audiobook_author_counts c
LEFT JOIN people p ON p.id = c.person_id
WHERE c.library_id = $1 ORDER BY ` + orderBy
args := []any{int(libraryID)}
if limit > 0 {
dataSQL += ` LIMIT $2 OFFSET $3`
@@ -786,15 +791,16 @@ func (s *ABSMediaStore) ListLibraryAuthors(ctx context.Context, libraryID int64,
out := make([]abs.AuthorSummary, 0, 64)
for rows.Next() {
var (
id int64
name string
books int
addedAt time.Time
id int64
name string
books int
addedAt time.Time
hasPhoto bool
)
if err := rows.Scan(&id, &name, &books, &addedAt); err != nil {
if err := rows.Scan(&id, &name, &books, &addedAt, &hasPhoto); err != nil {
return nil, 0, fmt.Errorf("abs_media_store: scan author: %w", err)
}
out = append(out, abs.AuthorSummary{ID: fmt.Sprintf("%d", id), Name: name, NumBooks: books})
out = append(out, abs.AuthorSummary{ID: fmt.Sprintf("%d", id), Name: name, NumBooks: books, HasPhoto: hasPhoto})
}
return out, total, rows.Err()
}
@@ -846,7 +852,8 @@ func (s *ABSMediaStore) listLibraryAuthorsLive(ctx context.Context, libraryID in
orderBy = "LOWER(p.name) " + dir
}
dataSQL := `
SELECT p.id, p.name, COUNT(DISTINCT mi.content_id) AS num_books
SELECT p.id, p.name, COUNT(DISTINCT mi.content_id) AS num_books,
p.photo_path <> ''
FROM media_items mi
JOIN item_people ip ON ip.content_id = mi.content_id AND ip.kind = 7
JOIN people p ON p.id = ip.person_id
@@ -866,14 +873,15 @@ func (s *ABSMediaStore) listLibraryAuthorsLive(ctx context.Context, libraryID in
out := make([]abs.AuthorSummary, 0, 64)
for rows.Next() {
var (
id int64
name string
books int
id int64
name string
books int
hasPhoto bool
)
if err := rows.Scan(&id, &name, &books); err != nil {
if err := rows.Scan(&id, &name, &books, &hasPhoto); err != nil {
return nil, 0, fmt.Errorf("abs_media_store: scan author (live): %w", err)
}
out = append(out, abs.AuthorSummary{ID: fmt.Sprintf("%d", id), Name: name, NumBooks: books})
out = append(out, abs.AuthorSummary{ID: fmt.Sprintf("%d", id), Name: name, NumBooks: books, HasPhoto: hasPhoto})
}
return out, total, rows.Err()
}