Files
silo-server/internal/audiobooks/service.go
T
RXWatcherandClaude Opus 4.7 c735cd53b5 feat(audiobooks): wire Phase 1 close-out stores + mount routes
Wires RSSFeedStore into BuildABSHandler. Mounts the ten new
authenticated routes (stats x3 + author x1 + series x1 + continue x2
+ feeds-auth x3) under both /abs/api and /api inside bearerAuth.
Mounts the three public RSS routes (.xml + slug + /file/{ino}) in
the unauth public block. handleAuthorImage wired to CoverResolver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:25:55 +02:00

196 lines
6.7 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 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
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.ServerSettingsRepo
Auth absAuthAdapter // see BuildABSHandler below
// 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,
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.PresignURL(ctx, path, 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}
}
// 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("read audiobooks.enabled: %w", err)
}
return value == "true", nil
}