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 0000000..8423fdc Binary files /dev/null and b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3862532309_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts differ