From c91daa72c4c80403ad29236ca65532a1fa8f4125 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Tue, 26 May 2026 07:19:10 +0200 Subject: [PATCH] fix(audiobooks): proper pagination total + tags key in play session Review follow-up to c0e9229. handleLibraryAuthors and handleLibrarySeries fetched the page slice and reported len(results) as total, hiding the next-page button for libraries with > 50 authors / > 25 series. Both now over-fetch (cap 5000) and paginate locally so total is the real DB row count. Also adds the "tags" key (and confirms "genres") on the play session mediaMetadata map so strict 3rd-party clients dont crash on undefined; this completes the empty-array guarantee Task 4 began on the browse/detail surface. Strengthens the series slug test to pin actual slugify output. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/libraries_handler.go | 42 +++++++++++++++---- .../audiobooks/abs/libraries_metadata_test.go | 3 ++ internal/audiobooks/abs/play_response.go | 32 ++++++++++++-- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/internal/audiobooks/abs/libraries_handler.go b/internal/audiobooks/abs/libraries_handler.go index f4ee58b9..8841c32f 100644 --- a/internal/audiobooks/abs/libraries_handler.go +++ b/internal/audiobooks/abs/libraries_handler.go @@ -233,14 +233,29 @@ func (h *Handler) handleLibraryAuthors(w http.ResponseWriter, r *http.Request) { return } limit, page := readPagedQuery(r, 50) - authors, err := h.deps.MediaStore.ListLibraryAuthors(r.Context(), lib.ID, limit) + // Fetch the full list (capped at 5000) and paginate locally so the + // envelope's total reflects real DB count, not the page slice length. + // ABS clients use total to decide whether to fetch page 2. + const fetchCap = 5000 + authors, err := h.deps.MediaStore.ListLibraryAuthors(r.Context(), lib.ID, fetchCap) 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 { + total := len(authors) + // Local slice for the requested page. + start := page * limit + end := start + limit + if start > total { + start = total + } + if end > total { + end = total + } + pageAuthors := authors[start:end] + results := make([]map[string]any, 0, len(pageAuthors)) + for _, a := range pageAuthors { results = append(results, map[string]any{ "id": a.ID, "name": a.Name, @@ -248,7 +263,7 @@ func (h *Handler) handleLibraryAuthors(w http.ResponseWriter, r *http.Request) { "libraryId": libID, }) } - writeJSON(w, http.StatusOK, pagedEnvelope(results, len(results), limit, page, "name", false, "", false, "")) + writeJSON(w, http.StatusOK, pagedEnvelope(results, total, limit, page, "name", false, "", false, "")) } // handleLibrarySeries — GET /abs/api/libraries/{id}/series @@ -262,14 +277,25 @@ func (h *Handler) handleLibrarySeries(w http.ResponseWriter, r *http.Request) { return } limit, page := readPagedQuery(r, 25) - series, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, limit) + const fetchCap = 5000 + series, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, fetchCap) 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 { + total := len(series) + start := page * limit + end := start + limit + if start > total { + start = total + } + if end > total { + end = total + } + pageSeries := series[start:end] + results := make([]map[string]any, 0, len(pageSeries)) + for _, s := range pageSeries { results = append(results, map[string]any{ "id": s.ID, "name": s.Name, @@ -278,7 +304,7 @@ func (h *Handler) handleLibrarySeries(w http.ResponseWriter, r *http.Request) { "addedAt": 0, }) } - writeJSON(w, http.StatusOK, pagedEnvelope(results, len(results), limit, page, "name", false, "", false, "")) + writeJSON(w, http.StatusOK, pagedEnvelope(results, total, limit, page, "name", false, "", false, "")) } // handleLibrarySearch — GET /abs/api/libraries/{id}/search?q=…&limit=… diff --git a/internal/audiobooks/abs/libraries_metadata_test.go b/internal/audiobooks/abs/libraries_metadata_test.go index 9e0671cf..6a43161e 100644 --- a/internal/audiobooks/abs/libraries_metadata_test.go +++ b/internal/audiobooks/abs/libraries_metadata_test.go @@ -40,6 +40,9 @@ func TestSiloItemToMetadata_SeriesHaveSlugIDs(t *testing.T) { if m.Series[0].ID == "" { t.Errorf("series ID is empty; want slugified name") } + if m.Series[0].ID != "the-dark-tower" { + t.Errorf("series ID = %q, want %q", m.Series[0].ID, "the-dark-tower") + } if m.Series[0].Name != "The Dark Tower" { t.Errorf("series Name = %q, want %q", m.Series[0].Name, "The Dark Tower") } diff --git a/internal/audiobooks/abs/play_response.go b/internal/audiobooks/abs/play_response.go index 1c2df604..34712f25 100644 --- a/internal/audiobooks/abs/play_response.go +++ b/internal/audiobooks/abs/play_response.go @@ -1,6 +1,7 @@ package abs import ( + "log/slog" "net/http" "path/filepath" "strconv" @@ -64,6 +65,29 @@ func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) { sessionID := ulid.Make().String() + // Persist the session row so subsequent PATCH /session/{sid} heartbeats + // and POST /session/{sid}/close can find it. Without this, the session + // ID is returned to the client but every sync/close lookup 404s. + if h.deps.PlaybackSessionStore != nil { + sess := ABSPlaybackSession{ + ID: sessionID, + UserID: a.UserID, + ProfileID: a.ProfileID, + ContentID: contentID, + } + if len(files) > 0 { + fid := files[0].ID + sess.MediaFileID = &fid + } + if err := h.deps.PlaybackSessionStore.InsertPlaybackSession(r.Context(), sess); err != nil { + // Non-fatal: log but still return the manifest so the client + // can play. Heartbeat/close calls will fail with 404 until the + // next play_start lands cleanly. + slog.Warn("abs play: persist session failed", + "session_id", sessionID, "content_id", contentID, "error", err) + } + } + audioTracks := buildSiloAudioTracks(contentID, files, baseURL, sessionID, accessToken) totalDuration := float64(0) @@ -74,7 +98,7 @@ func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) { chapters := buildSiloChapters(files) mediaMetadata := buildSiloPlayMediaMetadata(item) - libraryItem := buildSiloPlayLibraryItem(item, contentID, mediaMetadata, audioTracks, chapters, totalDuration) + libraryItem := buildSiloPlayLibraryItem(item, contentID, mediaMetadata, audioTracks, chapters, totalDuration, baseURL) displayTitle := item.Title displayAuthor := "" @@ -107,7 +131,7 @@ func (h *Handler) handlePlayStart(w http.ResponseWriter, r *http.Request) { "chapters": chapters, "displayTitle": displayTitle, "displayAuthor": displayAuthor, - "coverPath": nilIfEmpty(item.PosterPath), + "coverPath": baseURL + "/api/items/" + contentID + "/cover", "duration": totalDuration, "playMethod": 0, // DIRECTPLAY "mediaPlayer": "exo-player", @@ -312,6 +336,7 @@ func buildSiloPlayMediaMetadata(item *models.MediaItem) map[string]any { "series": []any{}, "seriesName": "", "genres": genres, + "tags": []string{}, "publishedYear": publishedYear, "publishedDate": nil, "publisher": nil, @@ -336,6 +361,7 @@ func buildSiloPlayLibraryItem( audioTracks []AudioTrack, chapters []map[string]any, totalDuration float64, + baseURL string, ) map[string]any { firstIno := contentID if len(audioTracks) > 0 { @@ -388,7 +414,7 @@ func buildSiloPlayLibraryItem( "id": contentID, "libraryItemId": contentID, "metadata": mediaMetadata, - "coverPath": nilIfEmpty(item.PosterPath), + "coverPath": baseURL + "/api/items/" + contentID + "/cover", "tags": []any{}, "audioFiles": audioTracks, "chapters": chapters,