OpenSubsonic: playbackReport extension (reportPlayback), integrated with now-playing/history
This commit is contained in:
@@ -275,3 +275,13 @@ func TotalFilesSeenAcrossScans(db *sql.DB) (int64, error) {
|
|||||||
err := db.QueryRow(`SELECT COALESCE(SUM(files_seen), 0) FROM scan_state WHERE status = 'scanning'`).Scan(&n)
|
err := db.QueryRow(`SELECT COALESCE(SUM(files_seen), 0) FROM scan_state WHERE status = 'scanning'`).Scan(&n)
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordPlaybackReport stores one raw playback-timeline event (the
|
||||||
|
// OpenSubsonic reportPlayback extension), independent from the classic
|
||||||
|
// scrobbles/playback_history tables so a client's frequent timeline
|
||||||
|
// pings don't get conflated with actual "this was listened to" history.
|
||||||
|
func RecordPlaybackReport(db *sql.DB, userID, songID string, positionMs int, state, client string, playbackRate float64) error {
|
||||||
|
_, err := db.Exec(`INSERT INTO playback_reports (id, user_id, song_id, position_ms, state, playback_rate, client) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
idgen.New("pr"), userID, songID, positionMs, state, playbackRate, client)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestGetOpenSubsonicExtensions(t *testing.T) {
|
|||||||
// set once Phase 3 is done.
|
// set once Phase 3 is done.
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
|
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
|
||||||
"indexBasedQueue",
|
"indexBasedQueue", "playbackReport",
|
||||||
} {
|
} {
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Errorf("extension %q not advertised: %v", want, names)
|
t.Errorf("extension %q not advertised: %v", want, names)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user