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:
co-authored by
Claude Opus 4.7
parent
7785f286e7
commit
64e30997cb
+46
-2
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user