diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 0c912bd5..91461b74 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -75,7 +75,7 @@ func run() error { srv := server.New(logger, pool, scanner, subsonic.Config{ AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, - }, cfg.Events) + }, cfg.Events, cfg.Recommendation) httpServer := &http.Server{ Addr: cfg.Server.Address, Handler: srv.Router(), diff --git a/internal/api/api.go b/internal/api/api.go index 27ee3a2f..0ab003c5 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -6,19 +6,22 @@ package api import ( "log/slog" + "math/rand" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer) { - h := &handlers{pool: pool, logger: logger, events: events} +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig) { + rng := rand.New(rand.NewSource(rand.Int63())) + h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64} r.Route("/api", func(api chi.Router) { api.Post("/auth/login", h.handleLogin) @@ -54,4 +57,6 @@ type handlers struct { pool *pgxpool.Pool logger *slog.Logger events *playevents.Writer + recCfg config.RecommendationConfig + rng func() float64 } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index d91a21e6..b6f2d5e2 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -18,6 +18,7 @@ import ( "golang.org/x/crypto/bcrypt" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" @@ -49,7 +50,13 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { t.Fatalf("truncate: %v", err) } w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) - return &handlers{pool: pool, logger: logger, events: w}, pool + recCfg := config.RecommendationConfig{ + BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, + SkipPenalty: 1.0, JitterMagnitude: 0.1, + RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, + } + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }} + return h, pool } func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User { diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 0c970582..9c8e7714 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" + "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" ) @@ -440,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}) paths := []string{ "/api/artists", diff --git a/internal/api/radio.go b/internal/api/radio.go index d7d70e5d..6d84994f 100644 --- a/internal/api/radio.go +++ b/internal/api/radio.go @@ -3,11 +3,15 @@ package api import ( "errors" "net/http" + "strconv" "strings" + "time" "github.com/jackc/pgx/v5" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) // RadioResponse is the body of GET /api/radio. @@ -15,24 +19,40 @@ type RadioResponse struct { Tracks []TrackRef `json:"tracks"` } -// handleRadio implements GET /api/radio?seed_track=. +// handleRadio implements GET /api/radio?seed_track=&limit=. // -// M6 stub: returns a queue containing only the seed track. The full M4 -// implementation (similarity-based candidate pool + scoring) replaces the -// body without changing the request/response shape. +// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle +// picks from the user's library, scored by recommendation.Score. func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } raw := strings.TrimSpace(r.URL.Query().Get("seed_track")) if raw == "" { writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required") return } - id, ok := parseUUID(raw) + seedID, ok := parseUUID(raw) if !ok { writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id") return } + limit := h.recCfg.RadioSize + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") + return + } + limit = n + } + if limit > h.recCfg.RadioSizeMax { + limit = h.recCfg.RadioSizeMax + } q := dbq.New(h.pool) - track, err := q.GetTrackByID(r.Context(), id) + track, err := q.GetTrackByID(r.Context(), seedID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeErr(w, http.StatusNotFound, "not_found", "seed_track not found") @@ -54,7 +74,37 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } - writeJSON(w, http.StatusOK, RadioResponse{ - Tracks: []TrackRef{trackRefFrom(track, album.Title, artist.Name)}, - }) + candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours) + if err != nil { + h.logger.Error("api: radio: load candidates", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") + return + } + weights := recommendation.ScoringWeights{ + BaseWeight: h.recCfg.BaseWeight, + LikeBoost: h.recCfg.LikeBoost, + RecencyWeight: h.recCfg.RecencyWeight, + SkipPenalty: h.recCfg.SkipPenalty, + JitterMagnitude: h.recCfg.JitterMagnitude, + } + picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) + + out := make([]TrackRef, 0, len(picks)+1) + out = append(out, trackRefFrom(track, album.Title, artist.Name)) + for _, p := range picks { + al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID) + if err != nil { + h.logger.Error("api: radio: resolve album", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") + return + } + ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID) + if err != nil { + h.logger.Error("api: radio: resolve artist", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") + return + } + out = append(out, trackRefFrom(p.Track, al.Title, ar.Name)) + } + writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) } diff --git a/internal/api/radio_test.go b/internal/api/radio_test.go index 58964c85..732ec5bd 100644 --- a/internal/api/radio_test.go +++ b/internal/api/radio_test.go @@ -1,90 +1,130 @@ package api import ( + "context" "encoding/json" "net/http" "net/http/httptest" "testing" - - "github.com/go-chi/chi/v5" ) -func newRadioRouter(h *handlers) chi.Router { - r := chi.NewRouter() - r.Get("/api/radio", h.handleRadio) - return r +func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/radio?"+query, nil) + req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) + w := httptest.NewRecorder() + h.handleRadio(w, req) + return w } -func TestHandleRadio_Stub_ReturnsSeedOnly(t *testing.T) { +func TestHandleRadio_ColdStart_OnlySeedReturned(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) - artist := seedArtist(t, pool, "Beatles") - album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969) - track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000) - - req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track="+uuidToString(track.ID), nil) - w := httptest.NewRecorder() - newRadioRouter(h).ServeHTTP(w, req) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) if w.Code != http.StatusOK { t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) } - var got struct { - Tracks []TrackRef `json:"tracks"` + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp.Tracks) != 1 { + t.Fatalf("len = %d, want 1", len(resp.Tracks)) } - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if len(got.Tracks) != 1 { - t.Fatalf("len=%d, want 1", len(got.Tracks)) - } - if got.Tracks[0].Title != "Something" { - t.Errorf("title=%q", got.Tracks[0].Title) - } - if got.Tracks[0].AlbumTitle != "Abbey Road" || got.Tracks[0].ArtistName != "Beatles" { - t.Errorf("ref = %+v", got.Tracks[0]) + if resp.Tracks[0].Title != "Seed" { + t.Errorf("seed not first: %v", resp.Tracks[0].Title) } } -func TestHandleRadio_NotFound(t *testing.T) { +func TestHandleRadio_Typical_SeedFirstPlusPicks(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + for i := 2; i <= 6; i++ { + seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) + } - req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=00000000-0000-0000-0000-000000000000", nil) - w := httptest.NewRecorder() - newRadioRouter(h).ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) + if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } -} - -func TestHandleRadio_MissingSeed(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/radio", nil) - w := httptest.NewRecorder() - newRadioRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status=%d, want 400", w.Code) + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp.Tracks) != 6 { + t.Fatalf("len = %d, want 6 (seed + 5 picks)", len(resp.Tracks)) + } + if resp.Tracks[0].Title != "Seed" { + t.Errorf("seed not at index 0: %v", resp.Tracks[0].Title) } } -func TestHandleRadio_BlankSeed(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=%20%20", nil) - w := httptest.NewRecorder() - newRadioRouter(h).ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Errorf("status=%d, want 400", w.Code) +func TestHandleRadio_UnknownSeedIs404(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "x", false) + w := callRadio(h, user, "seed_track=00000000-0000-0000-0000-000000000000") + if w.Code != http.StatusNotFound { + t.Errorf("status = %d", w.Code) } } -func TestHandleRadio_BadUUID(t *testing.T) { - h, _ := testHandlers(t) - req := httptest.NewRequest(http.MethodGet, "/api/radio?seed_track=not-a-uuid", nil) - w := httptest.NewRecorder() - newRadioRouter(h).ServeHTTP(w, req) +func TestHandleRadio_BadSeedIs400(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "x", false) + w := callRadio(h, user, "seed_track=not-a-uuid") if w.Code != http.StatusBadRequest { - t.Errorf("status=%d, want 400", w.Code) + t.Errorf("status = %d", w.Code) + } +} + +func TestHandleRadio_MissingSeedIs400(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "x", false) + w := callRadio(h, user, "") + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d", w.Code) + } +} + +func TestHandleRadio_BadLimitIs400(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=0") + if w.Code != http.StatusBadRequest { + t.Errorf("limit=0 status = %d", w.Code) + } + w = callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=-1") + if w.Code != http.StatusBadRequest { + t.Errorf("limit=-1 status = %d", w.Code) + } +} + +func TestHandleRadio_LimitClampedToMax(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + artist := seedArtist(t, pool, "X") + album := seedAlbum(t, pool, artist.ID, "X", 1990) + seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000) + for i := 2; i <= 6; i++ { + seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000) + } + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=99999") + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var resp RadioResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + // We only have 6 tracks; clamped limit (max 200) returns all 6. + if len(resp.Tracks) != 6 { + t.Errorf("len = %d, want 6", len(resp.Tracks)) } } diff --git a/internal/server/server.go b/internal/server/server.go index 09a6c331..3e449693 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -29,15 +29,16 @@ type ScanTrigger interface { } type Server struct { - Logger *slog.Logger - Pool *pgxpool.Pool - Scanner ScanTrigger - SubsonicCfg subsonic.Config - EventsCfg config.EventsConfig + Logger *slog.Logger + Pool *pgxpool.Pool + Scanner ScanTrigger + SubsonicCfg subsonic.Config + EventsCfg config.EventsConfig + RecommendationCfg config.RecommendationConfig } -func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig) *Server { - return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg} +func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server { + return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg} } func (s *Server) Router() http.Handler { @@ -54,7 +55,7 @@ func (s *Server) Router() http.Handler { s.EventsCfg.SkipMaxCompletionRatio, s.EventsCfg.SkipMaxDurationPlayedMs, ) - api.Mount(r, s.Pool, s.Logger, writer) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg) r.Route("/api/admin", func(admin chi.Router) { admin.Use(auth.RequireAdmin(s.Pool)) if s.Scanner != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 66358ca4..9629ec33 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -14,7 +14,7 @@ import ( ) func TestHealthz(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -37,7 +37,7 @@ func TestHealthz(t *testing.T) { } func TestRouter_ServesSPAAtRoot(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -56,7 +56,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) { } func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -75,7 +75,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) { } func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -94,7 +94,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) { } func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close()