Detail page: - collapse Chapters section behind header toggle (73-chapter pages no longer push content below the fold) - square cover frames throughout (audiobook covers are Audible-style 1:1, not 2:3 book portrait) - narrator picker dropdown when multiple narrations of the same book exist - clickable genre badges (route to /audiobooks?genre=X) - reordered so credits/rails sit above the chapter list - Play-from-Start button forces remount via playToken counter (was a no-op when player was already at position 0) - regression test for the Play-from-Start fix Mini bar / Now Listening: - mini bar respects --app-sidebar-offset so it stops getting covered by the desktop sidebar - Now Listening adds overflow scroll + a labeled "Back to player" button so controls are never inaccessible on short viewports Library page: - infinite scroll (replaces Previous/Next pagination) - genre filter chip with X-to-clear Scanner: - 8-worker parallel reconcile (env SILO_AUDIOBOOK_SCAN_WORKERS to override) - file-path-first dedup so cleaned titles don't collapse separate narrations - title cleanup at write time (strips "Read by X" / "(unabridged)" suffixes; original tag preserved in original_title) - audiobook_series upsert from tag-derived series_name/series_position - secondary dedup check (author + narrator + year + duration ±0.5% + title-prefix) so two folders of the same book attach to one row Backend detail handler: - new fetchAlsoByAuthor, fetchInSeries (with series row > 1 entry guard), fetchSimilar (embedding-first with shared-genre fallback), fetchOtherNarrations (regex-strips narrator suffix to group siblings) - audiobookDetailResponse gained also_by_author, in_series, similar_audiobooks, other_narrations fields - list endpoint accepts a genre query param Embeddings: - BuildEmbeddingText branches on item.Type == "audiobook" to use author/narrator credits instead of cast/director/writer - mediaTypeLabel helper centralizes movie / "TV series" / audiobook - ListEmbeddingTextCandidates SQL mirrors the Go branching exactly - ItemsNeedingEmbedding and TotalMediaItemCount loosen status='matched' gate to also include audiobooks (which don't go through TMDB match) - FindSimilar gains a mediaType filter so cross-type results never appear - callers in similar.go and personal.go pass the source item's type Collections: - MediaAudiobook MediaKind + audiobook(s) case in templateEligibleForLibrary (stops offering broken movie/TV templates to audiobook libraries) - useAddItemToCollection hook (user + admin/library endpoints) - AddToCollectionDialog wired into audiobook detail and movie/series ActionBar overflow menu - ManualCollectionItemsEditor gained a search-and-add panel with debounced live results - QueryDefinition.media_scope, QuerySortRelevanceScope, ALL_MEDIA_SCOPES extended to include "audiobook" - CatalogFilterBar gained an Audiobooks media scope option - parseCatalogMediaScope (backend) accepts "audiobook" Migrations: - 145_audiobook_series: per-book series_name/series_index with a best-effort title-pattern backfill for the existing corpus - 146_audiobook_title_cleanup: strips narrator suffix / (unabridged) noise from existing titles, preserving raw in original_title Scripts: - scripts/dedup_audiobooks.py: one-shot merge for "Title" vs "Title: Subtitle" duplicates, file-path-stable, dry-run by default Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
111 lines
3.9 KiB
Go
111 lines
3.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/recommendations"
|
|
"github.com/Silo-Server/silo-server/internal/scanner"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// AudiobookHandler serves silo-native audiobook endpoints under
|
|
// /api/v1/audiobooks/*. Sub-plan 3 implements list/detail/progress;
|
|
// sub-plan 4's ABS-compat layer is separate.
|
|
type AudiobookHandler struct {
|
|
Items *catalog.ItemRepository
|
|
Files *scanner.FileRepository
|
|
StoreProvider userstore.UserStoreProvider
|
|
Detail *catalog.DetailService
|
|
// Recs is optional. When set, the detail handler uses embedding-based
|
|
// nearest-neighbor search for the "You might also like" rail and falls
|
|
// back to shared-genre matching only when no embedding exists for the
|
|
// source book yet.
|
|
Recs *recommendations.Repo
|
|
}
|
|
|
|
// HandleListAudiobooks serves GET /api/v1/audiobooks?limit=&offset=.
|
|
// Paginated catalog of media_items with type='audiobook'.
|
|
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
genre := r.URL.Query().Get("genre")
|
|
|
|
pool := h.Files.Pool()
|
|
ctx := r.Context()
|
|
var total int
|
|
if err := pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM media_items
|
|
WHERE type='audiobook' AND ($1 = '' OR $1 = ANY(genres))
|
|
`, genre).Scan(&total); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks count failed")
|
|
return
|
|
}
|
|
rows, err := pool.Query(ctx, `
|
|
SELECT
|
|
mi.content_id,
|
|
mi.title,
|
|
COALESCE(mi.year, 0),
|
|
COALESCE(mi.poster_path, ''),
|
|
COALESCE(SUM(mf.duration), 0)::int AS duration_seconds,
|
|
COALESCE(MIN(mf.container), '') AS container,
|
|
COALESCE(MIN(mf.codec_audio), '') AS codec_audio,
|
|
COUNT(mf.id) AS file_count
|
|
FROM media_items mi
|
|
LEFT JOIN media_files mf ON mf.content_id = mi.content_id
|
|
WHERE mi.type='audiobook'
|
|
AND ($3 = '' OR $3 = ANY(mi.genres))
|
|
GROUP BY mi.content_id, mi.title, mi.year, mi.poster_path, mi.sort_title
|
|
ORDER BY LOWER(mi.sort_title), LOWER(mi.title)
|
|
LIMIT $1 OFFSET $2
|
|
`, limit, offset, genre)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := make([]audiobookListItem, 0, limit)
|
|
for rows.Next() {
|
|
var it audiobookListItem
|
|
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &it.PosterURL, &it.DurationSeconds, &it.Container, &it.CodecAudio, &it.FileCount); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "scan audiobook row failed")
|
|
return
|
|
}
|
|
if h.Detail != nil && it.PosterURL != "" {
|
|
it.PosterURL = h.Detail.PresignURL(ctx, it.PosterURL, "card")
|
|
}
|
|
items = append(items, it)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "iterate audiobook rows failed")
|
|
return
|
|
}
|
|
resp := struct {
|
|
Items []audiobookListItem `json:"items"`
|
|
Total int `json:"total"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
}{Items: items, Total: total, Limit: limit, Offset: offset}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
type audiobookListItem struct {
|
|
ContentID string `json:"content_id"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year"`
|
|
PosterURL string `json:"poster_url,omitempty"`
|
|
DurationSeconds int `json:"duration_seconds"`
|
|
Container string `json:"container,omitempty"`
|
|
CodecAudio string `json:"codec_audio,omitempty"`
|
|
FileCount int `json:"file_count"`
|
|
}
|