From 339ed200745cba0a49bbb0fb49894b3b23cc104d Mon Sep 17 00:00:00 2001 From: RXWatcher Date: Tue, 9 Jun 2026 00:17:08 +0200 Subject: [PATCH 01/27] fix(audiobooks): improve poster enrichment throughput (#94) * fix(audiobooks): improve poster enrichment throughput * fix(audiobooks): enable ABS compat by default * chore(audiobooks): remove stale feature flag setting * fix(audiobooks): validate scoped scan roots --------- Co-authored-by: rxwatcher --- internal/audiobooks/enrichment.go | 28 +++++--- internal/audiobooks/enrichment_test.go | 44 ++++++++++++ internal/audiobooks/service.go | 2 +- internal/audiobooks/service_test.go | 54 ++++++++++++++ internal/catalog/detail.go | 10 ++- internal/catalog/detail_audiobook_test.go | 36 ++++++++++ internal/config/db_loader.go | 6 +- internal/config/db_loader_test.go | 10 ++- internal/scanner/audiobook_test.go | 71 +++++++++++++++++++ internal/scanner/scanner.go | 48 ++++++++++++- ...1414_add_audiobookshelf_compat_enabled.sql | 8 +-- ...0_enable_audiobookshelf_compat_default.sql | 25 +++++++ 12 files changed, 318 insertions(+), 24 deletions(-) create mode 100644 migrations/sql/20260608183000_enable_audiobookshelf_compat_default.sql diff --git a/internal/audiobooks/enrichment.go b/internal/audiobooks/enrichment.go index b9783e2a..a7ca1d9d 100644 --- a/internal/audiobooks/enrichment.go +++ b/internal/audiobooks/enrichment.go @@ -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), } } diff --git a/internal/audiobooks/enrichment_test.go b/internal/audiobooks/enrichment_test.go index 6c440da9..903847bc 100644 --- a/internal/audiobooks/enrichment_test.go +++ b/internal/audiobooks/enrichment_test.go @@ -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} diff --git a/internal/audiobooks/service.go b/internal/audiobooks/service.go index cfd15ffb..7553365b 100644 --- a/internal/audiobooks/service.go +++ b/internal/audiobooks/service.go @@ -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 diff --git a/internal/audiobooks/service_test.go b/internal/audiobooks/service_test.go index 4e2e1321..1ad9d07d 100644 --- a/internal/audiobooks/service_test.go +++ b/internal/audiobooks/service_test.go @@ -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 +} diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index 54a5862e..4c56ba73 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -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 { diff --git a/internal/catalog/detail_audiobook_test.go b/internal/catalog/detail_audiobook_test.go index 8228c5f8..35763e1a 100644 --- a/internal/catalog/detail_audiobook_test.go +++ b/internal/catalog/detail_audiobook_test.go @@ -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 +} diff --git a/internal/config/db_loader.go b/internal/config/db_loader.go index c521f118..9b16b074 100644 --- a/internal/config/db_loader.go +++ b/internal/config/db_loader.go @@ -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 } diff --git a/internal/config/db_loader_test.go b/internal/config/db_loader_test.go index 460bc43c..fbd95802 100644 --- a/internal/config/db_loader_test.go +++ b/internal/config/db_loader_test.go @@ -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) } diff --git a/internal/scanner/audiobook_test.go b/internal/scanner/audiobook_test.go index 36459b9c..a438e6c1 100644 --- a/internal/scanner/audiobook_test.go +++ b/internal/scanner/audiobook_test.go @@ -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{} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 927708d3..89040001 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -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) } diff --git a/migrations/sql/20260607161414_add_audiobookshelf_compat_enabled.sql b/migrations/sql/20260607161414_add_audiobookshelf_compat_enabled.sql index 1790584f..d99320ea 100644 --- a/migrations/sql/20260607161414_add_audiobookshelf_compat_enabled.sql +++ b/migrations/sql/20260607161414_add_audiobookshelf_compat_enabled.sql @@ -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 diff --git a/migrations/sql/20260608183000_enable_audiobookshelf_compat_default.sql b/migrations/sql/20260608183000_enable_audiobookshelf_compat_default.sql new file mode 100644 index 00000000..3c877392 --- /dev/null +++ b/migrations/sql/20260608183000_enable_audiobookshelf_compat_default.sql @@ -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 From cf4e080bf49885ad13e8003b927009cef15de742 Mon Sep 17 00:00:00 2001 From: d3v1l1989 <64855033+d3v1l1989@users.noreply.github.com> Date: Tue, 9 Jun 2026 03:05:33 +0200 Subject: [PATCH 02/27] fix(jellycompat): Wholphin (jellyfin-sdk-kotlin) playback & genre compatibility (#100) * fix(jellycompat): match MediaSourceId across UUID formats (compact vs dashed) * fix(jellycompat): honor ImageTypes=Backdrop as a filter on /Items Wholphin genre cards request /Items?imageTypes=Backdrop&limit=1&sortBy=Random and assume every returned item has a backdrop. Silo ignored ImageTypes, so a random pick could lack a backdrop (BackdropImageTags: null), crashing Wholphin. Push the filter down to the catalog browse SQL (NULLIF(BTRIM(backdrop_path),'') IS NOT NULL) so random/limited selections only ever consider backdrop-having items; empty genres correctly return []. * fix(jellycompat): case-insensitive PlaySessionId + api_key in stream auth Wholphin's jellyfin-sdk-kotlin builds its own direct-play URL (/Videos/{id}/stream?static=true&playSessionId=...&mediaSourceId=...) with a lowercase 'playSessionId', no api_key, and no auth header (ExoPlayer's data source drops it). PlaybackSessionAuth read 'PlaySessionId'/'PlaySessionID' case-sensitively, so the fallback never matched -> 401 on every direct-play stream -> forced (often failing) transcode fallback. Resolve PlaySessionId via newCaseInsensitiveQuery, and likewise accept case-variant api_key in ExtractToken. * fix(jellycompat): support Wholphin season item queries --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> --- internal/catalog/browse.go | 8 ++ internal/catalog/browse_backdrop_test.go | 31 ++++++++ internal/jellycompat/auth.go | 10 ++- internal/jellycompat/auth_test.go | 54 ++++++++++++++ internal/jellycompat/content_direct.go | 1 + internal/jellycompat/dto.go | 1 + internal/jellycompat/handlers_items.go | 51 ++++++++++++- internal/jellycompat/handlers_items_test.go | 74 ++++++++++++++++++- internal/jellycompat/handlers_playback.go | 2 +- internal/jellycompat/idcodec.go | 15 ++++ .../jellycompat/idcodec_mediasource_test.go | 63 ++++++++++++++++ internal/jellycompat/mapping.go | 6 +- internal/jellycompat/mapping_sort_test.go | 36 +++++++++ internal/jellycompat/playback_sessions.go | 2 +- internal/jellycompat/query.go | 18 +++++ internal/jellycompat/query_imagetypes_test.go | 37 ++++++++++ internal/jellycompat/streams.go | 10 +-- 17 files changed, 404 insertions(+), 15 deletions(-) create mode 100644 internal/catalog/browse_backdrop_test.go create mode 100644 internal/jellycompat/idcodec_mediasource_test.go create mode 100644 internal/jellycompat/query_imagetypes_test.go diff --git a/internal/catalog/browse.go b/internal/catalog/browse.go index 2f0bc859..f87bd908 100644 --- a/internal/catalog/browse.go +++ b/internal/catalog/browse.go @@ -36,6 +36,7 @@ type BrowseFilters struct { MaxLimit int // optional caller-specific cap; zero keeps the default browse cap Offset int SnapshotAt *time.Time // pagination fence: exclude items created after this timestamp + RequireBackdrop bool // only return items with a non-empty backdrop_path (Jellyfin ImageTypes=Backdrop filter) } // BrowseResult contains the paginated result of a browse query. @@ -295,6 +296,13 @@ func (r *BrowseRepository) buildBrowsePlan(filters BrowseFilters) (browseQueryPl argIdx++ } + // Backdrop presence filter (Jellyfin ImageTypes=Backdrop). Pushed down so + // random/limited selections only ever pick items that actually have a + // backdrop — filtering after the LIMIT would wrongly return empty pages. + if filters.RequireBackdrop { + conditions = append(conditions, "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL") + } + // Library access control: restrict to user's accessible libraries. needsLibJoin := filters.LibraryID > 0 || filters.LibraryIDs != nil || len(filters.DisabledLibraryIDs) > 0 || filters.Sort == "recently_added" needsPersonJoin := filters.PersonID > 0 diff --git a/internal/catalog/browse_backdrop_test.go b/internal/catalog/browse_backdrop_test.go new file mode 100644 index 00000000..b5bac9d6 --- /dev/null +++ b/internal/catalog/browse_backdrop_test.go @@ -0,0 +1,31 @@ +package catalog + +import ( + "strings" + "testing" +) + +// TestBuildBrowsePlan_RequireBackdrop asserts the ImageTypes=Backdrop filter +// (BrowseFilters.RequireBackdrop) renders the backdrop-presence predicate into +// the WHERE clause, and is absent otherwise. Guards against a future refactor +// of buildBrowsePlan silently dropping the condition. +func TestBuildBrowsePlan_RequireBackdrop(t *testing.T) { + const predicate = "NULLIF(BTRIM(mi.backdrop_path), '') IS NOT NULL" + repo := &BrowseRepository{} + + plan, earlyEmpty, err := repo.buildBrowsePlan(BrowseFilters{Type: "movie", RequireBackdrop: true}) + if err != nil || earlyEmpty { + t.Fatalf("buildBrowsePlan(RequireBackdrop) err=%v earlyEmpty=%v", err, earlyEmpty) + } + if !strings.Contains(plan.whereClause, predicate) { + t.Fatalf("RequireBackdrop=true: whereClause missing predicate.\ngot: %s", plan.whereClause) + } + + plan, _, err = repo.buildBrowsePlan(BrowseFilters{Type: "movie"}) + if err != nil { + t.Fatalf("buildBrowsePlan err=%v", err) + } + if strings.Contains(plan.whereClause, predicate) { + t.Fatalf("RequireBackdrop unset: predicate should be absent.\ngot: %s", plan.whereClause) + } +} diff --git a/internal/jellycompat/auth.go b/internal/jellycompat/auth.go index 10b99237..1b7531b5 100644 --- a/internal/jellycompat/auth.go +++ b/internal/jellycompat/auth.go @@ -62,7 +62,8 @@ func ExtractToken(r *http.Request) (string, bool) { if token := strings.TrimSpace(r.Header.Get("X-Mediabrowser-Token")); token != "" { return token, true } - if token := strings.TrimSpace(r.URL.Query().Get("api_key")); token != "" { + // Case-insensitive: Jellyfin clients vary the casing (api_key / Api_Key / API_KEY). + if token := strings.TrimSpace(newCaseInsensitiveQuery(r.URL.Query()).Get("api_key")); token != "" { return token, true } @@ -185,7 +186,12 @@ func PlaybackSessionAuth(sessions *SessionStore, playbackStore *PlaybackSessionS // no auth header or api_key. Resolve the negotiated session's // CompatToken — which for an API-key stream is itself the sa_ key, // so it must go through the same session-or-API-key resolution. - if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" { + // + // The lookup must be case-insensitive: Wholphin's jellyfin-sdk-kotlin + // builds its own direct-play URL with a lowercase "playSessionId" + // (and no api_key / auth header), so a case-sensitive match would + // miss it and 401 the stream — forcing a needless transcode fallback. + if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" { if playSession, found := playbackStore.Get(playSessionID); found { if session, ok := resolveCompatToken(r.Context(), sessions, keyAuth, playSession.CompatToken); ok { serveWithSession(next, w, r, session) diff --git a/internal/jellycompat/auth_test.go b/internal/jellycompat/auth_test.go index bef63ea0..d2e964ea 100644 --- a/internal/jellycompat/auth_test.go +++ b/internal/jellycompat/auth_test.go @@ -119,6 +119,60 @@ func TestRequireSession_NoAuthService_PassesThroughExpiredStreamAppToken(t *test } } +func TestPlaybackSessionAuth_CaseInsensitivePlaySessionId(t *testing.T) { + now := fixedNow() + clock := func() time.Time { return now } + sessions := NewSessionStore(30*24*time.Hour, clock) + _ = sessions.Put(Session{Token: "compat-tok", StreamAppUserID: 1}) + playbackStore := NewPlaybackSessionStore(time.Hour, clock) + playbackStore.Put(PlaybackSession{ID: "ps-abc", CompatToken: "compat-tok"}) + + mw := PlaybackSessionAuth(sessions, playbackStore, nil) + + cases := []struct { + name string + rawQuery string + wantCode int + }{ + // Wholphin's jellyfin-sdk-kotlin direct-play URL: lowercase playSessionId, + // no api_key and no auth header. Previously 401'd (case-sensitive lookup). + {"lowercase playSessionId (Wholphin)", "static=true&mediaSourceId=x&playSessionId=ps-abc", http.StatusOK}, + {"canonical PlaySessionId", "PlaySessionId=ps-abc", http.StatusOK}, + {"legacy PlaySessionID", "PlaySessionID=ps-abc", http.StatusOK}, + {"unknown play session", "playSessionId=does-not-exist", http.StatusUnauthorized}, + {"no auth at all", "static=true", http.StatusUnauthorized}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/Videos/itm/stream?"+tc.rawQuery, nil) + rec := httptest.NewRecorder() + gotSession := false + mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if SessionFromContext(r.Context()) != nil { + gotSession = true + } + w.WriteHeader(http.StatusOK) + })).ServeHTTP(rec, req) + + if rec.Code != tc.wantCode { + t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantCode, rec.Body.String()) + } + if tc.wantCode == http.StatusOK && !gotSession { + t.Fatal("expected authenticated session in context") + } + }) + } +} + +func TestExtractToken_CaseInsensitiveAPIKey(t *testing.T) { + for _, key := range []string{"api_key", "Api_Key", "API_KEY"} { + req := httptest.NewRequest("GET", "/Videos/itm/stream?"+key+"=tok123", nil) + if got, ok := ExtractToken(req); !ok || got != "tok123" { + t.Fatalf("%s: ExtractToken = (%q, %v), want (tok123, true)", key, got, ok) + } + } +} + func TestRequireAdminAPIKey_AcceptsAdminKey(t *testing.T) { authn := newAdminAPIKeyAuthForTest( &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}, diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index 1b30d730..7d369052 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -226,6 +226,7 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session Limit: fetchLimit, MaxLimit: compatBrowseMaxLimit, Offset: requestedOffset, + RequireBackdrop: parseBool(params.Get("require_backdrop"), false), } var collected []upstreamListItem diff --git a/internal/jellycompat/dto.go b/internal/jellycompat/dto.go index c81307be..eeb21054 100644 --- a/internal/jellycompat/dto.go +++ b/internal/jellycompat/dto.go @@ -73,6 +73,7 @@ type baseItemDTO struct { SpecialFeatureCount int `json:"SpecialFeatureCount,omitempty"` MovieCount int `json:"MovieCount,omitempty"` SeriesCount int `json:"SeriesCount,omitempty"` + SeasonCount int `json:"SeasonCount,omitempty"` EpisodeCount int `json:"EpisodeCount,omitempty"` LockedFields []string `json:"LockedFields,omitempty"` LockData bool `json:"LockData,omitempty"` diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index cd729145..e55a5757 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -140,6 +140,8 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) { h.handleSearchItems(w, r, session, query) case query.isFavorite: h.handleFavoriteItems(w, r, session, query) + case isSeasonChildItemsQuery(query): + h.handleSeasonChildItems(w, r, session, query) case query.parentLibraryID == 0 && len(query.itemTypes) == 0: // No ParentId and no type filter: return top-level library views. // Jellyfin clients (e.g. Findroid "My Media") call GET /Items?userId=... @@ -150,6 +152,18 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) { } } +func isSeasonChildItemsQuery(query itemsQuery) bool { + if query.parentItemID == "" { + return false + } + for _, itemType := range query.itemTypes { + if itemType == "season" { + return true + } + } + return false +} + // HandleItem serves GET /Items/{id}. func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) @@ -209,6 +223,7 @@ func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) { browsableSeasons := filterBrowsableSeasons(seasons) dto.ChildCount = len(browsableSeasons) dto.RecursiveItemCount = len(browsableSeasons) + dto.SeasonCount = len(browsableSeasons) } } if strings.EqualFold(detail.Type, "episode") && detail.SeriesID != "" { @@ -919,9 +934,35 @@ func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) { writeCompatUpstreamError(w, err) return } + h.writeSeasonItemsResponse(w, r, session, seriesID, seasons, query, false) +} + +func (h *ItemsHandler) handleSeasonChildItems(w http.ResponseWriter, r *http.Request, session *Session, query itemsQuery) { + seasons, err := h.content.ListSeasons(r.Context(), session, query.parentItemID, nil) + if err != nil { + writeCompatUpstreamError(w, err) + return + } + h.writeSeasonItemsResponse(w, r, session, query.parentItemID, seasons, query, true) +} + +func (h *ItemsHandler) writeSeasonItemsResponse(w http.ResponseWriter, r *http.Request, session *Session, seriesID string, seasons []upstreamSeason, query itemsQuery, page bool) { seasons = filterBrowsableSeasons(seasons) h.rememberSeasonImages(seasons, seriesID) + total := len(seasons) + if page { + start := query.startIndex + if start > total { + start = total + } + end := total + if query.limit > 0 && start+query.limit < end { + end = start + query.limit + } + seasons = seasons[start:end] + } + favorites, err := resolveFavoritesForContentIDs(r.Context(), session, h.userData, seasonContentIDs(seasons)) if err != nil { writeCompatUpstreamError(w, err) @@ -944,10 +985,16 @@ func (h *ItemsHandler) HandleSeasons(w http.ResponseWriter, r *http.Request) { } items = append(items, h.mapper.seasonFromUpstream(season, seriesID, favorites[season.ContentID])) } + applyImageTypeLimit(items, query.imageTypeLimit) + + startIndex := 0 + if page { + startIndex = query.startIndex + } writeJSON(w, http.StatusOK, queryResultDTO{ Items: items, - TotalRecordCount: len(items), - StartIndex: 0, + TotalRecordCount: total, + StartIndex: startIndex, }) } diff --git a/internal/jellycompat/handlers_items_test.go b/internal/jellycompat/handlers_items_test.go index 91006012..7826ab85 100644 --- a/internal/jellycompat/handlers_items_test.go +++ b/internal/jellycompat/handlers_items_test.go @@ -2,6 +2,7 @@ package jellycompat import ( "context" + "encoding/json" "net/http/httptest" "net/url" "strings" @@ -19,7 +20,10 @@ import ( type countingContentService struct { episodeDetail *upstreamItemDetail seriesDetail *upstreamItemDetail + seasons []upstreamSeason getItemDetailCalls int + listSeasonsCalls int + listSeasonsSeries string } func (s *countingContentService) GetItemDetail(_ context.Context, _ *Session, contentID string, _ *int) (*upstreamItemDetail, error) { @@ -50,8 +54,15 @@ func (s *countingContentService) SearchItems(context.Context, *Session, string, panic("unused") } -func (s *countingContentService) ListSeasons(context.Context, *Session, string, *int) ([]upstreamSeason, error) { - panic("unused") +func (s *countingContentService) ListSeasons(_ context.Context, _ *Session, seriesID string, _ *int) ([]upstreamSeason, error) { + s.listSeasonsCalls++ + s.listSeasonsSeries = seriesID + if s.seasons == nil { + panic("unused") + } + out := make([]upstreamSeason, len(s.seasons)) + copy(out, s.seasons) + return out, nil } func (s *countingContentService) GetSeason(context.Context, *Session, string, int, *int) (*upstreamSeason, error) { @@ -70,6 +81,65 @@ func (s *countingContentService) ListItemFilters(context.Context, *Session, url. panic("unused") } +func TestHandleItems_SeriesParentSeasonFilterReturnsPagedSeasons(t *testing.T) { + codec := NewResourceIDCodec() + seriesContentID := "series-1" + encodedSeriesID := codec.EncodeStringID(EncodedIDItem, seriesContentID) + contentSvc := &countingContentService{ + seasons: []upstreamSeason{ + {ContentID: "season-1", SeasonNumber: 1, Title: "Season 1", EpisodeCount: 10}, + {ContentID: "season-2", SeasonNumber: 2, Title: "Season 2", EpisodeCount: 8}, + }, + } + + h := &ItemsHandler{ + content: contentSvc, + userData: &mockUserDataService{}, + codec: codec, + mapper: newMapper(codec, &config.Config{}), + images: NewImageCache(time.Hour, time.Now), + } + + req := httptest.NewRequest("GET", "/Users/test/Items?ParentId="+encodedSeriesID+ + "&IncludeItemTypes=Season&Recursive=false&SortBy=IndexNumber&SortOrder=Ascending"+ + "&Fields=PrimaryImageAspectRatio,CanDelete&StartIndex=1&Limit=1", nil) + req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, &Session{ + StreamAppUserID: 1, + ProfileID: "profile-1", + })) + + rec := httptest.NewRecorder() + h.HandleItems(rec, req) + + if rec.Code != 200 { + t.Fatalf("expected status 200; got %d, body=%s", rec.Code, rec.Body.String()) + } + if contentSvc.listSeasonsCalls != 1 || contentSvc.listSeasonsSeries != seriesContentID { + t.Fatalf("ListSeasons calls = %d for %q, want 1 for %q", + contentSvc.listSeasonsCalls, contentSvc.listSeasonsSeries, seriesContentID) + } + + var result queryResultDTO + if err := json.NewDecoder(rec.Body).Decode(&result); err != nil { + t.Fatalf("decode response: %v", err) + } + if result.TotalRecordCount != 2 || result.StartIndex != 1 { + t.Fatalf("TotalRecordCount/StartIndex = %d/%d, want 2/1", + result.TotalRecordCount, result.StartIndex) + } + if len(result.Items) != 1 { + t.Fatalf("len(Items) = %d, want 1", len(result.Items)) + } + item := result.Items[0] + if item.Type != "Season" || item.Name != "Season 2" { + t.Fatalf("item = {%q %q}, want Season/Season 2", item.Type, item.Name) + } + if item.ParentID != encodedSeriesID || item.SeriesID != encodedSeriesID { + t.Fatalf("ParentID/SeriesID = %q/%q, want %q/%q", + item.ParentID, item.SeriesID, encodedSeriesID, encodedSeriesID) + } +} + // TestHandleItem_Episode_FetchesSeriesDetailForStableParentImageTags verifies // that episode detail responses fetch parent series image metadata even when // image URLs are already cached. Cached URLs are not enough to build stable diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index 6c64bf85..aa90ebe2 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -439,7 +439,7 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ allow4KTranscode := h.allow4KVideoTranscode(r.Context()) for _, version := range detail.Versions { source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode) - if req.MediaSourceID != "" && source.ID != req.MediaSourceID { + if req.MediaSourceID != "" && !mediaSourceIDsEqual(source.ID, req.MediaSourceID) { continue } sources = append(sources, source) diff --git a/internal/jellycompat/idcodec.go b/internal/jellycompat/idcodec.go index ccf3d167..bcae3dce 100644 --- a/internal/jellycompat/idcodec.go +++ b/internal/jellycompat/idcodec.go @@ -141,6 +141,21 @@ func (c *ResourceIDCodec) LookupMediaSourceOwner(fileID int64) (string, bool) { return contentID, ok } +// mediaSourceIDsEqual reports whether two media-source IDs refer to the same +// source, tolerating UUID format differences. Silo exposes the canonical +// dashed compat UUID (e.g. "03000000-0000-0000-0000-00000019e8c2"), but some +// Jellyfin clients (e.g. Wholphin) echo it back in the compact 32-char hex +// form ("0300000000000000000000000019e8c2"). Both parse to the same UUID, so +// matching must compare the parsed values rather than the raw strings. +func mediaSourceIDsEqual(a, b string) bool { + if a == b { + return true + } + ua, errA := uuid.Parse(a) + ub, errB := uuid.Parse(b) + return errA == nil && errB == nil && ua == ub +} + // DecodeID unpacks a compat UUID into its original numeric value. func DecodeID(raw string) (DecodedID, error) { parsed, err := uuid.Parse(raw) diff --git a/internal/jellycompat/idcodec_mediasource_test.go b/internal/jellycompat/idcodec_mediasource_test.go new file mode 100644 index 00000000..c24802e1 --- /dev/null +++ b/internal/jellycompat/idcodec_mediasource_test.go @@ -0,0 +1,63 @@ +package jellycompat + +import "testing" + +func TestMediaSourceIDsEqual(t *testing.T) { + cases := []struct { + name string + a string + b string + want bool + }{ + { + name: "identical dashed", + a: "03000000-0000-0000-0000-00000019e8c2", + b: "03000000-0000-0000-0000-00000019e8c2", + want: true, + }, + { + name: "dashed vs compact (Wholphin)", + a: "03000000-0000-0000-0000-00000019e8c2", + b: "0300000000000000000000000019e8c2", + want: true, + }, + { + name: "compact vs dashed", + a: "0300000000000000000000000019e8c2", + b: "03000000-0000-0000-0000-00000019e8c2", + want: true, + }, + { + name: "case insensitive hex", + a: "03000000-0000-0000-0000-00000019E8C2", + b: "0300000000000000000000000019e8c2", + want: true, + }, + { + name: "different uuids", + a: "03000000-0000-0000-0000-00000019e8c2", + b: "0300000000000000000000000019e8c3", + want: false, + }, + { + name: "non-uuid falls back to exact match", + a: "abc", + b: "abc", + want: true, + }, + { + name: "non-uuid mismatch", + a: "abc", + b: "def", + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := mediaSourceIDsEqual(tc.a, tc.b); got != tc.want { + t.Fatalf("mediaSourceIDsEqual(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + }) + } +} diff --git a/internal/jellycompat/mapping.go b/internal/jellycompat/mapping.go index cc2591d9..50b27fcb 100644 --- a/internal/jellycompat/mapping.go +++ b/internal/jellycompat/mapping.go @@ -106,8 +106,10 @@ func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress * dto.RecursiveItemCount = *item.EpisodeCount } if item.SeasonCount != nil { - dto.ChildCount = *item.SeasonCount - dto.RecursiveItemCount = *item.SeasonCount + seasonCount := *item.SeasonCount + dto.ChildCount = seasonCount + dto.RecursiveItemCount = seasonCount + dto.SeasonCount = seasonCount } primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item) if tags := imageTagsWithSeed(m.imageTagSigner, diff --git a/internal/jellycompat/mapping_sort_test.go b/internal/jellycompat/mapping_sort_test.go index f04c7551..08c73758 100644 --- a/internal/jellycompat/mapping_sort_test.go +++ b/internal/jellycompat/mapping_sort_test.go @@ -35,3 +35,39 @@ func TestItemListSortNamePrefersSortTitle(t *testing.T) { t.Fatalf("SortName = %q, want %q", dto.SortName, "Matrix, The") } } + +func TestSeriesListIncludesSeasonCount(t *testing.T) { + m := newMapper(NewResourceIDCodec(), &config.Config{}) + seasonCount := 4 + dto := m.itemFromList(upstreamListItem{ + ContentID: "series-1", + Type: "series", + Title: "Snowpiercer", + SeasonCount: &seasonCount, + }, false, nil, nil) + + if dto.SeasonCount != 4 { + t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount) + } + if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 { + t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount) + } +} + +func TestSeriesDetailIncludesSeasonCount(t *testing.T) { + m := newMapper(NewResourceIDCodec(), &config.Config{}) + seasonCount := 4 + dto := m.itemFromDetail(upstreamItemDetail{ + ContentID: "series-1", + Type: "series", + Title: "Snowpiercer", + SeasonCount: &seasonCount, + }, false, nil) + + if dto.SeasonCount != 4 { + t.Fatalf("SeasonCount = %d, want 4", dto.SeasonCount) + } + if dto.ChildCount != 4 || dto.RecursiveItemCount != 4 { + t.Fatalf("ChildCount/RecursiveItemCount = %d/%d, want 4/4", dto.ChildCount, dto.RecursiveItemCount) + } +} diff --git a/internal/jellycompat/playback_sessions.go b/internal/jellycompat/playback_sessions.go index 4b075a9c..2c8c156e 100644 --- a/internal/jellycompat/playback_sessions.go +++ b/internal/jellycompat/playback_sessions.go @@ -140,7 +140,7 @@ func (s *PlaybackSessionStore) FindByRoute(compatToken, routeID string) (*Playba return &cp, nil, true } for _, source := range session.MediaSources { - if source.ID == routeID { + if mediaSourceIDsEqual(source.ID, routeID) { cp := session sourceCopy := source return &cp, &sourceCopy, true diff --git a/internal/jellycompat/query.go b/internal/jellycompat/query.go index 0e89c8be..48dedc0a 100644 --- a/internal/jellycompat/query.go +++ b/internal/jellycompat/query.go @@ -19,6 +19,7 @@ type itemsQuery struct { namePrefix string maxOfficialRating string parentLibraryID int + parentItemID string specificIDs []string itemTypes []string genreName string @@ -32,6 +33,7 @@ type itemsQuery struct { personID int64 isPlayed *bool // nil = not specified imageTypeLimit *int // nil = not specified + requireBackdrop bool // true when ImageTypes includes Backdrop (filter, not just a hint) mediaTypes []string mediaTypesSet map[string]bool mediaTypesExplicit bool @@ -55,6 +57,8 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery { if parentID := strings.TrimSpace(q.Get("ParentId")); parentID != "" { if libraryID, err := codec.DecodeIntID(EncodedIDLibrary, parentID); err == nil { result.parentLibraryID = int(libraryID) + } else if contentID, itemErr := decodeItemID(codec, parentID); itemErr == nil && contentID != "" { + result.parentItemID = contentID } } @@ -111,6 +115,17 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery { } } + // ImageTypes acts as a filter: clients (e.g. Wholphin genre cards) request + // ImageTypes=Backdrop and assume every returned item has a backdrop. Only + // Backdrop is enforced — the catalog browse path can filter on backdrop_path. + for _, raw := range q.Values("ImageTypes") { + for part := range strings.SplitSeq(raw, ",") { + if strings.EqualFold(strings.TrimSpace(part), "Backdrop") { + result.requireBackdrop = true + } + } + } + mediaTypesRaw := q.Values("MediaTypes") result.mediaTypes = parseMediaTypes(mediaTypesRaw) result.mediaTypesExplicit = len(mediaTypesRaw) > 0 && strings.TrimSpace(strings.Join(mediaTypesRaw, "")) != "" @@ -211,6 +226,9 @@ func buildBrowseParams(query itemsQuery) url.Values { params.Set("is_played", "false") } } + if query.requireBackdrop { + params.Set("require_backdrop", "true") + } return params } diff --git a/internal/jellycompat/query_imagetypes_test.go b/internal/jellycompat/query_imagetypes_test.go new file mode 100644 index 00000000..18255227 --- /dev/null +++ b/internal/jellycompat/query_imagetypes_test.go @@ -0,0 +1,37 @@ +package jellycompat + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestParseItemsQuery_ImageTypesBackdropFilter(t *testing.T) { + codec := NewResourceIDCodec() + cases := []struct { + name string + rawQuery string + wantRequire bool + }{ + {"backdrop requested", "ImageTypes=Backdrop", true}, + {"lowercase param and value", "imagetypes=backdrop", true}, + {"backdrop among several", "ImageTypes=Primary,Backdrop,Logo", true}, + {"bracket array variant", "ImageTypes[]=Backdrop", true}, + {"primary only does not filter", "ImageTypes=Primary", false}, + {"absent does not filter", "Limit=1", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Items?"+tc.rawQuery, nil) + query := parseItemsQuery(req, codec) + if query.requireBackdrop != tc.wantRequire { + t.Fatalf("requireBackdrop = %v, want %v", query.requireBackdrop, tc.wantRequire) + } + params := buildBrowseParams(query) + gotParam := params.Get("require_backdrop") == "true" + if gotParam != tc.wantRequire { + t.Fatalf("require_backdrop param = %q, want emitted=%v", params.Get("require_backdrop"), tc.wantRequire) + } + }) + } +} diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index 8b8ecfd0..558f6dc7 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -123,7 +123,7 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re return } - playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")) + playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId") if playSessionID == "" { writeError(w, http.StatusBadRequest, "BadRequest", "PlaySessionId is required") return @@ -906,7 +906,7 @@ func audioSelectionChanged(session *PlaybackSession, mediaSourceID string, incom return true } for _, source := range session.MediaSources { - if mediaSourceID != "" && source.ID != mediaSourceID { + if mediaSourceID != "" && !mediaSourceIDsEqual(source.ID, mediaSourceID) { continue } if source.SelectedAudioStreamIndex == nil { @@ -925,7 +925,7 @@ func (h *PlaybackHandler) setSelectedAudioStream(playSessionID, mediaSourceID st if mediaSourceID != "" { sourceIndex = -1 for index := range current.MediaSources { - if current.MediaSources[index].ID == mediaSourceID { + if mediaSourceIDsEqual(current.MediaSources[index].ID, mediaSourceID) { sourceIndex = index break } @@ -1061,7 +1061,7 @@ func (h *PlaybackHandler) createStaticPlaySession(ctx context.Context, session * } func (h *PlaybackHandler) resolvePlaybackRoute(r *http.Request, compatSession *Session, routeID, mediaSourceID string) (*PlaybackSession, *PlaybackMediaSource, error) { - if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" { + if playSessionID := newCaseInsensitiveQuery(r.URL.Query()).Get("PlaySessionId"); playSessionID != "" { playSession, ok := h.playbackStore.Get(playSessionID) if !ok || playSession.CompatToken != compatSession.Token { return nil, nil, ErrSessionNotFound @@ -1099,7 +1099,7 @@ func findMediaSource(session *PlaybackSession, mediaSourceID string) *PlaybackMe return nil } for _, source := range session.MediaSources { - if source.ID == mediaSourceID { + if mediaSourceIDsEqual(source.ID, mediaSourceID) { copy := source return © } From 5855cebe75542542426cdcafdc06696a5033f6e9 Mon Sep 17 00:00:00 2001 From: RXWatcher Date: Tue, 9 Jun 2026 03:55:35 +0200 Subject: [PATCH 03/27] fix(audiobooks): complete playback and catalog parity (#96) * fix(audiobooks): complete playback and catalog parity * fix(web): allow podcast continue targets * fix(audiobooks): use folder sidecar covers during scan * fix(audiobooks): read nullable poster paths during cover scan * feat(home): split continue listening sections * fix(catalog): coalesce nullable media artwork fields * feat(playback): surface audiobook sessions in admin activity * fix(audiobooks): address playback review feedback --------- Co-authored-by: rxwatcher Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> --- internal/api/handlers/items.go | 83 ++++++ internal/api/handlers/libraries.go | 5 + internal/api/handlers/playback.go | 19 +- internal/api/handlers/playback_test.go | 62 ++++ internal/api/handlers/sections.go | 27 +- internal/api/handlers/sections_test.go | 60 ++++ internal/catalog/item_repo.go | 37 ++- internal/playback/session.go | 34 ++- internal/recommendations/accuracy_test.go | 31 ++ internal/recommendations/personal.go | 2 +- internal/recommendations/repo.go | 18 +- internal/scanner/audiobook_cover.go | 110 +++++++ internal/scanner/audiobook_cover_test.go | 111 +++++++ internal/scanner/audiobook_scan.go | 8 + internal/sections/continue_type.go | 105 +++++++ internal/sections/defaults.go | 35 ++- internal/sections/defaults_test.go | 57 ++++ internal/sections/fetcher.go | 212 ++++++++----- .../fetcher_continue_watching_test.go | 64 ++++ internal/sections/recipes/library_staples.go | 67 ++++- .../sections/recipes/library_staples_test.go | 24 ++ internal/sections/repo.go | 114 +++++++ internal/sections/types.go | 2 +- ...0_enable_audiobookshelf_compat_default.sql | 15 +- ...obook_continue_listening_section_title.sql | 27 ++ ...08224625_home_continue_listening_split.sql | 102 +++++++ web/src/App.tsx | 25 +- web/src/api/types.ts | 3 + web/src/components/AdminLayout.tsx | 5 +- .../components/ContinueWatchingCard.test.tsx | 35 +++ web/src/components/ContinueWatchingCard.tsx | 32 +- web/src/components/HeroBanner.test.tsx | 114 ++++++- web/src/components/HeroBanner.tsx | 71 +++-- web/src/components/ItemCard.test.tsx | 22 ++ web/src/components/ItemCard.tsx | 7 + web/src/components/Layout.tsx | 5 +- web/src/components/MediaItemMenu.test.tsx | 19 ++ web/src/components/MediaItemMenu.tsx | 24 +- .../RecipeGallery/RecipeParamFields.tsx | 21 ++ web/src/components/SectionItemCard.tsx | 1 + web/src/components/WatchTonightCard.test.tsx | 41 +++ web/src/components/WatchTonightCard.tsx | 12 +- web/src/components/WatchTonightDialog.tsx | 2 +- .../catalog/CatalogFiltersPanel.tsx | 7 +- .../catalog/catalogFilterBadges.test.ts | 53 ++++ .../components/catalog/catalogFilterBadges.ts | 14 +- .../CollectionGuidedRulesEditor.tsx | 10 +- .../sections/EditableSectionRows.tsx | 14 + .../sections/SectionEditorDrawer.test.ts | 47 +++ .../components/watchtonight/CardStack.test.ts | 39 +++ web/src/components/watchtonight/CardStack.tsx | 26 +- web/src/components/watchtonight/SwipeCard.tsx | 11 +- web/src/hooks/queries/homeDismissals.test.ts | 24 ++ web/src/hooks/queries/homeDismissals.ts | 12 +- web/src/lib/mediaNavigation.test.ts | 11 + web/src/lib/mediaNavigation.ts | 38 +++ .../ItemDetail/AudiobookContent.test.tsx | 105 +++++-- web/src/pages/ItemDetail/AudiobookContent.tsx | 95 +++--- .../components/ChaptersSection.test.tsx | 3 +- .../audiobooks/components/ChaptersSection.tsx | 2 +- .../audiobooks/player/AudiobookPlayer.tsx | 96 +++++- .../player/audiobookPlaybackContext.tsx | 114 +++++++ .../player/useAudiobookPlayback.test.ts | 218 +++++++++++--- .../audiobooks/player/useAudiobookPlayback.ts | 280 +++++++++++++++++- web/src/player/hooks/usePlaybackSession.ts | 37 +-- web/src/player/stream-url.ts | 26 ++ 66 files changed, 2815 insertions(+), 337 deletions(-) create mode 100644 internal/scanner/audiobook_cover_test.go create mode 100644 internal/sections/continue_type.go create mode 100644 migrations/sql/20260608210000_audiobook_continue_listening_section_title.sql create mode 100644 migrations/sql/20260608224625_home_continue_listening_split.sql create mode 100644 web/src/components/WatchTonightCard.test.tsx create mode 100644 web/src/components/catalog/catalogFilterBadges.test.ts create mode 100644 web/src/components/sections/SectionEditorDrawer.test.ts create mode 100644 web/src/components/watchtonight/CardStack.test.ts create mode 100644 web/src/lib/mediaNavigation.test.ts create mode 100644 web/src/lib/mediaNavigation.ts create mode 100644 web/src/pages/audiobooks/player/audiobookPlaybackContext.tsx create mode 100644 web/src/player/stream-url.ts diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index 43358491..48025d5f 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -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, diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index b2b2889f..eb60ee9e 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -574,6 +574,11 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil { slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr) } + if sections.IsAudiobookLibraryType(folder.Type) { + if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil { + slog.Warn("ensure home continue listening section", "library_id", folder.ID, "error", seedErr) + } + } if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil { slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr) } diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index cc224b6d..7149bb3a 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -49,6 +49,8 @@ type SessionManagerInterface interface { SetEffectiveMediaFileID(sessionID string, fileID int) error SetTranscodeNodeURL(sessionID, url string) error SetWebSocket(sessionID string, connected bool) error + SetRealtimeConnection(sessionID string, connected bool) error + SetProgressPersistenceDisabled(sessionID string, disabled bool) error StopSession(sessionID string) error GetSession(sessionID string) (*playback.Session, error) } @@ -257,6 +259,7 @@ type startPlaybackRequest struct { StartPosition *float64 `json:"start_position,omitempty"` AudioTrackIndex *int `json:"audio_track_index,omitempty"` PreserveDirectAudioSelection bool `json:"preserve_direct_audio_selection,omitempty"` + DisableProgressPersistence bool `json:"disable_progress_persistence,omitempty"` CodecsVideo []string `json:"codecs_video"` CodecsAudio []string `json:"codecs_audio"` Containers []string `json:"containers"` @@ -761,6 +764,9 @@ func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback if h.StoreProvider == nil || h.fileResolver == nil { return } + if session == nil || session.DisableProgressPersistence { + return + } file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID) targetID := playbackProgressTarget(file) @@ -799,7 +805,7 @@ func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *pl if h.StoreProvider == nil || h.fileResolver == nil { return watchstate.PlaybackStopResult{} } - if session == nil || session.Position <= 0 { + if session == nil || session.DisableProgressPersistence || session.Position <= 0 { return watchstate.PlaybackStopResult{} } @@ -1281,6 +1287,13 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req return } setPlaybackSessionLogContext(r, session.ID) + if req.DisableProgressPersistence { + if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil { + slog.Error("failed to disable progress persistence", "session", session.ID, "error", err) + } else { + session.DisableProgressPersistence = true + } + } if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil { slog.Error("failed to set audio track", "session", session.ID, "error", err) @@ -1320,7 +1333,7 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req } else { h.restoreSessionProgress(r.Context(), session, file) } - if h.WatchScrobbler != nil && effectiveFile != nil { + if !session.DisableProgressPersistence && h.WatchScrobbler != nil && effectiveFile != nil { targetID := playbackProgressTarget(effectiveFile) if targetID != "" { event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position) @@ -1549,7 +1562,7 @@ func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Re // Persist progress to UserStore (best-effort). if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil { h.persistProgress(r.Context(), sess) - if h.WatchScrobbler != nil && wasPaused != sess.IsPaused { + if !sess.DisableProgressPersistence && h.WatchScrobbler != nil && wasPaused != sess.IsPaused { if file, loadErr := h.loadFileByPreferredID(r.Context(), requestedMediaFileID(sess), sess.MediaFileID); loadErr == nil && file != nil { targetID := playbackProgressTarget(file) if targetID != "" { diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 30da0cfa..f711869d 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -129,6 +129,10 @@ func (failingSessionManager) SetTranscodeNodeURL(string, string) error { return func (failingSessionManager) SetWebSocket(string, bool) error { return nil } +func (failingSessionManager) SetRealtimeConnection(string, bool) error { return nil } + +func (failingSessionManager) SetProgressPersistenceDisabled(string, bool) error { return nil } + func (failingSessionManager) StopSession(string) error { return nil } func (failingSessionManager) GetSession(string) (*playback.Session, error) { return nil, nil } @@ -283,6 +287,64 @@ func TestHandleStartPlayback_UsesExplicitZeroStartPositionInsteadOfStoredResume( } } +func TestPlaybackSessionProgressPersistenceCanBeDisabled(t *testing.T) { + store := newPlaybackTestStore(t) + if err := store.SetProgress(context.Background(), "profile-1", "book-1", 500, 1200, userstore.ProgressThresholds{}); err != nil { + t.Fatalf("seed progress: %v", err) + } + + file := &models.MediaFile{ + ID: 42, + ContentID: "book-1", + FilePath: writePlaybackTestMediaFile(t, "book.m4b"), + Duration: 600, + } + sessionMgr := playback.NewSessionManager(0, 0) + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.StoreProvider = testUserStoreProvider{store: store} + handler.ItemAccess = allowAllPlaybackItemAccess{} + + req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct","start_position":120,"disable_progress_persistence":true}`)) + req = req.WithContext(newAuthorizedPlaybackContext()) + + rr := httptest.NewRecorder() + handler.HandleStartPlayback(rr, req) + if rr.Code != 201 { + t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String()) + } + + var resp playbackSessionResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + progressReq := httptest.NewRequest("POST", "/api/v1/playback/"+resp.SessionID+"/progress", strings.NewReader(`{"position":240,"is_paused":false}`)) + progressReq = progressReq.WithContext(newAuthorizedPlaybackContext()) + progressReq = withPlaybackRouteParam(progressReq, "session_id", resp.SessionID) + progressRR := httptest.NewRecorder() + handler.HandleUpdateProgress(progressRR, progressReq) + if progressRR.Code != 204 { + t.Fatalf("progress status = %d, body = %s", progressRR.Code, progressRR.Body.String()) + } + + stopReq := httptest.NewRequest("DELETE", "/api/v1/playback/"+resp.SessionID, nil) + stopReq = stopReq.WithContext(newAuthorizedPlaybackContext()) + stopReq = withPlaybackRouteParam(stopReq, "session_id", resp.SessionID) + stopRR := httptest.NewRecorder() + handler.HandleStopPlayback(stopRR, stopReq) + if stopRR.Code != 204 { + t.Fatalf("stop status = %d, body = %s", stopRR.Code, stopRR.Body.String()) + } + + progress, err := store.GetProgress(context.Background(), "profile-1", "book-1") + if err != nil { + t.Fatalf("get progress: %v", err) + } + if progress == nil || progress.PositionSeconds != 500 || progress.DurationSeconds != 1200 { + t.Fatalf("progress after disabled session = %+v, want 500/1200", progress) + } +} + func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) { file := &models.MediaFile{Duration: 3600} diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go index 58e27e55..d9551383 100644 --- a/internal/api/handlers/sections.go +++ b/internal/api/handlers/sections.go @@ -157,8 +157,11 @@ 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 + } + if _, err := sections.ParseContinueType(config); err != nil { + return err.Error(), false } var rawLibraryConfig struct { @@ -1408,8 +1411,8 @@ func (h *SectionHandler) maybeInjectNextUp(ctx context.Context, resolved []secti return resolved } -// injectNextUpSection inserts a synthetic SectionNextUp entry immediately after -// SectionContinueWatching in the resolved sections list. +// injectNextUpSection inserts a synthetic SectionNextUp entry after the +// contiguous continue rows that start with the video Continue Watching row. func injectNextUpSection(resolved []sections.ResolvedSection) []sections.ResolvedSection { nextUp := sections.ResolvedSection{ ID: "system-next-up", @@ -1418,6 +1421,20 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve ItemLimit: 20, } + for i, s := range resolved { + if s.SectionType == sections.SectionContinueWatching && sections.ContinueTypeFromConfig(s.Config) == sections.ContinueTypeWatching { + insertAt := i + 1 + for insertAt < len(resolved) && resolved[insertAt].SectionType == sections.SectionContinueWatching { + insertAt++ + } + result := make([]sections.ResolvedSection, 0, len(resolved)+1) + result = append(result, resolved[:insertAt]...) + result = append(result, nextUp) + result = append(result, resolved[insertAt:]...) + return result + } + } + for i, s := range resolved { if s.SectionType == sections.SectionContinueWatching { result := make([]sections.ResolvedSection, 0, len(resolved)+1) @@ -1428,7 +1445,7 @@ func injectNextUpSection(resolved []sections.ResolvedSection) []sections.Resolve } } - // If no continue_watching found, prepend + // If no resume row is found, prepend. return append([]sections.ResolvedSection{nextUp}, resolved...) } diff --git a/internal/api/handlers/sections_test.go b/internal/api/handlers/sections_test.go index 982e565c..a9dbd772 100644 --- a/internal/api/handlers/sections_test.go +++ b/internal/api/handlers/sections_test.go @@ -282,6 +282,66 @@ func TestBuildSectionsResponseBatchResolvesImageURLs(t *testing.T) { } } +func TestValidateSectionConfigAcceptsContinueTypes(t *testing.T) { + tests := []string{ + `{"continue_type":"watching"}`, + `{"continue_type":"listening"}`, + `{"continue_type":"reading"}`, + `{"filter_type":"audiobook"}`, + } + + for _, config := range tests { + t.Run(config, func(t *testing.T) { + if msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(config)); !ok { + t.Fatalf("validateSectionConfig(%s) rejected config: %s", config, msg) + } + }) + } +} + +func TestValidateSectionConfigRejectsUnknownContinueType(t *testing.T) { + msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(`{"continue_type":"scrolling"}`)) + if ok { + t.Fatal("validateSectionConfig accepted unknown continue_type") + } + if msg != "continue_type must be 'watching', 'listening', or 'reading'" { + t.Fatalf("message = %q", msg) + } +} + +func TestInjectNextUpAfterContiguousContinueRows(t *testing.T) { + in := []sections.ResolvedSection{ + { + ID: "cw", + SectionType: sections.SectionContinueWatching, + Title: "Continue Watching", + Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching), + }, + { + ID: "cl", + SectionType: sections.SectionContinueWatching, + Title: "Continue Listening", + Config: sections.ContinueTypeConfig(sections.ContinueTypeListening), + }, + {ID: "recent", SectionType: sections.SectionRecentlyAdded, Title: "Recently Added"}, + } + + got := injectNextUpSection(in) + gotIDs := make([]string, 0, len(got)) + for _, section := range got { + gotIDs = append(gotIDs, section.ID) + } + wantIDs := []string{"cw", "cl", "system-next-up", "recent"} + if len(gotIDs) != len(wantIDs) { + t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs) + } + for i := range wantIDs { + if gotIDs[i] != wantIDs[i] { + t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs) + } + } +} + func TestDropEmptySeasonalSectionsRemovesOnlyEmptySeasonal(t *testing.T) { in := []sections.SectionWithItems{ // empty seasonal — drop diff --git a/internal/catalog/item_repo.go b/internal/catalog/item_repo.go index 70889762..2b7e358b 100644 --- a/internal/catalog/item_repo.go +++ b/internal/catalog/item_repo.go @@ -35,6 +35,26 @@ func NewItemRepository(pool *pgxpool.Pool) *ItemRepository { return &ItemRepository{pool: pool} } +// GetPosterPath returns the current poster path for a media item. Missing or +// NULL poster values are returned as an empty string. +func (r *ItemRepository) GetPosterPath(ctx context.Context, contentID string) (string, error) { + if r == nil || r.pool == nil { + return "", ErrItemNotFound + } + var posterPath string + if err := r.pool.QueryRow(ctx, ` + SELECT COALESCE(poster_path, '') + FROM media_items + WHERE content_id = $1 + `, contentID).Scan(&posterPath); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrItemNotFound + } + return "", fmt.Errorf("getting media item poster path: %w", err) + } + return posterPath, nil +} + // overviewMatchFloor is the minimum ts_rank_cd score an overview-only match // must achieve to be returned when no title FTS match exists in the candidate // set. The overview tsvector is built without setweight(), so PostgreSQL's @@ -50,13 +70,22 @@ const itemColumns = `content_id, type, title, sort_title, default_metadata_langu content_rating, runtime, overview, tagline, rating_imdb, rating_tmdb, rating_rt_critic, rating_rt_audience, imdb_id, tmdb_id, tvdb_id, - poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path, - metadata_s3_path, metadata_etag, season_count, + COALESCE(poster_path, ''), COALESCE(poster_thumbhash, ''), COALESCE(backdrop_path, ''), COALESCE(backdrop_thumbhash, ''), COALESCE(logo_path, ''), + COALESCE(metadata_s3_path, ''), COALESCE(metadata_etag, ''), season_count, studios, networks, countries, keywords, original_language, release_date::text, first_air_date, last_air_date, air_time, air_timezone, show_status, matched_at, last_refreshed, refresh_failures, episode_metadata_incomplete, episode_metadata_last_checked_at, locked_fields, status, created_at, updated_at` +func qualifiedNullableStringColumn(alias, col string) string { + switch col { + case "poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path", "metadata_s3_path", "metadata_etag": + return fmt.Sprintf("COALESCE(%s.%s, '')", alias, col) + default: + return alias + "." + col + } +} + func qualifiedItemColumns(alias string) string { cols := []string{ "content_id", "type", "title", "sort_title", "default_metadata_language", "original_title", "year", "genres", @@ -72,7 +101,7 @@ func qualifiedItemColumns(alias string) string { } prefixed := make([]string, len(cols)) for i, col := range cols { - prefixed[i] = alias + "." + col + prefixed[i] = qualifiedNullableStringColumn(alias, col) } return strings.Join(prefixed, ", ") } @@ -96,7 +125,7 @@ func qualifiedListItemColumns(alias string) string { prefixed[i] = effectiveLastAirDateExpr(alias) continue } - prefixed[i] = alias + "." + col + prefixed[i] = qualifiedNullableStringColumn(alias, col) } return strings.Join(prefixed, ", ") } diff --git a/internal/playback/session.go b/internal/playback/session.go index 23d0e0ed..18463e29 100644 --- a/internal/playback/session.go +++ b/internal/playback/session.go @@ -31,14 +31,15 @@ type Session struct { TargetBitrateKbps int // requested output bitrate cap for transcodes TranscodeHWAccel string // effective hardware acceleration mode for transcodes - Position float64 - IsPaused bool - HasWebSocket bool - HasRealtimeConnection bool - StartedAt time.Time - UpdatedAt time.Time - LastActivityAt time.Time - activeTransportCount int + Position float64 + IsPaused bool + HasWebSocket bool + HasRealtimeConnection bool + DisableProgressPersistence bool + StartedAt time.Time + UpdatedAt time.Time + LastActivityAt time.Time + activeTransportCount int } // SessionStreamState stores the mutable stream-specific details that can @@ -371,6 +372,23 @@ func (m *SessionManager) SetRealtimeConnection(sessionID string, connected bool) return nil } +// SetProgressPersistenceDisabled controls whether session progress updates and +// stop events should write resume/history state. This is useful for players +// whose resume timeline is not the same as the session's file-local timeline. +func (m *SessionManager) SetProgressPersistenceDisabled(sessionID string, disabled bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + s, ok := m.sessions[sessionID] + if !ok { + return ErrSessionNotFound + } + + s.DisableProgressPersistence = disabled + m.touchSessionLocked(s) + return nil +} + // TouchActivity refreshes the session's activity timestamp without changing // any other playback state. func (m *SessionManager) TouchActivity(sessionID string) error { diff --git a/internal/recommendations/accuracy_test.go b/internal/recommendations/accuracy_test.go index 4119f08c..6d5c86b9 100644 --- a/internal/recommendations/accuracy_test.go +++ b/internal/recommendations/accuracy_test.go @@ -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}, diff --git a/internal/recommendations/personal.go b/internal/recommendations/personal.go index c0b15be0..a35919c7 100644 --- a/internal/recommendations/personal.go +++ b/internal/recommendations/personal.go @@ -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. diff --git a/internal/recommendations/repo.go b/internal/recommendations/repo.go index 47d7920a..b4183e10 100644 --- a/internal/recommendations/repo.go +++ b/internal/recommendations/repo.go @@ -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, ` diff --git a/internal/scanner/audiobook_cover.go b/internal/scanner/audiobook_cover.go index f1d6d797..b4f5b714 100644 --- a/internal/scanner/audiobook_cover.go +++ b/internal/scanner/audiobook_cover.go @@ -4,11 +4,18 @@ import ( "bytes" "context" "fmt" + "io" "log/slog" + "os" "os/exec" + "path/filepath" "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" ) +const maxAudiobookSidecarCoverSize = 8 * 1024 * 1024 + // audiobookCoverCacher is the narrow slice of imagecache.Cacher the // audiobook scanner uses. Defined with scalar args (not the imagecache // request struct) to avoid an import cycle: imagecache imports metadata @@ -17,6 +24,11 @@ type audiobookCoverCacher interface { CacheAudiobookCover(ctx context.Context, data []byte, contentID string) (basePath string, ext string, thumbhash string, err error) } +type audiobookCoverMetadataStore interface { + GetPosterPath(ctx context.Context, contentID string) (string, error) + UpdateMetadata(ctx context.Context, contentID string, upd *catalog.MetadataUpdate) error +} + // FFmpegPathFromFFprobe derives the ffmpeg binary path from a configured // ffprobe path. They live side by side in every silo deployment. func FFmpegPathFromFFprobe(ffprobePath string) string { @@ -76,3 +88,101 @@ func ExtractAndUploadAudiobookCover( } return fmt.Sprintf("%s/original%s", basePath, ext), thumbhash } + +func applyAudiobookSidecarCover(ctx context.Context, store audiobookCoverMetadataStore, cacher audiobookCoverCacher, contentID string, folderPath string) error { + if store == nil || cacher == nil || contentID == "" || folderPath == "" { + return nil + } + cover, _, err := findSidecarAudiobookCover(folderPath) + if err != nil || len(cover) == 0 { + return err + } + existingPosterPath, err := store.GetPosterPath(ctx, contentID) + if err != nil { + return fmt.Errorf("get audiobook poster path for cover: %w", err) + } + if strings.TrimSpace(existingPosterPath) != "" { + return nil + } + basePath, ext, thumbhash, err := cacher.CacheAudiobookCover(ctx, cover, contentID) + if err != nil { + return err + } + posterPath := strings.TrimRight(basePath, "/") + "/original" + ext + update := &catalog.MetadataUpdate{PosterPath: &posterPath} + if thumbhash != "" { + update.PosterThumbhash = &thumbhash + } + return store.UpdateMetadata(ctx, contentID, update) +} + +var sidecarAudiobookCoverNames = []string{"cover", "folder", "front", "poster", "thumbnail"} +var sidecarAudiobookCoverExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".bmp"} + +func findSidecarAudiobookCover(dir string) ([]byte, string, error) { + if dir == "" { + return nil, "", nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, "", err + } + byName := make(map[string]string, len(entries)) + for _, entry := range entries { + if !isRegularDirEntry(entry) { + continue + } + byName[strings.ToLower(entry.Name())] = filepath.Join(dir, entry.Name()) + } + for _, name := range sidecarAudiobookCoverNames { + for _, ext := range sidecarAudiobookCoverExtensions { + path := byName[name+ext] + if path == "" { + continue + } + data, err := readSidecarAudiobookCover(path) + if err != nil { + return nil, path, err + } + return data, path, nil + } + } + return nil, "", nil +} + +func isRegularDirEntry(entry os.DirEntry) bool { + if entry == nil || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return false + } + info, err := entry.Info() + if err != nil { + return false + } + return info.Mode().IsRegular() +} + +func readSidecarAudiobookCover(path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if info.Size() > maxAudiobookSidecarCoverSize { + return nil, fmt.Errorf("audiobook sidecar cover too large: %s", path) + } + data, err := io.ReadAll(io.LimitReader(file, maxAudiobookSidecarCoverSize+1)) + if err != nil { + return nil, err + } + if len(data) > maxAudiobookSidecarCoverSize { + return nil, fmt.Errorf("audiobook sidecar cover too large: %s", path) + } + if len(data) == 0 { + return nil, fmt.Errorf("audiobook sidecar cover empty: %s", path) + } + return data, nil +} diff --git a/internal/scanner/audiobook_cover_test.go b/internal/scanner/audiobook_cover_test.go new file mode 100644 index 00000000..5fea0788 --- /dev/null +++ b/internal/scanner/audiobook_cover_test.go @@ -0,0 +1,111 @@ +package scanner + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type fakeAudiobookCoverCacher struct { + calls int + data []byte + contentID string + err error +} + +func (f *fakeAudiobookCoverCacher) CacheAudiobookCover(_ context.Context, data []byte, contentID string) (string, string, string, error) { + f.calls++ + f.data = append([]byte(nil), data...) + f.contentID = contentID + if f.err != nil { + return "", "", "", f.err + } + return "local/audiobooks/" + contentID + "/poster", ".webp", "thumb", nil +} + +type fakeAudiobookCoverStore struct { + posterPath string + getErr error + contentID string + update *catalog.MetadataUpdate + err error +} + +func (f *fakeAudiobookCoverStore) GetPosterPath(_ context.Context, _ string) (string, error) { + return f.posterPath, f.getErr +} + +func (f *fakeAudiobookCoverStore) UpdateMetadata(_ context.Context, contentID string, update *catalog.MetadataUpdate) error { + f.contentID = contentID + f.update = update + return f.err +} + +func TestApplyAudiobookSidecarCoverCachesFolderCover(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "folder.png"), []byte("audiobook-sidecar-cover"), 0o644); err != nil { + t.Fatalf("write cover: %v", err) + } + cacher := &fakeAudiobookCoverCacher{} + store := &fakeAudiobookCoverStore{} + + err := applyAudiobookSidecarCover(context.Background(), store, cacher, "content-1", dir) + if err != nil { + t.Fatalf("applyAudiobookSidecarCover: %v", err) + } + + if cacher.calls != 1 || string(cacher.data) != "audiobook-sidecar-cover" { + t.Fatalf("cache call = calls %d data %q", cacher.calls, string(cacher.data)) + } + if store.update == nil || store.update.PosterPath == nil || *store.update.PosterPath != "local/audiobooks/content-1/poster/original.webp" { + t.Fatalf("poster update = %#v", store.update) + } + if store.update.PosterThumbhash == nil || *store.update.PosterThumbhash != "thumb" { + t.Fatalf("poster thumbhash = %#v", store.update.PosterThumbhash) + } +} + +func TestApplyAudiobookSidecarCoverPreservesExistingPoster(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("audiobook-sidecar-cover"), 0o644); err != nil { + t.Fatalf("write cover: %v", err) + } + cacher := &fakeAudiobookCoverCacher{} + store := &fakeAudiobookCoverStore{ + posterPath: "provider/poster.webp", + } + + err := applyAudiobookSidecarCover(context.Background(), store, cacher, "content-1", dir) + if err != nil { + t.Fatalf("applyAudiobookSidecarCover: %v", err) + } + + if cacher.calls != 0 { + t.Fatalf("cache calls = %d, want 0", cacher.calls) + } + if store.update != nil { + t.Fatalf("unexpected update = %#v", store.update) + } +} + +func TestFindSidecarAudiobookCoverSkipsSymlinks(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "secret.jpg") + if err := os.WriteFile(target, []byte("not-a-cover"), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + if err := os.Symlink(target, filepath.Join(dir, "cover.jpg")); err != nil { + t.Fatalf("symlink cover: %v", err) + } + + data, path, err := findSidecarAudiobookCover(dir) + if err != nil { + t.Fatalf("findSidecarAudiobookCover: %v", err) + } + if data != nil || path != "" { + t.Fatalf("findSidecarAudiobookCover returned data %q path %q, want no cover", string(data), path) + } +} diff --git a/internal/scanner/audiobook_scan.go b/internal/scanner/audiobook_scan.go index 8000d153..68fe66e0 100644 --- a/internal/scanner/audiobook_scan.go +++ b/internal/scanner/audiobook_scan.go @@ -316,6 +316,14 @@ func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.M if err := s.upsertAudiobookMediaFiles(ctx, folder, contentID, folderPath, parsed); err != nil { return fmt.Errorf("upsert audiobook files: %w", err) } + if err := applyAudiobookSidecarCover(ctx, s.itemRepo, s.imageCacher, contentID, folderPath); err != nil { + slog.Warn("audiobook scan: sidecar cover upload failed", + "folder_id", folder.ID, + "content_id", contentID, + "path", folderPath, + "error", err, + ) + } if err := s.upsertAudiobookPeople(ctx, contentID, parsed); err != nil { return fmt.Errorf("upsert audiobook people: %w", err) } diff --git a/internal/sections/continue_type.go b/internal/sections/continue_type.go new file mode 100644 index 00000000..815a1859 --- /dev/null +++ b/internal/sections/continue_type.go @@ -0,0 +1,105 @@ +package sections + +import ( + "encoding/json" + "fmt" + "strings" +) + +type ContinueType string + +const ( + ContinueTypeWatching ContinueType = "watching" + ContinueTypeListening ContinueType = "listening" + ContinueTypeReading ContinueType = "reading" +) + +type continueTypeConfig struct { + ContinueType string `json:"continue_type"` + FilterType string `json:"filter_type"` + MediaScope string `json:"media_scope"` +} + +func ContinueTypeConfig(typ ContinueType) json.RawMessage { + if !IsValidContinueType(typ) { + typ = ContinueTypeWatching + } + raw, err := json.Marshal(map[string]string{"continue_type": string(typ)}) + if err != nil { + return json.RawMessage(`{"continue_type":"watching"}`) + } + return raw +} + +func ContinueTypeFromConfig(config json.RawMessage) ContinueType { + typ, _ := ParseContinueType(config) + return typ +} + +func ParseContinueType(config json.RawMessage) (ContinueType, error) { + var raw continueTypeConfig + if len(config) > 0 { + if err := json.Unmarshal(config, &raw); err != nil { + return ContinueTypeWatching, err + } + } + + if strings.TrimSpace(raw.ContinueType) != "" { + typ := ContinueType(strings.ToLower(strings.TrimSpace(raw.ContinueType))) + if !IsValidContinueType(typ) { + return ContinueTypeWatching, fmt.Errorf("continue_type must be 'watching', 'listening', or 'reading'") + } + return typ, nil + } + + switch strings.ToLower(strings.TrimSpace(raw.FilterType)) { + case "audiobook": + return ContinueTypeListening, nil + case "ebook": + return ContinueTypeReading, nil + } + + switch strings.ToLower(strings.TrimSpace(raw.MediaScope)) { + case "audiobook": + return ContinueTypeListening, nil + case "ebook": + return ContinueTypeReading, nil + } + + return ContinueTypeWatching, nil +} + +func IsValidContinueType(typ ContinueType) bool { + switch typ { + case ContinueTypeWatching, ContinueTypeListening, ContinueTypeReading: + return true + default: + return false + } +} + +func ContinueTypeMatchesItem(typ ContinueType, itemType string) bool { + switch typ { + case ContinueTypeWatching: + return itemType == "movie" || itemType == "episode" + case ContinueTypeListening: + return itemType == "audiobook" + case ContinueTypeReading: + return itemType == "ebook" + default: + return false + } +} + +func ContinueTypeAllowsNextUp(typ ContinueType) bool { + return typ == ContinueTypeWatching +} + +func IsAudiobookLibraryType(libraryType string) bool { + switch strings.ToLower(strings.TrimSpace(libraryType)) { + case "audiobook", "audiobooks": + return true + default: + return false + } +} diff --git a/internal/sections/defaults.go b/internal/sections/defaults.go index 1ba0ca58..99a93960 100644 --- a/internal/sections/defaults.go +++ b/internal/sections/defaults.go @@ -11,12 +11,23 @@ import ( // DefaultHomeSections returns the canonical default sections for the home scope. // These are used for seeding fresh installs and the admin "Restore Defaults" action. func DefaultHomeSections(libraries []*models.MediaFolder) []*PageSection { - emptyCfg := json.RawMessage(`{}`) result := []*PageSection{ - {ID: "default-continue-watching", Scope: "home", Position: 0, SectionType: SectionContinueWatching, Title: "Continue Watching", ItemLimit: 20, Config: emptyCfg, Enabled: true}, + {ID: "default-continue-watching", Scope: "home", Position: 0, SectionType: SectionContinueWatching, Title: "Continue Watching", ItemLimit: 20, Config: ContinueTypeConfig(ContinueTypeWatching), Enabled: true}, + } + if hasAudiobookLibrary(libraries) { + result = append(result, &PageSection{ + ID: "default-continue-listening", + Scope: "home", + Position: 1, + SectionType: SectionContinueWatching, + Title: "Continue Listening", + ItemLimit: 20, + Config: ContinueTypeConfig(ContinueTypeListening), + Enabled: true, + }) } - position := 1 + position := len(result) for _, library := range libraries { if library == nil { continue @@ -63,6 +74,15 @@ func DefaultHomeSections(libraries []*models.MediaFolder) []*PageSection { return result } +func hasAudiobookLibrary(libraries []*models.MediaFolder) bool { + for _, library := range libraries { + if library != nil && IsAudiobookLibraryType(library.Type) { + return true + } + } + return false +} + func generatedHomeLibraryRecentID(section *PageSection, libraryID int) string { kind := generatedHomeLibraryRecentKindForSection(section) if kind == generatedHomeLibraryRecentKindReleasedEpisodes { @@ -147,11 +167,12 @@ func defaultRecentEpisodesConfig() json.RawMessage { // library defaults. func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSection { emptyCfg := json.RawMessage(`{}`) + continueWatchingCfg := ContinueTypeConfig(ContinueTypeWatching) switch libraryType { case "movies", "movie": return []*PageSection{ - {ID: "default-continue-watching", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Watching", ItemLimit: 20, Config: emptyCfg, Enabled: true}, + {ID: "default-continue-watching", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Watching", ItemLimit: 20, Config: continueWatchingCfg, Enabled: true}, {ID: "default-recently-added-movies", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added Movies", ItemLimit: 20, Config: defaultMediaScopeConfig("movie"), Enabled: true}, {ID: "default-recently-released-movies", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionRecentlyReleased, Title: "Recently Released Movies", ItemLimit: 20, Config: defaultMediaScopeConfig("movie"), Enabled: true}, {ID: "default-top-rated-movies", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionCustomFilter, Title: "Top Rated Movies", ItemLimit: 20, Config: defaultTopRatedConfig("movie"), Enabled: true}, @@ -160,7 +181,7 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe } case "series": return []*PageSection{ - {ID: "default-continue-watching", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Watching", ItemLimit: 20, Config: emptyCfg, Enabled: true}, + {ID: "default-continue-watching", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Watching", ItemLimit: 20, Config: continueWatchingCfg, Enabled: true}, {ID: "default-recently-added-tv", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added TV", ItemLimit: 20, Config: defaultMediaScopeConfig("series"), Enabled: true}, {ID: "default-recently-released-episodes", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionCustomFilter, Title: "Recently Released Episodes", ItemLimit: 20, Config: defaultRecentEpisodesConfig(), Enabled: true}, {ID: "default-top-rated-tv", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionCustomFilter, Title: "Top Rated TV", ItemLimit: 20, Config: defaultTopRatedConfig("series"), Enabled: true}, @@ -169,7 +190,7 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe } case "audiobooks", "audiobook": return []*PageSection{ - {ID: "default-continue-listening", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Listening", ItemLimit: 20, Config: emptyCfg, Enabled: true}, + {ID: "default-continue-listening", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Listening", ItemLimit: 20, Config: ContinueTypeConfig(ContinueTypeListening), Enabled: true}, {ID: "default-recently-added-audiobooks", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added Audiobooks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true}, {ID: "default-recently-released-audiobooks", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionRecentlyReleased, Title: "Recently Released Audiobooks", ItemLimit: 20, Config: defaultMediaScopeConfig("audiobook"), Enabled: true}, {ID: "default-recommended-for-you", Scope: "library", LibraryID: libraryID, Position: 3, SectionType: SectionRecommendedForYou, Title: "Recommended for You", ItemLimit: 20, Config: emptyCfg, Enabled: true}, @@ -186,7 +207,7 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe func DefaultLibrarySections(libraryID *int) []*PageSection { emptyCfg := json.RawMessage(`{}`) return []*PageSection{ - {ID: "default-continue-watching", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Watching", ItemLimit: 20, Config: emptyCfg, Enabled: true}, + {ID: "default-continue-watching", Scope: "library", LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: "Continue Watching", ItemLimit: 20, Config: ContinueTypeConfig(ContinueTypeWatching), Enabled: true}, {ID: "default-recently-added", Scope: "library", LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added", ItemLimit: 20, Config: emptyCfg, Enabled: true}, {ID: "default-recently-released", Scope: "library", LibraryID: libraryID, Position: 2, SectionType: SectionRecentlyReleased, Title: "Recently Released", ItemLimit: 20, Config: emptyCfg, Enabled: true}, } diff --git a/internal/sections/defaults_test.go b/internal/sections/defaults_test.go index 4d331036..cfabcdf0 100644 --- a/internal/sections/defaults_test.go +++ b/internal/sections/defaults_test.go @@ -17,6 +17,7 @@ func TestDefaultHomeSectionsWithoutLibraries(t *testing.T) { if sections[0].SectionType != SectionContinueWatching { t.Fatalf("expected continue watching, got %s", sections[0].SectionType) } + assertContinueType(t, sections[0].Config, ContinueTypeWatching) } func TestDefaultHomeSectionsWithLibraries(t *testing.T) { @@ -61,6 +62,7 @@ func TestDefaultHomeSectionsWithLibraries(t *testing.T) { t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position) } if tt.libraryID == 0 { + assertContinueType(t, section.Config, ContinueTypeWatching) continue } libraryID, ok := ParseGeneratedHomeLibraryRecentConfig(section.Config) @@ -81,6 +83,46 @@ func TestDefaultHomeSectionsWithLibraries(t *testing.T) { }) } +func TestDefaultHomeSectionsWithAudiobookLibrary(t *testing.T) { + libraries := []*models.MediaFolder{ + {ID: 7, Name: "Movies", Type: "movies", SortOrder: 1}, + {ID: 10, Name: "Books", Type: "audiobooks", SortOrder: 2}, + } + + got := DefaultHomeSections(libraries) + if len(got) != 9 { + t.Fatalf("expected 9 default home sections, got %d", len(got)) + } + + tests := []struct { + index int + id string + title string + position int + continueType ContinueType + }{ + {index: 0, id: "default-continue-watching", title: "Continue Watching", position: 0, continueType: ContinueTypeWatching}, + {index: 1, id: "default-continue-listening", title: "Continue Listening", position: 1, continueType: ContinueTypeListening}, + } + for _, tt := range tests { + section := got[tt.index] + if section.ID != tt.id { + t.Fatalf("section %d id = %q, want %q", tt.index, section.ID, tt.id) + } + if section.Title != tt.title { + t.Fatalf("section %d title = %q, want %q", tt.index, section.Title, tt.title) + } + if section.Position != tt.position { + t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position) + } + assertContinueType(t, section.Config, tt.continueType) + } + + if got[2].Position != 2 || got[2].Title != "Recently Added in Movies" { + t.Fatalf("first generated library row = position %d title %q", got[2].Position, got[2].Title) + } +} + func TestDefaultLibrarySectionsForTypeMovies(t *testing.T) { libraryID := 42 got := DefaultLibrarySectionsForType(&libraryID, "movies") @@ -122,6 +164,7 @@ func TestDefaultLibrarySectionsForTypeMovies(t *testing.T) { t.Fatalf("section %d featured = true, want false", tt.index) } } + assertContinueType(t, got[0].Config, ContinueTypeWatching) assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{ MediaScope: "movie", @@ -185,6 +228,7 @@ func TestDefaultLibrarySectionsForTypeSeries(t *testing.T) { t.Fatalf("section %d featured = true, want false", tt.index) } } + assertContinueType(t, got[0].Config, ContinueTypeWatching) assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{ MediaScope: "series", @@ -253,6 +297,7 @@ func TestDefaultLibrarySectionsForTypeAudiobooks(t *testing.T) { t.Fatalf("section %d featured = true, want false", tt.index) } } + assertContinueType(t, got[0].Config, ContinueTypeListening) assertQueryDefinition(t, got[1].Config, catalog.QueryDefinition{ MediaScope: "audiobook", @@ -310,6 +355,7 @@ func TestDefaultLibrarySectionsForTypeMixed(t *testing.T) { t.Fatalf("section %d position = %d, want %d", tt.index, section.Position, tt.position) } } + assertContinueType(t, got[0].Config, ContinueTypeWatching) } func assertQueryDefinition(t *testing.T, raw json.RawMessage, want catalog.QueryDefinition) { @@ -357,6 +403,17 @@ func assertEmptyJSON(t *testing.T, raw json.RawMessage) { } } +func assertContinueType(t *testing.T, raw json.RawMessage, want ContinueType) { + t.Helper() + got, err := ParseContinueType(raw) + if err != nil { + t.Fatalf("ParseContinueType(%s): %v", string(raw), err) + } + if got != want { + t.Fatalf("continue_type = %q, want %q (config %s)", got, want, string(raw)) + } +} + func TestHomeDefaultsIncludeRecipeRichSet(t *testing.T) { defs := DefaultHomeSections(nil) want := []SectionType{ diff --git a/internal/sections/fetcher.go b/internal/sections/fetcher.go index 279f809f..d08f7b89 100644 --- a/internal/sections/fetcher.go +++ b/internal/sections/fetcher.go @@ -357,6 +357,7 @@ func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved Res if limit <= 0 { limit = 20 } + continueType := ContinueTypeFromConfig(resolved.Config) cfgFilters := ParseConfigFilters(resolved.Config) configLibraryIDs := cfgFilters.LibraryIDs() @@ -367,78 +368,49 @@ func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved Res effectiveLibraryIDs = configLibraryIDs } - progressEntries, err := store.ListProgress(ctx, profileID, "in_progress", limit, 0) - if err != nil { - return SectionWithItems{}, fmt.Errorf("listing progress: %w", err) - } - progressEntries = f.filterContinueWatchingDismissals(ctx, store, profileID, progressEntries) - - // Build in-progress items - var orderedItems []*models.MediaItem + dismissals := f.listContinueWatchingDismissals(ctx, store, profileID) + orderedItems := make([]*models.MediaItem, 0, limit) itemMeta := make(map[string]SectionItemMeta) - if len(progressEntries) > 0 { - contentIDs := make([]string, 0, len(progressEntries)) - for _, entry := range progressEntries { - contentIDs = append(contentIDs, entry.MediaItemID) + for offset := 0; len(orderedItems) < limit && offset < continueProgressMaxScanned; offset += continueProgressPageSize { + pageLimit := continueProgressPageSize + if remaining := continueProgressMaxScanned - offset; remaining < pageLimit { + pageLimit = remaining } + progressEntries, err := store.ListProgress(ctx, profileID, "in_progress", pageLimit, offset) + if err != nil { + return SectionWithItems{}, fmt.Errorf("listing progress: %w", err) + } + if len(progressEntries) == 0 { + break + } + rawProgressCount := len(progressEntries) + progressEntries = filterContinueWatchingDismissals(progressEntries, dismissals) - mediaItems, err := f.fetchItemsByContentIDs(ctx, contentIDs, effectiveLibID, effectiveLibraryIDs, filter) + pageItems, pageMeta, err := f.fetchContinueProgressItems(ctx, store, profileID, progressEntries, continueType, effectiveLibID, effectiveLibraryIDs, filter) if err != nil { return SectionWithItems{}, err } - - episodeItems, episodeMeta, err := f.fetchEpisodeTargetsByContentIDs(ctx, contentIDs, effectiveLibID, effectiveLibraryIDs, filter) - if err != nil { - return SectionWithItems{}, err - } - - itemByID := make(map[string]*models.MediaItem, len(mediaItems)+len(episodeItems)) - for _, item := range mediaItems { - itemByID[item.ContentID] = item - } - for _, item := range episodeItems { - itemByID[item.ContentID] = item - } - - maps.Copy(itemMeta, episodeMeta) - supersededEpisodeProgress, err := f.fetchSupersededEpisodeProgressIDs(ctx, store, profileID, progressEntries) - if err != nil { - return SectionWithItems{}, err - } - progressEntries = filterSupersededEpisodeProgressEntries(progressEntries, supersededEpisodeProgress) - - for _, entry := range progressEntries { - meta := itemMeta[entry.MediaItemID] - position := entry.PositionSeconds - duration := entry.DurationSeconds - meta.PositionSeconds = &position - meta.DurationSeconds = &duration - meta.ProgressUpdatedAt = &entry.UpdatedAt - meta.ItemSource = "in_progress" - if updatedAt, parseErr := time.Parse(time.RFC3339, entry.UpdatedAt); parseErr == nil { - meta.SortTimestamp = updatedAt - } - itemMeta[entry.MediaItemID] = meta - } - - orderedItems = make([]*models.MediaItem, 0, len(progressEntries)) - for _, entry := range progressEntries { - item, ok := itemByID[entry.MediaItemID] - if !ok { - continue + for _, item := range pageItems { + if len(orderedItems) >= limit { + break } orderedItems = append(orderedItems, item) } + maps.Copy(itemMeta, pageMeta) + if rawProgressCount < pageLimit { + break + } } - // Fetch next-up items (combined mode by default) - nextUpMode, _ := store.GetSetting(ctx, "next_up_mode") - if nextUpMode == "" { - nextUpMode = "combined" + nextUpMode := "" + if ContinueTypeAllowsNextUp(continueType) { + nextUpMode, _ = store.GetSetting(ctx, "next_up_mode") + if nextUpMode == "" { + nextUpMode = "combined" + } } - - if nextUpMode == "combined" { + if ContinueTypeAllowsNextUp(continueType) && nextUpMode == "combined" { nextUpItems, nextUpMeta, nextUpErr := f.FetchNextUpItems(ctx, userID, profileID, effectiveLibID, effectiveLibraryIDs, filter, limit) if nextUpErr != nil { slog.Error("fetching next-up items", "error", nextUpErr) @@ -448,18 +420,6 @@ func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved Res } } - // Apply type filter after next-up items are appended - 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") { - filtered = append(filtered, item) - } - } - orderedItems = filtered - } - if nextUpMode == "combined" && len(orderedItems) > 1 { orderedItems = collapseContinueWatchingSeriesCandidates(orderedItems, itemMeta) } @@ -484,6 +444,84 @@ func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved Res }, nil } +const ( + continueProgressPageSize = 100 + continueProgressMaxScanned = 1000 +) + +func (f *Fetcher) fetchContinueProgressItems(ctx context.Context, store userstore.UserStore, profileID string, entries []userstore.WatchProgress, continueType ContinueType, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, map[string]SectionItemMeta, error) { + if len(entries) == 0 { + return nil, map[string]SectionItemMeta{}, nil + } + + contentIDs := make([]string, 0, len(entries)) + for _, entry := range entries { + contentIDs = append(contentIDs, entry.MediaItemID) + } + + mediaItems, err := f.fetchItemsByContentIDs(ctx, contentIDs, libraryID, libraryIDs, filter) + if err != nil { + return nil, nil, err + } + + episodeItems, episodeMeta, err := f.fetchEpisodeTargetsByContentIDs(ctx, contentIDs, libraryID, libraryIDs, filter) + if err != nil { + return nil, nil, err + } + + itemByID := make(map[string]*models.MediaItem, len(mediaItems)+len(episodeItems)) + for _, item := range mediaItems { + itemByID[item.ContentID] = item + } + for _, item := range episodeItems { + itemByID[item.ContentID] = item + } + + matchingEntries := make([]userstore.WatchProgress, 0, len(entries)) + hasEpisodeEntries := false + for _, entry := range entries { + item, ok := itemByID[entry.MediaItemID] + if !ok || !ContinueTypeMatchesItem(continueType, item.Type) { + continue + } + if item.Type == "episode" { + hasEpisodeEntries = true + } + matchingEntries = append(matchingEntries, entry) + } + if ContinueTypeAllowsNextUp(continueType) && hasEpisodeEntries { + supersededEpisodeProgress, err := f.fetchSupersededEpisodeProgressIDs(ctx, store, profileID, matchingEntries) + if err != nil { + return nil, nil, err + } + matchingEntries = filterSupersededEpisodeProgressEntries(matchingEntries, supersededEpisodeProgress) + } + + itemMeta := make(map[string]SectionItemMeta, len(matchingEntries)) + orderedItems := make([]*models.MediaItem, 0, len(matchingEntries)) + for _, entry := range matchingEntries { + item, ok := itemByID[entry.MediaItemID] + if !ok { + continue + } + meta := episodeMeta[entry.MediaItemID] + position := entry.PositionSeconds + duration := entry.DurationSeconds + meta.PositionSeconds = &position + meta.DurationSeconds = &duration + progressUpdatedAt := entry.UpdatedAt + meta.ProgressUpdatedAt = &progressUpdatedAt + meta.ItemSource = "in_progress" + if updatedAt, parseErr := time.Parse(time.RFC3339, entry.UpdatedAt); parseErr == nil { + meta.SortTimestamp = updatedAt + } + itemMeta[entry.MediaItemID] = meta + orderedItems = append(orderedItems, item) + } + + return orderedItems, itemMeta, nil +} + func (f *Fetcher) fetchNextUpSection(ctx context.Context, resolved ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) (SectionWithItems, error) { emptyResult := SectionWithItems{ ResolvedSection: resolved, @@ -792,24 +830,23 @@ func episodeOrdinal(meta SectionItemMeta) int { return season*100000 + episode } -func (f *Fetcher) filterContinueWatchingDismissals(ctx context.Context, store userstore.UserStore, profileID string, entries []userstore.WatchProgress) []userstore.WatchProgress { - if len(entries) == 0 { - return entries - } - +func (f *Fetcher) listContinueWatchingDismissals(ctx context.Context, store userstore.UserStore, profileID string) map[string]userstore.HomeItemDismissal { dismissals, err := store.ListHomeDismissals(ctx, profileID, userstore.HomeSurfaceContinueWatching) if err != nil { slog.Error("listing continue watching dismissals", "profile_id", profileID, "error", err) - return entries + return map[string]userstore.HomeItemDismissal{} } - if len(dismissals) == 0 { - return entries - } - dismissalByItemID := make(map[string]userstore.HomeItemDismissal, len(dismissals)) for _, dismissal := range dismissals { dismissalByItemID[dismissal.MediaItemID] = dismissal } + return dismissalByItemID +} + +func filterContinueWatchingDismissals(entries []userstore.WatchProgress, dismissalByItemID map[string]userstore.HomeItemDismissal) []userstore.WatchProgress { + if len(entries) == 0 || len(dismissalByItemID) == 0 { + return entries + } filtered := make([]userstore.WatchProgress, 0, len(entries)) for _, entry := range entries { @@ -2803,6 +2840,19 @@ func applyConfigTypeFilter(alias string, filterType string, conditions *[]string *argIdx++ } +func matchesContinueWatchingFilter(filterType string, itemType string) bool { + switch filterType { + case "movie", "series", "episode": + return ContinueTypeMatchesItem(ContinueTypeWatching, itemType) + case "audiobook": + return ContinueTypeMatchesItem(ContinueTypeListening, itemType) + case "ebook": + return ContinueTypeMatchesItem(ContinueTypeReading, itemType) + default: + return true + } +} + // fetchSeasonalThemed returns items for a seasonal_themed section. // // Multi-theme mode (EnabledThemes set): picks the highest-priority enabled diff --git a/internal/sections/fetcher_continue_watching_test.go b/internal/sections/fetcher_continue_watching_test.go index 53c31591..53d74b81 100644 --- a/internal/sections/fetcher_continue_watching_test.go +++ b/internal/sections/fetcher_continue_watching_test.go @@ -107,6 +107,70 @@ 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: "movie keeps episode through watching type", filterType: "movie", itemType: "episode", 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: "ebook keeps ebook", filterType: "ebook", itemType: "ebook", want: true}, + {name: "unknown passes through audiobook", filterType: "unknown", itemType: "audiobook", want: true}, + } + + 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 TestParseContinueType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config string + want ContinueType + }{ + {name: "missing defaults watching", config: `{}`, want: ContinueTypeWatching}, + {name: "explicit watching", config: `{"continue_type":"watching"}`, want: ContinueTypeWatching}, + {name: "explicit listening", config: `{"continue_type":"listening"}`, want: ContinueTypeListening}, + {name: "legacy audiobook filter", config: `{"filter_type":"audiobook"}`, want: ContinueTypeListening}, + {name: "legacy audiobook media scope", config: `{"media_scope":"audiobook"}`, want: ContinueTypeListening}, + {name: "future reading", config: `{"continue_type":"reading"}`, want: ContinueTypeReading}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseContinueType([]byte(tt.config)) + if err != nil { + t.Fatalf("ParseContinueType(%s): %v", tt.config, err) + } + if got != tt.want { + t.Fatalf("ParseContinueType(%s) = %q, want %q", tt.config, got, tt.want) + } + }) + } +} + +func TestParseContinueTypeRejectsUnknownExplicitType(t *testing.T) { + t.Parallel() + + if _, err := ParseContinueType([]byte(`{"continue_type":"scrolling"}`)); err == nil { + t.Fatal("ParseContinueType accepted unknown continue_type") + } +} + func TestCompletedProgressSnapshotsPagesThroughConfiguredStore(t *testing.T) { t.Parallel() diff --git a/internal/sections/recipes/library_staples.go b/internal/sections/recipes/library_staples.go index 9c1c44f3..ecb5a647 100644 --- a/internal/sections/recipes/library_staples.go +++ b/internal/sections/recipes/library_staples.go @@ -2,6 +2,8 @@ package recipes import ( "encoding/json" + "fmt" + "strings" "time" ) @@ -15,18 +17,36 @@ type libStaple struct { icon string descShort string cacheTTL time.Duration + presets []GalleryPreset } -func (l *libStaple) Type() string { return l.typ } -func (l *libStaple) NewParams() any { return &libStapleParams{} } -func (l *libStaple) Validate(_ json.RawMessage) error { return nil } // any JSON is acceptable; missing fields ignored -func (l *libStaple) DefaultCacheTTL() time.Duration { return l.cacheTTL } +func (l *libStaple) Type() string { return l.typ } +func (l *libStaple) NewParams() any { return &libStapleParams{} } +func (l *libStaple) Validate(raw json.RawMessage) error { + if l.typ != "continue_watching" { + return nil // any JSON is acceptable; missing fields ignored + } + var params struct { + ContinueType string `json:"continue_type"` + } + if len(raw) > 0 { + if err := json.Unmarshal(raw, ¶ms); err != nil { + return err + } + } + switch strings.ToLower(strings.TrimSpace(params.ContinueType)) { + case "", "watching", "listening", "reading": + return nil + default: + return fmt.Errorf("continue_type must be 'watching', 'listening', or 'reading'") + } +} +func (l *libStaple) DefaultCacheTTL() time.Duration { return l.cacheTTL } func (l *libStaple) Definition() RecipeDefinition { - return RecipeDefinition{ - Type: l.typ, - Category: CategoryLibraryStaples, - Presets: []GalleryPreset{ + presets := l.presets + if presets == nil { + presets = []GalleryPreset{ { Key: l.typ + "_default", DisplayName: l.displayName, @@ -34,7 +54,12 @@ func (l *libStaple) Definition() RecipeDefinition { DescriptionShort: l.descShort, DefaultParams: json.RawMessage(`{}`), }, - }, + } + } + return RecipeDefinition{ + Type: l.typ, + Category: CategoryLibraryStaples, + Presets: presets, } } @@ -46,7 +71,29 @@ func (l *libStaple) Resolve(rc ResolverContext) (ResolvedItems, error) { func init() { Register(&libStaple{typ: "recently_added", displayName: "Recently Added", icon: "🆕", descShort: "Latest additions to your library.", cacheTTL: 5 * time.Minute}) Register(&libStaple{typ: "recently_released", displayName: "New Releases", icon: "🎬", descShort: "Recently released titles.", cacheTTL: 30 * time.Minute}) - Register(&libStaple{typ: "continue_watching", displayName: "Continue Watching", icon: "▶️", descShort: "Pick up where you left off.", cacheTTL: time.Minute}) + Register(&libStaple{ + typ: "continue_watching", + displayName: "Continue Watching", + icon: "▶️", + descShort: "Pick up where you left off.", + cacheTTL: time.Minute, + presets: []GalleryPreset{ + { + Key: "continue_watching_default", + DisplayName: "Continue Watching", + Icon: "▶️", + DescriptionShort: "Pick up movies and episodes where you left off.", + DefaultParams: json.RawMessage(`{"continue_type":"watching"}`), + }, + { + Key: "continue_listening_default", + DisplayName: "Continue Listening", + Icon: "🎧", + DescriptionShort: "Pick up audiobooks where you left off.", + DefaultParams: json.RawMessage(`{"continue_type":"listening"}`), + }, + }, + }) Register(&libStaple{typ: "next_up", displayName: "On Deck", icon: "📺", descShort: "Next episodes ready to watch.", cacheTTL: time.Minute}) Register(&libStaple{typ: "watchlist", displayName: "Watchlist", icon: "🔖", descShort: "Items you've saved to watch.", cacheTTL: time.Minute}) Register(&libStaple{typ: "favorites", displayName: "Favorites", icon: "⭐", descShort: "Your favorites.", cacheTTL: time.Minute}) diff --git a/internal/sections/recipes/library_staples_test.go b/internal/sections/recipes/library_staples_test.go index 1bc1cfe2..d9aed5ed 100644 --- a/internal/sections/recipes/library_staples_test.go +++ b/internal/sections/recipes/library_staples_test.go @@ -30,3 +30,27 @@ func TestLibraryStaplesAcceptEmptyParams(t *testing.T) { } } } + +func TestContinueWatchingRecipeExposesWatchingAndListeningPresets(t *testing.T) { + rec, ok := Get("continue_watching") + if !ok { + t.Fatal("continue_watching recipe not registered") + } + presets := rec.Definition().Presets + if len(presets) != 2 { + t.Fatalf("presets len = %d, want 2", len(presets)) + } + if presets[0].DisplayName != "Continue Watching" || string(presets[0].DefaultParams) != `{"continue_type":"watching"}` { + t.Fatalf("watching preset = %+v", presets[0]) + } + if presets[1].DisplayName != "Continue Listening" || string(presets[1].DefaultParams) != `{"continue_type":"listening"}` { + t.Fatalf("listening preset = %+v", presets[1]) + } +} + +func TestContinueWatchingRecipeRejectsUnknownContinueType(t *testing.T) { + rec, _ := Get("continue_watching") + if err := rec.Validate(json.RawMessage(`{"continue_type":"scrolling"}`)); err == nil { + t.Fatal("Validate accepted unknown continue_type") + } +} diff --git a/internal/sections/repo.go b/internal/sections/repo.go index a0db33c5..0792bbd4 100644 --- a/internal/sections/repo.go +++ b/internal/sections/repo.go @@ -448,6 +448,120 @@ func (r *Repository) CreateGeneratedHomeLibraryRecentSections(ctx context.Contex return created, nil } +func (r *Repository) EnsureHomeContinueListeningSection(ctx context.Context) (*PageSection, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("beginning continue listening transaction: %w", err) + } + defer tx.Rollback(ctx) + + var existingID string + err = tx.QueryRow(ctx, ` + SELECT id + FROM page_sections + WHERE scope = 'home' + AND library_id IS NULL + AND section_type = $1 + AND ( + config->>'continue_type' = $2 + OR config->>'filter_type' = 'audiobook' + OR config->>'media_scope' = 'audiobook' + ) + ORDER BY position ASC + LIMIT 1 + `, SectionContinueWatching, ContinueTypeListening).Scan(&existingID) + if err == nil { + return nil, tx.Commit(ctx) + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("checking home continue listening section: %w", err) + } + + insertPosition, err := continueListeningInsertPosition(ctx, tx) + if err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, ` + UPDATE page_sections + SET position = position + 1, updated_at = NOW() + WHERE scope = 'home' + AND library_id IS NULL + AND position >= $1 + `, insertPosition); err != nil { + return nil, fmt.Errorf("shifting home sections: %w", err) + } + + id, err := idgen.NextID() + if err != nil { + return nil, fmt.Errorf("generate section id: %w", err) + } + query := fmt.Sprintf(`INSERT INTO page_sections (%s) VALUES ($1,'home',NULL,$2,$3,$4,false,20,$5,true,NOW(),NOW()) + RETURNING %s`, sectionColumns, sectionColumns) + created, err := scanSection(tx.QueryRow(ctx, query, + id, + insertPosition, + SectionContinueWatching, + "Continue Listening", + ContinueTypeConfig(ContinueTypeListening), + )) + if err != nil { + return nil, fmt.Errorf("creating home continue listening section: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("committing continue listening transaction: %w", err) + } + return created, nil +} + +type homeSectionPositionRow struct { + Position int + SectionType SectionType + Config json.RawMessage +} + +type queryer interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) +} + +func continueListeningInsertPosition(ctx context.Context, q queryer) (int, error) { + rows, err := q.Query(ctx, ` + SELECT position, section_type, config + FROM page_sections + WHERE scope = 'home' + AND library_id IS NULL + ORDER BY position ASC + `) + if err != nil { + return 0, fmt.Errorf("listing home section positions: %w", err) + } + defer rows.Close() + + insertPosition := 0 + foundWatching := false + for rows.Next() { + var row homeSectionPositionRow + if err := rows.Scan(&row.Position, &row.SectionType, &row.Config); err != nil { + return 0, fmt.Errorf("scanning home section position: %w", err) + } + if foundWatching { + if row.SectionType != SectionContinueWatching { + break + } + insertPosition = row.Position + 1 + continue + } + insertPosition = row.Position + 1 + if row.SectionType == SectionContinueWatching && ContinueTypeFromConfig(row.Config) == ContinueTypeWatching { + foundWatching = true + } + } + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("iterating home section positions: %w", err) + } + return insertPosition, nil +} + func (r *Repository) SyncGeneratedHomeLibraryRecentTitles(ctx context.Context, libraryID int, oldLibraryName, newLibraryName string) error { sections, err := r.listGeneratedHomeLibraryRecentSections(ctx, libraryID) if err != nil { diff --git a/internal/sections/types.go b/internal/sections/types.go index 6506f9b3..aaa424ab 100644 --- a/internal/sections/types.go +++ b/internal/sections/types.go @@ -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 { diff --git a/migrations/sql/20260608183000_enable_audiobookshelf_compat_default.sql b/migrations/sql/20260608183000_enable_audiobookshelf_compat_default.sql index 3c877392..0f5ccc51 100644 --- a/migrations/sql/20260608183000_enable_audiobookshelf_compat_default.sql +++ b/migrations/sql/20260608183000_enable_audiobookshelf_compat_default.sql @@ -6,7 +6,15 @@ UPDATE server_settings SET value = 'true' WHERE key = 'audiobookshelf_compat.enabled' - AND lower(trim(value)) IN ('', '0', 'false', 'no', 'off'); + AND trim(value) = ''; + +INSERT INTO server_settings (key, value) +SELECT 'audiobookshelf_compat.enabled', 'true' +WHERE NOT EXISTS ( + SELECT 1 + FROM server_settings + WHERE key = 'audiobookshelf_compat.enabled' +); DELETE FROM server_settings WHERE key = 'audiobooks.enabled'; @@ -14,11 +22,6 @@ WHERE key = 'audiobooks.enabled'; -- +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; diff --git a/migrations/sql/20260608210000_audiobook_continue_listening_section_title.sql b/migrations/sql/20260608210000_audiobook_continue_listening_section_title.sql new file mode 100644 index 00000000..e2d029c1 --- /dev/null +++ b/migrations/sql/20260608210000_audiobook_continue_listening_section_title.sql @@ -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 diff --git a/migrations/sql/20260608224625_home_continue_listening_split.sql b/migrations/sql/20260608224625_home_continue_listening_split.sql new file mode 100644 index 00000000..928e3714 --- /dev/null +++ b/migrations/sql/20260608224625_home_continue_listening_split.sql @@ -0,0 +1,102 @@ +-- +goose Up +-- +goose StatementBegin +UPDATE page_sections +SET config = config || '{"continue_type":"listening"}'::jsonb, + updated_at = NOW() +WHERE section_type = 'continue_watching' + AND NOT (config ? 'continue_type') + AND config->>'filter_type' = 'audiobook'; + +UPDATE page_sections ps +SET config = ps.config || '{"continue_type":"listening"}'::jsonb, + 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 NOT (ps.config ? 'continue_type'); + +UPDATE page_sections +SET config = config || '{"continue_type":"watching"}'::jsonb, + updated_at = NOW() +WHERE section_type = 'continue_watching' + AND NOT (config ? 'continue_type'); + +WITH insert_position AS ( + SELECT COALESCE( + ( + SELECT ps.position + 1 + FROM page_sections ps + WHERE ps.scope = 'home' + AND ps.library_id IS NULL + AND ps.section_type = 'continue_watching' + AND ps.config->>'continue_type' = 'watching' + ORDER BY ps.position ASC + LIMIT 1 + ), + ( + SELECT COALESCE(MAX(position) + 1, 0) + FROM page_sections + WHERE scope = 'home' + AND library_id IS NULL + ) + ) AS position + WHERE EXISTS ( + SELECT 1 + FROM media_folders + WHERE lower(type) IN ('audiobook', 'audiobooks') + ) + AND NOT EXISTS ( + SELECT 1 + FROM page_sections + WHERE scope = 'home' + AND library_id IS NULL + AND section_type = 'continue_watching' + AND config->>'continue_type' = 'listening' + ) +), +shifted AS ( + UPDATE page_sections + SET position = position + 1, + updated_at = NOW() + WHERE scope = 'home' + AND library_id IS NULL + AND position >= (SELECT position FROM insert_position) + RETURNING 1 +) +INSERT INTO page_sections ( + id, scope, library_id, position, section_type, title, featured, + item_limit, config, enabled, created_at, updated_at +) +SELECT + gen_random_uuid()::text, + 'home', + NULL, + position, + 'continue_watching', + 'Continue Listening', + false, + 20, + '{"continue_type":"listening"}'::jsonb, + true, + NOW(), + NOW() +FROM insert_position; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DELETE FROM page_sections +WHERE scope = 'home' + AND library_id IS NULL + AND section_type = 'continue_watching' + AND title = 'Continue Listening' + AND config->>'continue_type' = 'listening'; + +UPDATE page_sections +SET config = config - 'continue_type', + updated_at = NOW() +WHERE section_type = 'continue_watching' + AND config ? 'continue_type'; +-- +goose StatementEnd diff --git a/web/src/App.tsx b/web/src/App.tsx index 74386e48..ff9f0695 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 738c843e..b4654a7c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 { diff --git a/web/src/components/AdminLayout.tsx b/web/src/components/AdminLayout.tsx index 5b8186ea..6fead98d 100644 --- a/web/src/components/AdminLayout.tsx +++ b/web/src/components/AdminLayout.tsx @@ -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() {
diff --git a/web/src/components/ContinueWatchingCard.test.tsx b/web/src/components/ContinueWatchingCard.test.tsx index bd4a3f18..aa081c5b 100644 --- a/web/src/components/ContinueWatchingCard.test.tsx +++ b/web/src/components/ContinueWatchingCard.test.tsx @@ -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( + + + + + , + ); + + expect(markup).toContain('href="/item/book-001?libraryId=7&play=1"'); + expect(markup).not.toContain('href="/watch/book-001'); + }); }); diff --git a/web/src/components/ContinueWatchingCard.tsx b/web/src/components/ContinueWatchingCard.tsx index e528d65d..02780505 100644 --- a/web/src/components/ContinueWatchingCard.tsx +++ b/web/src/components/ContinueWatchingCard.tsx @@ -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,18 @@ 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 +55,15 @@ 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, @@ -69,6 +83,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { ? { itemId: props.sectionItem.content_id, surface: "next_up" as const, + mediaType: props.sectionItem.type, seriesId: props.sectionItem.series_id, } : undefined @@ -76,12 +91,14 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { ? { itemId: props.sectionItem.content_id, surface: "continue_watching" as const, + mediaType: props.sectionItem.type, progressUpdatedAt: props.sectionItem.progress_updated_at, } : undefined : { itemId: props.detail.content_id, surface: "continue_watching" as const, + mediaType: props.detail.type, progressUpdatedAt: props.progress.updated_at, }; const progressPercent = @@ -121,6 +138,10 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { return; } + if (!isVideoWatchHref(card.watchHref)) { + return; + } + const parsed = parseWatchHref(card.watchHref); if (!parsed) { return; @@ -232,6 +253,7 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) { mediaType={ "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 } diff --git a/web/src/components/HeroBanner.test.tsx b/web/src/components/HeroBanner.test.tsx index e6a741ef..f9341340 100644 --- a/web/src/components/HeroBanner.test.tsx +++ b/web/src/components/HeroBanner.test.tsx @@ -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( @@ -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( + + + , + ); + + 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( + + + , + ); + + expect(markup).toContain("Resume"); + }); + + it("labels completed audiobook hero actions as listen again", () => { + const markup = renderToStaticMarkup( + + + , + ); + + 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( + + + , + ); + + await userEvent.click(screen.getByRole("link", { name: /pause/i })); + + expect(playbackMocks.toggleActivePlayback).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/src/components/HeroBanner.tsx b/web/src/components/HeroBanner.tsx index b2a8ead9..8302ef05 100644 --- a/web/src/components/HeroBanner.tsx +++ b/web/src/components/HeroBanner.tsx @@ -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>({}); 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) => { + if (activeAudiobookPlaying == null) { + return; + } + event.preventDefault(); + audiobookPlayback?.toggleActivePlayback(); + }; return (
- - Play + {activeAudiobookPlaying ? ( + + ) : ( + + )} + {playLabel} diff --git a/web/src/components/ItemCard.test.tsx b/web/src/components/ItemCard.test.tsx index 4cbf5f11..06ed0693 100644 --- a/web/src/components/ItemCard.test.tsx +++ b/web/src/components/ItemCard.test.tsx @@ -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", diff --git a/web/src/components/ItemCard.tsx b/web/src/components/ItemCard.tsx index 07bcc89a..9e42ef64 100644 --- a/web/src/components/ItemCard.tsx +++ b/web/src/components/ItemCard.tsx @@ -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({ diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 5b042ccd..70dd1c9d 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -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 ? ( diff --git a/web/src/components/MediaItemMenu.test.tsx b/web/src/components/MediaItemMenu.test.tsx index 583c9c4d..692a113e 100644 --- a/web/src/components/MediaItemMenu.test.tsx +++ b/web/src/components/MediaItemMenu.test.tsx @@ -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", diff --git a/web/src/components/MediaItemMenu.tsx b/web/src/components/MediaItemMenu.tsx index a3fea951..262aa54e 100644 --- a/web/src/components/MediaItemMenu.tsx +++ b/web/src/components/MediaItemMenu.tsx @@ -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) 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["key"]) { switch (actionKey) { case "playFromBeginning": { + if (mediaType === "audiobook") { + navigate(buildMediaPlayHref({ contentId, type: mediaType, libraryId, restart: true })); + return; + } playbackController.startPlayback({ contentId, restart: true, diff --git a/web/src/components/RecipeGallery/RecipeParamFields.tsx b/web/src/components/RecipeGallery/RecipeParamFields.tsx index bf9388ba..c492b653 100644 --- a/web/src/components/RecipeGallery/RecipeParamFields.tsx +++ b/web/src/components/RecipeGallery/RecipeParamFields.tsx @@ -12,6 +12,9 @@ export default function RecipeParamFields({ def, params, onChange }: RecipeParam if (def.type === "collection") { return ; } + if (def.type === "continue_watching") { + return ; + } if (def.type === "seasonal_themed") { return ; } @@ -109,6 +112,24 @@ interface ParamFieldProps { onChange: (next: Record) => void; } +function ContinueTypeParamField({ params, onChange }: ParamFieldProps) { + const continueType = params.continue_type === "listening" ? "listening" : "watching"; + + return ( + + ); +} + // Order matches SeasonalThemeOrder in the backend. Higher entries take // priority when multiple enabled themes match the current date. const SEASONAL_THEMES: Array<{ key: string; label: string; window: string; icon: string }> = [ diff --git a/web/src/components/SectionItemCard.tsx b/web/src/components/SectionItemCard.tsx index 46e48e30..ac25513e 100644 --- a/web/src/components/SectionItemCard.tsx +++ b/web/src/components/SectionItemCard.tsx @@ -90,6 +90,7 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp diff --git a/web/src/components/WatchTonightCard.test.tsx b/web/src/components/WatchTonightCard.test.tsx new file mode 100644 index 00000000..c7667042 --- /dev/null +++ b/web/src/components/WatchTonightCard.test.tsx @@ -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( + + {}} + 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", + }} + /> + , + ); + + expect(screen.getByRole("link", { name: "Listen Book One" })).toHaveAttribute( + "href", + "/item/book-1?play=1", + ); + }); +}); diff --git a/web/src/components/WatchTonightCard.tsx b/web/src/components/WatchTonightCard.tsx index 3d68935a..69b8d898 100644 --- a/web/src/components/WatchTonightCard.tsx +++ b/web/src/components/WatchTonightCard.tsx @@ -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 = { 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) => { @@ -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
diff --git a/web/src/components/WatchTonightDialog.tsx b/web/src/components/WatchTonightDialog.tsx index 68877a5f..e0722978 100644 --- a/web/src/components/WatchTonightDialog.tsx +++ b/web/src/components/WatchTonightDialog.tsx @@ -115,7 +115,7 @@ export default function WatchTonightDialog({ open, onOpenChange }: WatchTonightD

Pick Up Where I Left Off

- Continue watching or start next episodes + Continue in-progress titles or start what is next

diff --git a/web/src/components/catalog/CatalogFiltersPanel.tsx b/web/src/components/catalog/CatalogFiltersPanel.tsx index 0ee1ef21..a21845fa 100644 --- a/web/src/components/catalog/CatalogFiltersPanel.tsx +++ b/web/src/components/catalog/CatalogFiltersPanel.tsx @@ -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 diff --git a/web/src/components/catalog/catalogFilterBadges.test.ts b/web/src/components/catalog/catalogFilterBadges.test.ts new file mode 100644 index 00000000..fbecb4ea --- /dev/null +++ b/web/src/components/catalog/catalogFilterBadges.test.ts @@ -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" }), + ); + }); +}); diff --git a/web/src/components/catalog/catalogFilterBadges.ts b/web/src/components/catalog/catalogFilterBadges.ts index 9e6513da..14131b14 100644 --- a/web/src/components/catalog/catalogFilterBadges.ts +++ b/web/src/components/catalog/catalogFilterBadges.ts @@ -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: "" }, }); } diff --git a/web/src/components/collections/CollectionGuidedRulesEditor.tsx b/web/src/components/collections/CollectionGuidedRulesEditor.tsx index 9fe14924..fb907b06 100644 --- a/web/src/components/collections/CollectionGuidedRulesEditor.tsx +++ b/web/src/components/collections/CollectionGuidedRulesEditor.tsx @@ -780,7 +780,7 @@ export default function CollectionGuidedRulesEditor({ {allowPersonalizedFilters ? (
- + diff --git a/web/src/components/sections/EditableSectionRows.tsx b/web/src/components/sections/EditableSectionRows.tsx index 2e4b1326..26c9777a 100644 --- a/web/src/components/sections/EditableSectionRows.tsx +++ b/web/src/components/sections/EditableSectionRows.tsx @@ -32,6 +32,17 @@ export function recipeLabel(catalog: RecipeCatalogResponse | undefined, type: st return sectionTypeLabel(type); } +function continueTypeLabel(config?: Record): string | null { + const value = config?.continue_type; + if (value === "listening") return "Listening"; + if (value === "watching") return "Watching"; + if (value === "reading") return "Reading"; + if (config?.filter_type === "audiobook" || config?.media_scope === "audiobook") { + return "Listening"; + } + return null; +} + export function SectionSummaryBadges({ section, libraries, @@ -55,10 +66,13 @@ export function SectionSummaryBadges({ ? section.config.user_collection_id : undefined; const collectionLabel = collectionId ? collectionLabels?.get(collectionId) : undefined; + const resumeLabel = + section.sectionType === "continue_watching" ? continueTypeLabel(section.config) : null; return (
{recipeLabel(catalog, section.sectionType)} + {resumeLabel ? {resumeLabel} : null} {queryDefinition.media_scope === "movie" ? Movies : null} {queryDefinition.media_scope === "series" ? Series : null} {queryDefinition.media_scope === "episode" ? Episodes : null} diff --git a/web/src/components/sections/SectionEditorDrawer.test.ts b/web/src/components/sections/SectionEditorDrawer.test.ts new file mode 100644 index 00000000..39e92307 --- /dev/null +++ b/web/src/components/sections/SectionEditorDrawer.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { buildAdminSectionPayload, buildProfileSectionSaveEntry } from "./SectionEditorDrawer"; +import { queryDefinitionFromSectionConfig } from "@/api/types"; + +describe("SectionEditorDrawer payload builders", () => { + it("preserves continue listening config for admin sections", () => { + const payload = buildAdminSectionPayload({ + section: null, + scope: "home", + currentLibraryId: null, + sectionType: "continue_watching", + title: "Continue Listening", + itemLimit: 20, + featured: false, + enabled: true, + queryDefinition: queryDefinitionFromSectionConfig(), + selectedCollectionId: "", + recipeParams: { continue_type: "listening" }, + }); + + expect(payload).toMatchObject({ + section_type: "continue_watching", + title: "Continue Listening", + config: { continue_type: "listening" }, + }); + }); + + it("preserves continue listening config for profile sections", () => { + const entry = buildProfileSectionSaveEntry({ + section: null, + sectionType: "continue_watching", + title: "Continue Listening", + itemLimit: 20, + featured: false, + queryDefinition: queryDefinitionFromSectionConfig(), + selectedCollectionId: "", + recipeParams: { continue_type: "listening" }, + }); + + expect(entry).toMatchObject({ + section_type: "continue_watching", + title: "Continue Listening", + is_custom: true, + config: { continue_type: "listening" }, + }); + }); +}); diff --git a/web/src/components/watchtonight/CardStack.test.ts b/web/src/components/watchtonight/CardStack.test.ts new file mode 100644 index 00000000..4050ed97 --- /dev/null +++ b/web/src/components/watchtonight/CardStack.test.ts @@ -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, + }); + }); +}); diff --git a/web/src/components/watchtonight/CardStack.tsx b/web/src/components/watchtonight/CardStack.tsx index aae8e0f8..6088e26c 100644 --- a/web/src/components/watchtonight/CardStack.tsx +++ b/web/src/components/watchtonight/CardStack.tsx @@ -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 && ( - + )} {/* Counter */} diff --git a/web/src/components/watchtonight/SwipeCard.tsx b/web/src/components/watchtonight/SwipeCard.tsx index 52f979d0..1d3d4ea2 100644 --- a/web/src/components/watchtonight/SwipeCard.tsx +++ b/web/src/components/watchtonight/SwipeCard.tsx @@ -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 (
diff --git a/web/src/hooks/queries/homeDismissals.test.ts b/web/src/hooks/queries/homeDismissals.test.ts index f5805dd0..a16a1d05 100644 --- a/web/src/hooks/queries/homeDismissals.test.ts +++ b/web/src/hooks/queries/homeDismissals.test.ts @@ -116,6 +116,7 @@ describe("home dismissal query hooks", () => { const variables: DismissHomeItemVariables = { itemId: "ep-1", surface: "continue_watching", + mediaType: "episode", progressUpdatedAt: "2026-03-22T18:10:00Z", }; @@ -138,4 +139,27 @@ describe("home dismissal query hooks", () => { }), ); }); + + it("uses continue listening toast copy for audiobook dismissals", async () => { + useDismissHomeItem(); + const mutation = latestMutationOptions(); + + const variables: DismissHomeItemVariables = { + itemId: "book-1", + surface: "continue_watching", + mediaType: "audiobook", + progressUpdatedAt: "2026-03-22T18:10:00Z", + }; + + await mutation.onSuccess?.(undefined, variables); + + expect(mocks.toastSuccess).toHaveBeenCalledWith( + "Removed from Continue Listening", + expect.objectContaining({ + action: expect.objectContaining({ + label: "Undo", + }), + }), + ); + }); }); diff --git a/web/src/hooks/queries/homeDismissals.ts b/web/src/hooks/queries/homeDismissals.ts index dc1a5c75..cdfa1bc7 100644 --- a/web/src/hooks/queries/homeDismissals.ts +++ b/web/src/hooks/queries/homeDismissals.ts @@ -12,6 +12,7 @@ export type HomeDismissalSurface = "continue_watching" | "next_up"; export interface DismissHomeItemVariables { itemId: string; surface: HomeDismissalSurface; + mediaType?: string; seriesId?: string; progressUpdatedAt?: string; } @@ -26,10 +27,11 @@ function dismissalBody({ progressUpdatedAt, seriesId, surface }: DismissHomeItem : { series_id: seriesId }; } -function dismissalSuccessLabel(surface: HomeDismissalSurface) { - return surface === "continue_watching" - ? "Removed from Continue Watching" - : "Removed from Next Up"; +function dismissalSuccessLabel({ mediaType, surface }: DismissHomeItemVariables) { + if (surface === "next_up") return "Removed from Next Up"; + return mediaType === "audiobook" + ? "Removed from Continue Listening" + : "Removed from Continue Watching"; } export function useDismissHomeItem() { @@ -62,7 +64,7 @@ export function useDismissHomeItem() { removeItemFromHomeSectionCaches(queryClient, variables.itemId, variables.surface); await invalidateMediaSurfaceQueries(queryClient, { itemId: variables.itemId }); bumpHomeRefreshSignal(queryClient); - toast.success(dismissalSuccessLabel(variables.surface), { + toast.success(dismissalSuccessLabel(variables), { action: { label: "Undo", onClick: () => undoMutation.mutate(variables), diff --git a/web/src/lib/mediaNavigation.test.ts b/web/src/lib/mediaNavigation.test.ts new file mode 100644 index 00000000..c8f35721 --- /dev/null +++ b/web/src/lib/mediaNavigation.test.ts @@ -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", + ); + }); +}); diff --git a/web/src/lib/mediaNavigation.ts b/web/src/lib/mediaNavigation.ts new file mode 100644 index 00000000..8977259a --- /dev/null +++ b/web/src/lib/mediaNavigation.ts @@ -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) { + 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) { + 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/"); +} diff --git a/web/src/pages/ItemDetail/AudiobookContent.test.tsx b/web/src/pages/ItemDetail/AudiobookContent.test.tsx index 110ad3a3..47df8624 100644 --- a/web/src/pages/ItemDetail/AudiobookContent.test.tsx +++ b/web/src/pages/ItemDetail/AudiobookContent.test.tsx @@ -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; + stopPlayback: ReturnType; + toggleActivePlayback: ReturnType; + }, + 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
player @ {initialPositionSeconds}
; - }, +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( , ); - 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( @@ -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( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: /^pause/i })); + + expect(mocks.toggleActivePlayback).toHaveBeenCalledTimes(1); + expect(mocks.startPlayback).not.toHaveBeenCalled(); }); }); diff --git a/web/src/pages/ItemDetail/AudiobookContent.tsx b/web/src/pages/ItemDetail/AudiobookContent.tsx index da7dd0c0..66f234f9 100644 --- a/web/src/pages/ItemDetail/AudiobookContent.tsx +++ b/web/src/pages/ItemDetail/AudiobookContent.tsx @@ -1,9 +1,9 @@ -import { useMemo, useState } from "react"; +import { useCallback, 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( () => @@ -140,17 +141,56 @@ 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); - } + const openPlayer = useCallback( + (atSeconds: number) => { + audiobookPlayback?.startPlayback({ + contentId: item.content_id, + title: item.title, + author, + narrator, + posterUrl: item.poster_url, + files, + initialPositionSeconds: atSeconds, + }); + }, + [audiobookPlayback, author, files, item.content_id, item.poster_url, item.title, narrator], + ); 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, openPlayer, resumeSeconds, searchParams]); + return (
- {playerOpen && ( - setPlayerOpen(false)} - /> - )} - )}
@@ -265,7 +290,7 @@ export default function AudiobookContent({
{narrator && } @@ -311,7 +336,7 @@ export default function AudiobookContent({ openPlayer(seconds)} />
diff --git a/web/src/pages/audiobooks/components/ChaptersSection.test.tsx b/web/src/pages/audiobooks/components/ChaptersSection.test.tsx index f19ee605..23f8c3ed 100644 --- a/web/src/pages/audiobooks/components/ChaptersSection.test.tsx +++ b/web/src/pages/audiobooks/components/ChaptersSection.test.tsx @@ -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(); 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 () => { diff --git a/web/src/pages/audiobooks/components/ChaptersSection.tsx b/web/src/pages/audiobooks/components/ChaptersSection.tsx index c1564566..1c40b3b2 100644 --- a/web/src/pages/audiobooks/components/ChaptersSection.tsx +++ b/web/src/pages/audiobooks/components/ChaptersSection.tsx @@ -155,7 +155,7 @@ export function ChaptersSection({ files, currentPositionSeconds, onSelect }: Cha {isCurrent && ( - playing + listening )} diff --git a/web/src/pages/audiobooks/player/AudiobookPlayer.tsx b/web/src/pages/audiobooks/player/AudiobookPlayer.tsx index 02ae98ce..3292063b 100644 --- a/web/src/pages/audiobooks/player/AudiobookPlayer.tsx +++ b/web/src/pages/audiobooks/player/AudiobookPlayer.tsx @@ -1,9 +1,21 @@ -import { useState } from "react"; +import { useEffect, useRef, 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,14 +40,94 @@ export default function AudiobookPlayer({ initialPositionSeconds = 0, autoPlay = true, onClose, + onPlaybackStateChange, + onControlsChange, }: AudiobookPlayerProps) { const playback = useAudiobookPlayback({ contentId, files, initialPositionSeconds, autoPlay, + onStopRequested: onClose, }); const [mode, setMode] = useState<"mini" | "now-listening">("mini"); + const lastPlaybackStateEmitRef = useRef<{ + emittedAt: number; + duration: number; + hasFile: boolean; + playing: boolean; + } | null>(null); + const playbackStateTimerRef = useRef(null); + + useEffect(() => { + onControlsChange?.({ togglePlay: playback.togglePlay }); + return () => onControlsChange?.(null); + }, [onControlsChange, playback.togglePlay]); + + useEffect(() => { + return () => { + if (playbackStateTimerRef.current != null) { + window.clearTimeout(playbackStateTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!onPlaybackStateChange) { + return; + } + + const status: AudiobookPlayerStatus = { + contentId, + playing: playback.playing, + currentTime: playback.currentTime, + duration: playback.duration, + hasFile: playback.hasFile, + }; + const last = lastPlaybackStateEmitRef.current; + const statusChanged = + !last || + last.playing !== status.playing || + last.duration !== status.duration || + last.hasFile !== status.hasFile; + const now = Date.now(); + + const emit = () => { + lastPlaybackStateEmitRef.current = { + emittedAt: Date.now(), + duration: status.duration, + hasFile: status.hasFile, + playing: status.playing, + }; + onPlaybackStateChange(status); + }; + + if (!last || statusChanged || now - last.emittedAt >= 1000) { + if (playbackStateTimerRef.current != null) { + window.clearTimeout(playbackStateTimerRef.current); + playbackStateTimerRef.current = null; + } + emit(); + return; + } + + if (playbackStateTimerRef.current == null) { + playbackStateTimerRef.current = window.setTimeout( + () => { + playbackStateTimerRef.current = null; + emit(); + }, + Math.max(0, 1000 - (now - last.emittedAt)), + ); + } + }, [ + contentId, + onPlaybackStateChange, + playback.currentTime, + playback.duration, + playback.hasFile, + playback.playing, + ]); return ( <> diff --git a/web/src/pages/audiobooks/player/audiobookPlaybackContext.tsx b/web/src/pages/audiobooks/player/audiobookPlaybackContext.tsx new file mode 100644 index 00000000..eb90cb45 --- /dev/null +++ b/web/src/pages/audiobooks/player/audiobookPlaybackContext.tsx @@ -0,0 +1,114 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { getAccessToken, getProfileToken } from "@/api/client"; +import type { AudiobookFile } from "@/lib/audiobooks/types"; +import { PlayerConfigProvider, type PlayerConfig } from "@/player"; +import { storage } from "@/utils/storage"; +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( + null, +); + +export function useAudiobookPlaybackController() { + return useContext(AudiobookPlaybackControllerContext); +} + +export function AudiobookPlaybackProvider({ children }: { children: ReactNode }) { + const [activeRequest, setActiveRequest] = useState(null); + const [active, setActive] = useState(null); + const [controls, setControls] = useState(null); + const playerConfig = useMemo( + () => ({ + apiBaseUrl: "/api/v1", + getAccessToken: () => getAccessToken(), + getProfileId: () => storage.get(storage.KEYS.PROFILE_ID), + getProfileToken: () => getProfileToken(), + }), + [], + ); + + const startPlayback = useCallback((input: AudiobookPlaybackStartInput) => { + setControls(null); + setActive(null); + setActiveRequest((previous) => ({ + ...input, + requestKey: + previous?.contentId === input.contentId + ? previous.requestKey + : (previous?.requestKey ?? 0) + 1, + })); + }, []); + + const stopPlayback = useCallback(() => { + setControls(null); + setActive(null); + setActiveRequest(null); + }, []); + + const toggleActivePlayback = useCallback(() => { + controls?.togglePlay(); + }, [controls]); + + const value = useMemo( + () => ({ + active, + activeRequest, + isBackgroundBarVisible: Boolean(activeRequest), + startPlayback, + stopPlayback, + toggleActivePlayback, + }), + [active, activeRequest, startPlayback, stopPlayback, toggleActivePlayback], + ); + + return ( + + {children} + {activeRequest && ( + + + + )} + + ); +} diff --git a/web/src/pages/audiobooks/player/useAudiobookPlayback.test.ts b/web/src/pages/audiobooks/player/useAudiobookPlayback.test.ts index 0cc51a39..d0a8897d 100644 --- a/web/src/pages/audiobooks/player/useAudiobookPlayback.test.ts +++ b/web/src/pages/audiobooks/player/useAudiobookPlayback.test.ts @@ -1,13 +1,26 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; +import { createElement, type MutableRefObject, type ReactNode } from "react"; +import { PlayerConfigProvider, type PlayerConfig } from "@/player"; import { useAudiobookPlayback } from "./useAudiobookPlayback"; import type { AudiobookFile } from "@/lib/audiobooks/types"; +import type { PlaybackRealtimeCommandEnvelope } from "@/player/realtime-protocol"; + +const realtimeOptions = vi.hoisted(() => ({ + current: null as null | { + sessionId: string | null; + onCommand: (command: PlaybackRealtimeCommandEnvelope) => Promise | void; + }, +})); vi.mock("@/hooks/queries/progress", () => ({ useReportMediaProgress: () => ({ mutate: vi.fn() }), })); -vi.mock("@/hooks/queries/downloads", () => ({ - buildDirectDownloadUrl: (id: number) => `/stream/${id}`, +vi.mock("@/player/hooks/usePlaybackRealtime", () => ({ + usePlaybackRealtime: vi.fn((options) => { + realtimeOptions.current = options; + return { connectionState: "connected" }; + }), })); const files: AudiobookFile[] = [ @@ -43,46 +56,154 @@ function makeAudio() { Object.defineProperty(audio, "paused", { value: true, writable: true }); audio.play = vi.fn().mockResolvedValue(undefined); audio.pause = vi.fn(); + audio.load = vi.fn(); return audio; } +const playerConfig: PlayerConfig = { + apiBaseUrl: "/api/v1", + getAccessToken: () => "token", + getProfileId: () => "profile-1", + getProfileToken: () => null, +}; + +function wrapper({ children }: { children: ReactNode }) { + return createElement(PlayerConfigProvider, { config: playerConfig, children }); +} + +function renderAudiobookPlayback( + options: Partial[0]> = {}, +) { + return renderHook( + () => + useAudiobookPlayback({ + contentId: "c", + files, + initialPositionSeconds: 0, + ...options, + }), + { wrapper }, + ); +} + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + ...init, + }); +} + +function realtimeCommand( + name: PlaybackRealtimeCommandEnvelope["name"], + payload?: Record, +): PlaybackRealtimeCommandEnvelope { + return { + type: "command", + command_id: `cmd-${name}`, + session_id: realtimeOptions.current?.sessionId ?? "session-1", + name, + payload, + }; +} + +async function flushAsyncWork() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + describe("useAudiobookPlayback", () => { beforeEach(() => { vi.useFakeTimers(); + let sessionCount = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/api/v1/playback/start") || url.endsWith("/playback/start")) { + sessionCount += 1; + return jsonResponse( + { + session_id: `session-${sessionCount}`, + user_id: 1, + profile_id: "profile-1", + media_file_id: 1, + play_method: "direct", + position: 0, + is_paused: false, + stream_url: `/stream/session-${sessionCount}`, + audio_track_index: 0, + duration_seconds: 600, + }, + { status: 201 }, + ); + } + if (url.includes("/progress") || init?.method === "DELETE") { + return new Response(null, { status: 204 }); + } + return jsonResponse({}); + }), + ); + realtimeOptions.current = null; }); it("returns a flattened chapter list across files", () => { - const { result } = renderHook(() => - useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }), - ); + const { result } = renderAudiobookPlayback(); expect(result.current.chapters).toHaveLength(2); expect(result.current.chapters[0]!.start_seconds).toBe(0); expect(result.current.chapters[1]!.start_seconds).toBe(300); }); - it("computes streamUrl from the first file id", () => { - const { result } = renderHook(() => - useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }), - ); - expect(result.current.streamUrl).toBe("/stream/1"); + it("starts a playback session and builds a tokenized stream URL", async () => { + const { result } = renderAudiobookPlayback(); + + await flushAsyncWork(); + + expect(result.current.streamUrl).toBe("/api/v1/stream/session-1?token=token"); + + const startCall = vi + .mocked(fetch) + .mock.calls.find(([url]) => String(url).endsWith("/playback/start")); + expect(startCall).toBeTruthy(); + expect(JSON.parse(String(startCall?.[1]?.body))).toMatchObject({ + file_id: 1, + profile_id: "profile-1", + play_method: "direct", + start_position: 0, + disable_progress_persistence: true, + }); }); - it("computes streamUrl from the part containing the initial absolute position", () => { - const { result } = renderHook(() => - useAudiobookPlayback({ contentId: "c", files: multiFile, initialPositionSeconds: 450 }), - ); - expect(result.current.streamUrl).toBe("/stream/2"); + it("starts from the part containing the initial absolute position", async () => { + const { result } = renderAudiobookPlayback({ + files: multiFile, + initialPositionSeconds: 450, + }); + + await flushAsyncWork(); + + expect(result.current.streamUrl).toBe("/api/v1/stream/session-1?token=token"); expect(result.current.currentTime).toBe(450); expect(result.current.duration).toBe(600); + + const startCall = vi + .mocked(fetch) + .mock.calls.find(([url]) => String(url).endsWith("/playback/start")); + expect(JSON.parse(String(startCall?.[1]?.body))).toMatchObject({ + file_id: 2, + start_position: 150, + disable_progress_persistence: true, + }); }); it("togglePlay invokes audio.play when paused, audio.pause otherwise", () => { - const { result } = renderHook(() => - useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }), - ); + const { result } = renderAudiobookPlayback(); const audio = makeAudio(); act(() => { - (result.current.audioRef as React.MutableRefObject).current = audio; + (result.current.audioRef as MutableRefObject).current = audio; }); act(() => result.current.togglePlay()); expect(audio.play).toHaveBeenCalled(); @@ -91,13 +212,48 @@ describe("useAudiobookPlayback", () => { expect(audio.pause).toHaveBeenCalled(); }); - it("seekTo clamps to [0, duration]", () => { - const { result } = renderHook(() => - useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }), - ); + it("executes realtime playback commands against the audio element", async () => { + const onStopRequested = vi.fn(); + const { result } = renderAudiobookPlayback({ onStopRequested }); const audio = makeAudio(); act(() => { - (result.current.audioRef as React.MutableRefObject).current = audio; + (result.current.audioRef as MutableRefObject).current = audio; + }); + + await flushAsyncWork(); + + expect(realtimeOptions.current?.sessionId).toBe("session-1"); + + await act(async () => { + await realtimeOptions.current?.onCommand(realtimeCommand("unpause")); + }); + expect(audio.play).toHaveBeenCalled(); + + Object.defineProperty(audio, "paused", { value: false, writable: true }); + await act(async () => { + await realtimeOptions.current?.onCommand(realtimeCommand("pause")); + }); + expect(audio.pause).toHaveBeenCalled(); + + await act(async () => { + await realtimeOptions.current?.onCommand(realtimeCommand("seek", { position_seconds: 120 })); + }); + expect(result.current.currentTime).toBe(120); + + await act(async () => { + await realtimeOptions.current?.onCommand(realtimeCommand("stop")); + }); + act(() => { + vi.runOnlyPendingTimers(); + }); + expect(onStopRequested).toHaveBeenCalled(); + }); + + it("seekTo clamps to [0, duration]", () => { + const { result } = renderAudiobookPlayback(); + const audio = makeAudio(); + act(() => { + (result.current.audioRef as MutableRefObject).current = audio; }); act(() => result.current.seekTo(1_000_000)); expect(audio.currentTime).toBe(599); // 600 - 1 (clamp to duration - 1 per existing behavior) @@ -106,20 +262,16 @@ describe("useAudiobookPlayback", () => { }); it("currentChapter starts at the first chapter when currentTime is 0", () => { - const { result } = renderHook(() => - useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }), - ); + const { result } = renderAudiobookPlayback(); expect(result.current.currentChapter?.title).toBe("One"); }); it("setSleep arms a duration timer that fires after the configured seconds", () => { - const { result } = renderHook(() => - useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }), - ); + const { result } = renderAudiobookPlayback(); const audio = makeAudio(); Object.defineProperty(audio, "paused", { value: false, writable: true }); act(() => { - (result.current.audioRef as React.MutableRefObject).current = audio; + (result.current.audioRef as MutableRefObject).current = audio; }); act(() => result.current.setSleep({ kind: "duration", seconds: 1 })); expect(result.current.sleep.remainingMs).toBeGreaterThan(0); @@ -130,9 +282,7 @@ describe("useAudiobookPlayback", () => { }); it("setSleep with off clears any armed timer", () => { - const { result } = renderHook(() => - useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }), - ); + const { result } = renderAudiobookPlayback(); act(() => result.current.setSleep({ kind: "duration", seconds: 5 })); expect(result.current.sleep.remainingMs).toBeGreaterThan(0); act(() => result.current.setSleep({ kind: "off" })); diff --git a/web/src/pages/audiobooks/player/useAudiobookPlayback.ts b/web/src/pages/audiobooks/player/useAudiobookPlayback.ts index 96285c51..c2169260 100644 --- a/web/src/pages/audiobooks/player/useAudiobookPlayback.ts +++ b/web/src/pages/audiobooks/player/useAudiobookPlayback.ts @@ -1,8 +1,13 @@ import { useEffect, useMemo, useRef, useState, useCallback, type RefObject } from "react"; -import { buildDirectDownloadUrl } from "@/hooks/queries/downloads"; +import { toast } from "sonner"; import { useReportMediaProgress } from "@/hooks/queries/progress"; import type { AudiobookFile } from "@/lib/audiobooks/types"; -import type { PlayerChapter } from "@/player/types"; +import { usePlayerConfig } from "@/player/context/PlayerConfigContext"; +import { playerFetch } from "@/player/player-fetch"; +import type { PlaybackRealtimeCommandEnvelope } from "@/player/realtime-protocol"; +import { buildPlayerStreamUrl } from "@/player/stream-url"; +import type { PlaybackSessionResponse, PlayerChapter } from "@/player/types"; +import { usePlaybackRealtime } from "@/player/hooks/usePlaybackRealtime"; import type { SleepSetting } from "@/player/components/SleepTimerMenu"; const REPORT_INTERVAL_MS = 10_000; @@ -12,6 +17,7 @@ export interface UseAudiobookPlaybackOptions { files: AudiobookFile[]; initialPositionSeconds: number; autoPlay?: boolean; + onStopRequested?: () => void; } export interface AudiobookPlayback { @@ -33,6 +39,11 @@ export interface AudiobookPlayback { setSleep: (next: SleepSetting) => void; } +interface AudiobookSessionState { + sessionId: string | null; + streamUrl: string; +} + interface AudiobookPart { file: AudiobookFile; start: number; @@ -144,33 +155,69 @@ function buildPlayerChapters(files: AudiobookFile[]): PlayerChapter[] { return out; } +function readNumericPayload( + payload: Record | undefined, + ...keys: string[] +): number | null { + if (!payload) { + return null; + } + for (const key of keys) { + const value = payload[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return null; +} + +function readStringPayload( + payload: Record | undefined, + key: string, +): string | null { + const value = payload?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + export function useAudiobookPlayback({ contentId, files, initialPositionSeconds, autoPlay = true, + onStopRequested, }: UseAudiobookPlaybackOptions): AudiobookPlayback { + const config = usePlayerConfig(); const audioRef = useRef(null); const parts = useMemo(() => buildParts(files), [files]); const duration = useMemo(() => totalDuration(parts), [parts]); const chapters = useMemo(() => buildPlayerChapters(files), [files]); - const [activeFileIndex, setActiveFileIndex] = useState(() => - findPartIndex(parts, initialPositionSeconds), - ); + const [activeFileIndex, setActiveFileIndex] = useState(() => { + return findPartIndex(parts, initialPositionSeconds); + }); const [playing, setPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(() => clampedBookTime(initialPositionSeconds, duration), ); const [buffered, setBuffered] = useState(null); const [rate, setRateState] = useState(1); + const [sessionState, setSessionState] = useState({ + sessionId: null, + streamUrl: "", + }); const { mutate: reportProgress } = useReportMediaProgress(); const activePart = activeFileIndex >= 0 ? parts[activeFileIndex] : undefined; const fileId = activePart?.file.id; - const streamUrl = fileId ? buildDirectDownloadUrl(fileId) : ""; const currentTimeRef = useRef(currentTime); + const activePartRef = useRef(activePart); + const sessionIdRef = useRef(null); const reportRef = useRef<(pos: number) => void>(() => {}); - const pendingLocalSeekRef = useRef(null); + const reportSessionRef = useRef<(pos: number, isPaused: boolean, keepalive?: boolean) => void>( + () => {}, + ); + const pendingLocalSeekRef = useRef( + localTimeForPart(activePart, clampedBookTime(initialPositionSeconds, duration)), + ); const playAfterSourceSwitchRef = useRef(false); const autoPlayPendingRef = useRef(autoPlay); @@ -183,6 +230,37 @@ export function useAudiobookPlayback({ [duration], ); + useEffect(() => { + activePartRef.current = activePart; + }, [activePart]); + + const buildKeepaliveHeaders = useCallback(() => { + const headers: Record = { + "Content-Type": "application/json", + }; + const token = config.getAccessToken(); + if (token) headers["Authorization"] = `Bearer ${token}`; + const profileId = config.getProfileId(); + if (profileId) headers["X-Profile-Id"] = profileId; + const profileToken = config.getProfileToken?.(); + if (profileToken) headers["X-Profile-Token"] = profileToken; + return headers; + }, [config]); + + const stopSession = useCallback( + (sessionId: string, keepalive = false) => { + const request = fetch(`${config.apiBaseUrl}/playback/${sessionId}`, { + method: "DELETE", + headers: buildKeepaliveHeaders(), + keepalive, + }); + request.catch(() => { + // Best effort: stale sessions are retired server-side. + }); + }, + [buildKeepaliveHeaders, config.apiBaseUrl], + ); + useEffect(() => { reportRef.current = (posSeconds: number) => { reportProgress({ @@ -190,9 +268,39 @@ export function useAudiobookPlayback({ positionSeconds: Math.floor(safeNumber(posSeconds)), durationSeconds: Math.floor(safeNumber(duration)), }); + reportSessionRef.current(posSeconds, audioRef.current?.paused ?? true); }; }, [contentId, duration, reportProgress]); + useEffect(() => { + reportSessionRef.current = (posSeconds: number, isPaused: boolean, keepalive = false) => { + const sessionId = sessionIdRef.current; + const part = activePartRef.current; + if (!sessionId || !part) { + return; + } + const body = JSON.stringify({ + position: localTimeForPart(part, posSeconds), + is_paused: isPaused, + }); + if (keepalive) { + fetch(`${config.apiBaseUrl}/playback/${sessionId}/progress`, { + method: "POST", + headers: buildKeepaliveHeaders(), + body, + keepalive: true, + }).catch(() => {}); + return; + } + playerFetch(config, `/playback/${sessionId}/progress`, { + method: "POST", + body, + }).catch(() => { + // Progress is best effort and should not interrupt playback. + }); + }; + }, [buildKeepaliveHeaders, config]); + useEffect(() => { const target = clampedBookTime(initialPositionSeconds, duration); const index = findPartIndex(parts, target); @@ -204,6 +312,78 @@ export function useAudiobookPlayback({ setCurrentTime(target); }, [autoPlay, contentId, duration, initialPositionSeconds, parts]); + useEffect(() => { + if (!fileId || !activePart) { + setSessionState({ sessionId: null, streamUrl: "" }); + sessionIdRef.current = null; + return; + } + + let canceled = false; + let startedSessionId: string | null = null; + const localStart = + pendingLocalSeekRef.current ?? localTimeForPart(activePart, currentTimeRef.current); + + setSessionState({ sessionId: null, streamUrl: "" }); + + (async () => { + const profileId = config.getProfileId(); + if (!profileId) { + throw new Error("Missing active profile"); + } + + const session = await playerFetch(config, "/playback/start", { + method: "POST", + body: JSON.stringify({ + file_id: fileId, + profile_id: profileId, + play_method: "direct", + start_position: localStart, + disable_progress_persistence: true, + codecs_video: [], + codecs_audio: [], + containers: [], + max_resolution: "", + hdr: false, + }), + }); + + if (canceled) { + stopSession(session.session_id, true); + return; + } + + startedSessionId = session.session_id; + sessionIdRef.current = session.session_id; + setSessionState({ + sessionId: session.session_id, + streamUrl: buildPlayerStreamUrl( + config.apiBaseUrl, + session.stream_url, + config.getAccessToken(), + session.play_method, + localStart, + ), + }); + })().catch((err) => { + if (!canceled) { + console.error("audiobook playback session failed", err); + toast.error(err instanceof Error ? err.message : "Failed to start audiobook playback"); + } + }); + + return () => { + canceled = true; + if (startedSessionId) { + reportSessionRef.current(currentTimeRef.current, true, true); + stopSession(startedSessionId, true); + if (sessionIdRef.current === startedSessionId) { + sessionIdRef.current = null; + } + } + }; + }, [activePart, config, fileId, stopSession]); + useEffect(() => { const audio = audioRef.current; if (!audio || !fileId || !activePart) return; @@ -233,7 +413,10 @@ export function useAudiobookPlayback({ }); } }; - const onPlay = () => setPlaying(true); + const onPlay = () => { + setPlaying(true); + reportSessionRef.current(absoluteFromAudio(), false); + }; const onPause = () => { setPlaying(false); reportRef.current(absoluteFromAudio()); @@ -360,6 +543,85 @@ export function useAudiobookPlayback({ if (audioRef.current) audioRef.current.playbackRate = nextRate; }, []); + const executeRealtimeCommand = useCallback( + async (command: PlaybackRealtimeCommandEnvelope) => { + const audio = audioRef.current; + + switch (command.name) { + case "pause": + audio?.pause(); + return; + case "unpause": + if (!audio) return; + await audio.play(); + return; + case "play_pause": + if (!audio) return; + if (audio.paused) { + await audio.play(); + } else { + audio.pause(); + } + return; + case "seek": { + const position = readNumericPayload( + command.payload, + "position", + "position_seconds", + "seconds", + ); + if (position === null) { + throw new Error("missing_seek_position"); + } + seekTo(position); + return; + } + case "set_volume": { + const volume = readNumericPayload(command.payload, "volume", "level"); + if (volume === null || !audio) { + throw new Error("missing_volume"); + } + audio.volume = Math.min(1, Math.max(0, volume)); + if (audio.volume > 0 && audio.muted) { + audio.muted = false; + } + return; + } + case "display_message": + toast.info( + readStringPayload(command.payload, "message") ?? "A server message was received.", + ); + return; + case "server_restarting": + case "server_shutting_down": + toast.warning( + readStringPayload(command.payload, "message") ?? + (command.name === "server_restarting" + ? "Playback may end shortly while the server restarts." + : "Playback may end shortly while the server shuts down."), + ); + return; + case "stop": + case "terminate": + audio?.pause(); + if (audio) { + audio.removeAttribute("src"); + audio.load(); + } + window.setTimeout(() => onStopRequested?.(), 0); + return; + default: + throw new Error("unsupported"); + } + }, + [onStopRequested, seekTo], + ); + + usePlaybackRealtime({ + sessionId: sessionState.sessionId, + onCommand: executeRealtimeCommand, + }); + const currentChapter = useMemo(() => { if (chapters.length === 0) return null; for (let i = chapters.length - 1; i >= 0; i--) { @@ -426,7 +688,7 @@ export function useAudiobookPlayback({ return { audioRef, - streamUrl, + streamUrl: sessionState.streamUrl, hasFile: Boolean(activePart), playing, currentTime, diff --git a/web/src/player/hooks/usePlaybackSession.ts b/web/src/player/hooks/usePlaybackSession.ts index 205c7d8f..f78e632a 100644 --- a/web/src/player/hooks/usePlaybackSession.ts +++ b/web/src/player/hooks/usePlaybackSession.ts @@ -8,6 +8,7 @@ import { matchByTraits, getPlaybackEnvironmentSnapshot, } from "./useCodecDetection"; +import { buildPlayerStreamUrl } from "../stream-url"; import type { ChangeAudioResponse, PlaybackSessionPlaybackInfo, @@ -112,32 +113,6 @@ export function buildStartPlaybackRequestPayload({ return payload; } -function buildStreamUrl( - apiBaseUrl: string, - streamPath: string, - token: string | null, - playMethod: PlayMethod, - initialPosition: number, -): string { - const params = new URLSearchParams(); - - if (token) { - params.set("token", token); - } - - if (playMethod === "remux" && initialPosition > 0) { - params.set("seek", initialPosition.toFixed(3)); - } - - const query = params.toString(); - // If the backend returned an absolute URL (proxy mode), use it directly. - const base = - streamPath.startsWith("http://") || streamPath.startsWith("https://") - ? streamPath - : `${apiBaseUrl}${streamPath}`; - return `${base}${query ? `?${query}` : ""}`; -} - function describePlaybackSessionError( error: unknown, fallbackMessage: string, @@ -300,7 +275,7 @@ export function usePlaybackSession( const restoredPosition = session.position ?? 0; return { - streamUrl: buildStreamUrl( + streamUrl: buildPlayerStreamUrl( config.apiBaseUrl, session.stream_url, token, @@ -315,9 +290,9 @@ export function usePlaybackSession( durationSeconds: session.duration_seconds ?? null, subtitleUrls: (session.subtitle_urls ?? []).map((s) => ({ ...s, - url: buildStreamUrl(config.apiBaseUrl, s.url, token, "direct", 0), + url: buildPlayerStreamUrl(config.apiBaseUrl, s.url, token, "direct", 0), font_bundle_url: s.font_bundle_url - ? buildStreamUrl(config.apiBaseUrl, s.font_bundle_url, token, "direct", 0) + ? buildPlayerStreamUrl(config.apiBaseUrl, s.font_bundle_url, token, "direct", 0) : undefined, })), playbackInfo: session.playback_info ?? null, @@ -522,7 +497,7 @@ export function usePlaybackSession( label: `${dl.release_name} (${dl.provider})`, source: "downloaded" as const, hearing_impaired: dl.hearing_impaired, - url: buildStreamUrl( + url: buildPlayerStreamUrl( config.apiBaseUrl, `/stream/${sid}/subtitles/${baseIndex + i}`, token, @@ -556,7 +531,7 @@ export function usePlaybackSession( const token = config.getAccessToken(); setState((prev) => ({ ...prev, - streamUrl: buildStreamUrl( + streamUrl: buildPlayerStreamUrl( config.apiBaseUrl, resp.stream_url, token, diff --git a/web/src/player/stream-url.ts b/web/src/player/stream-url.ts new file mode 100644 index 00000000..92a73f4b --- /dev/null +++ b/web/src/player/stream-url.ts @@ -0,0 +1,26 @@ +import type { PlayMethod } from "./types"; + +export function buildPlayerStreamUrl( + apiBaseUrl: string, + streamPath: string, + token: string | null, + playMethod: PlayMethod, + initialPosition: number, +): string { + const params = new URLSearchParams(); + + if (token) { + params.set("token", token); + } + + if (playMethod === "remux" && initialPosition > 0) { + params.set("seek", initialPosition.toFixed(3)); + } + + const query = params.toString(); + const base = + streamPath.startsWith("http://") || streamPath.startsWith("https://") + ? streamPath + : `${apiBaseUrl}${streamPath}`; + return `${base}${query ? `?${query}` : ""}`; +} From 2fa30379ad4dc989d31c6743711e5d7744fbd3d8 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:36:07 -0400 Subject: [PATCH 04/27] fix(jellycompat): include Wholphin display preference dimensions (#102) --- internal/jellycompat/handlers_displayprefs.go | 22 ++++++++------- .../jellycompat/handlers_displayprefs_test.go | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 internal/jellycompat/handlers_displayprefs_test.go diff --git a/internal/jellycompat/handlers_displayprefs.go b/internal/jellycompat/handlers_displayprefs.go index 12a319ef..51e24e5a 100644 --- a/internal/jellycompat/handlers_displayprefs.go +++ b/internal/jellycompat/handlers_displayprefs.go @@ -14,16 +14,18 @@ import ( // displayPreferencesDTO mirrors Jellyfin's DisplayPreferences response. type displayPreferencesDTO struct { - ID string `json:"Id"` - SortBy string `json:"SortBy"` - SortOrder string `json:"SortOrder"` - RememberIndexing bool `json:"RememberIndexing"` - RememberSorting bool `json:"RememberSorting"` - ScrollDirection string `json:"ScrollDirection"` - ShowBackdrop bool `json:"ShowBackdrop"` - ShowSidebar bool `json:"ShowSidebar"` - Client string `json:"Client"` - CustomPrefs map[string]string `json:"CustomPrefs"` + ID string `json:"Id"` + SortBy string `json:"SortBy"` + SortOrder string `json:"SortOrder"` + RememberIndexing bool `json:"RememberIndexing"` + RememberSorting bool `json:"RememberSorting"` + ScrollDirection string `json:"ScrollDirection"` + ShowBackdrop bool `json:"ShowBackdrop"` + ShowSidebar bool `json:"ShowSidebar"` + PrimaryImageHeight int `json:"PrimaryImageHeight"` + PrimaryImageWidth int `json:"PrimaryImageWidth"` + Client string `json:"Client"` + CustomPrefs map[string]string `json:"CustomPrefs"` } // DisplayPreferencesHandler serves Jellyfin display preferences endpoints, diff --git a/internal/jellycompat/handlers_displayprefs_test.go b/internal/jellycompat/handlers_displayprefs_test.go new file mode 100644 index 00000000..e06709e6 --- /dev/null +++ b/internal/jellycompat/handlers_displayprefs_test.go @@ -0,0 +1,27 @@ +package jellycompat + +import ( + "encoding/json" + "testing" +) + +func TestDefaultDisplayPreferencesIncludesRequiredImageDimensions(t *testing.T) { + dto := defaultDisplayPreferences("default", "Wholphin") + + body, err := json.Marshal(dto) + if err != nil { + t.Fatalf("marshal default display preferences: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("unmarshal default display preferences: %v", err) + } + + if _, ok := raw["PrimaryImageHeight"]; !ok { + t.Fatal("PrimaryImageHeight missing from display preferences JSON") + } + if _, ok := raw["PrimaryImageWidth"]; !ok { + t.Fatal("PrimaryImageWidth missing from display preferences JSON") + } +} From 7c64c06e3d51ce9a658e37bef67c4e5e26cbd4ad Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:36:22 -0400 Subject: [PATCH 05/27] Redirect Jellyfin image routes to upstream URLs (#101) * Redirect Jellyfin image routes to upstream URLs * fix(jellycompat): proxy images for incompatible clients --- internal/jellycompat/dto.go | 1 + internal/jellycompat/handlers_images.go | 71 +++- internal/jellycompat/idcodec.go | 1 + internal/jellycompat/image_cache.go | 4 +- internal/jellycompat/image_proxy_tags.go | 233 +++++++++++++ internal/jellycompat/image_proxy_tags_test.go | 111 +++++++ internal/jellycompat/image_tag_signer.go | 2 +- internal/jellycompat/images.go | 214 ++++++++++-- internal/jellycompat/images_test.go | 306 +++++++++++------- internal/jellycompat/router.go | 9 +- internal/jellycompat/server.go | 3 +- 11 files changed, 799 insertions(+), 156 deletions(-) create mode 100644 internal/jellycompat/image_proxy_tags.go create mode 100644 internal/jellycompat/image_proxy_tags_test.go diff --git a/internal/jellycompat/dto.go b/internal/jellycompat/dto.go index eeb21054..3c56ee2c 100644 --- a/internal/jellycompat/dto.go +++ b/internal/jellycompat/dto.go @@ -48,6 +48,7 @@ type baseItemDTO struct { ProviderIDs map[string]string `json:"ProviderIds,omitempty"` ProductionLocations []string `json:"ProductionLocations,omitempty"` ImageTags map[string]string `json:"ImageTags"` + PrimaryImageItemID string `json:"PrimaryImageItemId,omitempty"` BackdropImageTags []string `json:"BackdropImageTags,omitempty"` PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,omitempty"` ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` diff --git a/internal/jellycompat/handlers_images.go b/internal/jellycompat/handlers_images.go index 5dd65885..9e524a28 100644 --- a/internal/jellycompat/handlers_images.go +++ b/internal/jellycompat/handlers_images.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "time" @@ -16,7 +17,6 @@ import ( type ImagesHandler struct { content ContentService codec *ResourceIDCodec - httpClient *http.Client sessions *SessionStore images *ImageCache personRepo *catalog.PersonRepository @@ -29,6 +29,7 @@ type ImagesHandler struct { posterSigner LibraryPosterPresigner presignTTL time.Duration imageTags *imageTagSigner + httpClient *http.Client } type imageItemRepository interface { @@ -48,15 +49,14 @@ type imageFolderRepository interface { GetByID(ctx context.Context, id int) (*models.MediaFolder, error) } -// NewImagesHandler creates an image proxy handler. -func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient *http.Client, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string) *ImagesHandler { +// NewImagesHandler creates a Jellyfin-compatible image route handler. +func NewImagesHandler(content ContentService, codec *ResourceIDCodec, sessions *SessionStore, images *ImageCache, personRepo *catalog.PersonRepository, detailSvc *catalog.DetailService, itemRepo *catalog.ItemRepository, folderRepo *catalog.FolderRepository, seasonRepo *catalog.SeasonRepository, episodeRepo *catalog.EpisodeRepository, accessFilter AccessFilterResolver, posterSigner LibraryPosterPresigner, presignTTL time.Duration, imageTagSecret string, httpClient *http.Client) *ImagesHandler { if httpClient == nil { httpClient = http.DefaultClient } return &ImagesHandler{ content: content, codec: codec, - httpClient: httpClient, sessions: sessions, images: images, personRepo: personRepo, @@ -69,6 +69,7 @@ func NewImagesHandler(content ContentService, codec *ResourceIDCodec, httpClient posterSigner: posterSigner, presignTTL: presignTTL, imageTags: newImageTagSigner(imageTagSecret), + httpClient: httpClient, } } @@ -80,6 +81,10 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request) imageType := chiURLParam(r, "imageType") imageSize := compatRequestImageSize(r, imageType) tag := strings.TrimSpace(r.URL.Query().Get("tag")) + if canonicalRouteID, ok := canonicalCompatImageRouteID(h.codec, routeID); ok { + routeID = canonicalRouteID + r = withCompatImageProxyRouteRequest(r) + } if tag != "" { imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag) if err != nil { @@ -88,15 +93,15 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request) } if ok { h.images.RememberSizedUntil(routeID, imageType, imageURL.URL, imageSize, imageURL.ExpiresAt) - h.proxyImageURL(w, r, imageURL.URL) + h.serveImageURL(w, r, imageURL.URL) return } if imageURL, ok := h.images.LookupTag(tag); ok { - h.proxyImageURL(w, r, imageURL) + h.serveImageURL(w, r, imageURL) return } } else if imageURL, ok := h.images.LookupSized(routeID, imageType, "", imageSize); ok { - h.proxyImageURL(w, r, imageURL) + h.serveImageURL(w, r, imageURL) return } @@ -133,7 +138,7 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request) return } h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt) - h.proxyImageURL(w, r, imageURL) + h.serveImageURL(w, r, imageURL) } // handlePersonImage serves person photo images. @@ -159,7 +164,7 @@ func (h *ImagesHandler) handlePersonImage(w http.ResponseWriter, r *http.Request return } h.images.RememberSizedUntil(routeID, imageType, imageURL, imageSize, resolvedImage.ExpiresAt) - h.proxyImageURL(w, r, imageURL) + h.serveImageURL(w, r, imageURL) } func (h *ImagesHandler) resolveItemImageURL(ctx context.Context, session *Session, contentID, imageType string, r *http.Request) (catalog.ResolvedImageURL, error) { @@ -414,18 +419,45 @@ func firstResolvedImageURL(values ...catalog.ResolvedImageURL) catalog.ResolvedI return catalog.ResolvedImageURL{} } +func (h *ImagesHandler) serveImageURL(w http.ResponseWriter, r *http.Request, imageURL string) { + if shouldProxyCompatImageRequest(r) { + h.proxyImageURL(w, r, imageURL) + return + } + h.redirectImageURL(w, r, imageURL) +} + +func (h *ImagesHandler) redirectImageURL(w http.ResponseWriter, r *http.Request, imageURL string) { + if _, err := parseRemoteImageURL(imageURL); err != nil { + writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image") + return + } + + // Do not let clients cache the temporary redirect itself. The object-store + // response can still carry its own cache headers after the client follows it. + setCompatImageRouteNoStore(w.Header()) + http.Redirect(w, r, imageURL, http.StatusFound) +} + func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, imageURL string) { - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, imageURL, nil) + target, err := parseRemoteImageURL(imageURL) if err != nil { writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image") return } - for _, header := range []string{"If-None-Match", "If-Modified-Since"} { - if value := r.Header.Get(header); value != "" { - req.Header.Set(header, value) - } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil) + if err != nil { + writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image") + return } - resp, err := h.httpClient.Do(req) + copyConditionalImageRequestHeaders(req.Header, r.Header) + + client := h.httpClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) if err != nil { writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image") return @@ -433,6 +465,15 @@ func (h *ImagesHandler) proxyImageURL(w http.ResponseWriter, r *http.Request, im proxyImage(w, resp) } +func parseRemoteImageURL(imageURL string) (*url.URL, error) { + target, err := url.Parse(imageURL) + if err != nil || target.Scheme == "" || target.Host == "" || + (target.Scheme != "http" && target.Scheme != "https") { + return nil, errors.New("invalid remote image URL") + } + return target, nil +} + // HandleUserImage returns a deterministic placeholder avatar. func (h *ImagesHandler) HandleUserImage(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) diff --git a/internal/jellycompat/idcodec.go b/internal/jellycompat/idcodec.go index bcae3dce..bdd60fbe 100644 --- a/internal/jellycompat/idcodec.go +++ b/internal/jellycompat/idcodec.go @@ -21,6 +21,7 @@ const ( EncodedIDGenre EncodedIDType = 6 EncodedIDStudio EncodedIDType = 7 EncodedIDPerson EncodedIDType = 8 + EncodedIDImageProxy EncodedIDType = 9 ) var ( diff --git a/internal/jellycompat/image_cache.go b/internal/jellycompat/image_cache.go index ab0939f4..177e8b95 100644 --- a/internal/jellycompat/image_cache.go +++ b/internal/jellycompat/image_cache.go @@ -93,7 +93,7 @@ func (c *ImageCache) LookupSized(routeID, imageType, tag, size string) (string, return "", false } - if tag = strings.TrimSpace(tag); tag != "" { + if tag = canonicalCompatImageTag(tag); tag != "" { return c.LookupTag(tag) } @@ -108,7 +108,7 @@ func (c *ImageCache) LookupTag(tag string) (string, bool) { if c == nil { return "", false } - return c.lookupTag(strings.TrimSpace(tag)) + return c.lookupTag(canonicalCompatImageTag(tag)) } // lookupTag resolves a tag without size partitioning. Tags are sha1 of the diff --git a/internal/jellycompat/image_proxy_tags.go b/internal/jellycompat/image_proxy_tags.go new file mode 100644 index 00000000..79711304 --- /dev/null +++ b/internal/jellycompat/image_proxy_tags.go @@ -0,0 +1,233 @@ +package jellycompat + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" +) + +func compatImageProxyTagVariantMiddleware(codec *ResourceIDCodec) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !isCompatImageProxyClientRequest(r) || isWebSocketUpgrade(r) { + next.ServeHTTP(w, r) + return + } + + rw := &compatImageProxyTagResponseWriter{ResponseWriter: w, codec: codec} + next.ServeHTTP(rw, r) + rw.finish() + }) + } +} + +type compatImageProxyTagResponseWriter struct { + http.ResponseWriter + status int + body bytes.Buffer + passthrough bool + codec *ResourceIDCodec +} + +func (w *compatImageProxyTagResponseWriter) WriteHeader(status int) { + if w.passthrough { + w.ResponseWriter.WriteHeader(status) + return + } + w.status = status +} + +func (w *compatImageProxyTagResponseWriter) Write(p []byte) (int, error) { + if w.passthrough { + return w.ResponseWriter.Write(p) + } + if w.status == 0 { + w.status = http.StatusOK + } + if !isJSONResponse(w.Header().Get("Content-Type")) { + w.passthrough = true + w.ResponseWriter.WriteHeader(w.status) + return w.ResponseWriter.Write(p) + } + return w.body.Write(p) +} + +func (w *compatImageProxyTagResponseWriter) finish() { + if w.passthrough { + return + } + if w.status == 0 && w.body.Len() == 0 { + return + } + status := w.status + if status == 0 { + status = http.StatusOK + } + body := w.body.Bytes() + if isJSONResponse(w.Header().Get("Content-Type")) && len(body) > 0 { + body = rewriteCompatImageProxyTags(w.codec, body) + } + w.ResponseWriter.WriteHeader(status) + if len(body) > 0 { + _, _ = w.ResponseWriter.Write(body) + } +} + +func isJSONResponse(contentType string) bool { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + return contentType == "application/json" || strings.HasPrefix(contentType, "application/json;") +} + +func isWebSocketUpgrade(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") +} + +func rewriteCompatImageProxyTags(codec *ResourceIDCodec, body []byte) []byte { + var value any + if err := json.Unmarshal(body, &value); err != nil { + return body + } + if !appendCompatImageProxyTags(codec, value) { + return body + } + rewritten, err := json.Marshal(value) + if err != nil { + return body + } + return append(rewritten, '\n') +} + +func appendCompatImageProxyTags(codec *ResourceIDCodec, value any) bool { + switch typed := value.(type) { + case map[string]any: + changed := false + hasPrimaryImageTag := compatMapHasPrimaryImageTag(typed) + for key, child := range typed { + switch key { + case "ImageTags": + changed = appendCompatImageProxyTagsInMap(child) || changed + case "ImageBlurHashes": + changed = appendCompatImageProxyTagsInBlurHashes(child) || changed + case "BackdropImageTags", "ParentBackdropImageTags": + changed = appendCompatImageProxyTagsInSlice(child) || changed + case "PrimaryImageTag", "SeriesPrimaryImageTag", "ParentThumbImageTag", "BackdropImageTag", "ImageTag": + if tag, ok := child.(string); ok && tag != "" { + typed[key] = compatImageProxyTag(tag) + changed = true + } + default: + changed = appendCompatImageProxyTags(codec, child) || changed + } + } + if hasPrimaryImageTag { + changed = appendCompatPrimaryImageItemID(codec, typed) || changed + } + return changed + case []any: + changed := false + for _, child := range typed { + changed = appendCompatImageProxyTags(codec, child) || changed + } + return changed + default: + return false + } +} + +func compatMapHasPrimaryImageTag(value map[string]any) bool { + if tags, ok := value["ImageTags"].(map[string]any); ok { + if tag, ok := tags["Primary"].(string); ok && strings.TrimSpace(tag) != "" { + return true + } + } + for _, key := range []string{"PrimaryImageTag", "ImageTag"} { + if tag, ok := value[key].(string); ok && strings.TrimSpace(tag) != "" { + return true + } + } + return false +} + +func appendCompatPrimaryImageItemID(codec *ResourceIDCodec, value map[string]any) bool { + if codec == nil { + return false + } + routeID := compatStringMapValue(value, "PrimaryImageItemId") + if routeID == "" { + routeID = compatStringMapValue(value, "Id") + } + if routeID == "" { + routeID = compatStringMapValue(value, "ItemId") + } + if routeID == "" { + return false + } + proxyRouteID := compatImageProxyRouteID(codec, routeID) + if proxyRouteID == "" || proxyRouteID == routeID { + return false + } + if compatStringMapValue(value, "PrimaryImageItemId") == proxyRouteID { + return false + } + value["PrimaryImageItemId"] = proxyRouteID + return true +} + +func compatStringMapValue(value map[string]any, key string) string { + if raw, ok := value[key].(string); ok { + return strings.TrimSpace(raw) + } + return "" +} + +func appendCompatImageProxyTagsInMap(value any) bool { + tags, ok := value.(map[string]any) + if !ok { + return false + } + changed := false + for key, raw := range tags { + if tag, ok := raw.(string); ok && tag != "" { + tags[key] = compatImageProxyTag(tag) + changed = true + } + } + return changed +} + +func appendCompatImageProxyTagsInSlice(value any) bool { + tags, ok := value.([]any) + if !ok { + return false + } + changed := false + for idx, raw := range tags { + if tag, ok := raw.(string); ok && tag != "" { + tags[idx] = compatImageProxyTag(tag) + changed = true + } + } + return changed +} + +func appendCompatImageProxyTagsInBlurHashes(value any) bool { + byImageType, ok := value.(map[string]any) + if !ok { + return false + } + changed := false + for imageType, rawTags := range byImageType { + tags, ok := rawTags.(map[string]any) + if !ok { + continue + } + next := make(map[string]any, len(tags)) + for tag, hash := range tags { + next[compatImageProxyTag(tag)] = hash + changed = true + } + byImageType[imageType] = next + } + return changed +} diff --git a/internal/jellycompat/image_proxy_tags_test.go b/internal/jellycompat/image_proxy_tags_test.go new file mode 100644 index 00000000..aaa8a07e --- /dev/null +++ b/internal/jellycompat/image_proxy_tags_test.go @@ -0,0 +1,111 @@ +package jellycompat + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCompatImageProxyTagVariantMiddlewareSuffixesInfuseImageTags(t *testing.T) { + codec := NewResourceIDCodec() + handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, queryResultDTO{ + Items: []baseItemDTO{{ + ID: "item-1", + Name: "Movie", + ImageTags: map[string]string{"Primary": "primary-tag"}, + BackdropImageTags: []string{"backdrop-tag"}, + SeriesPrimaryImageTag: "series-tag", + ParentBackdropImageTags: []string{"parent-backdrop-tag"}, + ParentThumbImageTag: "parent-thumb-tag", + ImageBlurHashes: map[string]map[string]string{ + "Primary": {"primary-tag": "thumbhash"}, + }, + }}, + TotalRecordCount: 1, + }) + })) + + req := httptest.NewRequest(http.MethodGet, "/Items", nil) + req.Header.Set("User-Agent", "Infuse-Direct/8.4.6") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) + } + var got queryResultDTO + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + item := got.Items[0] + if item.ImageTags["Primary"] != "primary-tag-p" { + t.Fatalf("primary tag = %q, want primary-tag-p", item.ImageTags["Primary"]) + } + if item.PrimaryImageItemID != compatImageProxyRouteID(codec, "item-1") { + t.Fatalf("PrimaryImageItemId = %q, want proxy route id", item.PrimaryImageItemID) + } + if canonical, ok := canonicalCompatImageRouteID(codec, item.PrimaryImageItemID); !ok || canonical != "item-1" { + t.Fatalf("canonical proxy route = %q, %v; want item-1, true", canonical, ok) + } + if item.BackdropImageTags[0] != "backdrop-tag-p" { + t.Fatalf("backdrop tag = %q, want backdrop-tag-p", item.BackdropImageTags[0]) + } + if item.SeriesPrimaryImageTag != "series-tag-p" { + t.Fatalf("series tag = %q, want series-tag-p", item.SeriesPrimaryImageTag) + } + if item.ParentBackdropImageTags[0] != "parent-backdrop-tag-p" { + t.Fatalf("parent backdrop tag = %q, want parent-backdrop-tag-p", item.ParentBackdropImageTags[0]) + } + if item.ParentThumbImageTag != "parent-thumb-tag-p" { + t.Fatalf("parent thumb tag = %q, want parent-thumb-tag-p", item.ParentThumbImageTag) + } + if _, ok := item.ImageBlurHashes["Primary"]["primary-tag-p"]; !ok { + t.Fatalf("blurhash keys = %#v, want suffixed primary tag", item.ImageBlurHashes["Primary"]) + } +} + +func TestCompatImageProxyTagVariantMiddlewareLeavesOtherClientsUnchanged(t *testing.T) { + codec := NewResourceIDCodec() + handler := compatImageProxyTagVariantMiddleware(codec)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, queryResultDTO{ + Items: []baseItemDTO{{ + ID: "item-1", + Name: "Movie", + ImageTags: map[string]string{"Primary": "primary-tag"}, + }}, + TotalRecordCount: 1, + }) + })) + + req := httptest.NewRequest(http.MethodGet, "/Items", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) + } + var got queryResultDTO + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Items[0].ImageTags["Primary"] != "primary-tag" { + t.Fatalf("primary tag = %q, want primary-tag", got.Items[0].ImageTags["Primary"]) + } + if got.Items[0].PrimaryImageItemID != "" { + t.Fatalf("PrimaryImageItemId = %q, want empty", got.Items[0].PrimaryImageItemID) + } +} + +func TestCompatImageProxyRouteIDCanonicalizesNumericRouteWithoutRegistration(t *testing.T) { + routeID := EncodeNumericID(EncodedIDItem, 12345).String() + proxyRouteID := compatImageProxyRouteID(NewResourceIDCodec(), routeID) + + canonical, ok := canonicalCompatImageRouteID(NewResourceIDCodec(), proxyRouteID) + if !ok || canonical != routeID { + t.Fatalf("canonical proxy route = %q, %v; want %q, true", canonical, ok, routeID) + } +} diff --git a/internal/jellycompat/image_tag_signer.go b/internal/jellycompat/image_tag_signer.go index a2669043..00bc770a 100644 --- a/internal/jellycompat/image_tag_signer.go +++ b/internal/jellycompat/image_tag_signer.go @@ -40,7 +40,7 @@ func (s *imageTagSigner) Equal(seed, fallbackURL, actual string) bool { if s == nil { return false } - actual = strings.TrimSpace(actual) + actual = canonicalCompatImageTag(actual) expected := s.Tag(seed, fallbackURL) if expected == "" || actual == "" || len(expected) != len(actual) { return false diff --git a/internal/jellycompat/images.go b/internal/jellycompat/images.go index 083735d5..61ec5d89 100644 --- a/internal/jellycompat/images.go +++ b/internal/jellycompat/images.go @@ -2,48 +2,226 @@ package jellycompat import ( "bytes" + "context" "crypto/sha1" + "encoding/binary" "image" "image/color" "image/png" "io" "net/http" + "strings" + + "github.com/google/uuid" ) -const defaultImageProxyCacheControl = "public, max-age=300, must-revalidate" +var compatImageProxyUserAgentSubstrings = []string{ + "infuse", +} + +const compatImageRouteCacheControl = "private, no-store, no-cache, max-age=0, s-maxage=0, must-revalidate" +const compatImageProxyTagSuffix = "-p" +const compatImageProxyRoutePrefix = "__jellycompat_image_proxy__:" + +type compatImageProxyRouteContextKey struct{} + +var compatImageProxyHeaders = []string{ + "Accept-Ranges", + "Content-Length", + "Content-Type", + "ETag", + "Last-Modified", +} + +func shouldProxyCompatImageRequest(r *http.Request) bool { + return isCompatImageProxyClientRequest(r) || + isCompatImageProxyRouteRequest(r) || + isCompatImageProxyTag(r.URL.Query().Get("tag")) +} + +func isCompatImageProxyClientRequest(r *http.Request) bool { + if r == nil { + return false + } + clientText := strings.ToLower(strings.Join([]string{ + r.UserAgent(), + r.Header.Get("X-Emby-Authorization"), + }, " ")) + for _, substring := range compatImageProxyUserAgentSubstrings { + if strings.Contains(clientText, substring) { + return true + } + } + return false +} + +func compatImageProxyTag(tag string) string { + tag = strings.TrimSpace(tag) + if tag == "" || isCompatImageProxyTag(tag) { + return tag + } + return tag + compatImageProxyTagSuffix +} + +func isCompatImageProxyTag(tag string) bool { + return strings.HasSuffix(strings.TrimSpace(tag), compatImageProxyTagSuffix) +} + +func canonicalCompatImageTag(tag string) string { + tag = strings.TrimSpace(tag) + return strings.TrimSuffix(tag, compatImageProxyTagSuffix) +} + +func compatImageProxyRouteID(codec *ResourceIDCodec, routeID string) string { + routeID = strings.TrimSpace(routeID) + if codec == nil || routeID == "" { + return routeID + } + if canonical, ok := canonicalCompatImageRouteID(codec, routeID); ok { + return compatImageProxyRouteID(codec, canonical) + } + if decoded, ok := decodePackedCompatRouteID(routeID); ok { + return encodeNumericCompatImageProxyRouteID(decoded) + } + return codec.EncodeStringID(EncodedIDItem, compatImageProxyRoutePrefix+routeID) +} + +func canonicalCompatImageRouteID(codec *ResourceIDCodec, routeID string) (string, bool) { + if canonical, ok := decodeNumericCompatImageProxyRouteID(routeID); ok { + return canonical, true + } + if codec == nil { + return routeID, false + } + value, err := codec.DecodeStringID(EncodedIDItem, strings.TrimSpace(routeID)) + if err != nil || !strings.HasPrefix(value, compatImageProxyRoutePrefix) { + return routeID, false + } + canonical := strings.TrimSpace(strings.TrimPrefix(value, compatImageProxyRoutePrefix)) + if canonical == "" { + return routeID, false + } + return canonical, true +} + +func encodeNumericCompatImageProxyRouteID(decoded DecodedID) string { + var raw [16]byte + raw[0] = byte(EncodedIDImageProxy) + raw[1] = byte(decoded.Type) + binary.BigEndian.PutUint64(raw[8:], decoded.Value) + return uuid.UUID(raw).String() +} + +func decodeNumericCompatImageProxyRouteID(routeID string) (string, bool) { + parsed, err := uuid.Parse(strings.TrimSpace(routeID)) + if err != nil || EncodedIDType(parsed[0]) != EncodedIDImageProxy { + return "", false + } + kind := EncodedIDType(parsed[1]) + if !isPackedCompatRouteIDType(kind) || !zeroUUIDBytes(parsed[2:8]) { + return "", false + } + return EncodeNumericID(kind, binary.BigEndian.Uint64(parsed[8:])).String(), true +} + +func decodePackedCompatRouteID(routeID string) (DecodedID, bool) { + parsed, err := uuid.Parse(strings.TrimSpace(routeID)) + if err != nil { + return DecodedID{}, false + } + kind := EncodedIDType(parsed[0]) + if !isPackedCompatRouteIDType(kind) || !zeroUUIDBytes(parsed[1:8]) { + return DecodedID{}, false + } + return DecodedID{Type: kind, Value: binary.BigEndian.Uint64(parsed[8:])}, true +} + +func isPackedCompatRouteIDType(kind EncodedIDType) bool { + switch kind { + case EncodedIDLibrary, + EncodedIDItem, + EncodedIDMediaSource, + EncodedIDSeason, + EncodedIDPlaySession, + EncodedIDGenre, + EncodedIDStudio, + EncodedIDPerson: + return true + default: + return false + } +} + +func zeroUUIDBytes(values []byte) bool { + for _, value := range values { + if value != 0 { + return false + } + } + return true +} + +func withCompatImageProxyRouteRequest(r *http.Request) *http.Request { + if r == nil { + return r + } + return r.WithContext(context.WithValue(r.Context(), compatImageProxyRouteContextKey{}, true)) +} + +func isCompatImageProxyRouteRequest(r *http.Request) bool { + if r == nil { + return false + } + forceProxy, _ := r.Context().Value(compatImageProxyRouteContextKey{}).(bool) + return forceProxy +} + +func copyConditionalImageRequestHeaders(dst, src http.Header) { + for _, key := range []string{"If-Match", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since"} { + if value := src.Values(key); len(value) > 0 { + dst[key] = append([]string(nil), value...) + } + } +} func proxyImage(w http.ResponseWriter, resp *http.Response) { defer resp.Body.Close() - copyImageProxyHeaders(w.Header(), resp.Header) + if resp.StatusCode == http.StatusNotModified { + copyImageProxyHeaders(w.Header(), resp.Header) + setCompatImageRouteNoStore(w.Header()) w.WriteHeader(http.StatusNotModified) return } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + setCompatImageRouteNoStore(w.Header()) writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image") return } - w.WriteHeader(http.StatusOK) + + copyImageProxyHeaders(w.Header(), resp.Header) + setCompatImageRouteNoStore(w.Header()) + w.WriteHeader(resp.StatusCode) _, _ = io.Copy(w, resp.Body) } func copyImageProxyHeaders(dst, src http.Header) { - for _, name := range []string{"Content-Type", "Cache-Control", "ETag", "Last-Modified", "Expires"} { - values := src.Values(name) - if len(values) == 0 { - values = src[name] - } - if len(values) == 0 { - continue - } - dst.Del(name) - for _, value := range values { - dst.Add(name, value) + for _, key := range compatImageProxyHeaders { + if value := src.Values(key); len(value) > 0 { + dst[key] = append([]string(nil), value...) } } - if dst.Get("Cache-Control") == "" { - dst.Set("Cache-Control", defaultImageProxyCacheControl) - } +} + +func setCompatImageRouteNoStore(header http.Header) { + header.Set("Cache-Control", compatImageRouteCacheControl) + header.Set("CDN-Cache-Control", "private, no-store, no-cache, max-age=0") + header.Set("Expires", "Thu, 01 Jan 1970 00:00:00 GMT") + header.Set("Pragma", "no-cache") + header.Set("Surrogate-Control", "no-store") + header.Set("X-Accel-Expires", "0") + header.Add("Vary", "User-Agent") } func placeholderAvatarPNG(seed string) ([]byte, error) { diff --git a/internal/jellycompat/images_test.go b/internal/jellycompat/images_test.go index 43e38e29..0f0acd90 100644 --- a/internal/jellycompat/images_test.go +++ b/internal/jellycompat/images_test.go @@ -2,10 +2,8 @@ package jellycompat import ( "context" - "io" "net/http" "net/http/httptest" - "strings" "testing" "time" @@ -16,88 +14,66 @@ import ( "github.com/Silo-Server/silo-server/internal/models" ) -func TestProxyImageDefaultsToRevalidatingCachePolicy(t *testing.T) { - rec := httptest.NewRecorder() - resp := &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{ - "Content-Type": []string{"image/jpeg"}, - "ETag": []string{`"abc"`}, - "Last-Modified": []string{"Tue, 12 May 2026 12:00:00 GMT"}, - "Expires": []string{"Tue, 12 May 2026 12:05:00 GMT"}, - }, - Body: io.NopCloser(strings.NewReader("image-bytes")), - } - - proxyImage(rec, resp) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if got := rec.Header().Get("Cache-Control"); got != defaultImageProxyCacheControl { - t.Fatalf("Cache-Control = %q, want %q", got, defaultImageProxyCacheControl) - } - if got := rec.Header().Get("ETag"); got != `"abc"` { - t.Fatalf("ETag = %q", got) - } - if strings.Contains(rec.Header().Get("Cache-Control"), "immutable") { - t.Fatalf("Cache-Control unexpectedly immutable: %q", rec.Header().Get("Cache-Control")) - } -} - -func TestProxyImageRelaysNotModified(t *testing.T) { - rec := httptest.NewRecorder() - resp := &http.Response{ - StatusCode: http.StatusNotModified, - Header: http.Header{ - "ETag": []string{`"abc"`}, - "Cache-Control": []string{"public, max-age=60"}, - }, - Body: io.NopCloser(strings.NewReader("")), - } - - proxyImage(rec, resp) - - if rec.Code != http.StatusNotModified { - t.Fatalf("status = %d, want 304", rec.Code) - } - if got := rec.Header().Get("ETag"); got != `"abc"` { - t.Fatalf("ETag = %q", got) - } - if rec.Body.Len() != 0 { - t.Fatalf("304 body length = %d, want 0", rec.Body.Len()) - } -} - -func TestProxyImageURLForwardsConditionalHeaders(t *testing.T) { +func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { + upstreamCalled := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("If-None-Match"); got != `"abc"` { - t.Fatalf("If-None-Match = %q", got) - } - if got := r.Header.Get("If-Modified-Since"); got != "Tue, 12 May 2026 12:00:00 GMT" { - t.Fatalf("If-Modified-Since = %q", got) - } - w.Header().Set("ETag", `"abc"`) - w.WriteHeader(http.StatusNotModified) + upstreamCalled = true + w.WriteHeader(http.StatusTeapot) })) defer upstream.Close() - h := &ImagesHandler{httpClient: upstream.Client()} - req := httptest.NewRequest(http.MethodGet, "/Items/1/Images/Primary", nil) - req.Header.Set("If-None-Match", `"abc"`) - req.Header.Set("If-Modified-Since", "Tue, 12 May 2026 12:00:00 GMT") + codec := NewResourceIDCodec() + contentID := "movie-1" + routeID := codec.EncodeStringID(EncodedIDItem, contentID) + updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + item := &models.MediaItem{ + ContentID: contentID, + PosterPath: upstream.URL, + PosterThumbhash: "poster-thumbhash", + UpdatedAt: updatedAt, + } + cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}} + tag := newMapper(codec, cfg).itemFromList(upstreamListItem{ + ContentID: contentID, + Type: "movie", + Title: "Movie", + PosterURL: item.PosterPath, + PosterPath: item.PosterPath, + PosterThumbhash: item.PosterThumbhash, + UpdatedAt: item.UpdatedAt, + }, false, nil, nil).ImageTags["Primary"] + h := &ImagesHandler{ + codec: codec, + images: NewImageCache(time.Hour, func() time.Time { return updatedAt }), + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner(cfg.Auth.JWTSecret), + } + + req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil) + req = withImageRouteParams(req, routeID, "Primary") rec := httptest.NewRecorder() - h.proxyImageURL(rec, req, upstream.URL) + h.HandleItemImage(rec, req) - if rec.Code != http.StatusNotModified { - t.Fatalf("status = %d, want 304", rec.Code) + assertImageRedirect(t, rec, upstream.URL) + if upstreamCalled { + t.Fatal("compat image route proxied the upstream image instead of redirecting") + } + if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" { + t.Fatal("signed-tag image URL was not cached after resolution") } } -func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { +func TestHandleItemImageProxiesInfuseSignedTagWithoutSessionOrCache(t *testing.T) { + upstreamCalled := false + var gotIfNoneMatch string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalled = true + gotIfNoneMatch = r.Header.Get("If-None-Match") + w.Header().Set("Cache-Control", "public, max-age=14400") w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("ETag", `"poster-v1"`) + w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("image-bytes")) })) defer upstream.Close() @@ -124,13 +100,15 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { }, false, nil, nil).ImageTags["Primary"] h := &ImagesHandler{ codec: codec, - httpClient: upstream.Client(), images: NewImageCache(time.Hour, func() time.Time { return updatedAt }), itemRepo: fakeImageItemRepo{item: item}, imageTags: newImageTagSigner(cfg.Auth.JWTSecret), + httpClient: upstream.Client(), } - req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+tag, nil) + req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+compatImageProxyTag(tag), nil) + req.Header.Set("If-None-Match", `"poster-v1"`) + req.Header.Set("User-Agent", "Infuse-Direct/8.4.6") req = withImageRouteParams(req, routeID, "Primary") rec := httptest.NewRecorder() @@ -140,10 +118,90 @@ func TestHandleItemImageAcceptsSignedTagWithoutSessionOrCache(t *testing.T) { t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) } if got := rec.Body.String(); got != "image-bytes" { - t.Fatalf("body = %q, want image bytes", got) + t.Fatalf("body = %q, want image-bytes", got) + } + if got := rec.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) + } + if got := rec.Header().Get("Content-Type"); got != "image/jpeg" { + t.Fatalf("Content-Type = %q, want image/jpeg", got) + } + if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl { + t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl) + } + if got := rec.Header().Get("CDN-Cache-Control"); got != "private, no-store, no-cache, max-age=0" { + t.Fatalf("CDN-Cache-Control = %q, want private, no-store, no-cache, max-age=0", got) + } + if got := rec.Header().Get("X-Accel-Expires"); got != "0" { + t.Fatalf("X-Accel-Expires = %q, want 0", got) + } + if got := gotIfNoneMatch; got != `"poster-v1"` { + t.Fatalf("forwarded If-None-Match = %q, want poster-v1", got) + } + if !upstreamCalled { + t.Fatal("Infuse compat image route did not proxy the upstream image") + } +} + +func TestHandleItemImageProxyRouteIDUsesCanonicalItemAndProxy(t *testing.T) { + upstreamCalled := false + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalled = true + w.Header().Set("Content-Type", "image/webp") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("proxy-route-image")) + })) + defer upstream.Close() + + codec := NewResourceIDCodec() + contentID := "movie-1" + routeID := codec.EncodeStringID(EncodedIDItem, contentID) + proxyRouteID := compatImageProxyRouteID(codec, routeID) + updatedAt := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + item := &models.MediaItem{ + ContentID: contentID, + PosterPath: upstream.URL, + PosterThumbhash: "poster-thumbhash", + UpdatedAt: updatedAt, + } + cfg := &config.Config{Auth: config.AuthConfig{JWTSecret: "image-secret"}} + tag := newMapper(codec, cfg).itemFromList(upstreamListItem{ + ContentID: contentID, + Type: "movie", + Title: "Movie", + PosterURL: item.PosterPath, + PosterPath: item.PosterPath, + PosterThumbhash: item.PosterThumbhash, + UpdatedAt: item.UpdatedAt, + }, false, nil, nil).ImageTags["Primary"] + h := &ImagesHandler{ + codec: codec, + images: NewImageCache(time.Hour, func() time.Time { return updatedAt }), + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner(cfg.Auth.JWTSecret), + httpClient: upstream.Client(), + } + + req := httptest.NewRequest(http.MethodGet, "/Items/"+proxyRouteID+"/Images/Primary?fillHeight=267&fillWidth=474&quality=96&tag="+compatImageProxyTag(tag), nil) + req = withImageRouteParams(req, proxyRouteID, "Primary") + rec := httptest.NewRecorder() + + h.HandleItemImage(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != "proxy-route-image" { + t.Fatalf("body = %q, want proxy-route-image", got) + } + if got := rec.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) } if cached, ok := h.images.LookupSized(routeID, "Primary", "", compatRequestImageSize(req, "Primary")); !ok || cached == "" { - t.Fatal("signed-tag image URL was not cached after resolution") + t.Fatal("proxy route image URL was not cached under the canonical route ID") + } + if !upstreamCalled { + t.Fatal("proxy route did not fetch the upstream image") } } @@ -168,10 +226,9 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) { UpdatedAt: item.UpdatedAt, }, false, nil, nil).ImageTags["Primary"] h := &ImagesHandler{ - codec: codec, - httpClient: http.DefaultClient, - itemRepo: fakeImageItemRepo{item: item}, - imageTags: newImageTagSigner(""), + codec: codec, + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner(""), } req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil) @@ -186,9 +243,10 @@ func TestHandleItemImageRejectsUnsignedTagWhenSecretBlank(t *testing.T) { } func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t *testing.T) { + upstreamCalled := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("backdrop-bytes")) + upstreamCalled = true + w.WriteHeader(http.StatusTeapot) })) defer upstream.Close() @@ -201,9 +259,8 @@ func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t upstream.URL, ) h := &ImagesHandler{ - codec: codec, - httpClient: upstream.Client(), - images: NewImageCache(time.Hour, time.Now), + codec: codec, + images: NewImageCache(time.Hour, time.Now), itemRepo: fakeImageItemRepo{item: &models.MediaItem{ ContentID: contentID, BackdropPath: upstream.URL, @@ -217,18 +274,17 @@ func TestHandleItemImageAcceptsSignedCanonicalBackdropTagWithoutSessionOrCache(t h.HandleItemImage(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) - } - if got := rec.Body.String(); got != "backdrop-bytes" { - t.Fatalf("body = %q, want backdrop bytes", got) + assertImageRedirect(t, rec, upstream.URL) + if upstreamCalled { + t.Fatal("compat image route proxied the upstream image instead of redirecting") } } func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing.T) { + upstreamCalled := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("library-poster")) + upstreamCalled = true + w.WriteHeader(http.StatusTeapot) })) defer upstream.Close() @@ -243,7 +299,6 @@ func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing. ) h := &ImagesHandler{ codec: codec, - httpClient: upstream.Client(), images: NewImageCache(time.Hour, time.Now), folderRepo: fakeImageFolderRepo{folder: &models.MediaFolder{ID: libraryID, PosterPath: posterPath}}, posterSigner: fakeLibraryPosterPresigner{url: upstream.URL}, @@ -256,18 +311,17 @@ func TestHandleItemImageAcceptsLibraryPosterTagWithoutSessionOrCache(t *testing. h.HandleItemImage(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) - } - if got := rec.Body.String(); got != "library-poster" { - t.Fatalf("body = %q, want library poster", got) + assertImageRedirect(t, rec, upstream.URL) + if upstreamCalled { + t.Fatal("compat image route proxied the upstream image instead of redirecting") } } func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing.T) { + upstreamCalled := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("cached-image")) + upstreamCalled = true + w.WriteHeader(http.StatusTeapot) })) defer upstream.Close() @@ -276,10 +330,9 @@ func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing cache := NewImageCache(time.Hour, time.Now) cache.RememberSized(routeID, "Primary", upstream.URL, compatCardImageSize) h := &ImagesHandler{ - codec: codec, - httpClient: upstream.Client(), - images: cache, - imageTags: newImageTagSigner("image-secret"), + codec: codec, + images: cache, + imageTags: newImageTagSigner("image-secret"), } req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tagValue(upstream.URL), nil) @@ -288,11 +341,9 @@ func TestHandleItemImageAcceptsLegacyCachedURLTagWithoutRouteFallback(t *testing h.HandleItemImage(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s; want 200", rec.Code, rec.Body.String()) - } - if got := rec.Body.String(); got != "cached-image" { - t.Fatalf("body = %q, want cached image", got) + assertImageRedirect(t, rec, upstream.URL) + if upstreamCalled { + t.Fatal("compat image route proxied the upstream image instead of redirecting") } } @@ -328,11 +379,10 @@ func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) { UpdatedAt: item.UpdatedAt, }, false, nil, nil).ImageTags["Primary"] h := &ImagesHandler{ - codec: codec, - httpClient: upstream.Client(), - images: cache, - itemRepo: fakeImageItemRepo{item: item}, - imageTags: newImageTagSigner("new-secret"), + codec: codec, + images: cache, + itemRepo: fakeImageItemRepo{item: item}, + imageTags: newImageTagSigner("new-secret"), } req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil) @@ -349,6 +399,34 @@ func TestHandleItemImageRevalidatesTagBeforeRouteCacheHit(t *testing.T) { } } +func TestRedirectImageURLRejectsNonHTTPURL(t *testing.T) { + h := &ImagesHandler{} + req := httptest.NewRequest(http.MethodGet, "/Items/1/Images/Primary", nil) + rec := httptest.NewRecorder() + + h.redirectImageURL(rec, req, "catalog/poster.jpg") + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, body = %s; want 502", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) + } +} + +func assertImageRedirect(t *testing.T, rec *httptest.ResponseRecorder, wantLocation string) { + t.Helper() + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, body = %s; want 302", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != wantLocation { + t.Fatalf("Location = %q, want %q", got, wantLocation) + } + if got := rec.Header().Get("Cache-Control"); got != compatImageRouteCacheControl { + t.Fatalf("Cache-Control = %q, want %q", got, compatImageRouteCacheControl) + } +} + type fakeImageItemRepo struct { item *models.MediaItem } diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index a2239969..74e87647 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -56,6 +56,7 @@ func NewRouter(deps Dependencies) chi.Router { } slog.Info("jellycompat debug logging enabled", logAttrs...) } + r.Use(compatImageProxyTagVariantMiddleware(deps.IDCodec)) r.Use(requestLoggerMiddleware) r.Use(middleware.Recoverer) @@ -99,7 +100,7 @@ func NewRouter(deps Dependencies) chi.Router { playbackHandler.S3Client = deps.S3Client playbackHandler.S3Bucket = deps.S3Bucket } - imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.HTTPClient, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret) + imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret, deps.HTTPClient) displayPrefsHandler := NewDisplayPreferencesHandler(deps.UserStoreProvider) recsHandler := NewRecommendationsHandler(deps.Recommender, deps.ItemRepo, deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.AccessFilterFn) @@ -261,15 +262,15 @@ func withDefaults(deps Dependencies) Dependencies { if deps.Config != nil && deps.Config.JellyfinCompat.PlaybackSessionTTL > 0 { playbackTTL = deps.Config.JellyfinCompat.PlaybackSessionTTL } - if deps.HTTPClient == nil { - deps.HTTPClient = &http.Client{Timeout: 30 * time.Second} - } if deps.DeviceProfiles == nil { deps.DeviceProfiles = NewDeviceProfileStore(playbackTTL, deps.Now) } if deps.PlaybackStore == nil { deps.PlaybackStore = NewPlaybackSessionStore(playbackTTL, deps.Now) } + if deps.HTTPClient == nil { + deps.HTTPClient = &http.Client{Timeout: 30 * time.Second} + } // Build ContentService from repos if not provided if deps.ContentService == nil && deps.BrowseRepo != nil && deps.ItemRepo != nil && deps.DetailSvc != nil { diff --git a/internal/jellycompat/server.go b/internal/jellycompat/server.go index f5733aea..ef6cf956 100644 --- a/internal/jellycompat/server.go +++ b/internal/jellycompat/server.go @@ -27,7 +27,6 @@ type Dependencies struct { DB *pgxpool.Pool SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set) ClientIPResolver *clientip.Resolver - HTTPClient *http.Client Now func() time.Time TokenGenerator func() string SessionStore *SessionStore @@ -38,6 +37,7 @@ type Dependencies struct { LoginResolver loginResolver Authenticator *Authenticator WebFS fs.FS + HTTPClient *http.Client // Direct service dependencies (replaces Client) ContentService ContentService @@ -145,6 +145,5 @@ func NewDependencies(cfg *config.Config) Dependencies { Config: cfg, Now: time.Now, TokenGenerator: uuid.NewString, - HTTPClient: &http.Client{Timeout: 30 * time.Second}, } } From b7439884ecacc110d87482fe1a22cb67a3320646 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:46:44 -0400 Subject: [PATCH 06/27] fix(tasks): skip idle audiobook maintenance --- internal/audiobooks/enrichment.go | 26 ++++ internal/audiobooks/podcastfeed/refresher.go | 26 +++- internal/plugins/service_connection.go | 73 +++++++++++ internal/plugins/service_connection_test.go | 30 +++++ internal/plugins/service_hot_reload_test.go | 4 +- internal/taskmanager/manager.go | 34 ++++- internal/taskmanager/manager_test.go | 122 ++++++++++++++++++ internal/taskmanager/task.go | 7 + .../tasks/sync_audiobook_metadata.go | 7 + .../taskmanager/tasks/sync_podcast_feeds.go | 7 + 10 files changed, 330 insertions(+), 6 deletions(-) diff --git a/internal/audiobooks/enrichment.go b/internal/audiobooks/enrichment.go index a7ca1d9d..8f63bb8f 100644 --- a/internal/audiobooks/enrichment.go +++ b/internal/audiobooks/enrichment.go @@ -178,6 +178,32 @@ func (e *Enricher) Run(ctx context.Context) (int, error) { return enriched, nil } +// HasPendingItems reports whether a scheduled sweep has any audiobook rows to +// process. It mirrors claimBatch's eligibility predicate without loading rows +// or provider IDs, so the scheduler can skip no-op executions without adding +// task-history noise. +func (e *Enricher) HasPendingItems(ctx context.Context) (bool, error) { + if e == nil || e.pool == nil || e.chainRepo == nil { + return false, nil + } + + var exists bool + err := e.pool.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 + FROM media_items mi + WHERE mi.type = 'audiobook' + AND (mi.poster_path IS NULL OR mi.poster_path = '') + AND mi.last_refreshed IS NULL + LIMIT 1 + ) + `).Scan(&exists) + if err != nil { + return false, fmt.Errorf("checking pending audiobook enrichment: %w", err) + } + return exists, nil +} + // runBatch processes items in parallel using e.workers goroutines. The // enrichFn parameter exists for testing: production calls pass e.enrichItem. // Returns the count of items where enrichFn returned nil. diff --git a/internal/audiobooks/podcastfeed/refresher.go b/internal/audiobooks/podcastfeed/refresher.go index a9fd8d5e..b33add5e 100644 --- a/internal/audiobooks/podcastfeed/refresher.go +++ b/internal/audiobooks/podcastfeed/refresher.go @@ -106,6 +106,23 @@ func (r *Refresher) WithHTTPClient(hc *http.Client) *Refresher { return r } +// HasDue reports whether RefreshDue would attempt at least one feed. The task +// scheduler uses this to avoid recording empty maintenance runs when no podcast +// feeds exist or every feed is still inside its refresh window. +func (r *Refresher) HasDue(ctx context.Context, s Store) (bool, error) { + feeds, err := s.ListPodcastFeeds(ctx) + if err != nil { + return false, fmt.Errorf("list podcast feeds: %w", err) + } + now := time.Now() + for _, f := range feeds { + if shouldRefreshFeed(f, now) { + return true, nil + } + } + return false, nil +} + // RefreshDue walks every podcast_feeds row and refreshes those whose // last_refreshed_at + refresh_interval has elapsed. Per-feed failures are // logged at Warn and do not abort the walk. Returns the count of feeds @@ -118,10 +135,7 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) { now := time.Now() attempted := 0 for _, f := range feeds { - if f.FeedURL == "" { - continue - } - if !isDue(f, now) { + if !shouldRefreshFeed(f, now) { continue } attempted++ @@ -136,6 +150,10 @@ func (r *Refresher) RefreshDue(ctx context.Context, s Store) (int, error) { return attempted, nil } +func shouldRefreshFeed(f PodcastFeed, now time.Time) bool { + return f.FeedURL != "" && isDue(f, now) +} + // RefreshOne refreshes a single podcast feed. Public so the admin // force-refresh endpoint can call it directly. Records success or failure // in podcast_feeds.last_refresh_error regardless of outcome. diff --git a/internal/plugins/service_connection.go b/internal/plugins/service_connection.go index 941e297f..c5170c18 100644 --- a/internal/plugins/service_connection.go +++ b/internal/plugins/service_connection.go @@ -45,6 +45,16 @@ var runPluginConnectionCheck = func( if err != nil { return err } + capability := metadataProviderConnectionCheckCapability(manifest, capabilityID) + if !metadataProviderSupportsConnectionProbe(capability, "movie") { + slog.Debug( + "skipping metadata provider connection check for unsupported probe type", + "plugin_id", manifest.GetPluginId(), + "capability_id", capabilityID, + "item_type", "movie", + ) + return nil + } metadataClient, err := client.MetadataProvider(capabilityID) if err != nil { @@ -224,3 +234,66 @@ func metadataProviderConnectionCheckCapabilityID(manifest *pluginv1.PluginManife Cause: ErrConnectionTestUnsupported, } } + +func metadataProviderConnectionCheckCapability( + manifest *pluginv1.PluginManifest, + capabilityID string, +) *pluginv1.CapabilityDescriptor { + for _, capability := range manifest.GetCapabilities() { + if capability.GetType() == "metadata_provider.v1" && capability.GetId() == capabilityID { + return capability + } + } + return nil +} + +func metadataProviderSupportsConnectionProbe( + capability *pluginv1.CapabilityDescriptor, + contentType string, +) bool { + priorities, ok := metadataProviderDefaultPriorities(capability) + if !ok { + return true + } + return priorities[contentType] > 0 +} + +func metadataProviderDefaultPriorities( + capability *pluginv1.CapabilityDescriptor, +) (map[string]float64, bool) { + if capability == nil || capability.GetMetadata() == nil { + return nil, false + } + + metadataMap := capability.GetMetadata().AsMap() + raw, ok := metadataMap["default_priority"] + if !ok { + nested, nestedOK := metadataMap["metadata"].(map[string]any) + if !nestedOK { + return nil, false + } + raw, ok = nested["default_priority"] + if !ok { + return nil, false + } + } + + rawMap, ok := raw.(map[string]any) + if !ok { + return nil, false + } + priorities := make(map[string]float64, len(rawMap)) + for key, value := range rawMap { + switch v := value.(type) { + case float64: + priorities[key] = v + case int: + priorities[key] = float64(v) + case int32: + priorities[key] = float64(v) + case int64: + priorities[key] = float64(v) + } + } + return priorities, len(priorities) > 0 +} diff --git a/internal/plugins/service_connection_test.go b/internal/plugins/service_connection_test.go index fc189bf6..192020ae 100644 --- a/internal/plugins/service_connection_test.go +++ b/internal/plugins/service_connection_test.go @@ -7,6 +7,7 @@ import ( "testing" pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" + "google.golang.org/protobuf/types/known/structpb" ) type fakeServiceConfigStore struct { @@ -149,6 +150,35 @@ func TestServiceTestGlobalConfigUsesMergedDraftAndStopsTemporaryInstance(t *test } } +func TestRunPluginConnectionCheckSkipsMovieProbeForAudiobookOnlyProvider(t *testing.T) { + metadata, err := structpb.NewStruct(map[string]any{ + "default_priority": map[string]any{ + "audiobook": 2, + }, + }) + if err != nil { + t.Fatalf("NewStruct() error = %v", err) + } + + manifest := connectionTestManifest(t, "silo.audiobook-metadata", "0.1.2") + manifest.Capabilities = []*pluginv1.CapabilityDescriptor{ + { + Type: "metadata_provider.v1", + Id: "audiobook-metadata", + DisplayName: "Audiobook Metadata", + Metadata: metadata, + }, + } + client := &fakePluginClient{manifest: manifest} + + if err := runPluginConnectionCheck(context.Background(), client, manifest); err != nil { + t.Fatalf("runPluginConnectionCheck() error = %v", err) + } + if client.metadataProviderCalls != 0 { + t.Fatalf("metadata provider calls = %d, want 0", client.metadataProviderCalls) + } +} + func TestServiceTestGlobalConfigReturnsUnsupportedWithoutStartingPlugin(t *testing.T) { manifest := connectionTestManifest(t, "silo.simple", "0.0.1") manifest.Capabilities = []*pluginv1.CapabilityDescriptor{ diff --git a/internal/plugins/service_hot_reload_test.go b/internal/plugins/service_hot_reload_test.go index 0e81803c..fcc1af50 100644 --- a/internal/plugins/service_hot_reload_test.go +++ b/internal/plugins/service_hot_reload_test.go @@ -188,7 +188,8 @@ func (f *fakeServiceHost) Shutdown(context.Context) error { } type fakePluginClient struct { - manifest *pluginv1.PluginManifest + manifest *pluginv1.PluginManifest + metadataProviderCalls int } func (f *fakePluginClient) Manifest() *pluginv1.PluginManifest { @@ -196,6 +197,7 @@ func (f *fakePluginClient) Manifest() *pluginv1.PluginManifest { } func (f *fakePluginClient) MetadataProvider(string) (*pluginhost.MetadataProviderClient, error) { + f.metadataProviderCalls++ return nil, nil } diff --git a/internal/taskmanager/manager.go b/internal/taskmanager/manager.go index bbc8bad0..4fbf1872 100644 --- a/internal/taskmanager/manager.go +++ b/internal/taskmanager/manager.go @@ -146,6 +146,16 @@ func (m *TaskManager) triggerLoop(ctx context.Context, w *taskWorker) { continue } + shouldRun, shouldRunErr := m.shouldRunScheduledTask(ctx, w) + if shouldRunErr != nil { + m.logger.Warn("scheduled task preflight failed; running task", + "task", w.task.Key(), "error", shouldRunErr) + } + if shouldRunErr == nil && !shouldRun { + m.rearmTriggersFromNow(w) + continue + } + result, err := w.run(ctx) if err != nil { // If the task is already running (e.g. via manual RunTask), don't @@ -168,12 +178,34 @@ func (m *TaskManager) triggerLoop(ctx context.Context, w *taskWorker) { } } +func (m *TaskManager) shouldRunScheduledTask(ctx context.Context, w *taskWorker) (bool, error) { + task, ok := w.task.(ScheduledConditionalTask) + if !ok { + return true, nil + } + return task.ShouldRun(ctx) +} + // rearmTriggers stops and restarts all triggers for a worker. func (m *TaskManager) rearmTriggers(w *taskWorker) { w.mu.Lock() + lastResult := w.lastResult for _, tr := range w.triggers { tr.Stop() - tr.Start(w.lastResult) + tr.Start(lastResult) + } + w.mu.Unlock() + w.notify() +} + +// rearmTriggersFromNow restarts triggers after a skipped conditional task. This +// intentionally ignores lastResult; otherwise an old completed_at can make an +// interval trigger fire immediately in a loop while there is no work. +func (m *TaskManager) rearmTriggersFromNow(w *taskWorker) { + w.mu.Lock() + for _, tr := range w.triggers { + tr.Stop() + tr.Start(nil) } w.mu.Unlock() w.notify() diff --git a/internal/taskmanager/manager_test.go b/internal/taskmanager/manager_test.go index 9f7ff7e9..84401172 100644 --- a/internal/taskmanager/manager_test.go +++ b/internal/taskmanager/manager_test.go @@ -50,6 +50,33 @@ func (fakeExecutionRepository) List(context.Context, string, int) ([]taskmanager return nil, nil } +type recordingExecutionRepository struct { + mu sync.Mutex + inserts []taskmanager.ExecutionResult + latest *taskmanager.ExecutionResult +} + +func (r *recordingExecutionRepository) Insert(_ context.Context, result taskmanager.ExecutionResult) error { + r.mu.Lock() + defer r.mu.Unlock() + r.inserts = append(r.inserts, result) + return nil +} + +func (r *recordingExecutionRepository) GetLatest(context.Context, string) (*taskmanager.ExecutionResult, error) { + return r.latest, nil +} + +func (r *recordingExecutionRepository) List(context.Context, string, int) ([]taskmanager.ExecutionResult, error) { + return nil, nil +} + +func (r *recordingExecutionRepository) insertCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.inserts) +} + type fakeTrigger struct { cfg taskmanager.TriggerConfig ch chan struct{} @@ -117,6 +144,34 @@ func (t stubTask) DefaultTriggers() []taskmanager.TriggerConfig { return append([]taskmanager.TriggerConfig(nil), t.triggers...) } +type conditionalStubTask struct { + stubTask + shouldRunCalled chan struct{} + mu sync.Mutex + executeCalls int +} + +func (t *conditionalStubTask) ShouldRun(context.Context) (bool, error) { + select { + case t.shouldRunCalled <- struct{}{}: + default: + } + return false, nil +} + +func (t *conditionalStubTask) Execute(context.Context, taskmanager.ProgressReporter) error { + t.mu.Lock() + defer t.mu.Unlock() + t.executeCalls++ + return nil +} + +func (t *conditionalStubTask) executeCount() int { + t.mu.Lock() + defer t.mu.Unlock() + return t.executeCalls +} + func newFakeTrigger(cfg taskmanager.TriggerConfig) taskmanager.Trigger { return &fakeTrigger{ cfg: cfg, @@ -265,3 +320,70 @@ func TestTaskManagerRunTaskNotifiesAfterTriggerRearm(t *testing.T) { last.NextRunAt.Sub(last.LastExecution.CompletedAt)) } } + +func TestTaskManagerTriggerSkipsConditionalTaskWithoutHistory(t *testing.T) { + triggerRepo := &fakeTriggerRepository{ + triggers: map[string][]taskmanager.TriggerConfig{ + "conditional": { + {Type: taskmanager.TriggerTypeInterval, IntervalMs: int64(time.Hour / time.Millisecond)}, + }, + }, + } + historyRepo := &recordingExecutionRepository{ + latest: &taskmanager.ExecutionResult{ + TaskKey: "conditional", + CompletedAt: time.Now().Add(-24 * time.Hour), + }, + } + var triggers []*fakeTrigger + factory := func(cfg taskmanager.TriggerConfig) taskmanager.Trigger { + tr := &fakeTrigger{ + cfg: cfg, + ch: make(chan struct{}, 1), + stopCh: make(chan struct{}), + } + triggers = append(triggers, tr) + return tr + } + manager := taskmanager.New( + triggerRepo, + historyRepo, + factory, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + task := &conditionalStubTask{ + stubTask: stubTask{key: "conditional"}, + shouldRunCalled: make(chan struct{}, 1), + } + manager.Register(task) + + ctx, cancel := context.WithCancel(context.Background()) + defer manager.Stop() + defer cancel() + manager.Start(ctx) + + if len(triggers) != 1 { + t.Fatalf("triggers = %d, want 1", len(triggers)) + } + beforeTrigger := time.Now() + triggers[0].ch <- struct{}{} + + select { + case <-task.shouldRunCalled: + case <-time.After(time.Second): + t.Fatal("scheduled preflight was not called") + } + + time.Sleep(25 * time.Millisecond) + if got := task.executeCount(); got != 0 { + t.Fatalf("Execute calls = %d, want 0", got) + } + if got := historyRepo.insertCount(); got != 0 { + t.Fatalf("history inserts = %d, want 0", got) + } + if !triggers[0].next.After(beforeTrigger) { + t.Fatalf("next run = %s, want rearmed after skip time %s", + triggers[0].next.Format(time.RFC3339Nano), + beforeTrigger.Format(time.RFC3339Nano)) + } +} diff --git a/internal/taskmanager/task.go b/internal/taskmanager/task.go index 8762e53e..bcb9857f 100644 --- a/internal/taskmanager/task.go +++ b/internal/taskmanager/task.go @@ -41,6 +41,13 @@ type Task interface { Execute(ctx context.Context, progress ProgressReporter) error } +// ScheduledConditionalTask lets a task suppress automatic trigger executions +// when there is no work to do. Manual RunTask calls still execute normally so +// admins can force a check and see a result. +type ScheduledConditionalTask interface { + ShouldRun(ctx context.Context) (bool, error) +} + // ProgressReporter allows tasks to report progress and result data during execution. type ProgressReporter interface { Report(percent float64, message string) diff --git a/internal/taskmanager/tasks/sync_audiobook_metadata.go b/internal/taskmanager/tasks/sync_audiobook_metadata.go index d8ce563b..b10ab95a 100644 --- a/internal/taskmanager/tasks/sync_audiobook_metadata.go +++ b/internal/taskmanager/tasks/sync_audiobook_metadata.go @@ -38,6 +38,13 @@ func (t *SyncAudiobookMetadataTask) DefaultTriggers() []taskmanager.TriggerConfi } } +func (t *SyncAudiobookMetadataTask) ShouldRun(ctx context.Context) (bool, error) { + if t == nil || t.enricher == nil { + return false, nil + } + return t.enricher.HasPendingItems(ctx) +} + func (t *SyncAudiobookMetadataTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { progress.Report(0, "Scanning for unenriched audiobooks") diff --git a/internal/taskmanager/tasks/sync_podcast_feeds.go b/internal/taskmanager/tasks/sync_podcast_feeds.go index 2589c6bd..ae56df83 100644 --- a/internal/taskmanager/tasks/sync_podcast_feeds.go +++ b/internal/taskmanager/tasks/sync_podcast_feeds.go @@ -39,6 +39,13 @@ func (t *SyncPodcastFeedsTask) DefaultTriggers() []taskmanager.TriggerConfig { } } +func (t *SyncPodcastFeedsTask) ShouldRun(ctx context.Context) (bool, error) { + if t == nil || t.refresher == nil || t.store == nil { + return false, nil + } + return t.refresher.HasDue(ctx, t.store) +} + func (t *SyncPodcastFeedsTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error { progress.Report(0, "Checking for due podcast feeds") From 66a4146fc257d31bdfea7f55c1156718ef4f744a Mon Sep 17 00:00:00 2001 From: fluxis Date: Tue, 9 Jun 2026 11:31:52 -0400 Subject: [PATCH 07/27] fix(metadata): exclude providers from content levels they don't declare (#106) ResolveChain falls back to every enabled metadata provider when a library + content-level has no enabled chain entry. That fallback was media-type blind: a provider declaring default_priority only for an unrelated level (e.g. an audiobook provider declaring {"audiobook": N}) was kept in the list (merely sorted last) and invoked for video content levels. In production this made silo.audiobook-metadata hammer external audiobook APIs with anime/movie/series titles every scheduled enrichment pass (MatchWorker, 30s) for the season/episode levels that had no enabled chain entry. Disabling the chain entries did not help because the fallback never consults them; only disabling the installation removed it from the global set. Treat a non-empty default_priority map as the provider enumerating the content levels it supports: in resolveEnabledProvidersByPriority, exclude providers whose declared map omits the requested level instead of ranking them last. Providers that declare no default_priority make no claim and stay eligible everywhere (legacy behavior). Fixes #105 Co-authored-by: Claude Opus 4.8 (1M context) --- internal/metadata/chain.go | 81 +++++++++++++++++++++++++-------- internal/metadata/chain_test.go | 59 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 19 deletions(-) create mode 100644 internal/metadata/chain_test.go diff --git a/internal/metadata/chain.go b/internal/metadata/chain.go index a3c9c608..2d5dfced 100644 --- a/internal/metadata/chain.go +++ b/internal/metadata/chain.go @@ -303,9 +303,21 @@ func resolveEnabledProvidersByPriority( priority int } - items := make([]ranked, len(caps)) - for i, c := range caps { - items[i] = ranked{cap: c, priority: LookupDefaultPriority(ctx, pool, c.PluginInstallationID, contentLevel)} + // Only providers that support contentLevel participate in the chain-less + // fallback. A provider declaring a non-empty default_priority map that omits + // this level is excluded outright (not merely ranked last), so a + // single-purpose provider is never invoked for content it does not handle. + items := make([]ranked, 0, len(caps)) + for _, c := range caps { + metadataJSON := lookupCapabilityMetadata(ctx, pool, c.PluginInstallationID) + if !providerSupportsLevel(metadataJSON, contentLevel) { + slog.Debug("skipping metadata provider: does not declare support for content level", + "installation_id", c.PluginInstallationID, + "capability_id", c.CapabilityID, + "content_level", contentLevel) + continue + } + items = append(items, ranked{cap: c, priority: extractDefaultPriority(metadataJSON, contentLevel)}) } sort.SliceStable(items, func(i, j int) bool { @@ -326,9 +338,27 @@ func resolveEnabledProvidersByPriority( return buildProviders(ctx, sorted, resolver, pool), nil } -// LookupDefaultPriority queries plugin_capabilities for a provider's declared -// default_priority at the given content level. Returns 0 if not found. -func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, contentLevel string) int { +// providerSupportsLevel reports whether a metadata provider should participate +// in the chain-less global fallback for contentLevel. A provider that declares +// a non-empty default_priority map is treated as enumerating the content levels +// it supports: it is eligible only for levels present in that map with a +// positive priority. A provider that declares no default_priority makes no +// claim and stays eligible for every level (legacy behavior), ranked last. +// +// This is what keeps a single-purpose provider (e.g. an audiobook metadata +// provider declaring only {"audiobook": N}) from being pulled into video +// content levels when a library has no enabled chain entry for that level. +func providerSupportsLevel(metadataJSON []byte, contentLevel string) bool { + levels, declared := declaredPriorityLevels(metadataJSON) + if !declared { + return true + } + return levels[contentLevel] > 0 +} + +// lookupCapabilityMetadata returns the raw plugin_capabilities.metadata JSON for +// a provider's metadata_provider.v1 capability, or nil if absent. +func lookupCapabilityMetadata(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int) []byte { var metadataJSON []byte err := pool.QueryRow(ctx, `SELECT metadata FROM plugin_capabilities @@ -337,21 +367,37 @@ func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstal pluginInstallationID, ).Scan(&metadataJSON) if err != nil { - return 0 + return nil } + return metadataJSON +} - return extractDefaultPriority(metadataJSON, contentLevel) +// LookupDefaultPriority queries plugin_capabilities for a provider's declared +// default_priority at the given content level. Returns 0 if not found. +func LookupDefaultPriority(ctx context.Context, pool *pgxpool.Pool, pluginInstallationID int, contentLevel string) int { + return extractDefaultPriority(lookupCapabilityMetadata(ctx, pool, pluginInstallationID), contentLevel) } // extractDefaultPriority parses the default_priority for a content level from // capability metadata JSON. func extractDefaultPriority(metadataJSON []byte, contentLevel string) int { + levels, _ := declaredPriorityLevels(metadataJSON) + if v, ok := levels[contentLevel]; ok && v > 0 { + return int(v) + } + return 0 +} + +// declaredPriorityLevels parses a capability's default_priority map. The map may +// sit at the top level or inside a "metadata" envelope (plugin capability +// metadata wraps plugin-declared fields in a "metadata" sub-object). The second +// return value is true only when a non-empty map was found, i.e. the provider +// explicitly enumerates the content levels it supports. +func declaredPriorityLevels(metadataJSON []byte) (map[string]float64, bool) { var meta map[string]json.RawMessage if err := json.Unmarshal(metadataJSON, &meta); err != nil { - return 0 + return nil, false } - // default_priority may be at the top level or nested inside a "metadata" sub-object - // (plugin capability metadata wraps plugin-declared fields in a "metadata" envelope). dpRaw, ok := meta["default_priority"] if !ok { if innerRaw, innerOK := meta["metadata"]; innerOK { @@ -360,18 +406,15 @@ func extractDefaultPriority(metadataJSON []byte, contentLevel string) int { dpRaw, ok = inner["default_priority"] } } - if !ok { - return 0 - } + } + if !ok { + return nil, false } var dpMap map[string]float64 if err := json.Unmarshal(dpRaw, &dpMap); err != nil { - return 0 + return nil, false } - if v, ok := dpMap[contentLevel]; ok && v > 0 { - return int(v) - } - return 0 + return dpMap, len(dpMap) > 0 } // ListEnabledMetadataCapabilities returns all metadata_provider.v1 capabilities diff --git a/internal/metadata/chain_test.go b/internal/metadata/chain_test.go new file mode 100644 index 00000000..2f439723 --- /dev/null +++ b/internal/metadata/chain_test.go @@ -0,0 +1,59 @@ +package metadata + +import "testing" + +// Capability metadata as stored in plugin_capabilities.metadata: plugin-declared +// fields are wrapped in a "metadata" envelope. +const ( + audiobookCapMetadata = `{"metadata":{"default_priority":{"audiobook":2}},"display_name":"Audiobook Metadata"}` + tmdbCapMetadata = `{"metadata":{"default_priority":{"movie":2,"season":3,"series":3,"episode":3}},"display_name":"TMDB"}` + tvdbCapMetadata = `{"metadata":{"default_priority":{"season":2,"series":2,"episode":2}},"display_name":"TVDB"}` +) + +func TestProviderSupportsLevel(t *testing.T) { + cases := []struct { + name string + metadataJSON string + contentLevel string + want bool + }{ + // An audiobook-only provider must NOT be pulled into video content + // levels by the chain-less global fallback. This is the regression + // that let silo.audiobook-metadata hammer audiobook APIs with + // anime/movie/series titles. + {"audiobook provider excluded from episode", audiobookCapMetadata, "episode", false}, + {"audiobook provider excluded from series", audiobookCapMetadata, "series", false}, + {"audiobook provider excluded from season", audiobookCapMetadata, "season", false}, + {"audiobook provider excluded from movie", audiobookCapMetadata, "movie", false}, + {"audiobook provider included for audiobook", audiobookCapMetadata, "audiobook", true}, + + // Providers that declare a level stay eligible for it. + {"tmdb included for movie", tmdbCapMetadata, "movie", true}, + {"tmdb included for season", tmdbCapMetadata, "season", true}, + {"tmdb included for episode", tmdbCapMetadata, "episode", true}, + + // tvdb declares no movie level -> excluded from the movie fallback. + {"tvdb excluded from movie", tvdbCapMetadata, "movie", false}, + {"tvdb included for series", tvdbCapMetadata, "series", true}, + + // A provider that declares no default_priority makes no claim and stays + // eligible everywhere (legacy behavior), ranked last by priority 0. + {"no metadata is eligible", ``, "episode", true}, + {"empty object is eligible", `{}`, "episode", true}, + {"empty priority map is eligible", `{"metadata":{"default_priority":{}}}`, "episode", true}, + {"malformed json is eligible (fail-open)", `{not json`, "episode", true}, + + // A declared level with non-positive priority is not supported. + {"zero priority level not supported", `{"metadata":{"default_priority":{"episode":0}}}`, "episode", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := providerSupportsLevel([]byte(tc.metadataJSON), tc.contentLevel) + if got != tc.want { + t.Fatalf("providerSupportsLevel(%q, %q) = %v, want %v", + tc.metadataJSON, tc.contentLevel, got, tc.want) + } + }) + } +} From 2c714e4ef24e2a9b97555c0704bfae8aa15f8c27 Mon Sep 17 00:00:00 2001 From: fluxis Date: Tue, 9 Jun 2026 13:00:48 -0400 Subject: [PATCH 08/27] feat(requests): pluginize request fulfillment behind request_router.v1 (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: design spec for pluginizing requests fulfillment Pluginize the requests fulfillment backend behind an agnostic request_router.v1 capability (high seam: whole-request fulfiller). Host keeps lifecycle/quota/policy/quality-governance and a generic two-tier connection registry; plugins own routing+submission+status. First plugin extracts multi-instance Sonarr/Radarr; Seerr follows in a separate spec. Preserves autoscan reuse of arr connection rows. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for requests pluginization Three-phase plan: (1) request_router.v1 SDK capability, (2) new silo-plugin-requests-arr plugin extracting multi-instance Sonarr/Radarr, (3) host refactor routing fulfillment through the plugin while keeping quality governance, target records, and autoscan connection reuse host-side. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(db): generalize request_integrations into a two-tier connection registry Co-Authored-By: Claude Opus 4.8 (1M context) * feat(requests): add generic connection fields to Integration + repo mapping * feat(pluginhost): typed RequestRouter capability client + resolver Co-Authored-By: Claude Opus 4.8 (1M context) * feat(requests): plugin-backed RequestRouterProvider seam Co-Authored-By: Claude Opus 4.8 (1M context) * feat(requests): route fulfillment through RequestRouterProvider; host keeps quality governance Co-Authored-By: Claude Opus 4.8 (1M context) * fix(requests): base auto-approve gate on router connection model Co-Authored-By: Claude Opus 4.8 (1M context) * feat(api): wire plugin-backed request router at both service sites Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(requests): remove in-host Sonarr/Radarr fulfillment code * test(autoscan): lock request-integration reuse after connection generalization Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): plugin-driven request integration config form Co-Authored-By: Claude Opus 4.8 (1M context) * feat(api): echo router connection fields in integration response * fix(requests): retry dropped qualities, contain to one router installation, dedupe targets Co-Authored-By: Claude Opus 4.8 (1M context) * fix(requests): harden plugin trust boundary (validate targets, contain bad connections, media-type routing) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(requests): tighten auto-approve gate, restore default/4k validation, propagate config-encode error, drop itoa wrapper, test status/options translation Co-Authored-By: Claude Opus 4.8 (1M context) * perf(requests): resolve integrations/settings/secrets once per reconcile cycle Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): dedupe config helpers, preserve zero profile id, stabilize installation default, drop redundant options write Co-Authored-By: Claude Opus 4.8 (1M context) * docs: design spec for schema-driven plugin config form Extends AdminFormDescriptor into a full form-description language (dynamic options, multi-select, conditional visibility, sections, validation) + a plugin Validate RPC, rendered by one reusable SchemaForm engine. Retires the bespoke arr connection form and integrationOptionsFromRouter so any request_router backend renders its config UI from manifest data with zero host changes. Addresses code-review finding #9. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for schema-driven plugin config form Six phases: SDK AdminFormDescriptor extensions + Validate RPC; reusable SchemaForm renderer (refactor PluginConfigForm onto it); host Validate plumbing + generic options + legacy-column derivation + retire integrationOptionsFromRouter; requests admin page swap to SchemaForm with per-plugin grouping; arr manifest enrichment + Validate impl; verification. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): extend plugin admin-form TS types (sections, conditions, validation, multi-select) * feat(web): schema-form pure utils (show_when, validation, value coercion) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): SchemaForm renderer (controls, sections, show_when, dynamic options, errors) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(web): render PluginConfigForm via the shared SchemaForm engine Co-Authored-By: Claude Opus 4.8 (1M context) * feat(requests): RequestRouter Validate client + provider seam Co-Authored-By: Claude Opus 4.8 (1M context) * feat(requests): plugin Validate on save, generic options, derive legacy columns from plugin_config Co-Authored-By: Claude Opus 4.8 (1M context) * feat(api): generic options response + 400 field_errors on plugin validation failure * feat(web): generic request-integration options type + surface validation field_errors Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): render request connections via SchemaForm; per-plugin grouping; retire bespoke arr form Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): silent connection-options probe with inline failure status (no toast spam) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(api): serialize admin_form sections/show_when/dynamic_options/validation to the client Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): drop show_when-hidden fields from buildSchemaValues payload Co-Authored-By: Claude Opus 4.8 (1M context) * feat(requests): pass requester user id as int64 (no truncation) * refactor(requests): drop legacy arr columns; plugin_config is sole source of truth Co-Authored-By: Claude Opus 4.8 (1M context) * fix(requests): backfill api key in plugin validate; centralize validation 400; drop duplicate host cross-field check; guard admin-form serializer Co-Authored-By: Claude Opus 4.8 (1M context) * fix(requests): refuse stored api key reuse when base_url changes (security hardening) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): SchemaForm regex-guard, default_value, type-driven coercion, validity callback Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): connection-options latest-wins + narrowed deps + clear stale errors; auto-select; type-driven persist; reuse types Co-Authored-By: Claude Opus 4.8 (1M context) * docs: design spec for silo-plugin-requests-seerr (request_router.v1 backend) * docs: implementation plan for silo-plugin-requests-seerr * docs(spec): FindExistingRequest uses /api/v1/request (carries request id) * docs(spec): seerr hardening — id-recovery, 404 terminal, media-status, sort pin, single missing-tmdb message * docs: design spec for shared plugin-platform SDK helpers (code-review #10) * docs: plan for plugin-platform SDK helpers (#10) + spec fix (inline broker wiring, no import cycle) * docs: design spec for typed 4K quality-tier signal (code-review #9) * docs: implementation plan for typed 4K quality-tier signal (#9) * feat(requests): stamp is4k per quality (host owns the 4K-tier fact) * fix(requests): store capability sub-id, not the type, in request_integrations request_integrations.capability_id carried the capability TYPE ("request_router.v1") instead of the capability sub-id ("arr"/"seerr"). The host resolves a router plugin via requireCapability("request_router.v1", id), which keys on (type, id), so storing the type resolved to no capability: every save/options/fulfill 500'd ("Request operation failed" / "no fulfillment backend configured") in ~1ms, before the arr/Seerr API was ever contacted. The path was internally split-brained (the fulfillment filter matched the type while the dispatcher needed the sub-id), so it never worked end-to-end; the unit tests hid it behind a fake provider that skips requireCapability. Align capability_id with the scan_source/metadata convention (sub-id): - validateInstance: require a non-empty sub-id; drop the default-to-type and the "!= request_router.v1" reject. - resolveRouterConnections / integrationConfigured / unbound-guidance: match on a non-empty capability, not type equality. - repository: persist capability_id verbatim (never default to the type). - web AdminRequests: send the selected plugin's capability.id in both the options probe and the save payload (was a hardcoded type constant). - migration 20260608131649: backfill capability_id from each bound installation's request_router.v1 capability and drop the column's misleading default. Unbound legacy rows are left for admin re-save. Tests: validateInstance now requires the sub-id, and the selected sub-id must reach the plugin Validate RPC (fakeRouterProvider records it). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): polish request connection cards (grouped toggles + option loading states) The schema-driven connection cards rendered each boolean as its own bordered, double-labeled box and showed dynamic SELECTs (root folder, quality profile, tags) as empty controls with a single "Loading options…" line while the host probed the service. - Toggles render as a cohesive settings list: consecutive switches collapse into one bordered, divided container; each row is toggle-first with the label + description hugging beside it (no stranded whitespace between a short label and its switch). Honors show_when, so conditional toggles still group correctly. - Dynamic SELECT/MULTI_SELECT fields show a per-field spinner + shimmer skeleton while options load, and only when there's nothing to show yet — a background re-probe never flashes over the operator's current value. - Sections get a softer surface and clearer titles; the card's enable switch is labeled Enabled/Disabled; the options-load failure is a proper inline alert with retry guidance. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(requests): treat "Any"/no-cap playback ceiling as 4K-allowed allowedQualities decided whether to also request 2160p with `CompareQuality(ceiling, PlaybackQuality4K) >= 0`. But an "Any" max playback quality resolves to an empty ceiling ("no cap"), and in qualityRank "" is the LOWEST rank (0) — so CompareQuality("", "2160p") returns -1 and 4K was dropped. A requester with unlimited playback quality only got a 1080p request, never the 4K one. Use access.QualityAllowed(PlaybackQuality4K, ceiling), which already encodes "empty ceiling == no cap == allows everything". Now: - "" / "Any" -> 1080p + 2160p - "2160p" -> 1080p + 2160p - "1080p" -> 1080p only - resolver error still fails safe to the HD ceiling. Tests: add an "any/no-cap ceiling adds 2160p" case; the unknown-quality, status-coercion, dedup, and per-quality-idempotency submit tests now pin an explicit HD ceiling (they relied on the old empty-default == HD-only behavior and were not about 4K entitlement). Co-Authored-By: Claude Opus 4.8 (1M context) * docs: design spec for collapsible Library + anime gate/nesting (request card UI, Spec A) Spec A of two for the request connection card UX: Library section becomes collapsible/collapsed (auto-expanding on validation errors) and the anime override fields move into a single gated section below Library instead of popping out as a detached sibling card. Single-default enforcement is Spec B. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for collapsible Library + anime gate/nesting (Spec A) Task-by-task TDD plan: SchemaForm auto-expand-on-error + nested-field affordance (silo-server), arr manifest regroup (collapsible Library, anime gate section), then build/deploy/reinstall + manual verify. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): auto-expand collapsible schema sections that have validation errors SchemaFormSection now accepts a forceOpen prop; when any field in the section has a mergedError (client validation or server error), the section expands automatically so required-field setup can never be hidden behind a collapsed accordion. The operator's manual toggle is preserved via a nullable userOpen state that only takes effect when forceOpen is false. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(web): indent show_when-revealed schema fields to read as nested Co-Authored-By: Claude Sonnet 4.6 * docs: design spec for schema-driven single-default exclusivity enforcement (Spec B) At most one connection per service_kind may be the HD default (is_default) or 4K default (is_default_4k). Generic exclusivity: a new AdminFormField exclusive_group_field declares the rule, the plugin Validate enforces it against host-supplied siblings (config only, no creds), and the admin UI auto-clears conflicts as you toggle. Host stays plugin-agnostic. Forward-only; no migration. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for single-default exclusivity enforcement (Spec B) Five TDD tasks across 3 repos: SDK proto (siblings + exclusive_group_field) + buf regen; arr Validate cross-sibling + manifest; host gathers siblings (config-only) into Validate; frontend generic mutual-exclusion helper; then re-vendor/rebuild/redeploy + plugininstall. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(requests): pass sibling connections to plugin Validate for cross-connection rules Adds siblings []ResolvedRouterConnection to RequestRouterProvider.Validate so the plugin can enforce cross-connection invariants (e.g. one default per service_kind) without the host resolving sibling credentials. The new siblingConnections helper gathers other connections on the same installation, carrying only ID + PluginConfig. Vendor updated to the Task 1 SDK version that carries ValidateRequest.Siblings. Co-Authored-By: Claude Sonnet 4.6 * feat(web): auto-clear mutually-exclusive defaults across request connection cards Adds generic applyExclusivity helper and wires it into updateCardConfig so turning on a field with exclusive_group_field proactively clears the same field on sibling cards sharing the same group value, matching server-side enforcement with a proactive UX. * docs: design spec for single-flighting plugin client launch (cold-start herd fix) Concurrent ensureClient calls for a cold installation each spawn a redundant plugin process (Host.Start releases its lock during launch). Wrap ensureClient in a per-installation singleflight.Group so concurrent first-use collapses to one launch. Host-only fix; surfaced while testing the request-router feature. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for single-flighting plugin client launch TDD: concurrency tests (herd collapses to one launch, warm-cache reuse, distinct installations stay parallel, failed launch propagates) + the singleflight wrapper around ensureClient; then rebuild/redeploy + verify. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(plugins): single-flight ensureClient to prevent cold-start launch herd Co-Authored-By: Claude Sonnet 4.6 * refactor(requests): harden capability containment + dedupe eligibility; UI/migration cleanups Addresses /code-review high findings on the previously-unreviewed commits: - resolveRouterConnections contains fulfillment to the first chosen (installation, capability) and locks only after a connection's key resolves, so a plugin exposing >1 request_router capability never mixes connections and a skipped bad-key connection never pins the capability (+ test). - extract eligibleRouterConnection, shared by resolveRouterConnections and integrationConfigured so the auto-approval gate and fulfillment filter can't drift. - SchemaForm: shared FieldDescription helper (field/switch/section); key switch groups by position so a show_when reveal doesn't remount the group (focus loss). - migration backfill uses a deterministic correlated subquery instead of a join cross-product when an installation exposes multiple request_router capabilities. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: design spec for opt-in Seerr per-user requester mapping Per-connection requester_mode (admin default | mapped). In mapped mode the host pushes the requester email/username into the Fulfill descriptor and the seerr plugin resolves/creates the matching Seerr user by email with operator-chosen default permissions, attributing the request (and gating Seerr-side approval via the auto-approve permission). Spans SDK (descriptor fields), host (extend UserIdentityLookup with email + a requester resolver), and the seerr plugin (Seerr user API + mapping). Fallback to admin on any failure. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: implementation plan for Seerr per-user requester mapping Five TDD tasks across 3 repos: SDK descriptor fields (requester_email/username); host resolves identity (UserIdentityLookup+email, RequesterIdentityResolver, populate descriptor at both Fulfill sites); seerr config+user API (find/create by email, exported PermissionBits); seerr Fulfill mapping + admin_form; then re-vendor/rebuild/redeploy + plugininstall (installation 6). Co-Authored-By: Claude Opus 4.8 (1M context) * docs: make Seerr unmapped-requester behavior a toggle (admin fallback | fail request) Per user feedback: require_mapped_user switch (default off = admin fallback, on = fail the request). Updates spec + plan Tasks 3/4 (config field, Fulfill honoring the toggle via a mapFailed signal, a new test, and the manifest switch). * feat(requests): resolve requester email/username into the Fulfill descriptor Co-Authored-By: Claude Sonnet 4.6 * docs: design spec for simplified Seerr mapped-user permissions Reduce the 5 permission toggles to two (request_4k_all + auto_approve); 1080p always granted; remove manage_requests; 4K eligibility per-user from the request's qualities (host-decided, same as arr) with a blanket override toggle. Seerr-plugin-only; permission-only override (host still gates 4K requests). * docs: implementation plan for simplified Seerr mapped-user permissions Two tasks (seerr-plugin-only): replace the 5 perm toggles with request_4k_all + auto_approve (1080p always; 4K from request qualities via userPermissions; remove PermManageRequests/PermissionBits; manifest + json_schema), then rebuild + reinstall (installation 6). No host/SDK change. * docs: design spec for host rebase onto main + #95 credential-model adoption Per-commit rebase of our 68 request-router commits onto the force-pushed origin/main (drops 188 patch-equivalent). At the credential-path conflicts, adopt #95's inline secret.Cipher model: keep our plugin columns + #95's encrypt/decrypt in repository.go; drop our SecretResolver and read in.APIKeyRef directly in service.go; wire NewRepository(pool, dataCipher). #39-area conflicts take ours (our pluginization supersedes it). Security review + SECRET_KEY deploy note. * docs: implementation plan for host rebase + #95 credential adoption Four tasks: (1) guided per-commit rebase onto origin/main, take-ours on credential files so it builds; (2) TDD integration commit adopting #95's secret.Cipher (encrypt/decrypt in repository.go, drop SecretResolver, read APIKeyRef directly, wire NewRepository(pool, cipher)); (3) security review; (4) pin published SDK v0.6.0, push fork, open host PR with SECRET_KEY deploy note. * chore(rebase): restore scan-source service methods + temp requests-repo arity Post-rebase conflict fixups: take-ours on internal/plugins/service.go dropped origin's ScanSourceClientByPluginID (independent upstream capability) — restored. mediarequests.NewRepository temporarily 1-arg to match our pre-#95 repo; Task 2 restores the cipher arg when adopting #95's at-rest credential model. * feat(requests): adopt at-rest credential cipher (#95) for plugin api keys; drop SecretResolver * build: pin published silo-plugin-sdk v0.6.0 (drop local replace) * test(requests): guard at-rest cipher round-trip + empty-key auto-approval (code-review) Max-effort code review of the #95 credential integration. Fixes the actionable findings: - TestEncryptAPIKeyRoundTripAndAAD: pins encryptAPIKey<->DecryptIfEncrypted inversion, the id-bound apiKeyAAD == secret.RowAAD(...) match (so #95's backfill rows decrypt), the blank-key "" sentinel, and row-bound AAD — the security- critical invariants had no automated guard (no DB harness for scanIntegration). - TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured: pins that a keyless connection reads as unconfigured (request stays pending, never submitted), so integrationConfigured and resolveRouterConnections can't drift. - Fix stale fulfillContext comment (referenced a resolved-API-key cache removed with SecretResolver). Assessed-not-changed (documented): decrypt-error-fails-closed and failed-backfill behaviors are origin/main #95 design we adopt; nil-cipher is unreachable in prod and matches the codebase-wide no-guard pattern. Co-Authored-By: Claude Opus 4.8 (1M context) * build: drop stale machine-local SDK replace comment from go.mod The replace directive was already removed when v0.6.0 was pinned (3410df7); this leftover comment falsely claimed a local replace still existed. Co-Authored-By: Claude Opus 4.8 (1M context) * style(web): prettier-format schema-form utils to 100-col width Co-Authored-By: Claude Opus 4.8 (1M context) * docs: drop internal superpowers specs/plans from PR These design specs and implementation plans are internal development artifacts; keep them out of the upstream PR diff. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(metadata): exclude providers from content levels they don't declare ResolveChain falls back to every enabled metadata provider when a library + content-level has no enabled chain entry. That fallback was media-type blind: a provider declaring default_priority only for an unrelated level (e.g. an audiobook provider declaring {"audiobook": N}) was kept in the list (merely sorted last) and invoked for video content levels. In production this made silo.audiobook-metadata hammer external audiobook APIs with anime/movie/series titles every scheduled enrichment pass (MatchWorker, 30s) for the season/episode levels that had no enabled chain entry. Disabling the chain entries did not help because the fallback never consults them; only disabling the installation removed it from the global set. Treat a non-empty default_priority map as the provider enumerating the content levels it supports: in resolveEnabledProvidersByPriority, exclude providers whose declared map omits the requested level instead of ranking them last. Providers that declare no default_priority make no claim and stay eligible everywhere (legacy behavior). Fixes #105 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(plugins): isolate singleflight launch from leader ctx cancellation The deduped ensureClient launch ran doEnsureClient under the leader caller's ctx, so if that caller's request was canceled/timed out mid-launch the shared plugin start was torn down and the error propagated to every waiter. Run the launch under context.WithoutCancel so a single caller cannot cancel work the other waiters depend on (values preserved for tracing/auth). (CodeRabbit) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(api): nil-guard request-router wiring RequestRouterClient dereferenced a.Svc unconditionally and AttachRequestRouter called SetRouterProvider even with nil deps, so a build without the plugin service would panic instead of degrading. Guard both: the adapter returns a controlled error and AttachRequestRouter no-ops, leaving fulfillment to fail with the existing "no backend configured" path. (CodeRabbit) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): correct value coercion + track capability sub-id in request form - schemaForm: Boolean("false") was true; parse string booleans explicitly. array:num now coerces decimals ("1.5"), array:int stays integer-only. - AdminRequests: track capability_id alongside installation_id (composite setField(field.key, nextValue)} - > - - - - - {(field.options ?? []).map((option) => ( - - {option.label} - - ))} - - - ) : field.control === "TEXTAREA" || field.multiline ? ( -