OpenSubsonic: playbackReport extension (reportPlayback), integrated with now-playing/history

This commit is contained in:
2026-08-23 08:08:35 -05:00
parent d3074ab6fe
commit 4a5add80b6
4 changed files with 283 additions and 1 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ func TestGetOpenSubsonicExtensions(t *testing.T) {
// set once Phase 3 is done.
for _, want := range []string{
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
"indexBasedQueue",
"indexBasedQueue", "playbackReport",
} {
if !names[want] {
t.Errorf("extension %q not advertised: %v", want, names)
+101
View File
@@ -0,0 +1,101 @@
package subsonic
import (
"database/sql"
"strconv"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func init() {
registerExtension("playbackReport", 1)
register("reportPlayback", handleReportPlayback)
}
// resolveMediaID maps a reportPlayback/getTranscodeDecision mediaId to a
// song, honoring mediaType: "song" IDs are looked up directly; "podcast"
// IDs may name either a podcast_episode (resolved to its downloaded
// song, once one exists) or, since a downloaded episode's song ID is
// itself usable everywhere, the song directly — both are accepted so
// clients don't need to track which kind of ID they were given.
func resolveMediaID(db *sql.DB, mediaID, mediaType string) (*database.Song, error) {
if song, err := database.GetSong(db, mediaID); err == nil {
return song, nil
}
if mediaType == "podcast" {
if ep, err := database.GetPodcastEpisode(db, mediaID); err == nil && ep.MediaFileID.Valid {
if song, err := database.GetSongByMediaFileID(db, ep.MediaFileID.String); err == nil {
return song, nil
}
}
}
return nil, database.ErrNotFound
}
// handleReportPlayback records a client's playback timeline state. This
// is deliberately separate from scrobble: it's meant for frequent,
// low-stakes position pings (used to drive now-playing displays and
// resume positions) rather than "this was a real listen" submission
// events, though a state=stopped report with ignoreScrobble=false does
// also log a playback_history entry, matching how a client naturally
// signals "playback ended here" without a distinct submission call.
func handleReportPlayback(rc *Context) *apiError {
mediaID, aerr := rc.Params.Required("mediaId")
if aerr != nil {
return aerr
}
mediaType, aerr := rc.Params.Required("mediaType")
if aerr != nil {
return aerr
}
positionMs, aerr := rc.Params.RequiredInt("positionMs")
if aerr != nil {
return aerr
}
state, aerr := rc.Params.Required("state")
if aerr != nil {
return aerr
}
switch state {
case "starting", "playing", "paused", "stopped":
default:
return newError(ErrGeneric, "invalid state: "+state)
}
playbackRate := 1.0
if v := rc.Params.Get("playbackRate"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
playbackRate = f
}
}
ignoreScrobble := rc.Params.Bool("ignoreScrobble", false)
client := rc.Params.Get("c")
db := rc.Server.DB
song, err := resolveMediaID(db, mediaID, mediaType)
if err != nil {
return errNotFound("Media")
}
if err := database.RecordPlaybackReport(db, rc.User.ID, song.ID, positionMs, state, client, playbackRate); err != nil {
return newError(ErrGeneric, err.Error())
}
switch state {
case "stopped":
if err := database.ClearNowPlaying(db, rc.User.ID); err != nil {
return newError(ErrGeneric, err.Error())
}
if !ignoreScrobble && positionMs > 0 {
if err := database.RecordPlayback(db, rc.User.ID, song.ID, float64(positionMs)/1000); err != nil {
return newError(ErrGeneric, err.Error())
}
}
default: // starting, playing, paused
if err := database.SetNowPlaying(db, rc.User.ID, song.ID, client, float64(positionMs)/1000); err != nil {
return newError(ErrGeneric, err.Error())
}
}
rc.Response = newResponse()
return nil
}
+171
View File
@@ -0,0 +1,171 @@
package subsonic
import (
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func TestReportPlaybackPlaying(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("mediaId", song.ID)
q.Set("mediaType", "song")
q.Set("positionMs", "5000")
q.Set("state", "playing")
resp := getPath(t, srv, "/rest/reportPlayback.view", q)
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("reportPlayback failed: %+v", x)
}
nowPlaying, err := database.ListNowPlaying(db)
if err != nil {
t.Fatal(err)
}
if len(nowPlaying) != 1 || nowPlaying[0].SongID != song.ID {
t.Fatalf("now playing = %v, want 1 entry for %s", nowPlaying, song.ID)
}
}
func TestReportPlaybackStoppedClearsNowPlayingAndRecordsHistory(t *testing.T) {
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("mediaId", song.ID)
q.Set("mediaType", "song")
q.Set("positionMs", "1000")
q.Set("state", "playing")
getPath(t, srv, "/rest/reportPlayback.view", q)
q2 := baseParams("alice", "password123")
q2.Set("mediaId", song.ID)
q2.Set("mediaType", "song")
q2.Set("positionMs", "45000")
q2.Set("state", "stopped")
resp := getPath(t, srv, "/rest/reportPlayback.view", q2)
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("reportPlayback stopped failed: %+v", x)
}
nowPlaying, err := database.ListNowPlaying(db)
if err != nil {
t.Fatal(err)
}
if len(nowPlaying) != 0 {
t.Fatalf("now playing should be cleared after stopped, got %v", nowPlaying)
}
history, err := database.ListRecentlyPlayed(db, u.ID, 10)
if err != nil {
t.Fatal(err)
}
if len(history) != 1 {
t.Fatalf("expected a playback_history entry after stopped, got %v", history)
}
}
func TestReportPlaybackIgnoreScrobble(t *testing.T) {
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("mediaId", song.ID)
q.Set("mediaType", "song")
q.Set("positionMs", "45000")
q.Set("state", "stopped")
q.Set("ignoreScrobble", "true")
getPath(t, srv, "/rest/reportPlayback.view", q)
history, err := database.ListRecentlyPlayed(db, u.ID, 10)
if err != nil {
t.Fatal(err)
}
if len(history) != 0 {
t.Fatalf("ignoreScrobble=true should not record history, got %v", history)
}
}
func TestReportPlaybackInvalidState(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
q := baseParams("alice", "password123")
q.Set("mediaId", song.ID)
q.Set("mediaType", "song")
q.Set("positionMs", "1000")
q.Set("state", "bogus")
resp := getPath(t, srv.URL, "/rest/reportPlayback.view", q)
x := decodeXML(t, resp)
if x.Status != "failed" {
t.Fatalf("expected failure for invalid state, got %+v", x)
}
}
func TestReportPlaybackMissingParams(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := getPath(t, srv.URL, "/rest/reportPlayback.view", baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
t.Fatalf("expected error 10, got %+v", x)
}
}
func TestReportPlaybackUnknownMedia(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("mediaId", "sg_doesnotexist")
q.Set("mediaType", "song")
q.Set("positionMs", "0")
q.Set("state", "playing")
resp := getPath(t, srv.URL, "/rest/reportPlayback.view", q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
t.Fatalf("expected error 70, got %+v", x)
}
}
// TestReportPlaybackDoesNotLeakOtherUsersBookmarksOrQueue guards the
// "never expose one user's private state to another" requirement: each
// user's saved play queue is scoped by user, so alice's report never
// becomes visible in bob's queue.
func TestReportPlaybackDoesNotLeakAcrossUsers(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
createTestUser(t, s, "bob", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
q := baseParams("alice", "password123")
q.Set("mediaId", song.ID)
q.Set("mediaType", "song")
q.Set("positionMs", "1000")
q.Set("state", "playing")
getPath(t, srv.URL, "/rest/reportPlayback.view", q)
q2 := baseParams("bob", "password123")
q2.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getPlayQueue.view", q2)
j := decodeJSON(t, resp)
if _, ok := j["playQueue"]; ok {
t.Fatalf("bob should not see alice's queue/report state: %v", j)
}
}