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)
|
||||
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.
|
||||
for _, want := range []string{
|
||||
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
|
||||
"indexBasedQueue",
|
||||
"indexBasedQueue", "playbackReport",
|
||||
} {
|
||||
if !names[want] {
|
||||
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