From 4a5add80b6d77b661be27aff96b49e7e50c1b09a Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 08:08:35 -0500 Subject: [PATCH] OpenSubsonic: playbackReport extension (reportPlayback), integrated with now-playing/history --- internal/database/subsonic_helpers2.go | 10 ++ internal/subsonic/opensubsonic_core_test.go | 2 +- internal/subsonic/playback_report.go | 101 ++++++++++++ internal/subsonic/playback_report_test.go | 171 ++++++++++++++++++++ 4 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 internal/subsonic/playback_report.go create mode 100644 internal/subsonic/playback_report_test.go diff --git a/internal/database/subsonic_helpers2.go b/internal/database/subsonic_helpers2.go index 1d482c8..11c43b5 100644 --- a/internal/database/subsonic_helpers2.go +++ b/internal/database/subsonic_helpers2.go @@ -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 +} diff --git a/internal/subsonic/opensubsonic_core_test.go b/internal/subsonic/opensubsonic_core_test.go index 037444b..66735e2 100644 --- a/internal/subsonic/opensubsonic_core_test.go +++ b/internal/subsonic/opensubsonic_core_test.go @@ -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) diff --git a/internal/subsonic/playback_report.go b/internal/subsonic/playback_report.go new file mode 100644 index 0000000..0bbb963 --- /dev/null +++ b/internal/subsonic/playback_report.go @@ -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 +} diff --git a/internal/subsonic/playback_report_test.go b/internal/subsonic/playback_report_test.go new file mode 100644 index 0000000..d7e8498 --- /dev/null +++ b/internal/subsonic/playback_report_test.go @@ -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) + } +}