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:
committed by
GitHub
co-authored by
Claude Fable 5
parent
11eb771165
commit
d08a4f232f
@@ -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)")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user