Skip to content

Commit 542a732

Browse files
b0bbywanclaude
andcommitted
feat(api): expose the MPRIS tracklist endpoints
GET /players/{player}/tracklist serves the cached list (404 when the player lacks the interface). GoTo and RemoveTrack take a track reference in the path: the last segment of the track's object path (or the %2F-encoded full path). Track IDs are player-chosen object paths with no common prefix, so the backend resolves the reference against the cached tracklist — which also rejects tracks not in the list. AddTrack keeps a JSON body (uri/after_track/set_as_current), after_track resolved the same way with a NoTrack prepend sentinel. AddTrack validates the uri per spec — absolute URI, scheme within the player's SupportedUriSchemes (now loaded from the root interface) — so a bad uri gets a 400 instead of a 202 the player silently drops. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sathks1sgo6iJF16zpiewx
1 parent 0d40c92 commit 542a732

9 files changed

Lines changed: 326 additions & 47 deletions

File tree

api/handlers_mpris_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ func TestHandleMPRISError(t *testing.T) {
178178
wantStatusCode: http.StatusForbidden,
179179
wantBodyMatch: "action not allowed",
180180
},
181+
{
182+
name: "TracklistUnsupportedError returns 404 Not Found",
183+
err: &mpris.TracklistUnsupportedError{
184+
BusName: "org.mpris.MediaPlayer2.firefox",
185+
},
186+
wantStatusCode: http.StatusNotFound,
187+
wantBodyMatch: "tracklist not supported",
188+
},
181189
{
182190
name: "generic error returns 500 Internal Server Error",
183191
err: http.ErrServerClosed,
@@ -205,6 +213,114 @@ func TestHandleMPRISError(t *testing.T) {
205213
}
206214
}
207215

