diff --git a/internal/audiobooks/abs/author_series_handler.go b/internal/audiobooks/abs/author_series_handler.go index b5b01948..939a502c 100644 --- a/internal/audiobooks/abs/author_series_handler.go +++ b/internal/audiobooks/abs/author_series_handler.go @@ -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)) diff --git a/internal/audiobooks/abs/author_series_handler_test.go b/internal/audiobooks/abs/author_series_handler_test.go index e4b7d7b6..cb1f8e42 100644 --- a/internal/audiobooks/abs/author_series_handler_test.go +++ b/internal/audiobooks/abs/author_series_handler_test.go @@ -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)") diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index e7912309..0ac3b63f 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -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. diff --git a/internal/audiobooks/abs/libraries_handler.go b/internal/audiobooks/abs/libraries_handler.go index 1c5d8dc7..f5ec3828 100644 --- a/internal/audiobooks/abs/libraries_handler.go +++ b/internal/audiobooks/abs/libraries_handler.go @@ -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 diff --git a/internal/audiobooks/media_store.go b/internal/audiobooks/media_store.go index c7113540..ceeac297 100644 --- a/internal/audiobooks/media_store.go +++ b/internal/audiobooks/media_store.go @@ -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() }