Something went wrong. Try again.
🍬 An atproto-based social Instant Mix plugin for Navidrome bubblemix.cuducos.me
navidrome-plugin
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213// Package api serves the HTTP interface: the landing page, /v1/related, and// /health.package api
import ( "context" "embed" "encoding/json/v2" "fmt" "io/fs" "log/slog" "net/http" "strconv"
"tangled.org/cuducos.me/bubblemix/internal/metrics" "tangled.org/cuducos.me/bubblemix/internal/model" "tangled.org/cuducos.me/bubblemix/internal/vector")
//go:embed homevar homeFS embed.FS
// Default and bounds for the /v1/related count and diversity parameters.const ( defaultCount = 32 maxCount = 128 defaultDiversity = 0.8 minDiversity = 0.0 maxDiversity = 1.0)
// Songs is the store surface the API needs.type Songs interface { FindSong(ctx context.Context, song model.Song) (model.Song, bool, error) SongsByID(ctx context.Context, ids []int64) (map[int64]model.Song, error)}
// Server serves the HTTP interface with the store and vector index wired// in.type Server struct { songs Songs store Store token string log *slog.Logger}
// Store is the vector store serving related-song queries.type Store interface { QueryByID(ctx context.Context, id uint64, limit int, diversity float32) ([]vector.ScoredPoint, error)}
// New wires the store and the vector index into a Server.func New(songs Songs, index Store, token string, log *slog.Logger) *Server { return &Server{songs: songs, store: index, token: token, log: log}}
// Handler builds the HTTP routes.func (s *Server) Handler() (http.Handler, error) { home, err := static() if err != nil { return nil, fmt.Errorf("building the landing page handler: %w", err) } mux := http.NewServeMux() mux.Handle("GET /", metrics.Instrumented("GET /", home)) mux.Handle("GET /metrics", metrics.Handler()) mux.Handle("GET /health", metrics.Instrumented("GET /health", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }))) mux.Handle("GET /v1/related", metrics.Instrumented("GET /v1/related", s.auth(http.HandlerFunc(s.related)))) return mux, nil}
// static serves the embedded static page assets: any file dropped into// home/ becomes available at the root of the service.func static() (http.Handler, error) { sub, err := fs.Sub(homeFS, "home") if err != nil { return nil, fmt.Errorf("reading the embedded home directory: %w", err) } return http.FileServerFS(sub), nil}
func (s *Server) auth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.token != "" && r.Header.Get("Authorization") != "Bearer "+s.token { writeJSON(w, http.StatusUnauthorized, errorBody{"missing or invalid bearer token"}) return } next.ServeHTTP(w, r) })}
type errorBody struct { Error string `json:"error"`}
type relatedResponse struct { Song model.Song `json:"song"` Results []model.Song `json:"results"` Count int `json:"count"`}
func (s *Server) related(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() song := model.Song{ Title: q.Get("title"), Artist: q.Get("artist"), Album: q.Get("album"), MBID: q.Get("mbid"), ISRC: q.Get("isrc"), } if song.MBID == "" && song.ISRC == "" && (song.Title == "" || song.Artist == "") { writeJSON(w, http.StatusBadRequest, errorBody{"provide mbid, isrc, or both artist and title"}) return } c := defaultCount if v := q.Get("count"); v != "" { n, err := strconv.Atoi(v) if err != nil || n < 1 || n > maxCount { writeJSON(w, http.StatusBadRequest, errorBody{fmt.Sprintf("count must be an integer between 1 and %d", maxCount)}) return } c = n } d := defaultDiversity if v := q.Get("diversity"); v != "" { f, err := strconv.ParseFloat(v, 64) if err != nil || f < minDiversity || f > maxDiversity { writeJSON(w, http.StatusBadRequest, errorBody{fmt.Sprintf("diversity must be a number between %v and %v", minDiversity, maxDiversity)}) return } d = f } got, ok, err := s.songs.FindSong(r.Context(), song) if err != nil { s.log.Error("resolving song", "error", err) writeJSON(w, http.StatusInternalServerError, errorBody{"failed to resolve song"}) return } if !ok { s.log.Info("unknown song", "artist", song.Artist, "title", song.Title, "mbid", song.MBID, "isrc", song.ISRC, ) writeJSON(w, http.StatusNotFound, errorBody{"unknown song"}) return } out := relatedResponse{Song: got, Results: []model.Song{}} hits, err := s.store.QueryByID(r.Context(), uint64(got.ID), c, float32(d)) if err != nil { s.log.Error("querying related songs", "error", err) writeJSON(w, http.StatusBadGateway, errorBody{"failed to query related songs"}) return } results, err := s.rank(r.Context(), hits) if err != nil { s.log.Error("mapping related songs", "error", err) writeJSON(w, http.StatusInternalServerError, errorBody{"failed to map related songs"}) return } out.Results = results out.Count = len(results) s.log.Info("related request", "artist", song.Artist, "title", song.Title, "mbid", song.MBID, "isrc", song.ISRC, "results", out.Count, ) writeJSON(w, http.StatusOK, out)}
// rank maps the related hits, already re-ranked server-side with MMR, back// to stored songs.func (s *Server) rank(ctx context.Context, hits []vector.ScoredPoint) ([]model.Song, error) { if len(hits) == 0 { return []model.Song{}, nil } ids := make([]int64, 0, len(hits)) seen := make(map[uint64]bool, len(hits)) for _, h := range hits { if seen[h.ID] { continue } seen[h.ID] = true ids = append(ids, int64(h.ID)) } songs, err := s.songs.SongsByID(ctx, ids) if err != nil { return nil, fmt.Errorf("loading songs: %w", err) } out := make([]model.Song, 0, len(hits)) for _, h := range hits { row, ok := songs[int64(h.ID)] if !ok { return nil, fmt.Errorf("vector point %d has no matching song", h.ID) } row.Score = h.Score out = append(out, row) } return out, nil}
func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.MarshalWrite(w, body); err != nil { slog.Error("encoding response", "error", err) }}