Files
silo-server/internal/audiobooks/recommender.go
T
RXWatcherandClaude Opus 4.7 01fbf54e7d 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>
2026-05-27 16:16:26 +02:00

93 lines
2.8 KiB
Go

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()
}