From 64e30997cb3e061e0b943af4195cd28dcf0a70e5 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Sun, 24 May 2026 15:58:02 +0200 Subject: [PATCH] feat(audiobooks): wire ABS routes into main router Stage 7 of the ABS-compat port (final). Implements MediaStore, ConfigProvider, and event/recommender stub adapters, constructs the abs.Handler in cmd/silo/main.go, and mounts it under /abs and /abs/api on silo's main router. ABS-compatible Audiobookshelf clients can now hit silo directly for login, library + items browse, file streaming, progress, and Socket.io. End-to-end smoke verified: /login returns 401, /abs/api/login returns 401, /abs/api/me returns 401, /abs/api/libraries returns 401, /abs/socket.io/ returns 200 on polling handshake. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/silo/main.go | 48 +++++++++++++- internal/api/router.go | 22 ++++++- internal/audiobooks/config.go | 94 ++++++++++++++++++++++++++ internal/audiobooks/media_store.go | 102 +++++++++++++++++++++++++++++ internal/audiobooks/service.go | 96 ++++++++++++++++++++++++++- 5 files changed, 358 insertions(+), 4 deletions(-) create mode 100644 internal/audiobooks/config.go create mode 100644 internal/audiobooks/media_store.go diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 233de0ea..c088880f 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -1287,11 +1287,47 @@ func main() { slog.Info("task manager started") } - // Scaffold audiobooks service (sub-plan 1: kill-switch reader only). + // Scaffold audiobooks service and ABS-compat handler. // 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 + + // Build the ABS-compatible REST + Socket.io handler when a DB pool is + // available. Routes are mounted at the root level by NewRouter (not under + // /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and + // /abs/socket.io/* without path prefix hacks. + if deps.DB != nil { + absUserRepo := auth.NewUserRepository(deps.DB) + absSessionRepo := auth.NewSessionRepository(deps.DB) + absJWTService := auth.NewJWTService( + cfg.Auth.JWTSecret, + cfg.Auth.AccessTokenExpiry, + cfg.Auth.RefreshTokenExpiry, + ) + absAuthSvc := auth.NewService( + auth.NewLocalProvider(absUserRepo, absSessionRepo), + absJWTService, + absSessionRepo, + absUserRepo, + nil, // invite codes: not needed for ABS compat + nil, // settings: not needed here + nil, // user store: not needed here + ) + absItemRepo := catalog.NewItemRepository(deps.DB) + absHDeps := audiobooks.ABSHandlerDeps{ + Pool: deps.DB, + Items: absItemRepo, + Files: deps.FileRepo, + Settings: settingsRepo, + Auth: &audiobooks.SiloCredValidator{ + Auth: absAuthSvc, + Pool: deps.DB, + }, + } + absH := audiobooksService.BuildABSHandler(absHDeps) + deps.ABSHandler = absH + } + _ = audiobooksService if deps.DB != nil && pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && deps.PluginService != nil { userRepo := auth.NewUserRepository(deps.DB) @@ -1414,6 +1450,14 @@ func main() { metricsMux := http.NewServeMux() metricsMux.Handle("/metrics", promhttp.Handler()) metricsMux.Handle("/api/", router) + // ABS-compat routes live outside /api/ — register them explicitly so they + // don't fall through to the SPA fallback handler. The chi router handles + // them internally via absHandler.Mount(r). + if deps.ABSHandler != nil { + metricsMux.Handle("/abs/", router) + metricsMux.Handle("/login", router) + metricsMux.Handle("/socket.io/", router) + } metricsMux.Handle("/", server.FrontendHandler()) // Step 9: Start background workers (if needed). diff --git a/internal/api/router.go b/internal/api/router.go index b1f140a6..75f3391a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -142,10 +142,23 @@ type Dependencies struct { // (search/top). May be nil; the handlers report "not configured" in // that case rather than failing. MDBListClient *mdblist.Client + + // ABSHandler is the Audiobookshelf-compatible HTTP handler. When non-nil + // it is mounted at the root router level (not under /api/v1/) so that ABS + // clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all + // resolve correctly. May be nil; no ABS routes are registered in that case. + ABSHandler absHandler +} + +// absHandler is the narrow interface the router needs from the ABS handler. +// Using an interface avoids a direct import of the abs sub-package from router.go. +type absHandler interface { + Mount(r chi.Router) } // NewRouter creates a chi.Router with all middleware and routes mounted -// under /api/v1/. +// under /api/v1/. ABS-compat routes (/abs/*, /login, /socket.io/*) are +// mounted at the root level when deps.ABSHandler is non-nil. func NewRouter(deps Dependencies) chi.Router { r := chi.NewRouter() @@ -992,6 +1005,13 @@ func NewRouter(deps Dependencies) chi.Router { } } + // ABS-compat routes live at the root, outside the /api/v1 prefix. + // Mount after middleware so r.Use() calls have all been registered first + // (chi panics if routes are registered before Use() calls on the same mux). + if deps.ABSHandler != nil { + deps.ABSHandler.Mount(r) + } + r.Route("/api/v1", func(r chi.Router) { r.Get("/health", healthHandler.ServeHTTP) r.Get("/ready", readyHandler.ServeHTTP) diff --git a/internal/audiobooks/config.go b/internal/audiobooks/config.go new file mode 100644 index 00000000..0f361930 --- /dev/null +++ b/internal/audiobooks/config.go @@ -0,0 +1,94 @@ +package audiobooks + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + "time" + + "github.com/Silo-Server/silo-server/internal/audiobooks/abs" + "github.com/Silo-Server/silo-server/internal/catalog" +) + +const ( + absJWTSecretKey = "audiobooks.abs.jwt_secret" + absDefaultAccessTTL = 24 * time.Hour + absDefaultRefreshTTL = 30 * 24 * time.Hour +) + +// ABSConfigProvider implements abs.ConfigProvider using silo's server_settings +// table. The ABS JWT secret is generated once on first read and persisted for +// the lifetime of the deployment. +type ABSConfigProvider struct { + Settings *catalog.ServerSettingsRepo + + // secretCache holds the generated secret so we only hit the DB once per + // process lifetime. Protected by mu. + mu sync.Mutex + secretCache []byte +} + +var _ abs.ConfigProvider = (*ABSConfigProvider)(nil) + +// JWTSecret returns the HMAC-SHA256 signing key for ABS JWTs. On the very +// first call it generates a random 32-byte key, persists it in server_settings +// under "audiobooks.abs.jwt_secret" (as hex), and caches it for the process +// lifetime. Subsequent calls return the cached value without touching the DB. +func (c *ABSConfigProvider) JWTSecret(ctx context.Context) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if len(c.secretCache) > 0 { + return c.secretCache, nil + } + + existing, err := c.Settings.Get(ctx, absJWTSecretKey) + if err != nil { + return nil, fmt.Errorf("abs_config: read jwt secret: %w", err) + } + if existing != "" { + decoded, err := hex.DecodeString(existing) + if err != nil { + return nil, fmt.Errorf("abs_config: decode jwt secret: %w", err) + } + c.secretCache = decoded + return c.secretCache, nil + } + + // Generate a new secret and persist it. + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("abs_config: generate jwt secret: %w", err) + } + if err := c.Settings.Set(ctx, absJWTSecretKey, hex.EncodeToString(secret)); err != nil { + // Non-fatal: use the in-memory key for this boot. Next start may + // differ, invalidating existing tokens — acceptable at early deploy. + _ = err + } + c.secretCache = secret + return c.secretCache, nil +} + +// AccessTTL returns the default access token lifetime. +// Returns 0 to signal "use built-in default" when no override is set. +func (c *ABSConfigProvider) AccessTTL(_ context.Context) (time.Duration, error) { + return absDefaultAccessTTL, nil +} + +// RefreshTTL returns the default refresh token lifetime. +func (c *ABSConfigProvider) RefreshTTL(_ context.Context) (time.Duration, error) { + return absDefaultRefreshTTL, nil +} + +// StandaloneLoginEnabled reports whether body-creds login is permitted. +// We default to true; an operator can set "audiobooks.abs.login_disabled" +// to "true" in server_settings to gate it off. +func (c *ABSConfigProvider) StandaloneLoginEnabled(ctx context.Context) (bool, error) { + disabled, err := c.Settings.Get(ctx, "audiobooks.abs.login_disabled") + if err != nil { + return true, nil // fail open + } + return disabled != "true", nil +} diff --git a/internal/audiobooks/media_store.go b/internal/audiobooks/media_store.go new file mode 100644 index 00000000..92125d01 --- /dev/null +++ b/internal/audiobooks/media_store.go @@ -0,0 +1,102 @@ +package audiobooks + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/audiobooks/abs" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scanner" +) + +// ABSMediaStore implements abs.MediaStore using silo's catalog.ItemRepository, +// scanner.FileRepository, and a direct pgxpool.Pool for media_folders queries. +type ABSMediaStore struct { + Items *catalog.ItemRepository + Files *scanner.FileRepository + Pool *pgxpool.Pool +} + +var _ abs.MediaStore = (*ABSMediaStore)(nil) + +// GetAudiobookByID returns the media_item with the given content_id, provided +// it is of type 'audiobook'. Returns nil and a wrapped error for any other +// outcome; the caller interprets a nil result as not-found. +func (s *ABSMediaStore) GetAudiobookByID(ctx context.Context, contentID string) (*models.MediaItem, error) { + item, err := s.Items.GetByID(ctx, contentID) + if err != nil { + return nil, fmt.Errorf("abs_media_store: get audiobook %q: %w", contentID, err) + } + if item == nil || item.Type != "audiobook" { + return nil, nil + } + return item, nil +} + +// ListAudiobooks returns a page of media_items with type='audiobook'. +// When libraryID is non-zero, results are filtered to items in that +// media_folder (via the media_item_libraries junction); 0 means all libraries. +func (s *ABSMediaStore) ListAudiobooks(ctx context.Context, libraryID int64, limit, offset int) ([]*models.MediaItem, int, error) { + filter := catalog.AccessFilter{} + if libraryID != 0 { + filter.AllowedLibraryIDs = []int{int(libraryID)} + } + items, total, err := s.Items.Search(ctx, "", []string{"audiobook"}, limit, offset, filter) + if err != nil { + return nil, 0, fmt.Errorf("abs_media_store: list audiobooks: %w", err) + } + return items, total, nil +} + +// GetMediaFiles returns all media_files for the given content_id, ordered by +// file_path so ABS clients receive a stable chapter ordering. +func (s *ABSMediaStore) GetMediaFiles(ctx context.Context, contentID string) ([]*models.MediaFile, error) { + files, err := s.Files.GetByContentID(ctx, contentID) + if err != nil { + return nil, fmt.Errorf("abs_media_store: get media files for %q: %w", contentID, err) + } + return files, nil +} + +// GetMediaFileByID fetches a single media_file by its integer PK. +func (s *ABSMediaStore) GetMediaFileByID(ctx context.Context, fileID int) (*models.MediaFile, error) { + file, err := s.Files.GetByID(ctx, fileID) + if err != nil { + return nil, fmt.Errorf("abs_media_store: get media file %d: %w", fileID, err) + } + return file, nil +} + +// ListAudiobookLibraries returns media_folder rows where type='audiobooks' +// (the canonical silo type for the audiobooks sub-plan). +func (s *ABSMediaStore) ListAudiobookLibraries(ctx context.Context) ([]abs.AudiobookLibrary, error) { + if s.Pool == nil { + return nil, nil + } + rows, err := s.Pool.Query(ctx, ` + SELECT id, name, type + FROM media_folders + WHERE type IN ('audiobooks', 'audiobook') + AND enabled = TRUE + ORDER BY sort_order, id`) + if err != nil { + return nil, fmt.Errorf("abs_media_store: list audiobook libraries: %w", err) + } + defer rows.Close() + + var libs []abs.AudiobookLibrary + for rows.Next() { + var lib abs.AudiobookLibrary + if err := rows.Scan(&lib.ID, &lib.Name, &lib.Type); err != nil { + return nil, fmt.Errorf("abs_media_store: scan library: %w", err) + } + libs = append(libs, lib) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("abs_media_store: iterate libraries: %w", err) + } + return libs, nil +} diff --git a/internal/audiobooks/service.go b/internal/audiobooks/service.go index 0700a70f..7e1454fc 100644 --- a/internal/audiobooks/service.go +++ b/internal/audiobooks/service.go @@ -3,6 +3,13 @@ package audiobooks import ( "context" "fmt" + + "github.com/Silo-Server/silo-server/internal/audiobooks/abs" + "github.com/Silo-Server/silo-server/internal/audiobooks/abssocket" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/scanner" + + "github.com/jackc/pgx/v5/pgxpool" ) // SettingsReader is the minimal slice of the server-settings store that @@ -18,7 +25,8 @@ type SettingsReader interface { // off Service as new capabilities (scanner branches, ABS handlers, etc.) // come online. type Service struct { - settings SettingsReader + settings SettingsReader + ABSHandler *abs.Handler } // New constructs a Service. The constructor takes the dependencies it @@ -27,6 +35,92 @@ func New(settings SettingsReader) *Service { return &Service{settings: settings} } +// ABSHandlerDeps bundles the concrete silo dependencies needed to construct +// the ABS-compat abs.Handler. All fields except Pool are optional; the handler +// degrades gracefully when any of them is nil. +type ABSHandlerDeps struct { + Pool *pgxpool.Pool + Items *catalog.ItemRepository + Files *scanner.FileRepository + Settings *catalog.ServerSettingsRepo + Auth absAuthAdapter // see BuildABSHandler below +} + +// absAuthAdapter is the narrow slice of internal/auth that BuildABSHandler +// needs. Defined as an interface to avoid an import cycle between the +// audiobooks package and internal/auth. main.go satisfies it with the +// concrete *auth.Service + *pgxpool.Pool pair via SiloCredValidator. +type absAuthAdapter = abs.ProfileCredentialValidator + +// BuildABSHandler wires all production adapters and returns a ready-to-mount +// *abs.Handler. Callers pass the handler to service.ABSHandler and to the +// HTTP router (via api.Dependencies.ABSHandler). +func (s *Service) BuildABSHandler(deps ABSHandlerDeps) *abs.Handler { + var mediaStore abs.MediaStore + if deps.Items != nil && deps.Files != nil { + mediaStore = &ABSMediaStore{ + Items: deps.Items, + Files: deps.Files, + Pool: deps.Pool, + } + } + + var tokenStore abs.TokenStore + if deps.Pool != nil { + tokenStore = &ABSSessionStore{Pool: deps.Pool} + } + + var progressStore abs.ProgressStore + if deps.Pool != nil { + progressStore = &ABSProgressStore{Pool: deps.Pool} + } + + var playbackSessionStore abs.ABSPlaybackSessionStore + if deps.Pool != nil { + playbackSessionStore = &ABSPlaybackSessionStore{Pool: deps.Pool} + } + + var configProvider abs.ConfigProvider + if deps.Settings != nil { + configProvider = &ABSConfigProvider{Settings: deps.Settings} + } + + // Socket.io server: secretFn reads the JWT secret at connection time so + // a secret-rotate takes effect without a restart. + var socketServer *abssocket.Server + if configProvider != nil && tokenStore != nil { + secretFn := func() []byte { + secret, _ := configProvider.JWTSecret(context.Background()) + return secret + } + var tokenValidator abssocket.TokenValidator + if ts := tokenStore; ts != nil { + tokenValidator = func(ctx context.Context, jti string) (bool, error) { + tok, err := ts.GetTokenByJTI(ctx, jti) + if err != nil { + return false, err + } + return tok.RevokedAt != nil, nil + } + } + socketServer = abssocket.New(secretFn, tokenValidator, nil, nil) + } + + h := abs.New(abs.Dependencies{ + MediaStore: mediaStore, + TokenStore: tokenStore, + CredValidator: deps.Auth, + Config: configProvider, + Publisher: nil, // EventPublisher: no-op stub; Socket.io handles realtime + Recommender: nil, // Recommender: stub returning empty + ProgressStore: progressStore, + PlaybackSessionStore: playbackSessionStore, + SocketIO: socketServer, + }) + s.ABSHandler = h + return h +} + // 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