From b77ccd242ab5b3f18580444fe8df9658db49ad78 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 00:15:58 -0500 Subject: [PATCH] Subsonic: annotation endpoints (star, unstar, setRating, scrobble) --- internal/subsonic/annotation.go | 124 ++++++++++++++++++ internal/subsonic/annotation_test.go | 115 ++++++++++++++++ .../index.m3u8 | 7 + .../seg00000.ts | Bin 0 -> 19928 bytes 4 files changed, 246 insertions(+) create mode 100644 internal/subsonic/annotation.go create mode 100644 internal/subsonic/annotation_test.go create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3862532309_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3862532309_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts diff --git a/internal/subsonic/annotation.go b/internal/subsonic/annotation.go new file mode 100644 index 0000000..3c76f2e --- /dev/null +++ b/internal/subsonic/annotation.go @@ -0,0 +1,124 @@ +package subsonic + +import ( + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func init() { + register("star", handleStar) + register("unstar", handleUnstar) + register("setRating", handleSetRating) + register("scrobble", handleScrobble) +} + +func handleStar(rc *Context) *apiError { + return applyStarUnstar(rc, true) +} + +func handleUnstar(rc *Context) *apiError { + return applyStarUnstar(rc, false) +} + +func applyStarUnstar(rc *Context, star bool) *apiError { + db := rc.Server.DB + ids := rc.Params.GetAll("id") + albumIDs := rc.Params.GetAll("albumId") + artistIDs := rc.Params.GetAll("artistId") + if len(ids) == 0 && len(albumIDs) == 0 && len(artistIDs) == 0 { + return errMissingParameter("id, albumId, or artistId") + } + + apply := func(itemType, itemID string) error { + if star { + return database.Star(db, rc.User.ID, itemType, itemID) + } + return database.Unstar(db, rc.User.ID, itemType, itemID) + } + for _, id := range ids { + if err := apply("song", id); err != nil { + return newError(ErrGeneric, err.Error()) + } + } + for _, id := range albumIDs { + if err := apply("album", id); err != nil { + return newError(ErrGeneric, err.Error()) + } + } + for _, id := range artistIDs { + if err := apply("artist", id); err != nil { + return newError(ErrGeneric, err.Error()) + } + } + + rc.Response = newResponse() + return nil +} + +func handleSetRating(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + rating, rerr := rc.Params.RequiredInt("rating") + if rerr != nil { + return rerr + } + if rating < 0 || rating > 5 { + return newError(ErrGeneric, "rating must be between 0 and 5") + } + + itemType := "song" + db := rc.Server.DB + if _, err := database.GetSong(db, id); err != nil { + if _, err := database.GetAlbum(db, id); err == nil { + itemType = "album" + } else if _, err := database.GetArtist(db, id); err == nil { + itemType = "artist" + } else { + return errNotFound("Item") + } + } + + if err := database.SetRating(db, rc.User.ID, itemType, id, rating); err != nil { + return newError(ErrGeneric, err.Error()) + } + rc.Response = newResponse() + return nil +} + +// handleScrobble records playback history and, when submission=true (the +// default), a scrobble record for potential Last.fm-style submission +// later. VibeSonic doesn't itself talk to Last.fm in Phase 2; it stores +// scrobbles so a future integration (or the user's own reporting) has +// real data rather than inventing play counts elsewhere. +func handleScrobble(rc *Context) *apiError { + ids := rc.Params.GetAll("id") + if len(ids) == 0 { + return errMissingParameter("id") + } + submission := rc.Params.Bool("submission", true) + client := rc.Params.Get("c") + db := rc.Server.DB + + for _, id := range ids { + song, err := database.GetSong(db, id) + if err != nil { + continue // unknown song id: skip rather than fail the whole batch + } + if submission { + if err := database.RecordPlayback(db, rc.User.ID, song.ID, 0); err != nil { + return newError(ErrGeneric, err.Error()) + } + if err := database.RecordScrobble(db, rc.User.ID, song.ID, client, false); err != nil { + return newError(ErrGeneric, err.Error()) + } + } else { + if err := database.SetNowPlaying(db, rc.User.ID, song.ID, client, 0); err != nil { + return newError(ErrGeneric, err.Error()) + } + } + } + + rc.Response = newResponse() + return nil +} diff --git a/internal/subsonic/annotation_test.go b/internal/subsonic/annotation_test.go new file mode 100644 index 0000000..473462c --- /dev/null +++ b/internal/subsonic/annotation_test.go @@ -0,0 +1,115 @@ +package subsonic + +import ( + "database/sql" + "net/http/httptest" + "testing" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func mustUserID(t *testing.T, db *sql.DB, username string) string { + t.Helper() + u, err := database.GetUserByUsername(db, username) + if err != nil { + t.Fatal(err) + } + return u.ID +} + +func TestStarUnstarSong(t *testing.T) { + s, db := newTestServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + song := firstSong(t, s) + srv := httptest.NewServer(s.Router()) + defer srv.Close() + + q := baseParams("alice", "password123") + q.Set("id", song.ID) + resp := getPath(t, srv.URL, "/rest/star.view", q) + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("star failed: %+v", x) + } + + starred, err := database.IsStarred(db, mustUserID(t, db, "alice"), "song", song.ID) + if err != nil || !starred { + t.Fatalf("IsStarred = %v, %v, want true", starred, err) + } + + resp = getPath(t, srv.URL, "/rest/unstar.view", q) + x = decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("unstar failed: %+v", x) + } + starred, err = database.IsStarred(db, mustUserID(t, db, "alice"), "song", song.ID) + if err != nil || starred { + t.Fatalf("IsStarred after unstar = %v, %v, want false", starred, err) + } +} + +func TestStarMissingParams(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + resp := getPath(t, srv.URL, "/rest/star.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 TestSetRating(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("id", song.ID) + q.Set("rating", "4") + resp := getPath(t, srv.URL, "/rest/setRating.view", q) + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("setRating failed: %+v", x) + } +} + +func TestSetRatingInvalidValue(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("id", song.ID) + q.Set("rating", "9") + resp := getPath(t, srv.URL, "/rest/setRating.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" { + t.Fatalf("expected failure for out-of-range rating, got %+v", x) + } +} + +func TestScrobbleRecordsPlayback(t *testing.T) { + s, db := newTestServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + song := firstSong(t, s) + srv := httptest.NewServer(s.Router()) + defer srv.Close() + + q := baseParams("alice", "password123") + q.Set("id", song.ID) + resp := getPath(t, srv.URL, "/rest/scrobble.view", q) + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("scrobble failed: %+v", x) + } + + history, err := database.ListRecentlyPlayed(db, mustUserID(t, db, "alice"), 10) + if err != nil || len(history) != 1 { + t.Fatalf("ListRecentlyPlayed = %v, %v, want 1 entry", history, err) + } +} diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3862532309_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3862532309_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 new file mode 100644 index 0000000..7017fd6 --- /dev/null +++ b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3862532309_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 @@ -0,0 +1,7 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:1 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:1.023322, +seg00000.ts +#EXT-X-ENDLIST diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3862532309_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3862532309_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts new file mode 100644 index 0000000000000000000000000000000000000000..8423fdc59578e47083377fbbd02304cf1d9df16d GIT binary patch literal 19928 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