Files
silo-server/internal/audiobooks/service.go
T
339ed20074 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>
2026-06-08 18:17:08 -04:00

195 lines
6.8 KiB
Go

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/recommendations"
"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
// 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 owns the Audiobookshelf compatibility adapters. Native audiobook
// libraries are normal catalog media_items and do not depend on this service.
type Service struct {
settings SettingsReader
ABSHandler *abs.Handler
}
// 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}
}
// ABSHandlerDeps bundles the concrete silo dependencies needed to construct
// the ABS-compat abs.Handler. Pool, Items, and Files are required — the ABS
// handler dereferences MediaStore from many request paths and a nil store
// would panic on the first request. BuildABSHandler validates this at startup.
type ABSHandlerDeps struct {
Pool *pgxpool.Pool
Items *catalog.ItemRepository
Files *scanner.FileRepository
Settings catalog.SettingsStore // encrypting decorator in production
Auth absAuthAdapter // see BuildABSHandler below
AccessResolver abs.AccessResolver
// Recs is the recommendations repository used to power the
// /items/{id}/similar endpoint via embedding nearest-neighbor search.
// Optional; when nil, ABSRecommender falls back to the shared-genre
// SQL path. When the full chain is unwired (Recs and Pool both nil),
// the ABS Recommender field stays nil and similar returns empty.
Recs *recommendations.Repo
// Detail resolves audiobook poster S3 paths into fully-qualified URLs
// that ABS clients can fetch. Optional; when nil, /api/items/{id}/cover
// 404s rather than redirecting to an unreachable storage path.
Detail *catalog.DetailService
}
// 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 {
if deps.Items == nil || deps.Files == nil {
panic("audiobooks.BuildABSHandler: Items and Files repositories are required")
}
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 bookmarkStore abs.BookmarkStore
if deps.Pool != nil {
bookmarkStore = &ABSBookmarkStore{Pool: deps.Pool}
}
var collectionStore abs.CollectionStore
if deps.Pool != nil {
collectionStore = &ABSCollectionStore{Pool: deps.Pool}
}
var playlistStore abs.PlaylistStore
if deps.Pool != nil {
playlistStore = &ABSPlaylistStore{Pool: deps.Pool}
}
var smartCollectionStore abs.SmartCollectionStore
if deps.Pool != nil {
smartCollectionStore = &ABSSmartCollectionStore{Pool: deps.Pool}
}
var rssFeedStore abs.RSSFeedStore
if deps.Pool != nil {
rssFeedStore = &ABSRSSFeedStore{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,
AccessResolver: deps.AccessResolver,
Config: configProvider,
Publisher: nil, // EventPublisher: no-op stub; Socket.io handles realtime
Recommender: buildABSRecommender(deps),
ProgressStore: progressStore,
PlaybackSessionStore: playbackSessionStore,
BookmarkStore: bookmarkStore,
CollectionStore: collectionStore,
PlaylistStore: playlistStore,
SmartCollectionStore: smartCollectionStore,
RSSFeedStore: rssFeedStore,
SocketIO: socketServer,
CoverResolver: func(ctx context.Context, path, variant string) string {
if deps.Detail == nil {
return ""
}
return deps.Detail.PresignImageURL(ctx, path, "poster", variant)
},
})
s.ABSHandler = h
return h
}
// buildABSRecommender returns the abs.Recommender adapter when at least one
// of its data sources is available. Nil result means /items/{id}/similar
// keeps returning an empty list (the route's documented degradation path).
func buildABSRecommender(deps ABSHandlerDeps) abs.Recommender {
if deps.Pool == nil && deps.Recs == nil {
return nil
}
return &ABSRecommender{Pool: deps.Pool, Recs: deps.Recs}
}
// ABSCompatEnabled reports whether the Audiobookshelf compatibility listener
// should be started. Native audiobook libraries intentionally do not read this
// setting.
func (s *Service) ABSCompatEnabled(ctx context.Context) (bool, error) {
if s == nil || s.settings == nil {
return false, nil
}
value, err := s.settings.GetString(ctx, "audiobookshelf_compat.enabled")
if err != nil {
return false, fmt.Errorf("read audiobookshelf_compat.enabled: %w", err)
}
return value == "true", nil
}