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:
RXWatcher
2026-05-27 16:16:26 +02:00
co-authored by Claude Opus 4.7
parent b231b27487
commit 01fbf54e7d
7 changed files with 185 additions and 32 deletions
+1 -1
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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
+4 -6
View File
@@ -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)
+92
View File
@@ -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
View File
@@ -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.
+5
View File
@@ -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")