diff --git a/internal/subsonic/star.go b/internal/subsonic/star.go index 4bffb6f5..7dea5cb4 100644 --- a/internal/subsonic/star.go +++ b/internal/subsonic/star.go @@ -126,3 +126,108 @@ func resolveStarID(ctx context.Context, q *dbq.Queries, raw string, kind entityK } return id, nil } + +func (m *mediaHandlers) handleGetStarred(w http.ResponseWriter, r *http.Request) { + user, ok := UserFromContext(r.Context()) + if !ok { + WriteFail(w, r, ErrGeneric, "Unauthenticated") + return + } + q := dbq.New(m.pool) + songs, albums, artists, err := loadStarred(r.Context(), q, user.ID) + if err != nil { + WriteFail(w, r, ErrGeneric, "Could not load starred") + return + } + Write(w, r, GetStarredResponse{ + Envelope: NewEnvelope("ok"), + Starred: GetStarredContainer{ + Artists: artists, + Albums: albums, + Songs: songs, + }, + }) +} + +func (m *mediaHandlers) handleGetStarred2(w http.ResponseWriter, r *http.Request) { + user, ok := UserFromContext(r.Context()) + if !ok { + WriteFail(w, r, ErrGeneric, "Unauthenticated") + return + } + q := dbq.New(m.pool) + songs, albums, artists, err := loadStarred(r.Context(), q, user.ID) + if err != nil { + WriteFail(w, r, ErrGeneric, "Could not load starred") + return + } + Write(w, r, GetStarred2Response{ + Envelope: NewEnvelope("ok"), + Starred2: StarredContainer{ + Artists: artists, + Albums: albums, + Songs: songs, + }, + }) +} + +// loadStarred fetches the user's full starred set in liked_at DESC order, +// projects to Subsonic refs (Song / Album / Artist). +func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]SongRef, []AlbumRef, []ArtistRef, error) { + const cap = 500 // bounded for safety; M2 doesn't paginate getStarred + songs := []SongRef{} + albums := []AlbumRef{} + artists := []ArtistRef{} + + trackRows, err := q.ListLikedTrackRows(ctx, dbq.ListLikedTrackRowsParams{ + UserID: userID, Limit: cap, Offset: 0, + }) + if err != nil { + return nil, nil, nil, err + } + for _, t := range trackRows { + album, err := q.GetAlbumByID(ctx, t.AlbumID) + if err != nil { + return nil, nil, nil, err + } + artist, err := q.GetArtistByID(ctx, t.ArtistID) + if err != nil { + return nil, nil, nil, err + } + songs = append(songs, songRef(t, album.Title, artist.Name)) + } + + albumRows, err := q.ListLikedAlbumRows(ctx, dbq.ListLikedAlbumRowsParams{ + UserID: userID, Limit: cap, Offset: 0, + }) + if err != nil { + return nil, nil, nil, err + } + for _, a := range albumRows { + artist, err := q.GetArtistByID(ctx, a.ArtistID) + if err != nil { + return nil, nil, nil, err + } + count, err := q.CountTracksByAlbum(ctx, a.ID) + if err != nil { + return nil, nil, nil, err + } + albums = append(albums, albumRef(a, artist.Name, int(count), 0)) + } + + artistRows, err := q.ListLikedArtistRows(ctx, dbq.ListLikedArtistRowsParams{ + UserID: userID, Limit: cap, Offset: 0, + }) + if err != nil { + return nil, nil, nil, err + } + for _, a := range artistRows { + albums, err := q.ListAlbumsByArtist(ctx, a.ID) + if err != nil { + return nil, nil, nil, err + } + artists = append(artists, artistRef(a, len(albums))) + } + + return songs, albums, artists, nil +} diff --git a/internal/subsonic/star_test.go b/internal/subsonic/star_test.go index d6dd006a..be27e876 100644 --- a/internal/subsonic/star_test.go +++ b/internal/subsonic/star_test.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "encoding/json" "io" "log/slog" "net/http" @@ -190,3 +191,61 @@ func contains(s, substr string) bool { } return false } + +func TestHandleGetStarred2_ReturnsAllThreeArrays(t *testing.T) { + pool, user, track, album, artist := testStarPool(t) + m := newStarHandlers(pool) + q := dbq.New(pool) + + // Star one of each. + _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: user.ID, TrackID: track.ID}) + _ = q.LikeAlbum(context.Background(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: album.ID}) + _ = q.LikeArtist(context.Background(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artist.ID}) + + req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil) + ctx := context.WithValue(req.Context(), userCtxKey, user) + w := httptest.NewRecorder() + m.handleGetStarred2(w, req.WithContext(ctx)) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + // Subsonic JSON envelope: {"subsonic-response":{...,"starred2":{...}}} + var raw map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &raw) + resp, _ := raw["subsonic-response"].(map[string]any) + starred, _ := resp["starred2"].(map[string]any) + if starred == nil { + t.Fatalf("missing starred2 envelope: %v", raw) + } + songs, _ := starred["song"].([]any) + albums, _ := starred["album"].([]any) + artists, _ := starred["artist"].([]any) + if len(songs) != 1 || len(albums) != 1 || len(artists) != 1 { + t.Errorf("counts: songs=%d albums=%d artists=%d", len(songs), len(albums), len(artists)) + } +} + +func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) { + pool, alice, track, _, _ := testStarPool(t) + q := dbq.New(pool) + bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{ + Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false, + }) + _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID}) + + m := newStarHandlers(pool) + req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil) + ctx := context.WithValue(req.Context(), userCtxKey, bob) + w := httptest.NewRecorder() + m.handleGetStarred2(w, req.WithContext(ctx)) + + var raw map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &raw) + resp, _ := raw["subsonic-response"].(map[string]any) + starred, _ := resp["starred2"].(map[string]any) + songs, _ := starred["song"].([]any) + if len(songs) != 0 { + t.Errorf("bob's starred songs should be empty, got %d", len(songs)) + } +} diff --git a/internal/subsonic/subsonic.go b/internal/subsonic/subsonic.go index c5af689b..c885bbf1 100644 --- a/internal/subsonic/subsonic.go +++ b/internal/subsonic/subsonic.go @@ -40,6 +40,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, ev register(sub, "/scrobble", m.handleScrobble) register(sub, "/star", m.handleStar) register(sub, "/unstar", m.handleUnstar) + register(sub, "/getStarred", m.handleGetStarred) + register(sub, "/getStarred2", m.handleGetStarred2) register(sub, "/getUser", handleGetUser) // Subsonic clients expect every /rest/* miss to come back as a diff --git a/internal/subsonic/types.go b/internal/subsonic/types.go index df0d1c18..19ffb63d 100644 --- a/internal/subsonic/types.go +++ b/internal/subsonic/types.go @@ -211,6 +211,34 @@ type SearchResult3Response struct { SearchResult3 SearchResult3Container `json:"searchResult3" xml:"searchResult3"` } +// StarredContainer is the body of getStarred / getStarred2. +type StarredContainer struct { + XMLName xml.Name `json:"-" xml:"starred2"` + Artists []ArtistRef `json:"artist" xml:"artist"` + Albums []AlbumRef `json:"album" xml:"album"` + Songs []SongRef `json:"song" xml:"song"` +} + +type GetStarred2Response struct { + Envelope + Starred2 StarredContainer `json:"starred2" xml:"starred2"` +} + +// GetStarredContainer mirrors getStarred (id3-less variant). Response uses +// the same shape; the only material difference from getStarred2 is the +// outer key name. +type GetStarredContainer struct { + XMLName xml.Name `json:"-" xml:"starred"` + Artists []ArtistRef `json:"artist" xml:"artist"` + Albums []AlbumRef `json:"album" xml:"album"` + Songs []SongRef `json:"song" xml:"song"` +} + +type GetStarredResponse struct { + Envelope + Starred GetStarredContainer `json:"starred" xml:"starred"` +} + // ---- conversion helpers ---- func artistRef(a dbq.Artist, albumCount int) ArtistRef {