diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 487745f7..d57810de 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -29,6 +29,7 @@ import ( pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" "github.com/Silo-Server/silo-server/internal/activitylog" + "github.com/Silo-Server/silo-server/internal/audiobooks" "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/api" "github.com/Silo-Server/silo-server/internal/api/handlers" @@ -1286,6 +1287,12 @@ func main() { slog.Info("task manager started") } + // Scaffold audiobooks service (sub-plan 1: kill-switch reader only). + // audiobooksSettingsAdapter (defined at package level below) bridges + // catalog.ServerSettingsRepo.Get to audiobooks.SettingsReader.GetString. + audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo}) + _ = audiobooksService // sub-plan 2+ will register routes / scheduled tasks + if deps.DB != nil && pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && deps.PluginService != nil { userRepo := auth.NewUserRepository(deps.DB) sessionRepo := auth.NewSessionRepository(deps.DB) @@ -1827,3 +1834,14 @@ func mapFolderTypeToMediaType(t string) string { return "mixed" } } + +// audiobooksSettingsAdapter bridges catalog.ServerSettingsRepo (which +// exposes Get) to the audiobooks.SettingsReader interface (which +// requires GetString). The two signatures are identical modulo name. +type audiobooksSettingsAdapter struct { + repo *catalog.ServerSettingsRepo +} + +func (a *audiobooksSettingsAdapter) GetString(ctx context.Context, key string) (string, error) { + return a.repo.Get(ctx, key) +} diff --git a/internal/audiobooks/doc.go b/internal/audiobooks/doc.go new file mode 100644 index 00000000..29011914 --- /dev/null +++ b/internal/audiobooks/doc.go @@ -0,0 +1,10 @@ +// Package audiobooks owns silo's first-party audiobook + podcast feature, +// absorbed from the historical silo-plugin-audiobooks. Sub-plan 1 lands +// only the package scaffold and the kill-switch reader; ABS-compat REST, +// Socket.io, scanner branches, podcast feed refresh, and the silo SPA +// pages arrive in later sub-plans. +// +// See docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md +// for the design and docs/superpowers/plans/2026-05-24-audiobooks-*.md +// for the staged implementation plans. +package audiobooks diff --git a/internal/audiobooks/service.go b/internal/audiobooks/service.go new file mode 100644 index 00000000..a8b6cf7d --- /dev/null +++ b/internal/audiobooks/service.go @@ -0,0 +1,43 @@ +package audiobooks + +import ( + "context" + "fmt" +) + +// SettingsReader is the minimal slice of the server-settings store that +// the audiobooks service needs. The production implementation is +// internal/catalog.ServerSettingsRepo (or whatever silo names that helper at +// wiring time); tests pass a fake. +type SettingsReader interface { + GetString(ctx context.Context, key string) (string, error) +} + +// Service is the audiobooks feature's top-level orchestrator. Sub-plan 1 +// exposes only Enabled(); subsequent sub-plans hang additional methods +// off Service as new capabilities (scanner branches, ABS handlers, etc.) +// come online. +type Service struct { + settings SettingsReader +} + +// New constructs a Service. The constructor takes the dependencies it +// will actually use; current sub-plan needs only the settings reader. +func New(settings SettingsReader) *Service { + return &Service{settings: settings} +} + +// Enabled reports whether the audiobooks feature flag (set by migration +// 142 and toggled by operators) is currently true. Any value other than +// the literal string "true" reads as false; this matches how silo +// treats other boolean server_settings rows. +func (s *Service) Enabled(ctx context.Context) (bool, error) { + if s == nil || s.settings == nil { + return false, nil + } + value, err := s.settings.GetString(ctx, "audiobooks.enabled") + if err != nil { + return false, fmt.Errorf("audiobooks: read audiobooks.enabled: %w", err) + } + return value == "true", nil +} diff --git a/internal/audiobooks/service_test.go b/internal/audiobooks/service_test.go new file mode 100644 index 00000000..c9fdcd1e --- /dev/null +++ b/internal/audiobooks/service_test.go @@ -0,0 +1,54 @@ +package audiobooks + +import ( + "context" + "errors" + "testing" +) + +type fakeSettingsReader struct { + value string + err error +} + +func (f *fakeSettingsReader) GetString(_ context.Context, key string) (string, error) { + if key != "audiobooks.enabled" { + return "", errors.New("unexpected key: " + key) + } + return f.value, f.err +} + +func TestServiceEnabledReadsFlag(t *testing.T) { + cases := []struct { + name string + stored string + want bool + }{ + {"flag true", "true", true}, + {"flag false", "false", false}, + {"flag empty defaults false", "", false}, + {"flag garbage defaults false", "yes-please", false}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + svc := New(&fakeSettingsReader{value: tc.stored}) + got, err := svc.Enabled(context.Background()) + if err != nil { + t.Fatalf("Enabled returned error: %v", err) + } + if got != tc.want { + t.Fatalf("Enabled = %v, want %v", got, tc.want) + } + }) + } +} + +func TestServiceEnabledPropagatesError(t *testing.T) { + wantErr := errors.New("db down") + svc := New(&fakeSettingsReader{err: wantErr}) + _, err := svc.Enabled(context.Background()) + if !errors.Is(err, wantErr) { + t.Fatalf("Enabled error = %v, want %v wrapped", err, wantErr) + } +}