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