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:
2026-04-26 16:45:04 -04:00
parent 32fb3fec20
commit c7f4adbcc3
4 changed files with 194 additions and 0 deletions
+105
View File
@@ -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
}
+59
View File
@@ -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))
}
}
+2
View File
@@ -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
+28
View File
@@ -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 {