From 01fbf54e7d03bdb3f2eec18e7d0cd480827388e5 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Wed, 27 May 2026 16:16:26 +0200 Subject: [PATCH] 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) --- Dockerfile | 2 +- cmd/silo/main.go | 65 ++++++++++++++++++--- docker-compose.yml | 1 + internal/api/router.go | 10 ++-- internal/audiobooks/recommender.go | 92 ++++++++++++++++++++++++++++++ internal/config/config.go | 42 ++++++++------ internal/config/db_loader.go | 5 ++ 7 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 internal/audiobooks/recommender.go diff --git a/Dockerfile b/Dockerfile index ff82578e..8e2eacd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 4acb3264..cbec4dd5 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -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 { diff --git a/docker-compose.yml b/docker-compose.yml index 928c1e50..ba08519c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/internal/api/router.go b/internal/api/router.go index 6cbf4de6..76a5b170 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/audiobooks/recommender.go b/internal/audiobooks/recommender.go new file mode 100644 index 00000000..289ae50f --- /dev/null +++ b/internal/audiobooks/recommender.go @@ -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() +} diff --git a/internal/config/config.go b/internal/config/config.go index b63c4ae2..07ae79fd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/config/db_loader.go b/internal/config/db_loader.go index 978ec47b..e23cc89a 100644 --- a/internal/config/db_loader.go +++ b/internal/config/db_loader.go @@ -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")