feat(abs): dedicated compat listener on :13378
Mounts the Audiobookshelf-compatible API on its own http.Server so discovery probes (/ping, /healthcheck, /status, /login, /socket.io) own the URL space at the root without colliding with silo's SPA fallback. Mirrors the Jellyfin compat pattern at :8096. Also adds the ABSRecommender adapter, wired into ABSHandlerDeps via recommendations.NewRepo + catalog.DetailService. The recommender file was previously untracked; service.go has already been referencing ABSRecommender, so HEAD did not build without it. 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
b231b27487
commit
01fbf54e7d
+1
-1
@@ -54,7 +54,7 @@ RUN apt-get update && \
|
||||
RUN mkdir -p /tmp/silo-transcode
|
||||
COPY --from=build /silo /usr/local/bin/silo
|
||||
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
|
||||
EXPOSE 8080 8096
|
||||
EXPOSE 8080 8096 13378
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
|
||||
ENTRYPOINT ["silo"]
|
||||
|
||||
+56
-9
@@ -20,6 +20,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -1353,6 +1355,17 @@ func main() {
|
||||
nil, // user store: not needed here
|
||||
)
|
||||
absItemRepo := catalog.NewItemRepository(deps.DB)
|
||||
absEpisodeRepo := catalog.NewEpisodeRepository(deps.DB)
|
||||
absSeasonRepo := catalog.NewSeasonRepository(deps.DB)
|
||||
absPersonRepo := catalog.NewPersonRepository(deps.DB)
|
||||
var absFileFetcher catalog.FileVersionFetcher
|
||||
if deps.FileRepo != nil {
|
||||
absFileFetcher = deps.FileRepo
|
||||
}
|
||||
absDetailSvc := catalog.NewDetailService(absItemRepo, absEpisodeRepo, absSeasonRepo, absPersonRepo, absFileFetcher)
|
||||
if deps.ImageResolver != nil {
|
||||
absDetailSvc.SetImageResolver(deps.ImageResolver)
|
||||
}
|
||||
absHDeps := audiobooks.ABSHandlerDeps{
|
||||
Pool: deps.DB,
|
||||
Items: absItemRepo,
|
||||
@@ -1362,6 +1375,8 @@ func main() {
|
||||
Auth: absAuthSvc,
|
||||
Pool: deps.DB,
|
||||
},
|
||||
Recs: recommendations.NewRepo(deps.DB),
|
||||
Detail: absDetailSvc,
|
||||
}
|
||||
absH := audiobooksService.BuildABSHandler(absHDeps)
|
||||
deps.ABSHandler = absH
|
||||
@@ -1489,14 +1504,11 @@ 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)
|
||||
}
|
||||
// ABS-compat is NOT mounted on the main listener — see the "ABS compat
|
||||
// listener" block below. It binds its own port so the discovery probes
|
||||
// (/ping, /healthcheck, /status, /init, /login, /socket.io) own the URL
|
||||
// space without collision with silo's SPA fallback. Mirrors how the
|
||||
// Jellyfin compat server is set up at :8096.
|
||||
metricsMux.Handle("/", server.FrontendHandler())
|
||||
|
||||
// Step 9: Start background workers (if needed).
|
||||
@@ -1715,7 +1727,29 @@ func main() {
|
||||
compatSrv.IdleTimeout = 120 * time.Second
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
// ABS-compat listener — dedicated http.Server bound to its own port
|
||||
// (default :13378) that hosts the Audiobookshelf-compatible API.
|
||||
// Mirrors the Jellyfin compat layout above. The ABS handler mounts
|
||||
// onto a fresh chi router here so /ping, /healthcheck, /status, /login,
|
||||
// /socket.io, etc. own the URL space at the root — no SPA fallback,
|
||||
// no collision with silo's /api/v1.
|
||||
var absSrv *http.Server
|
||||
if (mode == "integrated" || mode == "api") && deps.ABSHandler != nil && cfg.AudiobookshelfCompat.Listen != "" {
|
||||
absRouter := chi.NewRouter()
|
||||
absRouter.Use(chimiddleware.Recoverer)
|
||||
absRouter.Use(chimiddleware.Compress(5))
|
||||
deps.ABSHandler.Mount(absRouter)
|
||||
absSrv = &http.Server{
|
||||
Addr: cfg.AudiobookshelfCompat.Listen,
|
||||
Handler: absRouter,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 0,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
errCh := make(chan error, 3)
|
||||
go func() {
|
||||
slog.Info("HTTP server listening", "addr", cfg.Server.Listen)
|
||||
if listenErr := srv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed {
|
||||
@@ -1730,6 +1764,14 @@ func main() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
if absSrv != nil {
|
||||
go func() {
|
||||
slog.Info("ABS compat server listening", "addr", absSrv.Addr)
|
||||
if listenErr := absSrv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("abs compat server error: %w", listenErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Step 11: Wait for termination signal.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
@@ -1759,6 +1801,11 @@ func main() {
|
||||
slog.Error("jellyfin compat shutdown error", "error", shutdownErr)
|
||||
}
|
||||
}
|
||||
if absSrv != nil {
|
||||
if shutdownErr := absSrv.Shutdown(shutdownCtx); shutdownErr != nil {
|
||||
slog.Error("abs compat shutdown error", "error", shutdownErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clean up stale sessions.
|
||||
if sessionCleaner != nil {
|
||||
|
||||
@@ -43,6 +43,7 @@ services:
|
||||
ports:
|
||||
- "${PORT:-8090}:8080"
|
||||
- "${JF_PORT:-8096}:8096"
|
||||
- "${ABS_PORT:-13378}:13378"
|
||||
volumes:
|
||||
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}}:ro
|
||||
|
||||
@@ -1022,12 +1022,10 @@ 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)
|
||||
}
|
||||
// ABS-compat routes are NOT mounted here — they live on a dedicated
|
||||
// http.Server (see absCompatSrv in cmd/silo/main.go) so the discovery
|
||||
// probes (/ping, /healthcheck, /status, etc.) don't collide with the
|
||||
// SPA fallback. Same pattern as the Jellyfin compat listener on 8096.
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/health", healthHandler.ServeHTTP)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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/models"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
)
|
||||
|
||||
// ABSRecommender implements abs.Recommender by reusing the catalog
|
||||
// recommendations pipeline. Embedding-based nearest-neighbor search is
|
||||
// preferred (extended to audiobooks via gemini-embedding-001); when the
|
||||
// source item has no embedding yet, we fall back to a shared-genre
|
||||
// audiobook lookup so the /items/{id}/similar surface always returns
|
||||
// something useful.
|
||||
type ABSRecommender struct {
|
||||
Pool *pgxpool.Pool
|
||||
Recs *recommendations.Repo
|
||||
}
|
||||
|
||||
var _ abs.Recommender = (*ABSRecommender)(nil)
|
||||
|
||||
// Similar returns up to limit audiobook content_ids related to contentID.
|
||||
// Excludes books by the same author so callers can pair this with a
|
||||
// dedicated "Also by author" rail without overlap. Returns empty on any
|
||||
// upstream error rather than failing the whole detail page.
|
||||
func (r *ABSRecommender) Similar(ctx context.Context, contentID string, limit int) ([]string, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if r.Recs != nil {
|
||||
if emb, err := r.Recs.GetEmbedding(ctx, contentID); err == nil && emb != nil {
|
||||
scored, err := r.Recs.FindSimilar(ctx, emb, []string{contentID}, "audiobook", limit*3)
|
||||
if err == nil && len(scored) > 0 {
|
||||
ids := make([]string, 0, limit)
|
||||
for _, s := range scored {
|
||||
ids = append(ids, s.MediaItemID)
|
||||
if len(ids) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: shared-genre + same-language + different-author lookup.
|
||||
if r.Pool == nil {
|
||||
return nil, nil
|
||||
}
|
||||
const q = `
|
||||
WITH this_genres AS (
|
||||
SELECT unnest(genres) AS g FROM media_items WHERE content_id = $1
|
||||
),
|
||||
this_author AS (
|
||||
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
|
||||
)
|
||||
SELECT m.content_id
|
||||
FROM media_items m
|
||||
WHERE m.type = 'audiobook'
|
||||
AND m.content_id <> $1
|
||||
AND m.genres && (SELECT array_agg(g) FROM this_genres)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM item_people ip
|
||||
WHERE ip.content_id = m.content_id
|
||||
AND ip.kind = $2
|
||||
AND ip.person_id IN (SELECT person_id FROM this_author)
|
||||
)
|
||||
ORDER BY
|
||||
cardinality(ARRAY(SELECT unnest(m.genres) INTERSECT SELECT g FROM this_genres)) DESC,
|
||||
COALESCE(m.year, 0) DESC,
|
||||
LOWER(m.sort_title)
|
||||
LIMIT $3
|
||||
`
|
||||
rows, err := r.Pool.Query(ctx, q, contentID, models.PersonKindAuthor, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs recommender: fallback query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]string, 0, limit)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("abs recommender: scan: %w", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
+26
-16
@@ -198,6 +198,15 @@ type authConfigRaw struct {
|
||||
RefreshTokenExpiry string `yaml:"refresh_token_expiry"`
|
||||
}
|
||||
|
||||
// AudiobookshelfCompatConfig holds the dedicated ABS-compat listener
|
||||
// settings. The listener binds its own port (default :13378, ABS's
|
||||
// conventional port) and serves the full ABS protocol — login, libraries,
|
||||
// items, sessions — without colliding with silo's SPA on the main listener.
|
||||
type AudiobookshelfCompatConfig struct {
|
||||
Listen string `yaml:"listen"`
|
||||
PublicURL string `yaml:"public_url"`
|
||||
}
|
||||
|
||||
// JellyfinCompatConfig holds compatibility proxy settings.
|
||||
type JellyfinCompatConfig struct {
|
||||
Listen string `yaml:"listen"`
|
||||
@@ -255,22 +264,23 @@ type MetadataConfig struct {
|
||||
|
||||
// Config is the top-level configuration for Silo.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
S3 S3Config `yaml:"-"`
|
||||
UserDB UserDBConfig `yaml:"-"`
|
||||
Scanner ScannerConfig `yaml:"-"`
|
||||
Matcher MatcherConfig `yaml:"matcher"`
|
||||
Metadata MetadataConfig `yaml:"-"`
|
||||
Playback PlaybackConfig `yaml:"playback"`
|
||||
Redis RedisConfig `yaml:"redis"`
|
||||
RateLimit RateLimitConfig `yaml:"rate_limiting"`
|
||||
Auth AuthConfig `yaml:"-"`
|
||||
JellyfinCompat JellyfinCompatConfig `yaml:"-"`
|
||||
Recommendations RecommendationsConfig `yaml:"-"`
|
||||
Download DownloadConfig `yaml:"-"`
|
||||
TMDBAPIKey string `yaml:"-"`
|
||||
MDBListAPIKey string `yaml:"-"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
S3 S3Config `yaml:"-"`
|
||||
UserDB UserDBConfig `yaml:"-"`
|
||||
Scanner ScannerConfig `yaml:"-"`
|
||||
Matcher MatcherConfig `yaml:"matcher"`
|
||||
Metadata MetadataConfig `yaml:"-"`
|
||||
Playback PlaybackConfig `yaml:"playback"`
|
||||
Redis RedisConfig `yaml:"redis"`
|
||||
RateLimit RateLimitConfig `yaml:"rate_limiting"`
|
||||
Auth AuthConfig `yaml:"-"`
|
||||
JellyfinCompat JellyfinCompatConfig `yaml:"-"`
|
||||
AudiobookshelfCompat AudiobookshelfCompatConfig `yaml:"-"`
|
||||
Recommendations RecommendationsConfig `yaml:"-"`
|
||||
Download DownloadConfig `yaml:"-"`
|
||||
TMDBAPIKey string `yaml:"-"`
|
||||
MDBListAPIKey string `yaml:"-"`
|
||||
}
|
||||
|
||||
// configRaw is used for initial YAML unmarshaling with string durations.
|
||||
|
||||
@@ -355,6 +355,11 @@ func LoadFromDB(m map[string]string) (*Config, error) {
|
||||
}
|
||||
cfg.Auth.RefreshTokenExpiry = refreshTokenExpiry
|
||||
|
||||
// AudiobookshelfCompat — dedicated listener for ABS client apps.
|
||||
// Default :13378 mirrors the real Audiobookshelf server convention.
|
||||
cfg.AudiobookshelfCompat.Listen = stringOr(m, "audiobookshelf_compat.listen", ":13378")
|
||||
cfg.AudiobookshelfCompat.PublicURL = stringOr(m, "audiobookshelf_compat.public_url", "http://127.0.0.1:13378")
|
||||
|
||||
// JellyfinCompat
|
||||
cfg.JellyfinCompat.Listen = stringOr(m, "jellyfin_compat.listen", ":8096")
|
||||
cfg.JellyfinCompat.PublicURL = stringOr(m, "jellyfin_compat.public_url", "http://127.0.0.1:8096")
|
||||
|
||||
Reference in New Issue
Block a user