Files
silo-server/internal/audiobooks/media_store.go
d08a4f232f feat(audiobooks): emit non-null imagePath for photo-bearing ABS authors (#316)
* fix(audiobooks): select photo_path in ABS author detail query

GetAuthorByID selected poster_path from the people table, but the
person-image column is photo_path, so every GET /api/authors/{id}
request failed with SQLSTATE 42703 and a 500. Since photo_path is
NOT NULL DEFAULT '', the nullable scan is also dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audiobooks): emit non-null imagePath for photo-bearing ABS authors

Author responses always emitted imagePath: null, so ABS clients never
fetched /api/authors/{id}/image even though it works and ~7% of
audiobook authors have a people.photo_path. Real ABS puts a server-local
filesystem path in imagePath and clients treat any non-null value as the
cue to fetch the image endpoint, so a synthetic ABS-shaped path is
emitted whenever the person has a photo.

The detail path reads the flag from Author.PosterPath. The list path
carries a new AuthorSummary.HasPhoto: the MV query LEFT JOINs people at
read time (photo presence stays current instead of stale until the next
REFRESH) and the live fallback selects photo_path alongside its existing
people join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:45:47 -04:00

1072 lines
38 KiB
Go

package audiobooks
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
)
// ABSMediaStore implements abs.MediaStore using silo's catalog.ItemRepository,
// scanner.FileRepository, and a direct pgxpool.Pool for media_folders queries.
type ABSMediaStore struct {
Items *catalog.ItemRepository
Files *scanner.FileRepository
Pool *pgxpool.Pool
// countCache memoizes the per-(library, filter, access) COUNT(*) that
// ListAudiobooks would otherwise recompute on every page. A full client
// library sync pages through thousands of requests reading the same total;
// the count only shifts when the library changes, so a short TTL is safe.
countMu sync.Mutex
countCache map[string]absCountEntry
}
type absCountEntry struct {
n int
exp time.Time
}
// absCountCacheTTL bounds how stale the paginated total may be. During an active
// scan the count can lag by up to this window; clients re-sync, so that is fine.
const absCountCacheTTL = 60 * time.Second
// cachedAudiobookCount returns a memoized COUNT(*) for the given (countSQL, args)
// pair, running the query on a miss/expiry. The key is derived from the fully
// rendered SQL plus its bound args, so it automatically covers every input the
// count WHERE depends on (library, pushed-down filter, and all access
// predicates) — it can't drift as access logic evolves. The DB query runs
// outside the lock so concurrent syncs don't serialize on it.
func (s *ABSMediaStore) cachedAudiobookCount(ctx context.Context, countSQL string, args []any) (int, error) {
key := countSQL + "\x1f" + fmt.Sprintf("%v", args)
now := time.Now()
s.countMu.Lock()
if e, ok := s.countCache[key]; ok && now.Before(e.exp) {
s.countMu.Unlock()
return e.n, nil
}
s.countMu.Unlock()
var n int
if err := s.Pool.QueryRow(ctx, countSQL, args...).Scan(&n); err != nil {
return 0, err
}
s.countMu.Lock()
if s.countCache == nil {
s.countCache = make(map[string]absCountEntry)
}
// Sweep expired entries on write so per-filter keys (e.g. one per author
// during a sync) don't accumulate for the process lifetime.
for k, e := range s.countCache {
if now.After(e.exp) {
delete(s.countCache, k)
}
}
s.countCache[key] = absCountEntry{n: n, exp: now.Add(absCountCacheTTL)}
s.countMu.Unlock()
return n, nil
}
var _ abs.MediaStore = (*ABSMediaStore)(nil)
// GetAudiobookByID returns the media_item with the given content_id, provided
// it is of type 'audiobook'. Returns nil and a wrapped error for any other
// outcome; the caller interprets a nil result as not-found.
func (s *ABSMediaStore) GetAudiobookByID(ctx context.Context, contentID string, access catalog.AccessFilter) (*models.MediaItem, error) {
items, err := s.Items.GetByIDsWithAccess(ctx, []string{contentID}, access)
if err != nil {
return nil, fmt.Errorf("abs_media_store: get audiobook %q: %w", contentID, err)
}
if len(items) == 0 {
return nil, nil
}
item := items[0]
if item == nil || item.Type != "audiobook" {
return nil, nil
}
// Hydrate authors + narrators so the ABS metadata mapper can fill
// authorName / narratorName on the response.
if err := s.hydratePeople(ctx, []*models.MediaItem{item}); err != nil {
// Non-fatal: caller can still render the item without people data.
_ = err
}
if err := s.hydrateAudiobookSeries(ctx, []*models.MediaItem{item}); err != nil {
_ = err
}
return item, nil
}
// GetAudiobooksByIDs batch-fetches audiobooks by content_id, hydrating people
// and series once for the whole set. List/shelf handlers (continue-listening,
// similar, my-progress) previously called GetAudiobookByID per row, issuing a
// handful of queries per item — up to ~500 single fetches on app open. Returns
// a map keyed by content_id; missing or non-audiobook ids are omitted.
func (s *ABSMediaStore) GetAudiobooksByIDs(ctx context.Context, contentIDs []string, access catalog.AccessFilter) (map[string]*models.MediaItem, error) {
if len(contentIDs) == 0 {
return map[string]*models.MediaItem{}, nil
}
items, err := s.Items.GetByIDsWithAccess(ctx, contentIDs, access)
if err != nil {
return nil, fmt.Errorf("abs_media_store: get audiobooks: %w", err)
}
books := make([]*models.MediaItem, 0, len(items))
for _, item := range items {
if item != nil && item.Type == "audiobook" {
books = append(books, item)
}
}
if len(books) == 0 {
return map[string]*models.MediaItem{}, nil
}
// Hydrate the whole set in two queries rather than per item.
if err := s.hydratePeople(ctx, books); err != nil {
_ = err
}
if err := s.hydrateAudiobookSeries(ctx, books); err != nil {
_ = err
}
out := make(map[string]*models.MediaItem, len(books))
for _, b := range books {
out[b.ContentID] = b
}
return out, nil
}
// ListAudiobooks returns a page of media_items with type='audiobook'.
// When libraryID is non-zero, results are filtered to items in that
// media_folder (via the media_item_libraries junction); 0 means all libraries.
//
// We can't reuse ItemRepository.Search because Search is a text-search
// path that bails out when the query string is empty. We page content_ids
// here via SQL, then load full rows via GetByIDs so the scan logic stays
// in the catalog package.
func (s *ABSMediaStore) ListAudiobooks(ctx context.Context, libraryID int64, limit, offset int, access catalog.AccessFilter, filter abs.Filter) ([]*models.MediaItem, int, error) {
if s.Pool == nil {
return nil, 0, fmt.Errorf("abs_media_store: no pgx pool")
}
if offset < 0 {
offset = 0
}
var total int
conditions := []string{"mi.type = 'audiobook'"}
args := []any{}
argIdx := 1
if libraryID != 0 {
conditions = append(conditions, fmt.Sprintf(`EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id AND mil.media_folder_id = $%d
)`, argIdx))
args = append(args, int(libraryID))
argIdx++
}
appendAudiobookAccessConditions("mi", access, &conditions, &args, &argIdx)
// Push author/series/narrator filters into SQL so per-author album syncs
// don't load and hydrate the entire library on every request. Uses the
// idx_item_people_content_kind_person and audiobook_series indexes.
appendAudiobookFilterConditions(filter, &conditions, &args, &argIdx)
where := strings.Join(conditions, " AND ")
total, err := s.cachedAudiobookCount(ctx, `SELECT COUNT(*) FROM media_items mi WHERE `+where, args)
if err != nil {
return nil, 0, fmt.Errorf("abs_media_store: count audiobooks: %w", err)
}
if total == 0 {
return []*models.MediaItem{}, 0, nil
}
dataArgs := append([]any(nil), args...)
// Order by the same expression idx_media_items_sort_key is built on so the
// page can be served by an ordered index scan instead of sorting the whole
// library on every request; content_id is a stable tiebreaker so sequential
// pages don't skip or repeat rows when sort keys collide.
dataSQL := `SELECT mi.content_id FROM media_items mi WHERE ` + where +
` ORDER BY lower(coalesce(nullif(btrim(mi.sort_title), ''), mi.title)), mi.content_id`
if limit > 0 {
argIdx = len(dataArgs) + 1
dataSQL += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, argIdx, argIdx+1)
dataArgs = append(dataArgs, limit, offset)
}
rows, err := s.Pool.Query(ctx, dataSQL, dataArgs...)
if err != nil {
return nil, 0, fmt.Errorf("abs_media_store: list audiobooks: %w", err)
}
defer rows.Close()
ids := make([]string, 0, 32)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, 0, fmt.Errorf("abs_media_store: scan audiobook id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("abs_media_store: iterate audiobook ids: %w", err)
}
if len(ids) == 0 {
return []*models.MediaItem{}, total, nil
}
items, err := s.Items.GetByIDs(ctx, ids)
if err != nil {
return nil, 0, fmt.Errorf("abs_media_store: load audiobooks: %w", err)
}
// GetByIDs sorts by content_id ASC; preserve our sort_title order.
byID := make(map[string]*models.MediaItem, len(items))
for _, it := range items {
byID[it.ContentID] = it
}
ordered := make([]*models.MediaItem, 0, len(ids))
for _, id := range ids {
if it, ok := byID[id]; ok {
ordered = append(ordered, it)
}
}
// Hydrate item_people in one batched query so the ABS mapper can pull
// author/narrator names without N+1 lookups.
if err := s.hydratePeople(ctx, ordered); err != nil {
// Non-fatal: items without people still render (authorName/narratorName
// just stay empty in the ABS payload). Log via the caller if needed.
_ = err
}
if err := s.hydrateAudiobookSeries(ctx, ordered); err != nil {
_ = err
}
return ordered, total, nil
}
// appendAudiobookFilterConditions pushes an ABS authors/series/narrators
// filter down into the SQL WHERE clause. Author values are the numeric
// person_id (as returned by the /authors endpoint), with a name fallback for
// non-numeric values; narrator and series values are names. This mirrors the
// case-sensitive exact-match identity semantics of abs.Filter.Matches.
// Progress/genre/tag/language filters are not pushable here (they need
// per-user or detail data) and are handled by the caller in Go.
func appendAudiobookFilterConditions(filter abs.Filter, conditions *[]string, args *[]any, argIdx *int) {
switch filter.Kind {
case abs.FilterAuthors:
if id, err := strconv.ParseInt(filter.Value, 10, 64); err == nil {
*conditions = append(*conditions, fmt.Sprintf(
`EXISTS (SELECT 1 FROM item_people ip WHERE ip.content_id = mi.content_id AND ip.kind = %d AND ip.person_id = $%d)`,
models.PersonKindAuthor, *argIdx))
*args = append(*args, id)
} else {
*conditions = append(*conditions, fmt.Sprintf(
`EXISTS (SELECT 1 FROM item_people ip JOIN people p ON p.id = ip.person_id WHERE ip.content_id = mi.content_id AND ip.kind = %d AND p.name = $%d)`,
models.PersonKindAuthor, *argIdx))
*args = append(*args, filter.Value)
}
*argIdx = *argIdx + 1
case abs.FilterNarrators:
*conditions = append(*conditions, fmt.Sprintf(
`EXISTS (SELECT 1 FROM item_people ip JOIN people p ON p.id = ip.person_id WHERE ip.content_id = mi.content_id AND ip.kind = %d AND p.name = $%d)`,
models.PersonKindNarrator, *argIdx))
*args = append(*args, filter.Value)
*argIdx = *argIdx + 1
case abs.FilterSeries:
if filter.Value == abs.SentinelNoSeries {
*conditions = append(*conditions, `NOT EXISTS (SELECT 1 FROM audiobook_series abs WHERE abs.content_id = mi.content_id)`)
} else {
*conditions = append(*conditions, fmt.Sprintf(
`EXISTS (SELECT 1 FROM audiobook_series abs WHERE abs.content_id = mi.content_id AND abs.series_name = $%d)`, *argIdx))
*args = append(*args, filter.Value)
*argIdx = *argIdx + 1
}
}
}
func appendAudiobookAccessConditions(alias string, filter catalog.AccessFilter, conditions *[]string, args *[]any, argIdx *int) {
if filter.AllowedLibraryIDs != nil {
if len(filter.AllowedLibraryIDs) == 0 {
*conditions = append(*conditions, "1 = 0")
} else {
*conditions = append(*conditions, fmt.Sprintf(`EXISTS (
SELECT 1 FROM media_item_libraries mil_access
WHERE mil_access.content_id = %s.content_id
AND mil_access.media_folder_id = ANY($%d)
)`, alias, *argIdx))
*args = append(*args, filter.AllowedLibraryIDs)
*argIdx = *argIdx + 1
}
}
if len(filter.DisabledLibraryIDs) > 0 {
if filter.AllowedLibraryIDs == nil {
*conditions = append(*conditions, fmt.Sprintf(`EXISTS (
SELECT 1 FROM media_item_libraries mil_present
WHERE mil_present.content_id = %s.content_id
)`, alias))
}
*conditions = append(*conditions, fmt.Sprintf(`NOT EXISTS (
SELECT 1 FROM media_item_libraries mil_disabled
WHERE mil_disabled.content_id = %s.content_id
AND mil_disabled.media_folder_id = ANY($%d)
)`, alias, *argIdx))
*args = append(*args, filter.DisabledLibraryIDs)
*argIdx = *argIdx + 1
}
catalog.ApplySectionAccessFilter(alias, filter, conditions, args, argIdx)
}
func appendLibraryAccessConditions(alias string, filter catalog.AccessFilter, conditions *[]string, args *[]any, argIdx *int) {
if filter.AllowedLibraryIDs != nil {
if len(filter.AllowedLibraryIDs) == 0 {
*conditions = append(*conditions, "1 = 0")
} else {
*conditions = append(*conditions, fmt.Sprintf("%s.id = ANY($%d)", alias, *argIdx))
*args = append(*args, filter.AllowedLibraryIDs)
*argIdx = *argIdx + 1
}
}
if len(filter.DisabledLibraryIDs) > 0 {
*conditions = append(*conditions, fmt.Sprintf("NOT (%s.id = ANY($%d))", alias, *argIdx))
*args = append(*args, filter.DisabledLibraryIDs)
*argIdx = *argIdx + 1
}
}
// hydratePeople loads item_people rows for the given items and assigns the
// resulting slices to each item.People. Single SQL roundtrip regardless of
// page size.
func (s *ABSMediaStore) hydratePeople(ctx context.Context, items []*models.MediaItem) error {
if len(items) == 0 || s.Pool == nil {
return nil
}
ids := make([]string, 0, len(items))
for _, it := range items {
ids = append(ids, it.ContentID)
}
rows, err := s.Pool.Query(ctx, `
SELECT ip.content_id, p.id, COALESCE(p.name, ''), ip.kind, COALESCE(ip.character, ''), ip.sort_order
FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = ANY($1)
ORDER BY ip.content_id, ip.kind, ip.sort_order, p.name
`, ids)
if err != nil {
return fmt.Errorf("abs_media_store: load item_people: %w", err)
}
defer rows.Close()
grouped := make(map[string][]models.ItemPerson, len(items))
for rows.Next() {
var (
contentID string
personID int64
name string
kind models.PersonKind
character string
sortOrder int
)
if err := rows.Scan(&contentID, &personID, &name, &kind, &character, &sortOrder); err != nil {
return fmt.Errorf("abs_media_store: scan item_people: %w", err)
}
grouped[contentID] = append(grouped[contentID], models.ItemPerson{
Person: models.Person{ID: personID, Name: name},
Kind: kind,
Character: character,
SortOrder: sortOrder,
})
}
if err := rows.Err(); err != nil {
return fmt.Errorf("abs_media_store: iterate item_people: %w", err)
}
for _, it := range items {
if people, ok := grouped[it.ContentID]; ok {
it.People = people
}
}
return nil
}
func (s *ABSMediaStore) hydrateAudiobookSeries(ctx context.Context, items []*models.MediaItem) error {
if len(items) == 0 || s.Pool == nil {
return nil
}
ids := make([]string, 0, len(items))
for _, it := range items {
ids = append(ids, it.ContentID)
}
rows, err := s.Pool.Query(ctx, `
SELECT content_id, series_name, COALESCE(series_index::text, '')
FROM audiobook_series
WHERE content_id = ANY($1)
ORDER BY content_id, series_index NULLS LAST, series_name
`, ids)
if err != nil {
return fmt.Errorf("abs_media_store: load audiobook_series: %w", err)
}
defer rows.Close()
grouped := make(map[string][]models.AudiobookSeriesMembership, len(items))
for rows.Next() {
var contentID, name, indexRaw string
if err := rows.Scan(&contentID, &name, &indexRaw); err != nil {
return fmt.Errorf("abs_media_store: scan audiobook_series: %w", err)
}
membership := models.AudiobookSeriesMembership{Name: name}
if indexRaw != "" {
if f, err := strconv.ParseFloat(indexRaw, 64); err == nil {
membership.Index = &f
}
}
grouped[contentID] = append(grouped[contentID], membership)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("abs_media_store: iterate audiobook_series: %w", err)
}
for _, it := range items {
it.AudiobookSeries = grouped[it.ContentID]
}
return nil
}
// GetMediaFiles returns all media_files for the given content_id, ordered by
// file_path so ABS clients receive a stable chapter ordering.
func (s *ABSMediaStore) GetMediaFiles(ctx context.Context, contentID string, access catalog.AccessFilter) ([]*models.MediaFile, error) {
items, err := s.Items.GetByIDsWithAccess(ctx, []string{contentID}, access)
if err != nil {
return nil, fmt.Errorf("abs_media_store: check media file access for %q: %w", contentID, err)
}
if len(items) == 0 {
return []*models.MediaFile{}, nil
}
files, err := s.Files.GetByContentID(ctx, contentID)
if err != nil {
return nil, fmt.Errorf("abs_media_store: get media files for %q: %w", contentID, err)
}
return catalog.FilterMediaFilesByAccess(files, access), nil
}
// GetMediaFileByID fetches a single media_file by its integer PK.
func (s *ABSMediaStore) GetMediaFileByID(ctx context.Context, fileID int) (*models.MediaFile, error) {
file, err := s.Files.GetByID(ctx, fileID)
if err != nil {
return nil, fmt.Errorf("abs_media_store: get media file %d: %w", fileID, err)
}
return file, nil
}
// ListAudiobookLibraries returns media_folder rows where type='audiobooks'
// (the canonical silo type for the audiobooks sub-plan).
func (s *ABSMediaStore) ListAudiobookLibraries(ctx context.Context, access catalog.AccessFilter) ([]abs.AudiobookLibrary, error) {
if s.Pool == nil {
return nil, nil
}
conditions := []string{"type IN ('audiobooks', 'audiobook')", "enabled = TRUE"}
args := []any{}
argIdx := 1
appendLibraryAccessConditions("media_folders", access, &conditions, &args, &argIdx)
rows, err := s.Pool.Query(ctx, `
SELECT id, name, type
FROM media_folders
WHERE `+strings.Join(conditions, " AND ")+`
ORDER BY sort_order, id`, args...)
if err != nil {
return nil, fmt.Errorf("abs_media_store: list audiobook libraries: %w", err)
}
defer rows.Close()
var libs []abs.AudiobookLibrary
for rows.Next() {
var lib abs.AudiobookLibrary
if err := rows.Scan(&lib.ID, &lib.Name, &lib.Type); err != nil {
return nil, fmt.Errorf("abs_media_store: scan library: %w", err)
}
libs = append(libs, lib)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_media_store: iterate libraries: %w", err)
}
return libs, nil
}
// listAudiobookIDs is the shared helper used by Search/ContinueListening/
// RecentlyAdded/Discover. It runs a parameterized SQL fragment that yields
// a list of audiobook content_ids; the caller composes the WHERE/ORDER
// portions. Returned items have People hydrated.
func (s *ABSMediaStore) listAudiobookIDs(ctx context.Context, sql string, args []any) ([]*models.MediaItem, error) {
if s.Pool == nil {
return nil, fmt.Errorf("abs_media_store: no pgx pool")
}
rows, err := s.Pool.Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("abs_media_store: query audiobook ids: %w", err)
}
defer rows.Close()
ids := make([]string, 0, 16)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("abs_media_store: scan audiobook id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_media_store: iterate audiobook ids: %w", err)
}
if len(ids) == 0 {
return []*models.MediaItem{}, nil
}
items, err := s.Items.GetByIDs(ctx, ids)
if err != nil {
return nil, fmt.Errorf("abs_media_store: load audiobooks: %w", err)
}
byID := make(map[string]*models.MediaItem, len(items))
for _, it := range items {
byID[it.ContentID] = it
}
ordered := make([]*models.MediaItem, 0, len(ids))
for _, id := range ids {
if it, ok := byID[id]; ok {
ordered = append(ordered, it)
}
}
if err := s.hydratePeople(ctx, ordered); err != nil {
_ = err // non-fatal
}
if err := s.hydrateAudiobookSeries(ctx, ordered); err != nil {
_ = err // non-fatal
}
return ordered, nil
}
// SearchAudiobooks matches the query against title plus author/narrator name.
// Capped by limit; ordered by title-prefix match first, then title substring,
// then author/narrator matches, then alphabetical.
//
// Both arms are shaped to hit the existing pg_trgm GIN indexes rather than
// seq-scanning the whole library: the title arm matches media_items.
// title_normalized (idx_media_items_title_normalized_trgm) using the same
// normalize_search_text() the rest of catalog search uses, and the people arm
// matches people.name (idx_people_name_trgm). Running them as a UNION lets the
// planner drive each arm from its own index; GROUP BY content_id keeps the best
// rank when an item matches both. normalize_search_text($2) <> ” guards a
// punctuation-only query from degenerating into ILIKE '%%' over everything.
func (s *ABSMediaStore) SearchAudiobooks(ctx context.Context, libraryID int64, query string, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error) {
if limit <= 0 {
limit = 12
}
// $1 = raw-query substring pattern for people.name; $2 = raw query, normalized
// in-SQL for the title arm.
args := []any{"%" + query + "%", query}
argIdx := 3
// Library + access predicates are identical in both UNION arms and reuse the
// same positional placeholders, so append their args only once.
scopeConds := []string{}
if libraryID != 0 {
scopeConds = append(scopeConds, fmt.Sprintf(`EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id AND mil.media_folder_id = $%d
)`, argIdx))
args = append(args, int(libraryID))
argIdx++
}
appendAudiobookAccessConditions("mi", access, &scopeConds, &args, &argIdx)
scope := ""
if len(scopeConds) > 0 {
scope = " AND " + strings.Join(scopeConds, " AND ")
}
args = append(args, limit)
sql := `
SELECT content_id FROM (
SELECT mi.content_id AS content_id, mi.sort_title AS sort_title, mi.title AS title,
CASE WHEN mi.title_normalized LIKE normalize_search_text($2) || '%' THEN 0 ELSE 1 END AS rank
FROM media_items mi
WHERE mi.type = 'audiobook'
AND normalize_search_text($2) <> ''
AND mi.title_normalized ILIKE '%' || normalize_search_text($2) || '%'` + scope + `
UNION ALL
SELECT mi.content_id AS content_id, mi.sort_title AS sort_title, mi.title AS title, 2 AS rank
FROM media_items mi
WHERE mi.type = 'audiobook'
AND EXISTS (
SELECT 1 FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = mi.content_id
AND ip.kind IN (7, 8)
AND p.name ILIKE $1
)` + scope + `
) m
GROUP BY content_id, sort_title, title
ORDER BY MIN(rank), LOWER(sort_title), LOWER(title)
LIMIT $` + strconv.Itoa(argIdx) + `
`
return s.listAudiobookIDs(ctx, sql, args)
}
// ListContinueListening returns audiobooks the user has in-progress (and
// hasn't finished). userID is the silo integer-id-as-string from the ABS
// JWT; we filter by user_watch_progress for that user + this audiobook.
func (s *ABSMediaStore) ListContinueListening(ctx context.Context, userID, profileID string, libraryID int64, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error) {
if userID == "" {
return []*models.MediaItem{}, nil
}
if limit <= 0 {
limit = 10
}
args := []any{userID, profileID, limit}
conditions := []string{
`mi.type = 'audiobook'`,
`wp.user_id::text = $1`,
`($2 = '' OR wp.profile_id = $2)`,
`wp.position_seconds > 0`,
`COALESCE(wp.completed, FALSE) = FALSE`,
`COALESCE(wp.hide_from_continue, FALSE) = FALSE`,
}
argIdx := 4
if libraryID != 0 {
conditions = append(conditions, fmt.Sprintf(`EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id AND mil.media_folder_id = $%d
)`, argIdx))
args = append(args, int(libraryID))
argIdx++
}
appendAudiobookAccessConditions("mi", access, &conditions, &args, &argIdx)
sql := `
SELECT mi.content_id FROM media_items mi
JOIN user_watch_progress wp ON wp.media_item_id = mi.content_id
WHERE ` + strings.Join(conditions, " AND ") + `
ORDER BY wp.updated_at DESC
LIMIT $3
`
return s.listAudiobookIDs(ctx, sql, args)
}
// ListRecentlyAdded returns the most recently added audiobooks. Added-at
// for audiobooks comes from MIN(first_seen_at) in media_item_libraries.
func (s *ABSMediaStore) ListRecentlyAdded(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error) {
if limit <= 0 {
limit = 10
}
args := []any{limit}
conditions := []string{`mi.type = 'audiobook'`}
argIdx := 2
libFilter := ""
if libraryID != 0 {
libFilter = fmt.Sprintf(` AND mil.media_folder_id = $%d`, argIdx)
args = append(args, int(libraryID))
argIdx++
}
appendAudiobookAccessConditions("mi", access, &conditions, &args, &argIdx)
sql := `
SELECT mi.content_id FROM media_items mi
JOIN LATERAL (
SELECT MIN(first_seen_at) AS added_at
FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id` + libFilter + `
) added ON added.added_at IS NOT NULL
WHERE ` + strings.Join(conditions, " AND ") + `
ORDER BY added.added_at DESC
LIMIT $1
`
return s.listAudiobookIDs(ctx, sql, args)
}
// ListDiscover returns a random sampling of audiobooks for the home
// Discover shelf. Uses TABLESAMPLE for cheap random sampling on large
// libraries (38k+ books); falls back to ORDER BY random() for tiny libs.
func (s *ABSMediaStore) ListDiscover(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error) {
if limit <= 0 {
limit = 10
}
args := []any{limit}
conditions := []string{`mi.type = 'audiobook'`, `COALESCE(mi.poster_path, '') <> ''`}
argIdx := 2
if libraryID != 0 {
conditions = append(conditions, fmt.Sprintf(`EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id AND mil.media_folder_id = $%d
)`, argIdx))
args = append(args, int(libraryID))
argIdx++
}
appendAudiobookAccessConditions("mi", access, &conditions, &args, &argIdx)
// Random sample with poster preference so the shelf has cover art.
sql := `
SELECT mi.content_id FROM media_items mi
WHERE ` + strings.Join(conditions, " AND ") + `
ORDER BY random()
LIMIT $1
`
return s.listAudiobookIDs(ctx, sql, args)
}
// RefreshAuthorCounts rebuilds the abs_audiobook_author_counts materialized
// view that ListLibraryAuthors reads. CONCURRENTLY keeps it readable during the
// refresh (requires the unique index). Driven by a periodic ticker in the
// audiobooks service.
func (s *ABSMediaStore) RefreshAuthorCounts(ctx context.Context) error {
if s.Pool == nil {
return nil
}
_, err := s.Pool.Exec(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY abs_audiobook_author_counts`)
if err != nil {
return fmt.Errorf("abs_media_store: refresh author counts: %w", err)
}
return nil
}
// ListLibraryAuthors returns one page of distinct audiobook authors plus the
// total author count for the library. It reads the precomputed
// abs_audiobook_author_counts materialized view keyed by media_folder_id.
//
// The MV is keyed by library_id only and carries no per-item access predicate,
// so it is safe only when the caller has no item-level restriction. When the
// access filter carries an item-level predicate (a content-rating cap or
// excluded media types), reading the MV would leak authors of books the caller
// can't see, so we take the access-aware live path instead. Library-level
// access is already enforced by scoping to a single resolved library.
//
// When the MV read returns zero rows (stale, empty, or not-yet-refreshed view)
// we also fall back to the live GROUP BY so /authors never blanks out.
func (s *ABSMediaStore) ListLibraryAuthors(ctx context.Context, libraryID int64, limit, offset int, sortBy string, sortDesc bool, access catalog.AccessFilter) ([]abs.AuthorSummary, int, error) {
if s.Pool == nil {
return nil, 0, nil
}
if offset < 0 {
offset = 0
}
if access.MaxContentRating != "" || len(access.ExcludedMediaTypes) > 0 {
return s.listLibraryAuthorsLive(ctx, libraryID, limit, offset, sortBy, sortDesc, access)
}
var total int
if err := s.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM abs_audiobook_author_counts WHERE library_id = $1`, int(libraryID),
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("abs_media_store: count authors: %w", err)
}
if total == 0 {
// Stale/empty/unrefreshed MV: fall back to the live query so a fresh
// deploy (before the first REFRESH) still serves authors.
return s.listLibraryAuthorsLive(ctx, libraryID, limit, offset, sortBy, sortDesc, access)
}
dir := "ASC"
if sortDesc {
dir = "DESC"
}
var orderBy string
switch sortBy {
case "addedAt":
orderBy = "c.added_at " + dir + ", c.person_id"
case "numBooks":
orderBy = "c.num_books " + dir + ", LOWER(c.name)"
default: // name
orderBy = "LOWER(c.name) " + dir
}
// 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`
args = append(args, limit, offset)
}
rows, err := s.Pool.Query(ctx, dataSQL, args...)
if err != nil {
return nil, 0, fmt.Errorf("abs_media_store: list authors: %w", err)
}
defer rows.Close()
out := make([]abs.AuthorSummary, 0, 64)
for rows.Next() {
var (
id int64
name string
books int
addedAt time.Time
hasPhoto bool
)
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, HasPhoto: hasPhoto})
}
return out, total, rows.Err()
}
// listLibraryAuthorsLive is the pre-materialized-view live aggregation, kept as
// a fallback for ListLibraryAuthors when the MV has no rows for the library.
func (s *ABSMediaStore) listLibraryAuthorsLive(ctx context.Context, libraryID int64, limit, offset int, sortBy string, sortDesc bool, access catalog.AccessFilter) ([]abs.AuthorSummary, int, error) {
conditions := []string{`mi.type = 'audiobook'`}
args := []any{}
argIdx := 1
if libraryID != 0 {
conditions = append(conditions, fmt.Sprintf(`EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id AND mil.media_folder_id = $%d
)`, argIdx))
args = append(args, int(libraryID))
argIdx++
}
appendAudiobookAccessConditions("mi", access, &conditions, &args, &argIdx)
where := strings.Join(conditions, " AND ")
var total int
countSQL := `SELECT COUNT(*) FROM (
SELECT p.id
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
WHERE ` + where + `
GROUP BY p.id, p.name
) t`
if err := s.Pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("abs_media_store: count authors (live): %w", err)
}
if total == 0 {
return []abs.AuthorSummary{}, 0, nil
}
dir := "ASC"
if sortDesc {
dir = "DESC"
}
var orderBy string
switch sortBy {
case "addedAt":
orderBy = "p.created_at " + dir + ", p.id"
case "numBooks":
orderBy = "num_books " + dir + ", LOWER(p.name)"
default: // name
orderBy = "LOWER(p.name) " + dir
}
dataSQL := `
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
WHERE ` + where + `
GROUP BY p.id, p.name
ORDER BY ` + orderBy
dataArgs := append([]any(nil), args...)
if limit > 0 {
dataSQL += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, argIdx, argIdx+1)
dataArgs = append(dataArgs, limit, offset)
}
rows, err := s.Pool.Query(ctx, dataSQL, dataArgs...)
if err != nil {
return nil, 0, fmt.Errorf("abs_media_store: list authors (live): %w", err)
}
defer rows.Close()
out := make([]abs.AuthorSummary, 0, 64)
for rows.Next() {
var (
id int64
name string
books int
hasPhoto bool
)
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, HasPhoto: hasPhoto})
}
return out, total, rows.Err()
}
// ListLibrarySeries returns distinct series from audiobook_series for the
// audiobook library, with per-series book count and up to 4 book preview
// rows (content_id + title + updated_at) used by the ABS mobile client
// to render the LazySeriesCard cover stack.
func (s *ABSMediaStore) ListLibrarySeries(ctx context.Context, libraryID int64, limit, offset int, access catalog.AccessFilter) ([]abs.SeriesSummary, int, error) {
if s.Pool == nil {
return nil, 0, nil
}
if offset < 0 {
offset = 0
}
conditions := []string{`mi.type = 'audiobook'`}
args := []any{}
argIdx := 1
if libraryID != 0 {
conditions = append(conditions, fmt.Sprintf(`EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id AND mil.media_folder_id = $%d
)`, argIdx))
args = append(args, int(libraryID))
argIdx++
}
appendAudiobookAccessConditions("mi", access, &conditions, &args, &argIdx)
where := strings.Join(conditions, " AND ")
// Count distinct multi-book series (HAVING COUNT > 1) so pagination totals
// match what the data query returns.
var total int
countSQL := `SELECT COUNT(*) FROM (
SELECT s.series_name
FROM audiobook_series s JOIN media_items mi ON mi.content_id = s.content_id
WHERE ` + where + `
GROUP BY s.series_name HAVING COUNT(*) > 1
) t`
if err := s.Pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("abs_media_store: count series: %w", err)
}
if total == 0 {
return []abs.SeriesSummary{}, 0, nil
}
// Two-stage: window-rank books inside each series (lowest series_index
// first), then aggregate the top 4 ids/titles/updated_at into parallel
// arrays. `book_ids[]` is text because content_id is text in this
// schema; parallel `titles[]` and `updated_ats[]` keep iteration
// straightforward in Go without composite type plumbing.
dataSQL := `
WITH ranked AS (
SELECT
s.series_name,
s.content_id,
mi.title,
mi.updated_at,
ROW_NUMBER() OVER (
PARTITION BY s.series_name
ORDER BY COALESCE(s.series_index, 999999), s.content_id
) AS rn,
COUNT(*) OVER (PARTITION BY s.series_name) AS series_count
FROM audiobook_series s
JOIN media_items mi
ON mi.content_id = s.content_id
WHERE ` + where + `
)
SELECT
series_name,
MAX(series_count)::int AS num_books,
array_agg(content_id ORDER BY rn) FILTER (WHERE rn <= 4) AS book_ids,
array_agg(title ORDER BY rn) FILTER (WHERE rn <= 4) AS titles,
array_agg(updated_at ORDER BY rn) FILTER (WHERE rn <= 4) AS updated_ats
FROM ranked
GROUP BY series_name
HAVING MAX(series_count) > 1
ORDER BY LOWER(series_name)`
dataArgs := append([]any(nil), args...)
if limit > 0 {
dataSQL += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, argIdx, argIdx+1)
dataArgs = append(dataArgs, limit, offset)
}
rows, err := s.Pool.Query(ctx, dataSQL, dataArgs...)
if err != nil {
return nil, 0, fmt.Errorf("abs_media_store: list series: %w", err)
}
defer rows.Close()
out := make([]abs.SeriesSummary, 0, 64)
for rows.Next() {
var (
name string
books int
ids []string
titles []string
updatedAts []time.Time
)
if err := rows.Scan(&name, &books, &ids, &titles, &updatedAts); err != nil {
return nil, 0, fmt.Errorf("abs_media_store: scan series: %w", err)
}
previews := make([]abs.SeriesBookPreview, 0, len(ids))
for i := range ids {
p := abs.SeriesBookPreview{ContentID: ids[i]}
if i < len(titles) {
p.Title = titles[i]
}
if i < len(updatedAts) {
p.UpdatedAt = updatedAts[i]
}
previews = append(previews, p)
}
// Series ID is the canonical series name — there's no first-class
// series row yet, so the slug is stable for a given name.
out = append(out, abs.SeriesSummary{ID: name, Name: name, NumBooks: books, Books: previews})
}
return out, total, rows.Err()
}
// GetAuthorByID looks up the author by people.id and returns the row
// plus their audiobooks.
func (s *ABSMediaStore) GetAuthorByID(ctx context.Context, authorID string, access catalog.AccessFilter) (abs.Author, error) {
if s.Pool == nil {
return abs.Author{}, abs.ErrNotFound
}
id, err := strconv.Atoi(authorID)
if err != nil {
return abs.Author{}, abs.ErrNotFound
}
var name, photo string
row := s.Pool.QueryRow(ctx, `SELECT name, photo_path FROM people WHERE id = $1`, id)
if err := row.Scan(&name, &photo); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return abs.Author{}, abs.ErrNotFound
}
return abs.Author{}, fmt.Errorf("abs_media_store: get author: %w", err)
}
author := abs.Author{ID: authorID, Name: name, PosterPath: photo}
conditions := []string{`ip.person_id = $1`, `ip.kind = 7`, `mi.type = 'audiobook'`}
args := []any{id}
argIdx := 2
appendAudiobookAccessConditions("mi", access, &conditions, &args, &argIdx)
items, err := s.listAudiobookIDs(ctx, `
SELECT mi.content_id
FROM item_people ip
JOIN media_items mi ON mi.content_id = ip.content_id
WHERE `+strings.Join(conditions, " AND ")+`
ORDER BY LOWER(mi.title)`, args)
if err != nil {
return abs.Author{}, fmt.Errorf("abs_media_store: get author books: %w", err)
}
author.Books = items
return author, nil
}
// GetSeriesByName looks up a series case-insensitively, plus its books.
func (s *ABSMediaStore) GetSeriesByName(ctx context.Context, seriesName string, access catalog.AccessFilter) (abs.Series, error) {
if s.Pool == nil {
return abs.Series{}, abs.ErrNotFound
}
var canonicalName string
row := s.Pool.QueryRow(ctx, `
SELECT series_name FROM audiobook_series
WHERE LOWER(series_name) = LOWER($1)
LIMIT 1`, seriesName,
)
if err := row.Scan(&canonicalName); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return abs.Series{}, abs.ErrNotFound
}
return abs.Series{}, fmt.Errorf("abs_media_store: get series: %w", err)
}
series := abs.Series{ID: strings.ToLower(canonicalName), Name: canonicalName}
conditions := []string{`LOWER(asx.series_name) = LOWER($1)`, `mi.type = 'audiobook'`}
args := []any{seriesName}
argIdx := 2
appendAudiobookAccessConditions("mi", access, &conditions, &args, &argIdx)
items, err := s.listAudiobookIDs(ctx, `
SELECT mi.content_id
FROM audiobook_series asx
JOIN media_items mi ON mi.content_id = asx.content_id
WHERE `+strings.Join(conditions, " AND ")+`
ORDER BY asx.series_index NULLS LAST, LOWER(mi.title)`, args)
if err != nil {
return abs.Series{}, fmt.Errorf("abs_media_store: get series books: %w", err)
}
series.Books = items
return series, nil
}