feat(subsonic): add getStarred and getStarred2 handlers
Both return user's starred artists/albums/songs sorted liked_at DESC. Cap at 500 entries per category for v1 (Subsonic spec doesn't define pagination on these endpoints; M3+ can revisit if needed).
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user