Compare commits

...
52 changed files with 1351 additions and 161 deletions
+83
View File
@@ -225,6 +225,9 @@ type sortMetricsResponse struct {
ProgressRatio *float64 `json:"progress_ratio,omitempty"`
ViewedAt string `json:"viewed_at,omitempty"`
PlayCount *int `json:"play_count,omitempty"`
Author string `json:"author,omitempty"`
Narrator string `json:"narrator,omitempty"`
SeriesName string `json:"series_name,omitempty"`
}
// browseResponse is the paginated response for the /items endpoint.
@@ -891,10 +894,90 @@ func (h *ItemsHandler) listSortMetrics(
}
case "progress", "date_viewed", "plays":
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
case "author", "narrator", "series":
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
}
return metrics
}
func (h *ItemsHandler) listAudiobookSortMetrics(
ctx context.Context,
items []*models.MediaItem,
sortField string,
metrics map[string]*sortMetricsResponse,
) {
if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 {
return
}
contentIDs := uniqueItemContentIDs(items)
if len(contentIDs) == 0 {
return
}
var query string
switch sortField {
case "author":
query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor))
case "narrator":
query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator))
case "series":
query = `
SELECT target.content_id, BTRIM(s.series_name) AS value
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
JOIN audiobook_series s ON s.content_id = target.content_id
WHERE BTRIM(s.series_name) <> ''`
default:
return
}
rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var contentID, value string
if err := rows.Scan(&contentID, &value); err != nil {
return
}
value = strings.TrimSpace(value)
if contentID == "" || value == "" {
continue
}
resp := metrics[contentID]
if resp == nil {
resp = &sortMetricsResponse{}
metrics[contentID] = resp
}
switch sortField {
case "author":
resp.Author = value
case "narrator":
resp.Narrator = value
case "series":
resp.SeriesName = value
}
}
}
func audiobookPersonSortMetricQuery(kind int) string {
return fmt.Sprintf(`
SELECT target.content_id, person.name AS value
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
JOIN LATERAL (
SELECT BTRIM(p.name) AS name
FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = target.content_id
AND ip.kind = %d
AND p.name IS NOT NULL
AND BTRIM(p.name) <> ''
ORDER BY p.name ASC
LIMIT 1
) person ON TRUE`, kind)
}
func (h *ItemsHandler) listUserSortMetrics(
ctx context.Context,
items []*models.MediaItem,
+2 -2
View File
@@ -157,8 +157,8 @@ func validateSectionConfig(sectionType sections.SectionType, config json.RawMess
}
cfg := sections.ParseConfigFilters(config)
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" {
return "filter_type must be 'movie' or 'series'", false
if cfg.FilterType != "" && cfg.FilterType != "movie" && cfg.FilterType != "series" && cfg.FilterType != "audiobook" {
return "filter_type must be 'movie', 'series', or 'audiobook'", false
}
var rawLibraryConfig struct {
+20 -8
View File
@@ -44,7 +44,8 @@ const (
// defaultEnrichBatchSize is the maximum number of audiobook items processed
// per sweep invocation. Keeps latency bounded for large libraries.
defaultEnrichBatchSize = 50
// Override with SILO_AUDIOBOOK_ENRICH_BATCH_SIZE.
defaultEnrichBatchSize = 250
// defaultEnrichWorkers is the default fan-out used by Enricher.Run.
// Network-bound: each worker holds one provider HTTP call at a time, so
// 4 is enough to mask single-request latency without hammering plugins.
@@ -52,18 +53,28 @@ const (
defaultEnrichWorkers = 4
)
// audiobookEnrichBatchSize returns the configured maximum sweep size.
func audiobookEnrichBatchSize() int {
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
return parsed
}
}
return defaultEnrichBatchSize
}
// audiobookEnrichWorkers returns the configured number of parallel enrichment
// workers, capped to defaultEnrichBatchSize so workers never outnumber the
// batch they drain.
func audiobookEnrichWorkers() int {
// workers, capped to the active batch size so workers never outnumber the batch
// they drain.
func audiobookEnrichWorkers(batchSize int) int {
n := defaultEnrichWorkers
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_WORKERS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
n = parsed
}
}
if n > defaultEnrichBatchSize {
n = defaultEnrichBatchSize
if batchSize > 0 && n > batchSize {
n = batchSize
}
return n
}
@@ -105,6 +116,7 @@ func NewEnricher(
personRepo *catalog.PersonRepository,
providerIDs *catalog.ProviderIDRepository,
) *Enricher {
batchSize := audiobookEnrichBatchSize()
return &Enricher{
pool: pool,
chainRepo: chainRepo,
@@ -112,8 +124,8 @@ func NewEnricher(
itemRepo: itemRepo,
personRepo: personRepo,
providerIDs: providerIDs,
batchSize: defaultEnrichBatchSize,
workers: audiobookEnrichWorkers(),
batchSize: batchSize,
workers: audiobookEnrichWorkers(batchSize),
}
}
+44
View File
@@ -63,6 +63,50 @@ func TestEnricherRunFansOut(t *testing.T) {
}
}
func TestNewEnricherUsesConfiguredBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "123")
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
e := NewEnricher(nil, nil, nil, nil, nil, nil)
if e.batchSize != 123 {
t.Fatalf("batchSize = %d, want 123", e.batchSize)
}
if e.workers != 8 {
t.Fatalf("workers = %d, want 8", e.workers)
}
}
func TestNewEnricherCapsWorkersToConfiguredBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "50")
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "100")
e := NewEnricher(nil, nil, nil, nil, nil, nil)
if e.batchSize != 50 {
t.Fatalf("batchSize = %d, want 50", e.batchSize)
}
if e.workers != e.batchSize {
t.Fatalf("workers = %d, want capped batchSize %d", e.workers, e.batchSize)
}
}
func TestAudiobookEnrichWorkersCapsAtBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
if got := audiobookEnrichWorkers(3); got != 3 {
t.Fatalf("audiobookEnrichWorkers(3) = %d, want 3", got)
}
}
func TestAudiobookEnrichBatchSizeIgnoresInvalidEnv(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "nope")
if got := audiobookEnrichBatchSize(); got != defaultEnrichBatchSize {
t.Fatalf("audiobookEnrichBatchSize() = %d, want %d", got, defaultEnrichBatchSize)
}
}
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
cacher := &fakeAudiobookImageCacher{}
e := &Enricher{imageCacher: cacher}
+1 -1
View File
@@ -162,7 +162,7 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
if deps.Detail == nil {
return ""
}
return deps.Detail.PresignURL(ctx, path, variant)
return deps.Detail.PresignImageURL(ctx, path, "poster", variant)
},
})
s.ABSHandler = h
+54
View File
@@ -3,7 +3,14 @@ package audiobooks
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"unsafe"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/scanner"
)
type fakeSettingsReader struct {
@@ -74,3 +81,50 @@ func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
t.Fatal("ABSCompatEnabled = true, want false")
}
}
func TestBuildABSHandlerCoverResolverUsesPosterVariant(t *testing.T) {
resolver := &recordingImageResolver{}
detail := &catalog.DetailService{}
detail.SetImageResolver(resolver)
handler := New(nil).BuildABSHandler(ABSHandlerDeps{
Items: &catalog.ItemRepository{},
Files: &scanner.FileRepository{},
Detail: detail,
})
coverResolver := absCoverResolverForTest(t, handler)
got := coverResolver(context.Background(), "local/audiobooks/book-1/poster/original.webp", "card")
if !strings.Contains(got, "/w500.webp") {
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
}
if resolver.variant != "featured" {
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
}
}
func absCoverResolverForTest(t *testing.T, handler *abs.Handler) func(context.Context, string, string) string {
t.Helper()
field := reflect.ValueOf(handler).Elem().FieldByName("deps").FieldByName("CoverResolver")
return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface().(func(context.Context, string, string) string)
}
type recordingImageResolver struct {
path string
variant string
}
func (r *recordingImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
r.path = path
r.variant = variant
return "resolved://" + path
}
func (r *recordingImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
out := make(map[string]string, len(paths))
for _, path := range paths {
out[path] = r.ResolveImageURL(context.Background(), path, variant)
}
return out
}
+7 -3
View File
@@ -1061,6 +1061,10 @@ func firstNonEmptyString(values []string) string {
return ""
}
func (s *DetailService) presignAudiobookPosterURL(ctx context.Context, posterPath string) string {
return s.PresignImageURL(ctx, posterPath, "poster", "")
}
func appendAudiobookItemAccessConditions(
alias string,
filter AccessFilter,
@@ -1146,7 +1150,7 @@ func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentI
continue
}
seen[item.ContentID] = struct{}{}
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
out = append(out, item)
}
return out
@@ -1205,7 +1209,7 @@ func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, conte
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
return []AudiobookRelatedItem{}
}
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
out = append(out, item)
}
return out
@@ -1266,7 +1270,7 @@ func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID stri
item.SeriesIndex = &n
}
}
item.PosterURL = s.PresignURL(ctx, poster, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, poster)
entries = append(entries, item)
}
if len(entries) < 2 {
+36
View File
@@ -1,6 +1,8 @@
package catalog
import (
"context"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
@@ -40,3 +42,37 @@ func TestSortAudiobookMediaFilesFallsBackToPath(t *testing.T) {
}
}
}
func TestPresignAudiobookPosterURLUsesPosterVariant(t *testing.T) {
resolver := &recordingCatalogImageResolver{}
detail := &DetailService{}
detail.SetImageResolver(resolver)
got := detail.presignAudiobookPosterURL(context.Background(), "local/audiobooks/book/poster/original.webp")
if !strings.Contains(got, "/w500.webp") {
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
}
if resolver.variant != "featured" {
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
}
}
type recordingCatalogImageResolver struct {
path string
variant string
}
func (r *recordingCatalogImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
r.path = path
r.variant = variant
return "resolved://" + path
}
func (r *recordingCatalogImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
out := make(map[string]string, len(paths))
for _, path := range paths {
out[path] = r.ResolveImageURL(context.Background(), path, variant)
}
return out
}
+3 -3
View File
@@ -355,9 +355,9 @@ func LoadFromDB(m map[string]string) (*Config, error) {
}
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
// AudiobookshelfCompat — dedicated listener for ABS client apps. Keep the
// listener off unless ABS compatibility is explicitly enabled.
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", false)
// AudiobookshelfCompat — dedicated listener for ABS client apps. Docker
// deployments publish :13378, so listen by default unless disabled.
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", true)
if err != nil {
return nil, err
}
+9 -1
View File
@@ -35,7 +35,15 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
}
func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
cfg, err := LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
cfg, err := LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB() returned error: %v", err)
}
if cfg.AudiobookshelfCompat.Listen != ":13378" {
t.Fatalf("default audiobooks listener = %q, want default :13378", cfg.AudiobookshelfCompat.Listen)
}
cfg, err = LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
if err != nil {
t.Fatalf("LoadFromDB() returned error: %v", err)
}
+31
View File
@@ -90,6 +90,37 @@ func TestApplyMediaTypeFloorAddsAvailableSupplementalType(t *testing.T) {
}
}
func TestApplyMediaTypeFloorAddsAvailableAudiobooks(t *testing.T) {
items := []ScoredItem{
{MediaItemID: "m1", Score: 1.00},
{MediaItemID: "m2", Score: 0.99},
{MediaItemID: "m3", Score: 0.98},
{MediaItemID: "m4", Score: 0.97},
{MediaItemID: "m5", Score: 0.96},
{MediaItemID: "m6", Score: 0.95},
{MediaItemID: "m7", Score: 0.94},
{MediaItemID: "m8", Score: 0.93},
{MediaItemID: "m9", Score: 0.92},
{MediaItemID: "m10", Score: 0.91},
}
candidates := append([]ScoredItem(nil), items...)
candidates = append(candidates,
ScoredItem{MediaItemID: "a1", Score: 0.90},
ScoredItem{MediaItemID: "a2", Score: 0.89},
)
mediaTypes := map[string]string{
"m1": "movie", "m2": "movie", "m3": "movie", "m4": "movie", "m5": "movie",
"m6": "movie", "m7": "movie", "m8": "movie", "m9": "movie", "m10": "movie",
"a1": "audiobook", "a2": "audiobook",
}
mixed := applyMediaTypeFloor(items, candidates, mediaTypes)
if got := countMediaType(mixed, mediaTypes, "audiobook"); got != 2 {
t.Fatalf("expected 2 audiobooks from supplemental candidates, got %d in %#v", got, mixed)
}
}
func TestApplyMediaTypeFloorNoopsWithoutSupplementalType(t *testing.T) {
items := []ScoredItem{
{MediaItemID: "s1", Score: 1.00},
+1 -1
View File
@@ -11,7 +11,7 @@ import (
const aggregateMediaTypeFloorDivisor = 5
var aggregateSupplementMediaTypes = []string{"movie", "series"}
var aggregateSupplementMediaTypes = []string{"movie", "series", "audiobook"}
// ForYou returns personalised recommendations grouped by taste clusters.
// For cold-start users, non-personalized rows are returned.
+10 -8
View File
@@ -52,8 +52,8 @@ const tasteSeedCandidateQuery = `
SELECT mi.content_id
FROM media_items mi
LEFT JOIN watched_counts wc ON wc.item_id = mi.content_id
WHERE mi.status = 'matched'
AND mi.type IN ('movie', 'series')
WHERE (mi.status = 'matched' OR mi.type = 'audiobook')
AND mi.type IN ('movie', 'series', 'audiobook')
AND mi.poster_path IS NOT NULL
AND mi.poster_path <> ''
ORDER BY COALESCE(wc.watch_count, 0) DESC,
@@ -279,7 +279,7 @@ func (r *Repo) findTasteProfileCandidates(
}
conditions := []string{
"mi.status = 'matched'",
"(mi.status = 'matched' OR mi.type = 'audiobook')",
"e.media_item_id != ALL($2)",
}
args := []any{pgvector.NewVector(embedding), excludeIDs}
@@ -1223,7 +1223,7 @@ func (r *Repo) GetItemWatchers(ctx context.Context, minWatchers int, maxPerUser
// --- Cold Start Queries ---
// GetPopularItems returns the most-watched series/movies over the given number of days.
// GetPopularItems returns the most-played media items over the given number of days.
// Episodes are resolved to their parent series.
func (r *Repo) GetPopularItems(ctx context.Context, days, limit int) ([]ScoredItem, error) {
rows, err := r.pool.Query(ctx, `
@@ -1270,11 +1270,13 @@ func (r *Repo) GetPopularItems(ctx context.Context, days, limit int) ([]ScoredIt
}
// GetRecentlyAddedItems returns items added within the given number of days.
// Audiobooks bypass the matched-status gate because their scan-derived metadata
// is authoritative before any external-provider match exists.
func (r *Repo) GetRecentlyAddedItems(ctx context.Context, days, limit int) ([]ScoredItem, error) {
rows, err := r.pool.Query(ctx, `
SELECT content_id, created_at
FROM media_items
WHERE status = 'matched'
WHERE (status = 'matched' OR type = 'audiobook')
AND created_at > NOW() - ($1 || ' days')::interval
ORDER BY created_at DESC
LIMIT $2`,
@@ -1325,7 +1327,7 @@ func (r *Repo) GetTopRatedItems(ctx context.Context, minRatings, limit int) ([]S
return items, rows.Err()
}
// GetTasteSeedCandidates returns movie/series content IDs ordered for the
// GetTasteSeedCandidates returns movie/series/audiobook content IDs ordered for the
// taste-seeding picker: server engagement first (most-watched in the last
// 180 days), then rating reliability and rating score, then recency. This keeps
// fresh servers from front-loading single-vote TMDB 10.0 obscurities while
@@ -1393,7 +1395,7 @@ func (r *Repo) GetTopGenres(ctx context.Context, limit int) ([]string, error) {
return genres, rows.Err()
}
// GetGenreSamplerItems returns the most-watched series/movies in a specific genre.
// GetGenreSamplerItems returns the most-played media items in a specific genre.
func (r *Repo) GetGenreSamplerItems(ctx context.Context, genre string, limit int) ([]ScoredItem, error) {
rows, err := r.pool.Query(ctx, `
WITH watched_items AS (
@@ -1614,7 +1616,7 @@ func (r *Repo) GetAllUsersWithTasteProfiles(ctx context.Context) ([]StaleProfile
return profiles, rows.Err()
}
// GetWatchedItemIDs returns content IDs of series/movies the user has watched
// GetWatchedItemIDs returns content IDs of media items the user has watched
// (>= 50% progress or completed). Episodes are resolved to their parent series.
func (r *Repo) GetWatchedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) {
rows, err := r.pool.Query(ctx, `
+71
View File
@@ -206,6 +206,77 @@ func TestScanAudiobookFolderReturnsCanceledContext(t *testing.T) {
}
}
func TestScanSubtreeAudiobookLibraryUsesAudiobookPipeline(t *testing.T) {
root := t.TempDir()
bookDir := filepath.Join(root, "bad-book")
if err := os.Mkdir(bookDir, 0o755); err != nil {
t.Fatalf("mkdir book dir: %v", err)
}
if err := os.WriteFile(filepath.Join(bookDir, "chapter.mp3"), []byte("not real audio"), 0o644); err != nil {
t.Fatalf("write fake audio: %v", err)
}
s := &Scanner{ffprobePath: "definitely-missing-ffprobe"}
_, err := s.ScanSubtree(context.Background(), &models.MediaFolder{ID: 42, Type: "audiobooks"}, bookDir)
if err == nil {
t.Fatal("ScanSubtree returned nil, want audiobook parse failure")
}
if strings.Contains(err.Error(), "getting existing files") {
t.Fatalf("ScanSubtree used generic file pipeline: %v", err)
}
if !strings.Contains(err.Error(), "folder_id=42") {
t.Fatalf("error = %q, want audiobook scanner aggregate failure", err)
}
}
func TestScanSubtreeAudiobookLibraryRejectsInvalidScopedRoot(t *testing.T) {
s := &Scanner{}
_, err := s.ScanSubtree(context.Background(), &models.MediaFolder{ID: 42, Type: "audiobooks"}, "")
if err == nil {
t.Fatal("ScanSubtree returned nil, want invalid root error")
}
if !strings.Contains(err.Error(), "invalid audiobook scan root") {
t.Fatalf("error = %q, want invalid audiobook scan root", err)
}
}
func TestScanFileAudiobookLibraryUsesAudiobookPipeline(t *testing.T) {
root := t.TempDir()
bookDir := filepath.Join(root, "bad-book")
if err := os.Mkdir(bookDir, 0o755); err != nil {
t.Fatalf("mkdir book dir: %v", err)
}
filePath := filepath.Join(bookDir, "chapter.mp3")
if err := os.WriteFile(filePath, []byte("not real audio"), 0o644); err != nil {
t.Fatalf("write fake audio: %v", err)
}
s := &Scanner{ffprobePath: "definitely-missing-ffprobe"}
err := s.ScanFile(context.Background(), filePath, &models.MediaFolder{ID: 42, Type: "audiobooks"})
if err == nil {
t.Fatal("ScanFile returned nil, want audiobook parse failure")
}
if strings.Contains(err.Error(), "unrecognized video extension") {
t.Fatalf("ScanFile used video extension gate: %v", err)
}
if !strings.Contains(err.Error(), "folder_id=42") {
t.Fatalf("error = %q, want audiobook scanner aggregate failure", err)
}
}
func TestScanFileAudiobookLibraryRejectsRelativeScopedRoot(t *testing.T) {
s := &Scanner{}
err := s.ScanFile(context.Background(), "chapter.mp3", &models.MediaFolder{ID: 42, Type: "audiobooks"})
if err == nil {
t.Fatal("ScanFile returned nil, want invalid root error")
}
if !strings.Contains(err.Error(), "invalid audiobook scan root") {
t.Fatalf("error = %q, want invalid audiobook scan root", err)
}
}
func TestResolveAudiobookMediaItemReusesRootScopedContentID(t *testing.T) {
finder := &fakeRootContentFinder{contentID: "book-root-id"}
writer := &fakeFilesystemItemWriter{}
+47 -1
View File
@@ -250,9 +250,40 @@ func (s *Scanner) ScanSubtree(ctx context.Context, folder *models.MediaFolder, s
cleanSubtree := filepath.Clean(subtreePath)
watchCtx, stopWatch := s.watchFolderContext(ctx, folder.ID)
defer stopWatch()
if isAudiobookLibraryType(folder.Type) {
scanRoot, err := cleanScopedAudiobookScanRoot(subtreePath)
if err != nil {
return nil, err
}
if err := s.ScanAudiobookFolder(watchCtx, scopedFolderPaths(folder, []string{scanRoot})); err != nil {
return nil, err
}
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
return nil, err
}
return &ScanResult{}, nil
}
return s.scanPaths(watchCtx, folder, []string{cleanSubtree}, []string{cleanSubtree}, false)
}
func cleanScopedAudiobookScanRoot(path string) (string, error) {
clean := filepath.Clean(path)
if clean == "" || clean == "." || clean == ".." || clean == string(filepath.Separator) ||
strings.HasPrefix(clean, ".."+string(filepath.Separator)) || !filepath.IsAbs(clean) {
return "", fmt.Errorf("invalid audiobook scan root: %s", path)
}
return clean, nil
}
func scopedFolderPaths(folder *models.MediaFolder, paths []string) *models.MediaFolder {
if folder == nil {
return nil
}
clone := *folder
clone.Paths = paths
return &clone
}
func isMovieLibraryType(libraryType string) bool {
switch strings.ToLower(strings.TrimSpace(libraryType)) {
case "movie", "movies":
@@ -1454,8 +1485,23 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models.
return err
}
cleanFile := filepath.Clean(filePath)
if isAudiobookLibraryType(folder.Type) {
if !SupportsAudioFile(cleanFile) {
return fmt.Errorf("unrecognized audio extension: %s", strings.ToLower(filepath.Ext(cleanFile)))
}
scanRoot, err := cleanScopedAudiobookScanRoot(filepath.Dir(cleanFile))
if err != nil {
return err
}
if err := s.ScanAudiobookFolder(ctx, scopedFolderPaths(folder, []string{scanRoot})); err != nil {
return err
}
return s.syncFolderScopedAudioLibraryState(ctx, folder.ID)
}
// Verify the file extension is recognized.
ext := strings.ToLower(filepath.Ext(filePath))
ext := strings.ToLower(filepath.Ext(cleanFile))
if !videoExtensions[ext] {
return fmt.Errorf("unrecognized video extension: %s", ext)
}
+14 -2
View File
@@ -452,8 +452,7 @@ func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved Res
if cfgFilters.FilterType != "" {
filtered := make([]*models.MediaItem, 0, len(orderedItems))
for _, item := range orderedItems {
if (cfgFilters.FilterType == "movie" && item.Type == "movie") ||
(cfgFilters.FilterType == "series" && item.Type == "episode") {
if matchesContinueWatchingFilter(cfgFilters.FilterType, item.Type) {
filtered = append(filtered, item)
}
}
@@ -2803,6 +2802,19 @@ func applyConfigTypeFilter(alias string, filterType string, conditions *[]string
*argIdx++
}
func matchesContinueWatchingFilter(filterType string, itemType string) bool {
switch filterType {
case "movie":
return itemType == "movie"
case "series":
return itemType == "episode"
case "audiobook":
return itemType == "audiobook"
default:
return false
}
}
// fetchSeasonalThemed returns items for a seasonal_themed section.
//
// Multi-theme mode (EnabledThemes set): picks the highest-priority enabled
@@ -107,6 +107,31 @@ func TestFilterSupersededEpisodeProgressEntriesDropsOlderPartialsAfterLaterCompl
}
}
func TestMatchesContinueWatchingFilterIncludesAudiobooks(t *testing.T) {
t.Parallel()
tests := []struct {
name string
filterType string
itemType string
want bool
}{
{name: "movie keeps movie", filterType: "movie", itemType: "movie", want: true},
{name: "series keeps episode", filterType: "series", itemType: "episode", want: true},
{name: "audiobook keeps audiobook", filterType: "audiobook", itemType: "audiobook", want: true},
{name: "audiobook rejects movie", filterType: "audiobook", itemType: "movie", want: false},
{name: "unknown rejects audiobook", filterType: "unknown", itemType: "audiobook", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := matchesContinueWatchingFilter(tt.filterType, tt.itemType); got != tt.want {
t.Fatalf("matchesContinueWatchingFilter(%q, %q) = %v, want %v", tt.filterType, tt.itemType, got, tt.want)
}
})
}
}
func TestCompletedProgressSnapshotsPagesThroughConfiguredStore(t *testing.T) {
t.Parallel()
+1 -1
View File
@@ -181,7 +181,7 @@ func ParseConfigFilters(config json.RawMessage) SectionConfigFilters {
def, err := ParseQueryDefinition(config)
if err == nil {
switch def.MediaScope {
case "movie", "series":
case "movie", "series", "audiobook":
f.FilterType = def.MediaScope
}
if len(def.LibraryIDs) > 0 {
@@ -1,13 +1,7 @@
-- +goose Up
-- +goose StatementBegin
INSERT INTO server_settings (key, value)
VALUES (
'audiobookshelf_compat.enabled',
COALESCE(
(SELECT value FROM server_settings WHERE key = 'audiobooks.enabled'),
'false'
)
)
VALUES ('audiobookshelf_compat.enabled', 'true')
ON CONFLICT (key) DO NOTHING;
-- +goose StatementEnd
@@ -0,0 +1,25 @@
-- +goose Up
-- +goose StatementBegin
-- The original ABS compatibility setting copied the stale audiobooks.enabled
-- kill-switch, which defaulted to false. Docker deployments already publish
-- :13378, so keep the listener enabled unless an operator disables it later.
UPDATE server_settings
SET value = 'true'
WHERE key = 'audiobookshelf_compat.enabled'
AND lower(trim(value)) IN ('', '0', 'false', 'no', 'off');
DELETE FROM server_settings
WHERE key = 'audiobooks.enabled';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
UPDATE server_settings
SET value = 'false'
WHERE key = 'audiobookshelf_compat.enabled'
AND lower(trim(value)) = 'true';
INSERT INTO server_settings (key, value)
VALUES ('audiobooks.enabled', 'false')
ON CONFLICT (key) DO NOTHING;
-- +goose StatementEnd
@@ -0,0 +1,27 @@
-- +goose Up
-- +goose StatementBegin
UPDATE page_sections ps
SET title = 'Continue Listening',
updated_at = NOW()
FROM media_folders mf
WHERE ps.scope = 'library'
AND ps.library_id = mf.id
AND lower(mf.type) IN ('audiobook', 'audiobooks')
AND ps.section_type = 'continue_watching'
AND ps.position = 0
AND ps.title = 'Continue Watching';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
UPDATE page_sections ps
SET title = 'Continue Watching',
updated_at = NOW()
FROM media_folders mf
WHERE ps.scope = 'library'
AND ps.library_id = mf.id
AND lower(mf.type) IN ('audiobook', 'audiobooks')
AND ps.section_type = 'continue_watching'
AND ps.position = 0
AND ps.title = 'Continue Listening';
-- +goose StatementEnd
+14 -11
View File
@@ -92,6 +92,7 @@ import {
WatchPlaybackHost,
WatchPlaybackProvider,
} from "@/playback/WatchPlaybackChrome";
import { AudiobookPlaybackProvider } from "@/pages/audiobooks/player/audiobookPlaybackContext";
import type { ReactNode } from "react";
import {
buildLegacyBrowseCatalogHref,
@@ -567,17 +568,19 @@ export default function App() {
<ThemeProvider>
<CustomThemeProvider>
<WatchPlaybackProvider>
<RealtimeEventsProvider>
<RealtimeEventChannels />
<ScrollRestorationManager />
<RouteAnnouncer />
<QueryCacheManager />
<AppChrome />
<AppRoutes />
<WatchPlaybackHost />
<WatchPlaybackBar />
<Toaster />
</RealtimeEventsProvider>
<AudiobookPlaybackProvider>
<RealtimeEventsProvider>
<RealtimeEventChannels />
<ScrollRestorationManager />
<RouteAnnouncer />
<QueryCacheManager />
<AppChrome />
<AppRoutes />
<WatchPlaybackHost />
<WatchPlaybackBar />
<Toaster />
</RealtimeEventsProvider>
</AudiobookPlaybackProvider>
</WatchPlaybackProvider>
</CustomThemeProvider>
</ThemeProvider>
+3
View File
@@ -644,6 +644,9 @@ export interface BrowseItemSortMetrics {
progress_ratio?: number;
viewed_at?: string;
play_count?: number;
author?: string;
narrator?: string;
series_name?: string;
}
export interface BrowseItem {
+4 -1
View File
@@ -7,11 +7,14 @@ import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { resolveAdminDocumentTitle } from "@/lib/documentTitle";
import { Menu } from "lucide-react";
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
export default function AdminLayout() {
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation();
const { isBackgroundBarVisible } = useWatchPlaybackController();
const audiobookPlayback = useAudiobookPlaybackController();
const hasBackgroundBar = isBackgroundBarVisible || audiobookPlayback?.isBackgroundBarVisible;
useDocumentTitle(resolveAdminDocumentTitle(location.pathname));
@@ -67,7 +70,7 @@ export default function AdminLayout() {
<main
id="main-content"
className={`relative z-10 min-h-screen px-4 py-4 sm:px-6 lg:ml-[240px] lg:px-8 lg:py-8 xl:px-10 ${
isBackgroundBarVisible ? "pb-32 sm:pb-36" : ""
hasBackgroundBar ? "pb-32 sm:pb-36" : ""
}`}
>
<div className="admin-shell">
@@ -182,4 +182,39 @@ describe("ContinueWatchingCard", () => {
expect(markup).toContain("58 min left");
expect(markup).toContain("More actions");
});
it("routes audiobook continue cards to the audiobook detail player", () => {
const queryClient = new QueryClient();
const markup = renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ContinueWatchingCard
sectionItem={{
content_id: "book-001",
type: "audiobook",
title: "The Way of Kings",
year: 2010,
genres: [],
status: "matched",
rating_imdb: null,
overview: "",
item_source: "continue_watching",
position_seconds: 3600,
duration_seconds: 14400,
progress_updated_at: "2026-03-07T00:00:00Z",
poster_url: "/book-cover.jpg",
poster_thumbhash: "",
backdrop_url: "",
backdrop_thumbhash: "",
logo_url: "",
}}
libraryId={7}
/>
</MemoryRouter>
</QueryClientProvider>,
);
expect(markup).toContain('href="/item/book-001?libraryId=7&amp;play=1"');
expect(markup).not.toContain('href="/watch/book-001');
});
});
+19 -6
View File
@@ -10,6 +10,7 @@ import { overlayDataFromSectionItem, type CardOverlayPrefs } from "@/lib/overlay
import { upcomingBadgeClass, upcomingBadgeLabel } from "@/lib/upcomingEventPresentation";
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
import { parseWatchHref } from "@/pages/watchRouteHelpers";
import { buildItemHref, buildMediaPlayHref, isVideoWatchHref } from "@/lib/mediaNavigation";
type ContinueWatchingCardProps = (
| {
@@ -31,12 +32,15 @@ type ContinueWatchingCardProps = (
export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
const location = useLocation();
const playbackController = useWatchPlaybackController();
const libraryQuery = props.libraryId ? `?libraryId=${props.libraryId}` : "";
const card =
"sectionItem" in props && props.sectionItem
? {
watchHref: `/watch/${props.sectionItem.content_id}${libraryQuery}`,
itemHref: `/item/${props.sectionItem.content_id}${libraryQuery}`,
watchHref: buildMediaPlayHref({
contentId: props.sectionItem.content_id,
type: props.sectionItem.type,
libraryId: props.libraryId,
}),
itemHref: buildItemHref({ contentId: props.sectionItem.content_id, libraryId: props.libraryId }),
title: props.sectionItem.title,
seriesTitle: props.sectionItem.series_title,
seasonNumber: props.sectionItem.season_number,
@@ -48,8 +52,12 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
type: props.sectionItem.type,
}
: {
watchHref: `/watch/${props.detail.content_id}${libraryQuery}`,
itemHref: `/item/${props.detail.content_id}${libraryQuery}`,
watchHref: buildMediaPlayHref({
contentId: props.detail.content_id,
type: props.detail.type,
libraryId: props.libraryId,
}),
itemHref: buildItemHref({ contentId: props.detail.content_id, libraryId: props.libraryId }),
title: props.detail.title,
seriesTitle: props.detail.series_title,
seasonNumber: props.detail.season_number,
@@ -121,6 +129,10 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
return;
}
if (!isVideoWatchHref(card.watchHref)) {
return;
}
const parsed = parseWatchHref(card.watchHref);
if (!parsed) {
return;
@@ -230,8 +242,9 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
: props.detail.content_id
}
mediaType={
"sectionItem" in props && props.sectionItem ? props.sectionItem.type : props.detail.type
"sectionItem" in props && props.sectionItem ? props.sectionItem.type : props.detail.type
}
libraryId={props.libraryId}
userState={
"sectionItem" in props && props.sectionItem ? props.sectionItem.user_state : undefined
}
+113 -1
View File
@@ -1,9 +1,19 @@
import { MemoryRouter } from "react-router";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import HeroBanner from "./HeroBanner";
const playbackMocks = vi.hoisted(() => ({
controller: null as null | {
active: { contentId: string; playing: boolean };
toggleActivePlayback: () => void;
},
toggleActivePlayback: vi.fn(),
}));
vi.mock("@/hooks/useAmbientColor", () => ({
useAmbientColor: () => undefined,
}));
@@ -12,7 +22,34 @@ vi.mock("@/lib/thumbhash", () => ({
decodeThumbhash: () => "",
}));
vi.mock("@/pages/audiobooks/player/audiobookPlaybackContext", () => ({
useAudiobookPlaybackController: () => playbackMocks.controller,
}));
function audiobookSlide() {
return {
content_id: "book-1",
type: "audiobook" as const,
title: "Featured Audiobook",
year: 2025,
genres: ["Fantasy"],
status: "matched" as const,
rating_imdb: null,
overview: "Overview",
poster_url: "",
poster_thumbhash: "",
backdrop_url: "",
backdrop_thumbhash: "",
logo_url: "",
};
}
describe("HeroBanner", () => {
beforeEach(() => {
playbackMocks.controller = null;
playbackMocks.toggleActivePlayback.mockClear();
});
it("does not render the desktop spotlighting card", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
@@ -42,4 +79,79 @@ describe("HeroBanner", () => {
expect(markup).toContain("Featured Movie");
expect(markup).toContain("More Info");
});
it("routes audiobook play actions to the audiobook detail player", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
<HeroBanner
libraryId={7}
items={[
{
...audiobookSlide(),
},
]}
/>
</MemoryRouter>,
);
expect(markup).toContain('href="/item/book-1?libraryId=7&amp;play=1"');
expect(markup).not.toContain('href="/watch/book-1');
expect(markup).toContain("Listen");
});
it("labels audiobook hero actions as resume when progress exists", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
<HeroBanner
items={[
{
...audiobookSlide(),
position_seconds: 120,
duration_seconds: 3600,
},
]}
/>
</MemoryRouter>,
);
expect(markup).toContain("Resume");
});
it("labels completed audiobook hero actions as listen again", () => {
const markup = renderToStaticMarkup(
<MemoryRouter>
<HeroBanner
items={[
{
...audiobookSlide(),
user_state: {
played: true,
is_favorite: false,
in_watchlist: false,
},
},
]}
/>
</MemoryRouter>,
);
expect(markup).toContain("Listen Again");
});
it("pauses the active audiobook from the hero without navigating", async () => {
playbackMocks.controller = {
active: { contentId: "book-1", playing: true },
toggleActivePlayback: playbackMocks.toggleActivePlayback,
};
render(
<MemoryRouter>
<HeroBanner items={[audiobookSlide()]} />
</MemoryRouter>,
);
await userEvent.click(screen.getByRole("link", { name: /pause/i }));
expect(playbackMocks.toggleActivePlayback).toHaveBeenCalledTimes(1);
});
});
+52 -19
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo, type MouseEvent } from "react";
import { Link } from "react-router";
import { Info, ChevronLeft, ChevronRight, Play, Pause } from "lucide-react";
import { decodeThumbhash } from "@/lib/thumbhash";
@@ -6,6 +6,8 @@ import { HERO_BANNER_SIZE } from "@/lib/design-system";
import { useAmbientColor } from "@/hooks/useAmbientColor";
import { cn } from "@/lib/utils";
import type { SectionItem } from "@/api/types";
import { buildItemHref, buildMediaPlayHref } from "@/lib/mediaNavigation";
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
interface HeroBannerProps {
items: SectionItem[];
@@ -33,20 +35,6 @@ interface HeroBannerProps {
libraryId?: number;
}
/**
* Build the target href for the hero's Play CTA. Directly-playable types
* (movies, episodes) go to /watch; container types (series, season) have no
* single video, so Play falls back to the detail page where the existing
* in-context Play button handles next-episode / resume logic.
*/
function buildPlayHref(item: SectionItem, libraryId?: number): string {
const q = libraryId ? `?libraryId=${libraryId}` : "";
if (item.type === "movie" || item.type === "episode") {
return `/watch/${item.content_id}${q}`;
}
return `/item/${item.content_id}${q}`;
}
function formatRuntime(seconds: number | undefined | null): string | null {
if (!seconds || seconds <= 0) return null;
const minutes = Math.round(seconds / 60);
@@ -56,6 +44,27 @@ function formatRuntime(seconds: number | undefined | null): string | null {
return remaining === 0 ? `${hours}h` : `${hours}h ${remaining}m`;
}
function heroPlayLabel(item: SectionItem, activeAudiobookPlaying?: boolean | null): string {
if (item.type !== "audiobook") {
return "Play";
}
if (activeAudiobookPlaying === true) {
return "Pause";
}
if (activeAudiobookPlaying === false) {
return "Resume";
}
if (item.user_state?.played) {
return "Listen Again";
}
const position = item.position_seconds ?? 0;
const duration = item.duration_seconds ?? 0;
if (position > 0 && (duration <= 0 || position < duration)) {
return "Resume";
}
return "Listen";
}
export default function HeroBanner({
items,
maxSlides = 10,
@@ -68,6 +77,7 @@ export default function HeroBanner({
const [activeIndex, setActiveIndex] = useState(0);
const [loaded, setLoaded] = useState<Record<number, boolean>>({});
const [paused, setPaused] = useState(false);
const audiobookPlayback = useAudiobookPlaybackController();
// Bumped whenever auto-advance restarts a fresh 8s cycle — after the slide
// changes, or after the user unpauses. Used as part of the progress-rail key
// so the CSS animation restarts in lockstep with the setInterval timer
@@ -126,6 +136,24 @@ export default function HeroBanner({
const slideCount = slides.length;
const padded = (n: number) => String(n).padStart(2, "0");
const activeAudiobookPlaying =
current.type === "audiobook" && audiobookPlayback?.active?.contentId === current.content_id
? audiobookPlayback.active.playing
: null;
const playLabel = heroPlayLabel(current, activeAudiobookPlaying);
const playHref = buildMediaPlayHref({
contentId: current.content_id,
type: current.type,
libraryId,
});
const handlePlayClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (activeAudiobookPlaying == null) {
return;
}
event.preventDefault();
audiobookPlayback?.toggleActivePlayback();
};
return (
<section
@@ -220,14 +248,19 @@ export default function HeroBanner({
)}
<div className="flex flex-wrap items-center gap-3">
<Link
to={buildPlayHref(current, libraryId)}
to={playHref}
onClick={handlePlayClick}
className="pill pill-primary transition-colors duration-[--duration-fast]"
>
<Play className="h-4 w-4 fill-current" />
Play
{activeAudiobookPlaying ? (
<Pause className="h-4 w-4 fill-current" />
) : (
<Play className="h-4 w-4 fill-current" />
)}
{playLabel}
</Link>
<Link
to={`/item/${current.content_id}${libraryId ? `?libraryId=${libraryId}` : ""}`}
to={buildItemHref({ contentId: current.content_id, libraryId })}
className="pill pill-glass transition-colors duration-[--duration-fast]"
>
<Info className="h-4 w-4" />
+22
View File
@@ -116,6 +116,28 @@ describe("ItemCard SortMeta", () => {
expect(markup).toContain("2160p");
});
it("renders audiobook-native sort metadata", () => {
const audiobook = {
...baseItem,
content_id: "audiobook-1",
type: "audiobook" as const,
title: "The Way of Kings",
year: 2010,
content_rating: "",
sort_metrics: {
author: "Brandon Sanderson",
narrator: "Michael Kramer",
series_name: "The Stormlight Archive",
},
};
expect(renderCard({ sortField: "author", item: audiobook })).toContain("Brandon Sanderson");
expect(renderCard({ sortField: "narrator", item: audiobook })).toContain("Michael Kramer");
expect(renderCard({ sortField: "series", item: audiobook })).toContain(
"The Stormlight Archive",
);
});
it("renders episode sort metadata when an active sort has a value", () => {
const markup = renderCard({
sortField: "release_date",
+7
View File
@@ -114,6 +114,12 @@ function SortMeta({ item, sortField }: { item: BrowseItem; sortField?: string })
return <>{formatDate(item.sort_metrics?.viewed_at) ?? defaultLabel}</>;
case "plays":
return <>{item.sort_metrics?.play_count ?? defaultLabel}</>;
case "author":
return <>{item.sort_metrics?.author || defaultLabel}</>;
case "narrator":
return <>{item.sort_metrics?.narrator || defaultLabel}</>;
case "series":
return <>{item.sort_metrics?.series_name || defaultLabel}</>;
default:
if (episodeLabels) {
return <>{episodeLabels.episodeCode}</>;
@@ -228,6 +234,7 @@ export default function ItemCard({
<MediaItemMenu
contentId={item.content_id}
mediaType={item.type}
libraryId={libraryId}
userState={item.user_state}
variant="poster"
/>
+4 -1
View File
@@ -12,6 +12,7 @@ import ViewTransitionLink from "@/components/ViewTransitionLink";
import { buildQueryCatalogHref, parseCatalogSearchParams } from "@/pages/catalogSearchParams";
import type { ReactNode } from "react";
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
interface LayoutProps {
children: ReactNode;
@@ -28,6 +29,8 @@ export default function Layout({ children }: LayoutProps) {
const { profile } = useCurrentProfile();
const isAdmin = user?.role === "admin";
const { isBackgroundBarVisible } = useWatchPlaybackController();
const audiobookPlayback = useAudiobookPlaybackController();
const hasBackgroundBar = isBackgroundBarVisible || audiobookPlayback?.isBackgroundBarVisible;
const isHomePath = location.pathname === "/";
const isLibraryRoute = location.pathname.startsWith("/library/");
@@ -186,7 +189,7 @@ export default function Layout({ children }: LayoutProps) {
id="main-content"
className={`main-transition relative min-h-screen ${
isDetailImmersion ? "lg:ml-16" : "lg:ml-[260px]"
} ${isBackgroundBarVisible ? "pb-32 sm:pb-36" : ""}`}
} ${hasBackgroundBar ? "pb-32 sm:pb-36" : ""}`}
style={{ viewTransitionName: "main-content" }}
>
{needsNoPadding ? (
+19
View File
@@ -142,6 +142,25 @@ describe("buildMediaItemMenuModel", () => {
).toBe(true);
});
it("uses listening labels for audiobook state actions", () => {
const model = buildMediaItemMenuModel({
mediaType: "audiobook",
hasPartialProgress: true,
userState: {
played: false,
is_favorite: false,
in_watchlist: false,
},
isAdmin: false,
dismissLabel: "Remove from Continue Listening",
});
const actions = model.filter((item) => item.kind === "action");
expect(actions[0]?.label).toBe("Listen from Beginning");
expect(actions[1]?.label).toBe("Mark Listened");
expect(actions.some((item) => item.label === "Remove from Continue Listening")).toBe(true);
});
it("does not show play from beginning for non-leaf items", () => {
const model = buildMediaItemMenuModel({
mediaType: "series",
+20 -4
View File
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
import { buildMediaPlayHref } from "@/lib/mediaNavigation";
type MediaItemType = ItemDetail["type"];
@@ -48,6 +49,7 @@ interface BuildMediaItemMenuModelOptions {
interface MediaItemMenuProps {
contentId: string;
mediaType: MediaItemType;
libraryId?: number;
userState?: MediaItemUserState;
variant?: "poster" | "wide";
/** When false, hides favorites and watchlist actions (e.g. for episodes). Defaults to true. */
@@ -65,13 +67,14 @@ export function buildMediaItemMenuModel({
dismissLabel,
}: BuildMediaItemMenuModelOptions): MediaItemMenuEntry[] {
const entries: MediaItemMenuEntry[] = [];
const isLeaf = mediaType === "movie" || mediaType === "episode";
const isAudiobook = mediaType === "audiobook";
const isLeaf = mediaType === "movie" || mediaType === "episode" || isAudiobook;
if (isLeaf && (hasPartialProgress || userState?.played === true)) {
entries.push({
kind: "action",
key: "playFromBeginning",
label: "Play from Beginning",
label: isAudiobook ? "Listen from Beginning" : "Play from Beginning",
});
}
@@ -79,7 +82,13 @@ export function buildMediaItemMenuModel({
entries.push({
kind: "action",
key: "toggleWatched",
label: userState.played ? "Mark Unwatched" : "Mark Watched",
label: isAudiobook
? userState.played
? "Mark Unlistened"
: "Mark Listened"
: userState.played
? "Mark Unwatched"
: "Mark Watched",
});
if (showCollectionActions) {
@@ -138,6 +147,7 @@ function stopMenuEvent(event: Pick<Event, "preventDefault" | "stopPropagation">)
export default function MediaItemMenu({
contentId,
mediaType,
libraryId,
userState,
variant = "poster",
showCollectionActions = true,
@@ -167,7 +177,9 @@ export default function MediaItemMenu({
const dismissHomeItemMutation = useDismissHomeItem();
const dismissLabel =
dismissAction?.surface === "continue_watching"
? "Remove from Continue Watching"
? mediaType === "audiobook"
? "Remove from Continue Listening"
: "Remove from Continue Watching"
: dismissAction?.surface === "next_up"
? "Remove from Next Up"
: undefined;
@@ -201,6 +213,10 @@ export default function MediaItemMenu({
async function handleAction(actionKey: Extract<MediaItemMenuEntry, { kind: "action" }>["key"]) {
switch (actionKey) {
case "playFromBeginning": {
if (mediaType === "audiobook") {
navigate(buildMediaPlayHref({ contentId, type: mediaType, libraryId, restart: true }));
return;
}
playbackController.startPlayback({
contentId,
restart: true,
+1
View File
@@ -90,6 +90,7 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp
<MediaItemMenu
contentId={item.content_id}
mediaType={item.type}
libraryId={libraryId}
userState={item.user_state}
variant="poster"
/>
@@ -0,0 +1,41 @@
import { MemoryRouter } from "react-router";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import WatchTonightCard from "./WatchTonightCard";
vi.mock("@/playback/watchPlaybackContext", () => ({
useWatchPlaybackController: () => ({ startPlayback: vi.fn() }),
}));
describe("WatchTonightCard", () => {
it("uses listen copy for audiobook play links", () => {
render(
<MemoryRouter>
<WatchTonightCard
onPlay={() => {}}
item={{
content_id: "book-1",
type: "audiobook",
title: "Book One",
year: 2026,
genres: [],
status: "matched",
rating_imdb: null,
overview: "",
poster_url: "",
poster_thumbhash: "",
backdrop_url: "",
backdrop_thumbhash: "",
logo_url: "",
watch_tonight_source: "recommendation",
}}
/>
</MemoryRouter>,
);
expect(screen.getByRole("link", { name: "Listen Book One" })).toHaveAttribute(
"href",
"/item/book-1?play=1",
);
});
});
+9 -3
View File
@@ -6,6 +6,7 @@ import ViewTransitionLink from "@/components/ViewTransitionLink";
import { Badge } from "@/components/ui/badge";
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
import { parseWatchHref } from "@/pages/watchRouteHelpers";
import { buildItemHref, buildMediaPlayHref, isVideoWatchHref } from "@/lib/mediaNavigation";
const sourceLabels: Record<string, string> = {
continue_watching: "Continue",
@@ -22,8 +23,8 @@ export default function WatchTonightCard({ item, onPlay }: WatchTonightCardProps
const location = useLocation();
const playbackController = useWatchPlaybackController();
const watchHref = `/watch/${item.content_id}`;
const itemHref = `/item/${item.content_id}`;
const watchHref = buildMediaPlayHref({ contentId: item.content_id, type: item.type });
const itemHref = buildItemHref({ contentId: item.content_id });
const source = item.watch_tonight_source ?? "";
const isInProgress = source === "continue_watching";
const isNextUp = source === "next_up";
@@ -43,6 +44,7 @@ export default function WatchTonightCard({ item, onPlay }: WatchTonightCardProps
: isInProgress && (item.duration_seconds ?? 0) > 0
? `${Math.max(0, Math.round(((item.duration_seconds ?? 0) - (item.position_seconds ?? 0)) / 60))} min left`
: item.genres?.slice(0, 2).join(", ") || "\u00A0";
const playVerb = item.type === "audiobook" ? "Listen" : "Play";
const handlePlayClick = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
@@ -57,6 +59,10 @@ export default function WatchTonightCard({ item, onPlay }: WatchTonightCardProps
return;
}
if (!isVideoWatchHref(watchHref)) {
return;
}
const parsed = parseWatchHref(watchHref);
if (!parsed) return;
@@ -81,7 +87,7 @@ export default function WatchTonightCard({ item, onPlay }: WatchTonightCardProps
<ViewTransitionLink
to={watchHref}
onClick={handlePlayClick}
aria-label={`Play ${heading}`}
aria-label={`${playVerb} ${heading}`}
className="group/play relative w-44 shrink-0"
>
<div className="relative aspect-video overflow-hidden rounded-lg">
+1 -1
View File
@@ -115,7 +115,7 @@ export default function WatchTonightDialog({ open, onOpenChange }: WatchTonightD
<div>
<p className="font-semibold">Pick Up Where I Left Off</p>
<p className="text-muted-foreground mt-1 text-xs">
Continue watching or start next episodes
Continue in-progress titles or start what is next
</p>
</div>
</button>
@@ -52,7 +52,12 @@ export default function CatalogFiltersPanel({
const qd = state.query_definition ?? createEmptyQueryDefinition();
const guidedState = useMemo(() => queryDefinitionToGuidedState(qd), [qd]);
const badges = useMemo(() => getActiveFilterBadges(guidedState), [guidedState]);
const isAudiobookLibrary =
libraryType === "audiobook" || libraryType === "audiobooks" || guidedState.mediaScope === "audiobook";
const badges = useMemo(
() => getActiveFilterBadges(guidedState, { isAudiobookLibrary }),
[guidedState, isAudiobookLibrary],
);
const activeCount = useMemo(() => countActiveFilters(guidedState), [guidedState]);
// Locked sources don't support filtering
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { GuidedFormState } from "@/components/collections/CollectionGuidedRulesEditor";
import { getActiveFilterBadges } from "./catalogFilterBadges";
function state(watchStatus: GuidedFormState["watchStatus"]): GuidedFormState {
return {
mediaScope: "audiobook",
libraryIds: [],
genres: [],
decade: "",
yearFrom: "",
yearTo: "",
minRating: "",
contentRating: "",
originalLanguages: [],
actor: "",
director: "",
writer: "",
producer: "",
author: "",
narrator: "",
series: "",
studio: "",
network: "",
country: "",
status: "",
watchStatus,
addedInLast: "",
releasedInLast: "",
fourK: false,
hdr: false,
dolbyVision: false,
sortField: "title",
sortOrder: "asc",
};
}
describe("getActiveFilterBadges", () => {
it("uses listening copy for audiobook watch-status badges", () => {
expect(getActiveFilterBadges(state("watched"), { isAudiobookLibrary: true })).toContainEqual(
expect.objectContaining({ key: "watchStatus", label: "Listening: listened" }),
);
expect(getActiveFilterBadges(state("unwatched"), { isAudiobookLibrary: true })).toContainEqual(
expect.objectContaining({ key: "watchStatus", label: "Listening: unlistened" }),
);
});
it("keeps watch copy for non-audiobook badges", () => {
expect(getActiveFilterBadges(state("watched"))).toContainEqual(
expect.objectContaining({ key: "watchStatus", label: "Watch: watched" }),
);
});
});
@@ -11,7 +11,10 @@ export interface ActiveFilterBadge {
* Compute displayable badge descriptors for all active "secondary" filters.
* Skips mediaScope, sortField, sortOrder (shown inline in the bar).
*/
export function getActiveFilterBadges(state: GuidedFormState): ActiveFilterBadge[] {
export function getActiveFilterBadges(
state: GuidedFormState,
options: { isAudiobookLibrary?: boolean } = {},
): ActiveFilterBadge[] {
const badges: ActiveFilterBadge[] = [];
// Genres — one badge per selected genre
@@ -165,9 +168,16 @@ export function getActiveFilterBadges(state: GuidedFormState): ActiveFilterBadge
}
if (state.watchStatus) {
const statusLabel = options.isAudiobookLibrary
? state.watchStatus === "watched"
? "listened"
: state.watchStatus === "unwatched"
? "unlistened"
: state.watchStatus.replace("_", " ")
: state.watchStatus.replace("_", " ");
badges.push({
key: "watchStatus",
label: `Watch: ${state.watchStatus.replace("_", " ")}`,
label: `${options.isAudiobookLibrary ? "Listening" : "Watch"}: ${statusLabel}`,
clearPatch: { watchStatus: "" },
});
}
@@ -780,7 +780,7 @@ export default function CollectionGuidedRulesEditor({
{allowPersonalizedFilters ? (
<div className="space-y-2">
<Label>Watch Status</Label>
<Label>{isAudiobookLibrary ? "Listening Status" : "Watch Status"}</Label>
<Select
value={state.watchStatus || "__any__"}
onValueChange={(value) =>
@@ -795,8 +795,12 @@ export default function CollectionGuidedRulesEditor({
</SelectTrigger>
<SelectContent>
<SelectItem value="__any__">Any</SelectItem>
<SelectItem value="watched">Watched</SelectItem>
<SelectItem value="unwatched">Unwatched</SelectItem>
<SelectItem value="watched">
{isAudiobookLibrary ? "Listened" : "Watched"}
</SelectItem>
<SelectItem value="unwatched">
{isAudiobookLibrary ? "Unlistened" : "Unwatched"}
</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
</SelectContent>
</Select>
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import type { SwipeCard } from "@/hooks/queries/recommendations";
import { playTargetForSwipeCard } from "./CardStack";
function card(type: SwipeCard["type"]): SwipeCard {
return {
content_id: `${type}-1`,
type,
title: "Pick",
year: 2026,
genres: [],
status: "matched",
rating_imdb: null,
overview: "",
poster_url: "",
poster_thumbhash: "",
backdrop_url: "",
backdrop_thumbhash: "",
logo_url: "",
watch_tonight_source: "recommendation",
cast: [],
};
}
describe("playTargetForSwipeCard", () => {
it("routes audiobook cards to the detail player", () => {
expect(playTargetForSwipeCard(card("audiobook"))).toEqual({
href: "/item/audiobook-1?play=1",
isVideo: false,
});
});
it("keeps video cards on the watch route", () => {
expect(playTargetForSwipeCard(card("movie"))).toEqual({
href: "/watch/movie-1",
isVideo: true,
});
});
});
+23 -3
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useLocation } from "react-router";
import { useLocation, useNavigate } from "react-router";
import { RefreshCw, Tv } from "lucide-react";
import type { SwipeCard as SwipeCardType } from "@/hooks/queries/recommendations";
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
import { Skeleton } from "@/components/ui/skeleton";
import SwipeCard, { CardActions } from "./SwipeCard";
import { buildMediaPlayHref, isVideoWatchHref } from "@/lib/mediaNavigation";
// Stack positioning for each depth level (0 = top, 1 = behind, 2 = further).
const stackVariants = {
@@ -25,6 +26,14 @@ interface CardStackProps {
onReset: () => void;
}
export function playTargetForSwipeCard(card: SwipeCardType) {
const href = buildMediaPlayHref({ contentId: card.content_id, type: card.type });
return {
href,
isVideo: isVideoWatchHref(href),
};
}
export default function CardStack({
cards,
hasMore,
@@ -36,6 +45,7 @@ export default function CardStack({
const [topIndex, setTopIndex] = useState(0);
const prefetchTriggered = useRef(false);
const location = useLocation();
const navigate = useNavigate();
const playbackController = useWatchPlaybackController();
const visibleCards = cards.slice(topIndex, topIndex + 3);
@@ -61,11 +71,16 @@ export default function CardStack({
const card = cards[topIndex];
if (!card) return;
onClose();
const target = playTargetForSwipeCard(card);
if (!target.isVideo) {
navigate(target.href);
return;
}
playbackController.startPlayback({
contentId: card.content_id,
returnHref: `${location.pathname}${location.search}`,
});
}, [cards, topIndex, onClose, playbackController, location.pathname, location.search]);
}, [cards, topIndex, onClose, playbackController, location.pathname, location.search, navigate]);
// Keyboard navigation.
useEffect(() => {
@@ -163,7 +178,12 @@ export default function CardStack({
{/* Action buttons */}
{visibleCards.length > 0 && (
<CardActions onReject={advance} onAccept={handlePlay} onPlay={handlePlay} />
<CardActions
onReject={advance}
onAccept={handlePlay}
onPlay={handlePlay}
playLabel={visibleCards[0]?.type === "audiobook" ? "Listen now" : "Play now"}
/>
)}
{/* Counter */}
@@ -308,10 +308,17 @@ interface CardActionsProps {
onReject: () => void;
onAccept: () => void;
onPlay: () => void;
playLabel?: string;
disabled?: boolean;
}
export function CardActions({ onReject, onAccept, onPlay, disabled }: CardActionsProps) {
export function CardActions({
onReject,
onAccept,
onPlay,
playLabel = "Play now",
disabled,
}: CardActionsProps) {
return (
<div className="flex items-center justify-center gap-4 pt-4">
<button
@@ -338,7 +345,7 @@ export function CardActions({ onReject, onAccept, onPlay, disabled }: CardAction
"border-primary/40 text-primary hover:border-primary hover:bg-primary/10",
"disabled:pointer-events-none disabled:opacity-40",
)}
aria-label="Play now"
aria-label={playLabel}
>
<Play className="ml-0.5 h-7 w-7" fill="currentColor" />
</button>
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { buildMediaPlayHref } from "./mediaNavigation";
describe("buildMediaPlayHref", () => {
it("routes podcasts to item detail instead of video watch", () => {
expect(buildMediaPlayHref({ contentId: "podcast-1", type: "podcast" })).toBe(
"/item/podcast-1",
);
});
});
+38
View File
@@ -0,0 +1,38 @@
type PlayableMediaType = "movie" | "series" | "season" | "episode" | "audiobook" | "podcast";
interface MediaHrefInput {
contentId: string;
type: PlayableMediaType;
libraryId?: number;
restart?: boolean;
}
function appendQuery(base: string, params: Record<string, string | number | boolean | undefined>) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value == null || value === false) {
continue;
}
search.set(key, value === true ? "1" : String(value));
}
const suffix = search.toString();
return suffix ? `${base}?${suffix}` : base;
}
export function buildItemHref({ contentId, libraryId }: Pick<MediaHrefInput, "contentId" | "libraryId">) {
return appendQuery(`/item/${contentId}`, { libraryId });
}
export function buildMediaPlayHref({ contentId, type, libraryId, restart }: MediaHrefInput) {
if (type === "movie" || type === "episode") {
return appendQuery(`/watch/${contentId}`, { libraryId, restart });
}
if (type === "audiobook") {
return appendQuery(`/item/${contentId}`, { libraryId, play: true, restart });
}
return buildItemHref({ contentId, libraryId });
}
export function isVideoWatchHref(href: string) {
return href.startsWith("/watch/");
}
@@ -1,4 +1,3 @@
import { useEffect } from "react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
@@ -7,18 +6,20 @@ import { MemoryRouter } from "react-router";
import type { ItemDetail } from "@/api/types";
const mocks = vi.hoisted(() => ({
playerMounts: [] as Array<{ initialPositionSeconds: number; mountId: number }>,
controller: null as null | {
active: null | { contentId: string; playing: boolean; currentTime: number; duration: number; hasFile: boolean };
activeRequest: null | { contentId: string };
isBackgroundBarVisible: boolean;
startPlayback: ReturnType<typeof vi.fn>;
stopPlayback: ReturnType<typeof vi.fn>;
toggleActivePlayback: ReturnType<typeof vi.fn>;
},
startPlayback: vi.fn(),
toggleActivePlayback: vi.fn(),
}));
let playerMountCounter = 0;
vi.mock("@/pages/audiobooks/player/AudiobookPlayer", () => ({
default: function PlayerStub({ initialPositionSeconds }: { initialPositionSeconds: number }) {
useEffect(() => {
playerMountCounter += 1;
mocks.playerMounts.push({ initialPositionSeconds, mountId: playerMountCounter });
}, []);
return <div data-testid="player-stub">player @ {initialPositionSeconds}</div>;
},
vi.mock("@/pages/audiobooks/player/audiobookPlaybackContext", () => ({
useAudiobookPlaybackController: () => mocks.controller,
}));
vi.mock("@/components/AddToCollectionDialog", () => ({
@@ -102,32 +103,46 @@ function bookWithProgress(seconds: number): ItemDetail & { type: "audiobook" } {
};
}
describe("AudiobookContent Play-from-Start remount", () => {
describe("AudiobookContent playback actions", () => {
beforeEach(() => {
mocks.playerMounts.length = 0;
playerMountCounter = 0;
mocks.startPlayback.mockClear();
mocks.toggleActivePlayback.mockClear();
mocks.controller = {
active: null,
activeRequest: null,
isBackgroundBarVisible: false,
startPlayback: mocks.startPlayback,
stopPlayback: vi.fn(),
toggleActivePlayback: mocks.toggleActivePlayback,
};
});
it("forces a player remount on every openPlayer call, even when startSeconds is unchanged", async () => {
it("sends every Play-from-Start click to the shared audiobook player", async () => {
render(
<MemoryRouter>
<AudiobookContent item={bookWithProgress(5000)} />
</MemoryRouter>,
);
expect(mocks.playerMounts).toHaveLength(0);
expect(mocks.startPlayback).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: /play from start/i }));
expect(mocks.playerMounts).toHaveLength(1);
expect(mocks.playerMounts[0]?.initialPositionSeconds).toBe(0);
await userEvent.click(screen.getByRole("button", { name: /listen from start/i }));
expect(mocks.startPlayback).toHaveBeenCalledTimes(1);
expect(mocks.startPlayback.mock.calls[0]?.[0]).toMatchObject({
contentId: "book-1",
title: "Test Book",
initialPositionSeconds: 0,
});
await userEvent.click(screen.getByRole("button", { name: /play from start/i }));
expect(mocks.playerMounts).toHaveLength(2);
expect(mocks.playerMounts[1]?.initialPositionSeconds).toBe(0);
expect(mocks.playerMounts[1]?.mountId).not.toBe(mocks.playerMounts[0]?.mountId);
await userEvent.click(screen.getByRole("button", { name: /listen from start/i }));
expect(mocks.startPlayback).toHaveBeenCalledTimes(2);
expect(mocks.startPlayback.mock.calls[1]?.[0]).toMatchObject({
contentId: "book-1",
initialPositionSeconds: 0,
});
});
it("remounts when switching from Resume to Play-from-Start", async () => {
it("sends resume and Listen-from-Start with the correct start positions", async () => {
render(
<MemoryRouter>
<AudiobookContent item={bookWithProgress(5000)} />
@@ -135,11 +150,43 @@ describe("AudiobookContent Play-from-Start remount", () => {
);
await userEvent.click(screen.getByRole("button", { name: /^resume/i }));
expect(mocks.playerMounts).toHaveLength(1);
expect(mocks.playerMounts[0]?.initialPositionSeconds).toBe(5000);
expect(mocks.startPlayback).toHaveBeenCalledTimes(1);
expect(mocks.startPlayback.mock.calls[0]?.[0]).toMatchObject({
initialPositionSeconds: 5000,
});
await userEvent.click(screen.getByRole("button", { name: /play from start/i }));
expect(mocks.playerMounts).toHaveLength(2);
expect(mocks.playerMounts[1]?.initialPositionSeconds).toBe(0);
await userEvent.click(screen.getByRole("button", { name: /listen from start/i }));
expect(mocks.startPlayback).toHaveBeenCalledTimes(2);
expect(mocks.startPlayback.mock.calls[1]?.[0]).toMatchObject({
initialPositionSeconds: 0,
});
});
it("toggles the active shared player instead of starting a duplicate", async () => {
mocks.controller = {
active: {
contentId: "book-1",
playing: true,
currentTime: 5000,
duration: 36_000,
hasFile: true,
},
activeRequest: { contentId: "book-1" },
isBackgroundBarVisible: true,
startPlayback: mocks.startPlayback,
stopPlayback: vi.fn(),
toggleActivePlayback: mocks.toggleActivePlayback,
};
render(
<MemoryRouter>
<AudiobookContent item={bookWithProgress(5000)} />
</MemoryRouter>,
);
await userEvent.click(screen.getByRole("button", { name: /^pause/i }));
expect(mocks.toggleActivePlayback).toHaveBeenCalledTimes(1);
expect(mocks.startPlayback).not.toHaveBeenCalled();
});
});
+57 -33
View File
@@ -1,9 +1,9 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import type { ItemDetail, LeafItemUserData } from "@/api/types";
import { Button } from "@/components/ui/button";
import { FolderPlus, Play } from "lucide-react";
import { FolderPlus, Pause, Play } from "lucide-react";
import AddToCollectionDialog from "@/components/AddToCollectionDialog";
import AudiobookPlayer from "@/pages/audiobooks/player/AudiobookPlayer";
import { ChaptersSection } from "@/pages/audiobooks/components/ChaptersSection";
import { NarratorCard } from "@/pages/audiobooks/components/NarratorCard";
import { NarratorPicker } from "@/pages/audiobooks/components/NarratorPicker";
@@ -11,6 +11,7 @@ import { RelatedRail } from "@/pages/audiobooks/components/RelatedRail";
import DetailHero from "@/pages/ItemDetail/DetailHero";
import MetadataBadges from "@/pages/ItemDetail/components/MetadataBadges";
import type { AudiobookChapter, AudiobookFile } from "@/lib/audiobooks/types";
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
function formatSeconds(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return "";
@@ -109,10 +110,10 @@ export default function AudiobookContent({
item: ItemDetail & { type: "audiobook" };
libraryId?: number;
}) {
const [playerOpen, setPlayerOpen] = useState(false);
const [startSeconds, setStartSeconds] = useState(0);
const [searchParams] = useSearchParams();
const handledPlayParamRef = useRef(false);
const [addToCollectionOpen, setAddToCollectionOpen] = useState(false);
const [playToken, setPlayToken] = useState(0);
const audiobookPlayback = useAudiobookPlaybackController();
const files = useMemo<AudiobookFile[]>(
() =>
@@ -140,17 +141,55 @@ export default function AudiobookContent({
(progress.is_in_progress ?? !progress.played),
);
const chapters = useMemo(() => buildChapterList(files), [files]);
const isPlayerOpen = audiobookPlayback?.activeRequest?.contentId === item.content_id;
const activePlayback = isPlayerOpen ? audiobookPlayback?.active : null;
const currentPlayerSeconds =
activePlayback?.currentTime != null
? activePlayback.currentTime
: null;
function openPlayer(atSeconds: number) {
setStartSeconds(atSeconds);
setPlayToken((token) => token + 1);
setPlayerOpen(true);
audiobookPlayback?.startPlayback({
contentId: item.content_id,
title: item.title,
author,
narrator,
posterUrl: item.poster_url,
files,
initialPositionSeconds: atSeconds,
});
}
function handlePlayResume() {
if (activePlayback) {
audiobookPlayback?.toggleActivePlayback();
return;
}
openPlayer(hasProgress ? resumeSeconds : 0);
}
function primaryPlaybackLabel() {
if (activePlayback?.playing) {
return "Pause";
}
if (activePlayback) {
return "Resume";
}
if (hasProgress) {
const chapter = findChapterAt(chapters, resumeSeconds);
return chapter ? `Resume · ${chapter.label}` : "Resume";
}
return "Listen";
}
useEffect(() => {
if (handledPlayParamRef.current || searchParams.get("play") !== "1" || files.length === 0) {
return;
}
handledPlayParamRef.current = true;
openPlayer(searchParams.get("restart") === "1" ? 0 : hasProgress ? resumeSeconds : 0);
}, [files.length, hasProgress, resumeSeconds, searchParams]);
return (
<div>
<AddToCollectionDialog
@@ -160,20 +199,6 @@ export default function AudiobookContent({
itemTitle={item.title}
/>
{playerOpen && (
<AudiobookPlayer
key={`${item.content_id}-${startSeconds}-${playToken}`}
contentId={item.content_id}
title={item.title}
author={author}
narrator={narrator}
posterUrl={item.poster_url}
files={files}
initialPositionSeconds={startSeconds}
onClose={() => setPlayerOpen(false)}
/>
)}
<DetailHero
title={item.title}
context="Audiobook"
@@ -234,13 +259,12 @@ export default function AudiobookContent({
)}
<div className="flex flex-wrap items-center gap-3">
<Button onClick={handlePlayResume} size="lg" className="gap-2">
<Play className="h-4 w-4 fill-current" />
{hasProgress
? (() => {
const chapter = findChapterAt(chapters, resumeSeconds);
return chapter ? `Resume · ${chapter.label}` : "Resume";
})()
: "Play"}
{activePlayback?.playing ? (
<Pause className="h-4 w-4 fill-current" />
) : (
<Play className="h-4 w-4 fill-current" />
)}
{primaryPlaybackLabel()}
</Button>
<Button
variant="outline"
@@ -254,7 +278,7 @@ export default function AudiobookContent({
</Button>
{hasProgress && (
<Button variant="outline" size="lg" onClick={() => openPlayer(0)}>
Play from Start
Listen from Start
</Button>
)}
</div>
@@ -265,7 +289,7 @@ export default function AudiobookContent({
<div
className="page-shell space-y-12 py-10 sm:space-y-14"
style={playerOpen ? { paddingBottom: "8rem" } : undefined}
style={isPlayerOpen ? { paddingBottom: "8rem" } : undefined}
>
{narrator && <NarratorCard narrator={narrator} />}
@@ -311,7 +335,7 @@ export default function AudiobookContent({
<ChaptersSection
files={files}
currentPositionSeconds={playerOpen ? startSeconds : resumeSeconds || null}
currentPositionSeconds={currentPlayerSeconds ?? (resumeSeconds || null)}
onSelect={(seconds) => openPlayer(seconds)}
/>
</div>
@@ -36,11 +36,12 @@ describe("ChaptersSection", () => {
expect(screen.getByRole("button", { name: /Memory/ })).toBeInTheDocument();
});
it("highlights the currently-playing chapter once expanded", async () => {
it("highlights the currently-listening chapter once expanded", async () => {
render(<ChaptersSection files={files} currentPositionSeconds={250} onSelect={vi.fn()} />);
await expandChapters();
const row = screen.getByRole("button", { name: /Memory/ });
expect(row).toHaveAttribute("data-current", "true");
expect(within(row).getByText("listening")).toBeInTheDocument();
});
it("sort menu switches between position and longest-first orders", async () => {
@@ -155,7 +155,7 @@ export function ChaptersSection({ files, currentPositionSeconds, onSelect }: Cha
</span>
{isCurrent && (
<span className="text-primary flex shrink-0 items-center gap-1 text-xs">
<Play className="h-3 w-3 fill-current" /> playing
<Play className="h-3 w-3 fill-current" /> listening
</span>
)}
</button>
@@ -1,9 +1,21 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { AudiobookFile } from "@/lib/audiobooks/types";
import { useAudiobookPlayback } from "./useAudiobookPlayback";
import { MiniBar } from "./MiniBar";
import { NowListening } from "./NowListening";
export interface AudiobookPlayerStatus {
contentId: string;
playing: boolean;
currentTime: number;
duration: number;
hasFile: boolean;
}
export interface AudiobookPlayerControls {
togglePlay: () => void;
}
export interface AudiobookPlayerProps {
contentId: string;
title: string;
@@ -14,6 +26,8 @@ export interface AudiobookPlayerProps {
initialPositionSeconds?: number;
autoPlay?: boolean;
onClose?: () => void;
onPlaybackStateChange?: (status: AudiobookPlayerStatus) => void;
onControlsChange?: (controls: AudiobookPlayerControls | null) => void;
}
export default function AudiobookPlayer({
@@ -26,6 +40,8 @@ export default function AudiobookPlayer({
initialPositionSeconds = 0,
autoPlay = true,
onClose,
onPlaybackStateChange,
onControlsChange,
}: AudiobookPlayerProps) {
const playback = useAudiobookPlayback({
contentId,
@@ -35,6 +51,28 @@ export default function AudiobookPlayer({
});
const [mode, setMode] = useState<"mini" | "now-listening">("mini");
useEffect(() => {
onControlsChange?.({ togglePlay: playback.togglePlay });
return () => onControlsChange?.(null);
}, [onControlsChange, playback.togglePlay]);
useEffect(() => {
onPlaybackStateChange?.({
contentId,
playing: playback.playing,
currentTime: playback.currentTime,
duration: playback.duration,
hasFile: playback.hasFile,
});
}, [
contentId,
onPlaybackStateChange,
playback.currentTime,
playback.duration,
playback.hasFile,
playback.playing,
]);
return (
<>
{playback.hasFile && (
@@ -0,0 +1,102 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import type { AudiobookFile } from "@/lib/audiobooks/types";
import AudiobookPlayer, {
type AudiobookPlayerControls,
type AudiobookPlayerStatus,
} from "./AudiobookPlayer";
export interface AudiobookPlaybackStartInput {
contentId: string;
title: string;
author?: string;
narrator?: string;
posterUrl?: string;
files: AudiobookFile[];
initialPositionSeconds?: number;
autoPlay?: boolean;
}
interface ActiveAudiobookPlayback extends AudiobookPlaybackStartInput {
requestKey: number;
}
export interface AudiobookPlaybackControllerValue {
active: AudiobookPlayerStatus | null;
activeRequest: ActiveAudiobookPlayback | null;
isBackgroundBarVisible: boolean;
startPlayback: (input: AudiobookPlaybackStartInput) => void;
stopPlayback: () => void;
toggleActivePlayback: () => void;
}
const AudiobookPlaybackControllerContext =
createContext<AudiobookPlaybackControllerValue | null>(null);
export function useAudiobookPlaybackController() {
return useContext(AudiobookPlaybackControllerContext);
}
export function AudiobookPlaybackProvider({ children }: { children: ReactNode }) {
const [activeRequest, setActiveRequest] = useState<ActiveAudiobookPlayback | null>(null);
const [active, setActive] = useState<AudiobookPlayerStatus | null>(null);
const [controls, setControls] = useState<AudiobookPlayerControls | null>(null);
const startPlayback = useCallback((input: AudiobookPlaybackStartInput) => {
setControls(null);
setActive({
contentId: input.contentId,
playing: false,
currentTime: input.initialPositionSeconds ?? 0,
duration: 0,
hasFile: input.files.length > 0,
});
setActiveRequest((previous) => ({
...input,
requestKey: (previous?.requestKey ?? 0) + 1,
}));
}, []);
const stopPlayback = useCallback(() => {
setControls(null);
setActive(null);
setActiveRequest(null);
}, []);
const toggleActivePlayback = useCallback(() => {
controls?.togglePlay();
}, [controls]);
const value = useMemo<AudiobookPlaybackControllerValue>(
() => ({
active,
activeRequest,
isBackgroundBarVisible: Boolean(activeRequest),
startPlayback,
stopPlayback,
toggleActivePlayback,
}),
[active, activeRequest, startPlayback, stopPlayback, toggleActivePlayback],
);
return (
<AudiobookPlaybackControllerContext.Provider value={value}>
{children}
{activeRequest && (
<AudiobookPlayer
key={`${activeRequest.contentId}-${activeRequest.requestKey}`}
contentId={activeRequest.contentId}
title={activeRequest.title}
author={activeRequest.author}
narrator={activeRequest.narrator}
posterUrl={activeRequest.posterUrl}
files={activeRequest.files}
initialPositionSeconds={activeRequest.initialPositionSeconds}
autoPlay={activeRequest.autoPlay}
onClose={stopPlayback}
onPlaybackStateChange={setActive}
onControlsChange={setControls}
/>
)}
</AudiobookPlaybackControllerContext.Provider>
);
}