Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9fce1ebb2 | ||
|
|
bc93a2b28e | ||
|
|
fcac362197 | ||
|
|
62e44e1788 | ||
|
|
c79b06fda8 | ||
|
|
04f176b9bd |
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, `
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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&play=1"');
|
||||
expect(markup).not.toContain('href="/watch/book-001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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&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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user