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) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-24 15:58:02 +02:00
co-authored by Claude Opus 4.7
parent 7785f286e7
commit 64e30997cb
5 changed files with 358 additions and 4 deletions
+46 -2
View File
@@ -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).
+21 -1
View File
@@ -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)
+94
View File
@@ -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
}
+102
View File
@@ -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
}
+95 -1
View File
@@ -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