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 <rxwatcher@users.noreply.github.com>
This commit is contained in:
RXWatcher
2026-06-08 18:17:08 -04:00
committed by GitHub
co-authored by rxwatcher
parent 9e29e7b330
commit 339ed20074
12 changed files with 318 additions and 24 deletions
+20 -8
View File
@@ -44,7 +44,8 @@ const (
// defaultEnrichBatchSize is the maximum number of audiobook items processed
// per sweep invocation. Keeps latency bounded for large libraries.
defaultEnrichBatchSize = 50
// Override with SILO_AUDIOBOOK_ENRICH_BATCH_SIZE.
defaultEnrichBatchSize = 250
// defaultEnrichWorkers is the default fan-out used by Enricher.Run.
// Network-bound: each worker holds one provider HTTP call at a time, so
// 4 is enough to mask single-request latency without hammering plugins.
@@ -52,18 +53,28 @@ const (
defaultEnrichWorkers = 4
)
// audiobookEnrichBatchSize returns the configured maximum sweep size.
func audiobookEnrichBatchSize() int {
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
return parsed
}
}
return defaultEnrichBatchSize
}
// audiobookEnrichWorkers returns the configured number of parallel enrichment
// workers, capped to defaultEnrichBatchSize so workers never outnumber the
// batch they drain.
func audiobookEnrichWorkers() int {
// workers, capped to the active batch size so workers never outnumber the batch
// they drain.
func audiobookEnrichWorkers(batchSize int) int {
n := defaultEnrichWorkers
if v := os.Getenv("SILO_AUDIOBOOK_ENRICH_WORKERS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
n = parsed
}
}
if n > defaultEnrichBatchSize {
n = defaultEnrichBatchSize
if batchSize > 0 && n > batchSize {
n = batchSize
}
return n
}
@@ -105,6 +116,7 @@ func NewEnricher(
personRepo *catalog.PersonRepository,
providerIDs *catalog.ProviderIDRepository,
) *Enricher {
batchSize := audiobookEnrichBatchSize()
return &Enricher{
pool: pool,
chainRepo: chainRepo,
@@ -112,8 +124,8 @@ func NewEnricher(
itemRepo: itemRepo,
personRepo: personRepo,
providerIDs: providerIDs,
batchSize: defaultEnrichBatchSize,
workers: audiobookEnrichWorkers(),
batchSize: batchSize,
workers: audiobookEnrichWorkers(batchSize),
}
}
+44
View File
@@ -63,6 +63,50 @@ func TestEnricherRunFansOut(t *testing.T) {
}
}
func TestNewEnricherUsesConfiguredBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "123")
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
e := NewEnricher(nil, nil, nil, nil, nil, nil)
if e.batchSize != 123 {
t.Fatalf("batchSize = %d, want 123", e.batchSize)
}
if e.workers != 8 {
t.Fatalf("workers = %d, want 8", e.workers)
}
}
func TestNewEnricherCapsWorkersToConfiguredBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "50")
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "100")
e := NewEnricher(nil, nil, nil, nil, nil, nil)
if e.batchSize != 50 {
t.Fatalf("batchSize = %d, want 50", e.batchSize)
}
if e.workers != e.batchSize {
t.Fatalf("workers = %d, want capped batchSize %d", e.workers, e.batchSize)
}
}
func TestAudiobookEnrichWorkersCapsAtBatchSize(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_WORKERS", "8")
if got := audiobookEnrichWorkers(3); got != 3 {
t.Fatalf("audiobookEnrichWorkers(3) = %d, want 3", got)
}
}
func TestAudiobookEnrichBatchSizeIgnoresInvalidEnv(t *testing.T) {
t.Setenv("SILO_AUDIOBOOK_ENRICH_BATCH_SIZE", "nope")
if got := audiobookEnrichBatchSize(); got != defaultEnrichBatchSize {
t.Fatalf("audiobookEnrichBatchSize() = %d, want %d", got, defaultEnrichBatchSize)
}
}
func TestCacheRemotePosterCachesProviderURL(t *testing.T) {
cacher := &fakeAudiobookImageCacher{}
e := &Enricher{imageCacher: cacher}
+1 -1
View File
@@ -162,7 +162,7 @@ func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler {
if deps.Detail == nil {
return ""
}
return deps.Detail.PresignURL(ctx, path, variant)
return deps.Detail.PresignImageURL(ctx, path, "poster", variant)
},
})
s.ABSHandler = h
+54
View File
@@ -3,7 +3,14 @@ package audiobooks
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"unsafe"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/scanner"
)
type fakeSettingsReader struct {
@@ -74,3 +81,50 @@ func TestServiceABSCompatEnabledNilSettingsReturnsFalse(t *testing.T) {
t.Fatal("ABSCompatEnabled = true, want false")
}
}
func TestBuildABSHandlerCoverResolverUsesPosterVariant(t *testing.T) {
resolver := &recordingImageResolver{}
detail := &catalog.DetailService{}
detail.SetImageResolver(resolver)
handler := New(nil).BuildABSHandler(ABSHandlerDeps{
Items: &catalog.ItemRepository{},
Files: &scanner.FileRepository{},
Detail: detail,
})
coverResolver := absCoverResolverForTest(t, handler)
got := coverResolver(context.Background(), "local/audiobooks/book-1/poster/original.webp", "card")
if !strings.Contains(got, "/w500.webp") {
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
}
if resolver.variant != "featured" {
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
}
}
func absCoverResolverForTest(t *testing.T, handler *abs.Handler) func(context.Context, string, string) string {
t.Helper()
field := reflect.ValueOf(handler).Elem().FieldByName("deps").FieldByName("CoverResolver")
return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface().(func(context.Context, string, string) string)
}
type recordingImageResolver struct {
path string
variant string
}
func (r *recordingImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
r.path = path
r.variant = variant
return "resolved://" + path
}
func (r *recordingImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
out := make(map[string]string, len(paths))
for _, path := range paths {
out[path] = r.ResolveImageURL(context.Background(), path, variant)
}
return out
}
+7 -3
View File
@@ -1061,6 +1061,10 @@ func firstNonEmptyString(values []string) string {
return ""
}
func (s *DetailService) presignAudiobookPosterURL(ctx context.Context, posterPath string) string {
return s.PresignImageURL(ctx, posterPath, "poster", "")
}
func appendAudiobookItemAccessConditions(
alias string,
filter AccessFilter,
@@ -1146,7 +1150,7 @@ func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentI
continue
}
seen[item.ContentID] = struct{}{}
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
out = append(out, item)
}
return out
@@ -1205,7 +1209,7 @@ func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, conte
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
return []AudiobookRelatedItem{}
}
item.PosterURL = s.PresignURL(ctx, posterPath, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
out = append(out, item)
}
return out
@@ -1266,7 +1270,7 @@ func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID stri
item.SeriesIndex = &n
}
}
item.PosterURL = s.PresignURL(ctx, poster, "featured")
item.PosterURL = s.presignAudiobookPosterURL(ctx, poster)
entries = append(entries, item)
}
if len(entries) < 2 {
+36
View File
@@ -1,6 +1,8 @@
package catalog
import (
"context"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
@@ -40,3 +42,37 @@ func TestSortAudiobookMediaFilesFallsBackToPath(t *testing.T) {
}
}
}
func TestPresignAudiobookPosterURLUsesPosterVariant(t *testing.T) {
resolver := &recordingCatalogImageResolver{}
detail := &DetailService{}
detail.SetImageResolver(resolver)
got := detail.presignAudiobookPosterURL(context.Background(), "local/audiobooks/book/poster/original.webp")
if !strings.Contains(got, "/w500.webp") {
t.Fatalf("resolved URL = %q, want w500 poster variant", got)
}
if resolver.variant != "featured" {
t.Fatalf("resolver variant = %q, want featured", resolver.variant)
}
}
type recordingCatalogImageResolver struct {
path string
variant string
}
func (r *recordingCatalogImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
r.path = path
r.variant = variant
return "resolved://" + path
}
func (r *recordingCatalogImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
out := make(map[string]string, len(paths))
for _, path := range paths {
out[path] = r.ResolveImageURL(context.Background(), path, variant)
}
return out
}
+3 -3
View File
@@ -355,9 +355,9 @@ func LoadFromDB(m map[string]string) (*Config, error) {
}
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
// AudiobookshelfCompat — dedicated listener for ABS client apps. Keep the
// listener off unless ABS compatibility is explicitly enabled.
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", false)
// AudiobookshelfCompat — dedicated listener for ABS client apps. Docker
// deployments publish :13378, so listen by default unless disabled.
absCompatEnabled, err := boolOr(m, "audiobookshelf_compat.enabled", true)
if err != nil {
return nil, err
}
+9 -1
View File
@@ -35,7 +35,15 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
}
func TestLoadFromDBAudiobookshelfCompatFlagGatesCompatListener(t *testing.T) {
cfg, err := LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
cfg, err := LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB() returned error: %v", err)
}
if cfg.AudiobookshelfCompat.Listen != ":13378" {
t.Fatalf("default audiobooks listener = %q, want default :13378", cfg.AudiobookshelfCompat.Listen)
}
cfg, err = LoadFromDB(map[string]string{"audiobookshelf_compat.enabled": "false"})
if err != nil {
t.Fatalf("LoadFromDB() returned error: %v", err)
}
+71
View File
@@ -206,6 +206,77 @@ func TestScanAudiobookFolderReturnsCanceledContext(t *testing.T) {
}
}
func TestScanSubtreeAudiobookLibraryUsesAudiobookPipeline(t *testing.T) {
root := t.TempDir()
bookDir := filepath.Join(root, "bad-book")
if err := os.Mkdir(bookDir, 0o755); err != nil {
t.Fatalf("mkdir book dir: %v", err)
}
if err := os.WriteFile(filepath.Join(bookDir, "chapter.mp3"), []byte("not real audio"), 0o644); err != nil {
t.Fatalf("write fake audio: %v", err)
}
s := &Scanner{ffprobePath: "definitely-missing-ffprobe"}
_, err := s.ScanSubtree(context.Background(), &models.MediaFolder{ID: 42, Type: "audiobooks"}, bookDir)
if err == nil {
t.Fatal("ScanSubtree returned nil, want audiobook parse failure")
}
if strings.Contains(err.Error(), "getting existing files") {
t.Fatalf("ScanSubtree used generic file pipeline: %v", err)
}
if !strings.Contains(err.Error(), "folder_id=42") {
t.Fatalf("error = %q, want audiobook scanner aggregate failure", err)
}
}
func TestScanSubtreeAudiobookLibraryRejectsInvalidScopedRoot(t *testing.T) {
s := &Scanner{}
_, err := s.ScanSubtree(context.Background(), &models.MediaFolder{ID: 42, Type: "audiobooks"}, "")
if err == nil {
t.Fatal("ScanSubtree returned nil, want invalid root error")
}
if !strings.Contains(err.Error(), "invalid audiobook scan root") {
t.Fatalf("error = %q, want invalid audiobook scan root", err)
}
}
func TestScanFileAudiobookLibraryUsesAudiobookPipeline(t *testing.T) {
root := t.TempDir()
bookDir := filepath.Join(root, "bad-book")
if err := os.Mkdir(bookDir, 0o755); err != nil {
t.Fatalf("mkdir book dir: %v", err)
}
filePath := filepath.Join(bookDir, "chapter.mp3")
if err := os.WriteFile(filePath, []byte("not real audio"), 0o644); err != nil {
t.Fatalf("write fake audio: %v", err)
}
s := &Scanner{ffprobePath: "definitely-missing-ffprobe"}
err := s.ScanFile(context.Background(), filePath, &models.MediaFolder{ID: 42, Type: "audiobooks"})
if err == nil {
t.Fatal("ScanFile returned nil, want audiobook parse failure")
}
if strings.Contains(err.Error(), "unrecognized video extension") {
t.Fatalf("ScanFile used video extension gate: %v", err)
}
if !strings.Contains(err.Error(), "folder_id=42") {
t.Fatalf("error = %q, want audiobook scanner aggregate failure", err)
}
}
func TestScanFileAudiobookLibraryRejectsRelativeScopedRoot(t *testing.T) {
s := &Scanner{}
err := s.ScanFile(context.Background(), "chapter.mp3", &models.MediaFolder{ID: 42, Type: "audiobooks"})
if err == nil {
t.Fatal("ScanFile returned nil, want invalid root error")
}
if !strings.Contains(err.Error(), "invalid audiobook scan root") {
t.Fatalf("error = %q, want invalid audiobook scan root", err)
}
}
func TestResolveAudiobookMediaItemReusesRootScopedContentID(t *testing.T) {
finder := &fakeRootContentFinder{contentID: "book-root-id"}
writer := &fakeFilesystemItemWriter{}
+47 -1
View File
@@ -250,9 +250,40 @@ func (s *Scanner) ScanSubtree(ctx context.Context, folder *models.MediaFolder, s
cleanSubtree := filepath.Clean(subtreePath)
watchCtx, stopWatch := s.watchFolderContext(ctx, folder.ID)
defer stopWatch()
if isAudiobookLibraryType(folder.Type) {
scanRoot, err := cleanScopedAudiobookScanRoot(subtreePath)
if err != nil {
return nil, err
}
if err := s.ScanAudiobookFolder(watchCtx, scopedFolderPaths(folder, []string{scanRoot})); err != nil {
return nil, err
}
if err := s.syncFolderScopedAudioLibraryState(watchCtx, folder.ID); err != nil {
return nil, err
}
return &ScanResult{}, nil
}
return s.scanPaths(watchCtx, folder, []string{cleanSubtree}, []string{cleanSubtree}, false)
}
func cleanScopedAudiobookScanRoot(path string) (string, error) {
clean := filepath.Clean(path)
if clean == "" || clean == "." || clean == ".." || clean == string(filepath.Separator) ||
strings.HasPrefix(clean, ".."+string(filepath.Separator)) || !filepath.IsAbs(clean) {
return "", fmt.Errorf("invalid audiobook scan root: %s", path)
}
return clean, nil
}
func scopedFolderPaths(folder *models.MediaFolder, paths []string) *models.MediaFolder {
if folder == nil {
return nil
}
clone := *folder
clone.Paths = paths
return &clone
}
func isMovieLibraryType(libraryType string) bool {
switch strings.ToLower(strings.TrimSpace(libraryType)) {
case "movie", "movies":
@@ -1454,8 +1485,23 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models.
return err
}
cleanFile := filepath.Clean(filePath)
if isAudiobookLibraryType(folder.Type) {
if !SupportsAudioFile(cleanFile) {
return fmt.Errorf("unrecognized audio extension: %s", strings.ToLower(filepath.Ext(cleanFile)))
}
scanRoot, err := cleanScopedAudiobookScanRoot(filepath.Dir(cleanFile))
if err != nil {
return err
}
if err := s.ScanAudiobookFolder(ctx, scopedFolderPaths(folder, []string{scanRoot})); err != nil {
return err
}
return s.syncFolderScopedAudioLibraryState(ctx, folder.ID)
}
// Verify the file extension is recognized.
ext := strings.ToLower(filepath.Ext(filePath))
ext := strings.ToLower(filepath.Ext(cleanFile))
if !videoExtensions[ext] {
return fmt.Errorf("unrecognized video extension: %s", ext)
}
@@ -1,13 +1,7 @@
-- +goose Up
-- +goose StatementBegin
INSERT INTO server_settings (key, value)
VALUES (
'audiobookshelf_compat.enabled',
COALESCE(
(SELECT value FROM server_settings WHERE key = 'audiobooks.enabled'),
'false'
)
)
VALUES ('audiobookshelf_compat.enabled', 'true')
ON CONFLICT (key) DO NOTHING;
-- +goose StatementEnd
@@ -0,0 +1,25 @@
-- +goose Up
-- +goose StatementBegin
-- The original ABS compatibility setting copied the stale audiobooks.enabled
-- kill-switch, which defaulted to false. Docker deployments already publish
-- :13378, so keep the listener enabled unless an operator disables it later.
UPDATE server_settings
SET value = 'true'
WHERE key = 'audiobookshelf_compat.enabled'
AND lower(trim(value)) IN ('', '0', 'false', 'no', 'off');
DELETE FROM server_settings
WHERE key = 'audiobooks.enabled';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
UPDATE server_settings
SET value = 'false'
WHERE key = 'audiobookshelf_compat.enabled'
AND lower(trim(value)) = 'true';
INSERT INTO server_settings (key, value)
VALUES ('audiobooks.enabled', 'false')
ON CONFLICT (key) DO NOTHING;
-- +goose StatementEnd