Files
silo-server/internal/catalog/audiobook_groups.go
T
162e0cc449 feat(audiobooks): redesign audiobook library around resume and series progression (#116)
* 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>
2026-06-09 19:28:53 -04:00

202 lines
6.1 KiB
Go

package catalog
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/models"
)
// AudiobookGroupBy enumerates the supported audiobook grouping axes.
type AudiobookGroupBy string
const (
AudiobookGroupByAuthor AudiobookGroupBy = "author"
AudiobookGroupByNarrator AudiobookGroupBy = "narrator"
AudiobookGroupBySeries AudiobookGroupBy = "series"
)
// ParseAudiobookGroupBy validates a group_by request parameter.
func ParseAudiobookGroupBy(raw string) (AudiobookGroupBy, bool) {
switch AudiobookGroupBy(strings.ToLower(strings.TrimSpace(raw))) {
case AudiobookGroupByAuthor:
return AudiobookGroupByAuthor, true
case AudiobookGroupByNarrator:
return AudiobookGroupByNarrator, true
case AudiobookGroupBySeries:
return AudiobookGroupBySeries, true
default:
return "", false
}
}
// AudiobookGroupsQuery controls the grouped audiobook browse lookup.
type AudiobookGroupsQuery struct {
LibraryID int
GroupBy AudiobookGroupBy
// Sort is one of "name" (default), "count", "duration".
Sort string
Limit int
Offset int
}
// AudiobookGroup is one grouped browse row: an author, narrator, or series
// with aggregate stats over the audiobooks visible to the viewer.
type AudiobookGroup struct {
Name string
ItemCount int
TotalDurationSeconds int64
InProgressCount int
FinishedCount int
// PosterPaths holds up to four raw poster paths for cover stacks; the
// API layer presigns them.
PosterPaths []string
}
// ListAudiobookGroups returns grouped browse rows for an audiobook library.
// Groups are aggregated per person name (authors/narrators) or per series
// name, matching the case-insensitive semantics of the corresponding catalog
// filters, so a group name can be fed straight back into an author/narrator/
// series filter rule. Progress counts are scoped to the requesting profile via
// filter.UserID / filter.ProfileID.
func ListAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGroupsQuery, filter AccessFilter) ([]AudiobookGroup, int, error) {
if pool == nil {
return nil, 0, fmt.Errorf("audiobook groups: no database pool")
}
if q.LibraryID <= 0 {
return nil, 0, fmt.Errorf("audiobook groups: library id is required")
}
limit := q.Limit
if limit <= 0 {
limit = 200
}
if limit > 500 {
limit = 500
}
offset := max(q.Offset, 0)
args := []any{q.LibraryID}
argIdx := 2
conditions := []string{
"mi.type = 'audiobook'",
"mil.media_folder_id = $1",
}
if !appendAudiobookItemAccessConditions("mi", filter, &conditions, &args, &argIdx) {
return []AudiobookGroup{}, 0, nil
}
var joinClause, nameExpr, groupExpr, posterOrder string
switch q.GroupBy {
case AudiobookGroupByAuthor, AudiobookGroupByNarrator:
kind := models.PersonKindAuthor
if q.GroupBy == AudiobookGroupByNarrator {
kind = models.PersonKindNarrator
}
joinClause = fmt.Sprintf(`JOIN item_people ip ON ip.content_id = b.content_id AND ip.kind = $%d
JOIN people p ON p.id = ip.person_id`, argIdx)
args = append(args, int(kind))
argIdx++
nameExpr = "MIN(BTRIM(p.name))"
groupExpr = "LOWER(BTRIM(p.name))"
posterOrder = "b.sort_title"
case AudiobookGroupBySeries:
joinClause = "JOIN audiobook_series s ON s.content_id = b.content_id"
nameExpr = "MIN(BTRIM(s.series_name))"
groupExpr = "LOWER(BTRIM(s.series_name))"
posterOrder = "s.series_index NULLS LAST, b.sort_title"
default:
return nil, 0, fmt.Errorf("audiobook groups: unsupported group_by %q", q.GroupBy)
}
userArg := fmt.Sprintf("$%d", argIdx)
args = append(args, filter.UserID)
argIdx++
profileArg := fmt.Sprintf("$%d", argIdx)
args = append(args, filter.ProfileID)
argIdx++
var orderClause string
switch strings.ToLower(strings.TrimSpace(q.Sort)) {
case "", "name":
orderClause = "LOWER(name)"
case "count":
orderClause = "item_count DESC, LOWER(name)"
case "duration":
orderClause = "total_duration_seconds DESC, LOWER(name)"
default:
return nil, 0, fmt.Errorf("audiobook groups: unsupported sort %q", q.Sort)
}
query := fmt.Sprintf(`
WITH books AS (
SELECT
mi.content_id,
mi.poster_path,
mi.sort_title,
COALESCE((
SELECT SUM(mf.duration)
FROM media_files mf
WHERE mf.content_id = mi.content_id AND mf.missing_since IS NULL
), 0) AS duration_seconds
FROM media_items mi
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
WHERE %s
),
grouped AS (
SELECT
%s AS name,
COUNT(*)::int AS item_count,
COALESCE(SUM(b.duration_seconds), 0)::bigint AS total_duration_seconds,
COUNT(*) FILTER (WHERE uwp.media_item_id IS NOT NULL AND NOT uwp.completed)::int AS in_progress_count,
COUNT(*) FILTER (WHERE uwp.completed)::int AS finished_count,
(ARRAY_AGG(b.poster_path ORDER BY %s) FILTER (WHERE NULLIF(b.poster_path, '') IS NOT NULL))[1:4] AS poster_paths
FROM books b
%s
LEFT JOIN user_watch_progress uwp
ON uwp.media_item_id = b.content_id
AND uwp.user_id = %s
AND uwp.profile_id = %s
GROUP BY %s
)
SELECT name, item_count, total_duration_seconds, in_progress_count, finished_count, poster_paths,
COUNT(*) OVER ()::int AS total_groups
FROM grouped
ORDER BY %s
LIMIT $%d OFFSET $%d`,
strings.Join(conditions, " AND "),
nameExpr, posterOrder, joinClause, userArg, profileArg, groupExpr, orderClause,
argIdx, argIdx+1,
)
args = append(args, limit, offset)
rows, err := pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("querying audiobook groups: %w", err)
}
defer rows.Close()
total := 0
groups := make([]AudiobookGroup, 0, limit)
for rows.Next() {
var g AudiobookGroup
var posterPaths []string
if err := rows.Scan(&g.Name, &g.ItemCount, &g.TotalDurationSeconds, &g.InProgressCount, &g.FinishedCount, &posterPaths, &total); err != nil {
return nil, 0, fmt.Errorf("scanning audiobook group: %w", err)
}
if posterPaths == nil {
posterPaths = []string{}
}
g.PosterPaths = posterPaths
groups = append(groups, g)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterating audiobook groups: %w", err)
}
return groups, total, nil
}