diff --git a/model/scrobble.go b/model/scrobble.go index e1567abc3..c594c9701 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -8,6 +8,12 @@ type Scrobble struct { SubmissionTime time.Time } +type MostPlayedEntry struct { + MediaFile + PlayCount int `json:"playCount"` +} + type ScrobbleRepository interface { RecordScrobble(mediaFileID string, submissionTime time.Time) error + GetMostPlayed(offset, count int) ([]MostPlayedEntry, error) } diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 219a48198..7a247d50a 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -32,3 +32,49 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t _, err := r.executeSQL(insert) return err } + +type dbMostPlayedEntry struct { + dbMediaFile + PlayCount int `structs:"-"` +} + +func (e *dbMostPlayedEntry) PostScan() error { + return e.dbMediaFile.PostScan() +} + +func (r *scrobbleRepository) GetMostPlayed(offset, count int) ([]model.MostPlayedEntry, error) { + if offset < 0 { + offset = 0 + } + if count <= 0 { + count = 50 + } + userID := loggedUser(r.ctx).ID + sq := Select("m.*", "count(*) as play_count"). + From(r.tableName+" s"). + LeftJoin("media_file m ON m.id = s.media_file_id"). + Where(Eq{"s.user_id": userID}). + GroupBy("m.id"). + OrderBy("play_count DESC"). + Offset(uint64(offset)). + Limit(uint64(count)) + + var rows []dbMostPlayedEntry + if err := r.queryAll(sq, &rows); err != nil { + return nil, err + } + + entries := make([]model.MostPlayedEntry, 0, len(rows)) + for i := range rows { + if rows[i].MediaFile == nil { + continue + } + entries = append(entries, model.MostPlayedEntry{ + MediaFile: *rows[i].MediaFile, + PlayCount: rows[i].PlayCount, + }) + } + return entries, nil +} + +var _ model.ScrobbleRepository = (*scrobbleRepository)(nil) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 24bbca960..829217353 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -228,6 +228,30 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { return response, nil } +func (api *Router) GetMostPlayedSongs(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + count := min(p.IntOr("count", 50), 500) + offset := p.IntOr("offset", 0) + + ctx := r.Context() + entries, err := api.ds.Scrobble(ctx).GetMostPlayed(offset, count) + if err != nil { + log.Error(r, "Error retrieving most played songs", err) + return nil, err + } + + response := newResponse() + response.MostPlayed = &responses.MostPlayed{ + Song: slice.Map(entries, func(e model.MostPlayedEntry) responses.MostPlayedEntry { + return responses.MostPlayedEntry{ + Child: childFromMediaFile(ctx, e.MediaFile), + PlayCount: e.PlayCount, + } + }), + } + return response, nil +} + func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) size := min(p.IntOr("size", 10), 500) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 82e404228..9eac88e35 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -136,6 +136,7 @@ func (api *Router) routes() http.Handler { h(r, "getStarred", api.GetStarred) h(r, "getStarred2", api.GetStarred2) h(r, "getNowPlaying", api.GetNowPlaying) + h(r, "getMostPlayedSongs", api.GetMostPlayedSongs) h(r, "getRandomSongs", api.GetRandomSongs) h(r, "getSongsByGenre", api.GetSongsByGenre) }) diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 97b3cafcc..9035761d6 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -15,6 +15,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, + {Name: "mostPlayedSongs", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index e4217303f..08c3ba9b3 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,43 +44,13 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) - It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 7 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(6), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), - )) - Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( - ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), - ) - }) - }) - - Context("with sonic similarity plugin", func() { - BeforeEach(func() { - sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) - }) - - It("should return 7 extensions including sonicSimilarity", func() { - router.ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) @@ -92,6 +62,38 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "mostPlayedSongs", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 8 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(8), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "mostPlayedSongs", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), )) }) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 252eee4c6..60add75f5 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -63,6 +63,7 @@ type Subsonic struct { PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"` + MostPlayed *MostPlayed `xml:"mostPlayed,omitempty" json:"mostPlayed,omitempty"` } const ( @@ -448,6 +449,15 @@ type TopSongs struct { Song []Child `xml:"song,omitempty" json:"song,omitempty"` } +type MostPlayedEntry struct { + Child + PlayCount int `xml:"playCount,attr" json:"playCount"` +} + +type MostPlayed struct { + Song []MostPlayedEntry `xml:"song,omitempty" json:"song,omitempty"` +} + type SonicMatch struct { Entry Child `xml:"entry" json:"entry"` Similarity float64 `xml:"similarity" json:"similarity"` diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index 34561c257..2c96e2a1b 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -22,3 +22,7 @@ func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Tim }) return nil } + +func (m *MockScrobbleRepo) GetMostPlayed(offset, count int) ([]model.MostPlayedEntry, error) { + return nil, nil +}