* feat(audiobooks): redesign audiobook library around resume and series progression Audiobook libraries previously reused the video-shaped library page: a backdrop carousel hero (audiobooks have square covers and no backdrops), movie-style default sections, and a browse grid whose primary audiobook axes (author, narrator, series) were buried as filters. Backend: - New next_in_series section type: surfaces the next unstarted book, by series_index, in series the profile has finished a book of, ordered by most recent finish. Registered as a library-staple recipe. - New GET /api/v1/catalog/audiobook-groups endpoint: grouped browse by author/narrator/series with book count, total duration, per-profile progress counts, and poster URLs for cover stacks. - Audiobook library defaults: continue-listening is featured (renders as the Now Listening hero) with next-in-series directly after it. A data migration upgrades existing audiobook libraries, skipping layouts where an admin already featured a section. Frontend: - NowListeningHero replaces HeroBanner for audiobook libraries: resume deck with chapter position, hours left, ambient color from the cover, and one-click resume; remaining in-progress books render as the Continue Listening row. - Library tab gains Books/Series/Authors/Narrators browse axes persisted via the type param; selecting a group drops into the Books grid with the matching filter applied. - "Recommended" tab is labeled "Home" for audiobook libraries; audiobook continue cards use square covers and hr/min time-left formatting. - Shared audiobook chapter/file/duration helpers extracted to web/src/lib/audiobooks (deduplicated from AudiobookContent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(audiobooks): address review feedback on library redesign - Push library scoping into the next-in-series candidate SQL so finished series whose next book lives in another library can't consume the candidate limit and starve a library-scoped section (Codex P2). - Paginate the audiobook groups fetch until the server-reported total is reached (500/page, 20-page bound) so client-side filtering sees the complete author/narrator/series list (Codex P2, CodeRabbit). - Make the redesign migration rollback-safe: rows the Up touches carry config markers (featured_by_migration / seeded_by_migration) and the Down reverts only marked rows, leaving admin-set featured state and hand-created next_in_series sections alone (Codex P2, CodeRabbit). - Gate NowListeningHero's detail-derived files/credits on the detail matching the deck item, so Resume can't start the new book with the previous book's files while keepPreviousData shows stale detail (Codex P2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
109 lines
3.5 KiB
Go
109 lines
3.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
)
|
|
|
|
type audiobookGroupResponse struct {
|
|
Name string `json:"name"`
|
|
ItemCount int `json:"item_count"`
|
|
TotalDurationSeconds int64 `json:"total_duration_seconds"`
|
|
InProgressCount int `json:"in_progress_count"`
|
|
FinishedCount int `json:"finished_count"`
|
|
PosterURLs []string `json:"poster_urls"`
|
|
}
|
|
|
|
type audiobookGroupsResponse struct {
|
|
Total int `json:"total"`
|
|
Groups []audiobookGroupResponse `json:"groups"`
|
|
}
|
|
|
|
// HandleGetAudiobookGroups — GET /api/v1/catalog/audiobook-groups
|
|
//
|
|
// Grouped browse for audiobook libraries: authors, narrators, or series with
|
|
// aggregate stats (book count, total duration, per-profile progress counts)
|
|
// and up to four poster URLs for cover stacks. Query parameters:
|
|
// library_id=<id> (required), group_by=author|narrator|series (required),
|
|
// sort=name|count|duration (default name), limit, offset.
|
|
//
|
|
// Group names round-trip into the corresponding catalog filter fields
|
|
// (author / narrator / series), which match case-insensitively.
|
|
func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http.Request) {
|
|
if h == nil || h.itemsH == nil || h.itemsH.browseRepo == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
|
|
return
|
|
}
|
|
|
|
libraryID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("library_id")))
|
|
if err != nil || libraryID <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id must be a positive integer")
|
|
return
|
|
}
|
|
|
|
groupBy, ok := catalog.ParseAudiobookGroupBy(r.URL.Query().Get("group_by"))
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "group_by must be one of author, narrator, series")
|
|
return
|
|
}
|
|
|
|
sort := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort")))
|
|
switch sort {
|
|
case "", "name", "count", "duration":
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "bad_request", "sort must be one of name, count, duration")
|
|
return
|
|
}
|
|
|
|
limit := 200
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
|
n, parseErr := strconv.Atoi(raw)
|
|
if parseErr != nil || n <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
|
|
return
|
|
}
|
|
limit = n
|
|
}
|
|
offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0)
|
|
|
|
groups, total, err := catalog.ListAudiobookGroups(
|
|
r.Context(),
|
|
h.itemsH.browseRepo.Pool(),
|
|
catalog.AudiobookGroupsQuery{
|
|
LibraryID: libraryID,
|
|
GroupBy: groupBy,
|
|
Sort: sort,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
},
|
|
h.itemsH.accessFilter(r),
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list audiobook groups")
|
|
return
|
|
}
|
|
|
|
resp := audiobookGroupsResponse{Total: total, Groups: make([]audiobookGroupResponse, 0, len(groups))}
|
|
for _, g := range groups {
|
|
posterURLs := make([]string, 0, len(g.PosterPaths))
|
|
for _, path := range g.PosterPaths {
|
|
if url := h.itemsH.presignURL(r, cardThumbnailPath(path), "card"); url != "" {
|
|
posterURLs = append(posterURLs, url)
|
|
}
|
|
}
|
|
resp.Groups = append(resp.Groups, audiobookGroupResponse{
|
|
Name: g.Name,
|
|
ItemCount: g.ItemCount,
|
|
TotalDurationSeconds: g.TotalDurationSeconds,
|
|
InProgressCount: g.InProgressCount,
|
|
FinishedCount: g.FinishedCount,
|
|
PosterURLs: posterURLs,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|