216+
func TestTracklistHandler(t *testing.T) {
217+
tests := []struct {
218+
name string
219+
getTracklist func(string) (*mpris.TracklistResponse, error)
220+
wantStatusCode int
221+
wantBodyMatch string
222+
}{
223+
{
224+
name: "success returns 200 with tracks",
225+
getTracklist: func(string) (*mpris.TracklistResponse, error) {
226+
return &mpris.TracklistResponse{
227+
CanEditTracks: true,
228+
Tracks: []mpris.Track{{TrackID: "/org/mpris/MediaPlayer2/Track/1"}},
229+
}, nil
230+
},
231+
wantStatusCode: http.StatusOK,
232+
wantBodyMatch: `"/org/mpris/MediaPlayer2/Track/1"`,
233+
},
234+
{
235+
name: "empty tracklist returns 200 with empty array",
236+
getTracklist: func(string) (*mpris.TracklistResponse, error) {
237+
return &mpris.TracklistResponse{Tracks: []mpris.Track{}}, nil
238+
},
239+
wantStatusCode: http.StatusOK,
240+
wantBodyMatch: `"tracks":[]`,
241+
},
242+
{
243+
name: "unsupported returns 404",
244+
getTracklist: func(busName string) (*mpris.TracklistResponse, error) {
245+
return nil, &mpris.TracklistUnsupportedError{BusName: busName}
246+
},
247+
wantStatusCode: http.StatusNotFound,
248+
wantBodyMatch: "tracklist not supported",
249+
},
250+
{
251+
name: "player not found returns 404",
252+
getTracklist: func(busName string) (*mpris.TracklistResponse, error) {
253+
return nil, &mpris.PlayerNotFoundError{BusName: busName}
254+
},
255+
wantStatusCode: http.StatusNotFound,
256+
wantBodyMatch: "player not found",
257+
},
258+
}
259+
260+
for _, tt := range tests {
261+
t.Run(tt.name, func(t *testing.T) {
262+
handler := TracklistHandler(tt.getTracklist)
263+
264+
req := httptest.NewRequest("GET", "/players/org.mpris.MediaPlayer2.mpd/tracklist", nil)
265+
req.SetPathValue("player", "org.mpris.MediaPlayer2.mpd")
266+
w := httptest.NewRecorder()
267+
268+
handler(w, req)
269+
270+
if w.Code != tt.wantStatusCode {
271+
t.Errorf("status = %d, want %d", w.Code, tt.wantStatusCode)
272+
}
273+
if !strings.Contains(w.Body.String(), tt.wantBodyMatch) {
274+
t.Errorf("body = %q, want to contain %q", w.Body.String(), tt.wantBodyMatch)
275+
}
276+
})
277+
}
278+
}
279+
280+
func TestWithTrackRoutePattern(t *testing.T) {
281+
newMux := func(gotBus, gotTrack *string) *http.ServeMux {
282+
mux := http.NewServeMux()
283+
mux.HandleFunc("POST /players/{player}/tracklist/goto/{trackid}",
284+
withTrack(func(w http.ResponseWriter, r *http.Request, busName, trackRef string) {
285+
*gotBus, *gotTrack = busName, trackRef
286+
w.WriteHeader(http.StatusAccepted)
287+
}))
288+
return mux
289+
}
290+
291+
t.Run("last segment", func(t *testing.T) {
292+
var gotBus, gotTrack string
293+
req := httptest.NewRequest("POST", "/players/org.mpris.MediaPlayer2.mpd/tracklist/goto/7", nil)
294+
w := httptest.NewRecorder()
295+
newMux(&gotBus, &gotTrack).ServeHTTP(w, req)
296+
297+
if w.Code != http.StatusAccepted {
298+
t.Fatalf("status = %d, want %d", w.Code, http.StatusAccepted)
299+
}
300+
if gotBus != "org.mpris.MediaPlayer2.mpd" {
301+
t.Errorf("busName = %q, want %q", gotBus, "org.mpris.MediaPlayer2.mpd")
302+
}
303+
if gotTrack != "7" {
304+
t.Errorf("trackRef = %q, want %q", gotTrack, "7")
305+
}
306+
})
307+
308+
t.Run("percent-encoded full object path", func(t *testing.T) {
309+
var gotBus, gotTrack string
310+
req := httptest.NewRequest("POST",
311+
"/players/org.mpris.MediaPlayer2.mpd/tracklist/goto/%2Forg%2Fmpris%2FMediaPlayer2%2FTrack%2F7", nil)
312+
w := httptest.NewRecorder()
313+
newMux(&gotBus, &gotTrack).ServeHTTP(w, req)
314+
315+
if w.Code != http.StatusAccepted {
316+
t.Fatalf("status = %d, want %d", w.Code, http.StatusAccepted)
317+
}
318+
if gotTrack != "/org/mpris/MediaPlayer2/Track/7" {
319+
t.Errorf("trackRef = %q, want %q", gotTrack, "/org/mpris/MediaPlayer2/Track/7")
320+
}
321+
})
322+
}
323+
208324
// TestWithPlayer tests the middleware for extracting busName
209325
func TestWithPlayer(t *testing.T) {
210326
tests := []struct {

api/players.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package api
22

33
import (
4+
"encoding/json"
45
"errors"
56
"net/http"
67
"net/url"
@@ -47,6 +48,13 @@ func handleMPRISError(w http.ResponseWriter, err error) {
4748
return
4849
}
4950

51+
// Tracklist unsupported: the resource doesn't exist for this player
52+
var unsupportedErr *mpris.TracklistUnsupportedError
53+
if errors.As(err, &unsupportedErr) {
54+
http.Error(w, err.Error(), http.StatusNotFound)
55+
return
56+
}
57+
5058
// Handle capability errors
5159
var capErr *mpris.CapabilityError
5260
if errors.As(err, &capErr) {
@@ -135,6 +143,52 @@ func SetShuffleHandler(m *mpris.MPRISBackend) http.HandlerFunc {
135143
})
136144
}
137145

146+
// withTrack extracts the {trackid} parameter: the last segment of a track's
147+
// object path (or the %2F-encoded full path), resolved against the cached
148+
// tracklist by the backend.
149+
func withTrack(
150+
next func(w http.ResponseWriter, r *http.Request, busName, trackRef string),
151+
) http.HandlerFunc {
152+
return withPlayer(func(w http.ResponseWriter, r *http.Request, busName string) {
153+
next(w, r, busName, r.PathValue("trackid"))
154+
})
155+
}
156+
157+
func TracklistHandler(getTracklist func(string) (*mpris.TracklistResponse, error)) http.HandlerFunc {
158+
return withPlayer(func(w http.ResponseWriter, r *http.Request, busName string) {
159+
resp, err := getTracklist(busName)
160+
if err != nil {
161+
handleMPRISError(w, err)
162+
return
163+
}
164+
165+
w.Header().Set("Content-Type", "application/json")
166+
if err := json.NewEncoder(w).Encode(resp); err != nil {
167+
http.Error(w, err.Error(), http.StatusInternalServerError)
168+
}
169+
})
170+
}
171+
172+
func GoToHandler(m *mpris.MPRISBackend) http.HandlerFunc {
173+
return withTrack(func(w http.ResponseWriter, r *http.Request, busName, trackID string) {
174+
handleMPRISError(w, m.GoTo(busName, trackID))
175+
})
176+
}
177+
178+
func RemoveTrackHandler(m *mpris.MPRISBackend) http.HandlerFunc {
179+
return withTrack(func(w http.ResponseWriter, r *http.Request, busName, trackID string) {
180+
handleMPRISError(w, m.RemoveTrack(busName, trackID))
181+
})
182+
}
183+
184+
func AddTrackHandler(m *mpris.MPRISBackend) http.HandlerFunc {
185+
return withPlayer(func(w http.ResponseWriter, r *http.Request, busName string) {
186+
withBody(nil, func(w http.ResponseWriter, r *http.Request, req *mpris.AddTrackRequest) {
187+
handleMPRISError(w, m.AddTrack(busName, req.Uri, req.AfterTrack, req.SetAsCurrent))
188+
})(w, r)
189+
})
190+
}
191+
138192
func CoverHandler(getPlayer func(string) (*mpris.Player, error)) http.HandlerFunc {
139193
return withPlayer(func(w http.ResponseWriter, r *http.Request, busName string) {
140194
player, err := getPlayer(busName)

api/routes.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,20 @@ func (s *Server) registerMPRISRoutes(b *mpris.MPRISBackend) {
230230
"POST /players/{player}/shuffle",
231231
SetShuffleHandler(b),
232232
)
233+
s.mux.HandleFunc(
234+
"GET /players/{player}/tracklist",
235+
TracklistHandler(b.GetTracklist),
236+
)
237+
s.mux.HandleFunc(
238+
"POST /players/{player}/tracklist/goto/{trackid}",
239+
GoToHandler(b),
240+
)
241+
s.mux.HandleFunc(
242+
"POST /players/{player}/tracklist/add",
243+
AddTrackHandler(b),
244+
)
245+
s.mux.HandleFunc(
246+
"POST /players/{player}/tracklist/remove/{trackid}",
247+
RemoveTrackHandler(b),
248+
)
233249
}

backend/mpris/dbus.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@ func extractFloat64(v dbus.Variant) (float64, bool) {
162162
return val, ok
163163
}
164164

165+
// extractStringSlice extracts a []string from a dbus.Variant
166+
func extractStringSlice(v dbus.Variant) ([]string, bool) {
167+
val, ok := v.Value().([]string)
168+
return val, ok
169+
}
170+
165171
// extractMetadataMap extracts a metadata map from a dbus.Variant
166172
func extractMetadataMap(v dbus.Variant) (map[string]dbus.Variant, bool) {
167173
val, ok := v.Value().(map[string]dbus.Variant)

backend/mpris/mpris_test.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -965,14 +965,15 @@ func TestPlayerStructTags(t *testing.T) {
965965
dbusTag string
966966
ifaceTag string
967967
}{
968-
"Identity": {dbusTag: "Identity", ifaceTag: "org.mpris.MediaPlayer2"},
969-
"PlaybackStatus": {dbusTag: "PlaybackStatus", ifaceTag: "org.mpris.MediaPlayer2.Player"},
970-
"LoopStatus": {dbusTag: "LoopStatus", ifaceTag: "org.mpris.MediaPlayer2.Player"},
971-
"Shuffle": {dbusTag: "Shuffle", ifaceTag: "org.mpris.MediaPlayer2.Player"},
972-
"Volume": {dbusTag: "Volume", ifaceTag: "org.mpris.MediaPlayer2.Player"},
973-
"Position": {dbusTag: "Position", ifaceTag: "org.mpris.MediaPlayer2.Player"},
974-
"Rate": {dbusTag: "Rate", ifaceTag: "org.mpris.MediaPlayer2.Player"},
975-
"Metadata": {dbusTag: "Metadata", ifaceTag: "org.mpris.MediaPlayer2.Player"},
968+
"Identity": {dbusTag: "Identity", ifaceTag: "org.mpris.MediaPlayer2"},
969+
"SupportedUriSchemes": {dbusTag: "SupportedUriSchemes", ifaceTag: "org.mpris.MediaPlayer2"},
970+
"PlaybackStatus": {dbusTag: "PlaybackStatus", ifaceTag: "org.mpris.MediaPlayer2.Player"},
971+
"LoopStatus": {dbusTag: "LoopStatus", ifaceTag: "org.mpris.MediaPlayer2.Player"},
972+
"Shuffle": {dbusTag: "Shuffle", ifaceTag: "org.mpris.MediaPlayer2.Player"},
973+
"Volume": {dbusTag: "Volume", ifaceTag: "org.mpris.MediaPlayer2.Player"},
974+
"Position": {dbusTag: "Position", ifaceTag: "org.mpris.MediaPlayer2.Player"},
975+
"Rate": {dbusTag: "Rate", ifaceTag: "org.mpris.MediaPlayer2.Player"},
976+
"Metadata": {dbusTag: "Metadata", ifaceTag: "org.mpris.MediaPlayer2.Player"},
976977
}
977978

978979
for i := 0; i < playerType.NumField(); i++ {

backend/mpris/player.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ func (p *Player) loadFromDBus() error {
140140
field.SetInt(val)
141141
}
142142

143+
case reflect.Slice:
144+
if field.Type().Elem().Kind() == reflect.String {
145+
if val, ok := extractStringSlice(variant); ok {
146+
field.Set(reflect.ValueOf(val))
147+
}
148+
}
149+
143150
case reflect.Map:
144151
// Special case for Metadata
145152
if dbusTag == "Metadata" {

backend/mpris/tracklist.go

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package mpris
22

33
import (
4+
"net/url"
5+
"path"
6+
"slices"
47
"time"
58

69
"github.com/godbus/dbus/v5"
@@ -9,6 +12,19 @@ import (
912
"github.com/b0bbywan/go-odio-api/logger"
1013
)
1114

15+
// resolveTrackRef resolves an API-supplied track reference — a full object
16+
// path or just its last segment — against the cached tracklist. Track IDs are
17+
// player-chosen object paths with no common prefix, so the cache is the only
18+
// way to rebuild the full path from a bare ID.
19+
func resolveTrackRef(tracks []Track, ref string) (string, bool) {
20+
for i := range tracks {
21+
if tracks[i].TrackID == ref || path.Base(tracks[i].TrackID) == ref {
22+
return tracks[i].TrackID, true
23+
}
24+
}
25+
return "", false
26+
}
27+
1228
// mutateTracklist applies fn to the cached player and broadcasts the resulting
1329
// tracklist snapshot. fn returns false for no-op mutations (nothing stored,
1430
// nothing broadcast). fn must respect updatePlayers' copy-on-write contract:
@@ -206,14 +222,16 @@ func (m *MPRISBackend) GetTracklist(busName string) (*TracklistResponse, error)
206222
return &TracklistResponse{CanEditTracks: player.CanEditTracks, Tracks: tracks}, nil
207223
}
208224

209-
// GoTo skips to the given track. Not gated on CanEditTracks: the spec doesn't
210-
// class GoTo as an edit operation.
211-
func (m *MPRISBackend) GoTo(busName, trackID string) error {
212-
if _, err := m.tracklistPlayer(busName); err != nil {
225+
// GoTo skips to the referenced track. Not gated on CanEditTracks: the spec
226+
// doesn't class GoTo as an edit operation.
227+
func (m *MPRISBackend) GoTo(busName, trackRef string) error {
228+
player, err := m.tracklistPlayer(busName)
229+
if err != nil {
213230
return err
214231
}
215-
if !dbus.ObjectPath(trackID).IsValid() {
216-
return &ValidationError{Field: "track_id", Message: "must be a valid D-Bus object path"}
232+
trackID, ok := resolveTrackRef(player.Tracklist, trackRef)
233+
if !ok {
234+
return &ValidationError{Field: "track_id", Message: "unknown track: " + trackRef}
217235
}
218236

219237
logger.Debug("[mpris] going to track %s for %s", trackID, busName)
@@ -227,32 +245,46 @@ func (m *MPRISBackend) AddTrack(busName, uri, afterTrack string, setAsCurrent bo
227245
if err != nil {
228246
return err
229247
}
230-
if uri == "" {
231-
return &ValidationError{Field: "uri", Message: "must not be empty"}
248+
// The spec requires an absolute URI whose scheme the player declared in
249+
// SupportedUriSchemes; a bare path would be silently dropped player-side.
250+
u, err := url.Parse(uri)
251+
if err != nil || u.Scheme == "" {
252+
return &ValidationError{Field: "uri", Message: "must be an absolute URI (e.g. file:///path or http://...)"}
253+
}
254+
if len(player.SupportedUriSchemes) > 0 && !slices.Contains(player.SupportedUriSchemes, u.Scheme) {
255+
return &ValidationError{Field: "uri", Message: "scheme " + u.Scheme + " not supported by player"}
232256
}
233257

234-
if afterTrack == "" {
258+
switch afterTrack {
259+
case "":
235260
if n := len(player.Tracklist); n > 0 {
236261
afterTrack = player.Tracklist[n-1].TrackID
237262
} else {
238263
afterTrack = MPRIS_NO_TRACK
239264
}
240-
}
241-
if !dbus.ObjectPath(afterTrack).IsValid() {
242-
return &ValidationError{Field: "after_track", Message: "must be a valid D-Bus object path"}
265+
case MPRIS_NO_TRACK, "NoTrack": // explicit prepend
266+
afterTrack = MPRIS_NO_TRACK
267+
default:
268+
resolved, ok := resolveTrackRef(player.Tracklist, afterTrack)
269+
if !ok {
270+
return &ValidationError{Field: "after_track", Message: "unknown track: " + afterTrack}
271+
}
272+
afterTrack = resolved
243273
}
244274

245275
logger.Debug("[mpris] adding track %s after %s for %s", uri, afterTrack, busName)
246276
return m.callMethod(busName, MPRIS_METHOD_ADD_TRACK, uri, dbus.ObjectPath(afterTrack), setAsCurrent)
247277
}
248278

249-
// RemoveTrack asks the player to remove a track from its tracklist.
250-
func (m *MPRISBackend) RemoveTrack(busName, trackID string) error {
251-
if _, err := m.editableTracklistPlayer(busName); err != nil {
279+
// RemoveTrack asks the player to remove the referenced track from its tracklist.
280+
func (m *MPRISBackend) RemoveTrack(busName, trackRef string) error {
281+
player, err := m.editableTracklistPlayer(busName)
282+
if err != nil {
252283
return err
253284
}
254-
if !dbus.ObjectPath(trackID).IsValid() {
255-
return &ValidationError{Field: "track_id", Message: "must be a valid D-Bus object path"}
285+
trackID, ok := resolveTrackRef(player.Tracklist, trackRef)
286+
if !ok {
287+
return &ValidationError{Field: "track_id", Message: "unknown track: " + trackRef}
256288
}
257289

258290
logger.Debug("[mpris] removing track %s for %s", trackID, busName)

0 commit comments

Comments
 (0)