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