* 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>
202 lines
6.1 KiB
Go
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
|
|
}
|