From 660d8ab52ea19eb1d5b813f4ca4ef9dc49c69ebe Mon Sep 17 00:00:00 2001 From: Voten641 Date: Mon, 22 Jun 2026 20:06:48 +0200 Subject: [PATCH] feat(subsonic): add getMostPlayedSongs endpoint --- model/scrobble.go | 6 ++++ persistence/scrobble_repository.go | 43 ++++++++++++++++++++++++++ server/subsonic/album_lists.go | 24 ++++++++++++++ server/subsonic/api.go | 1 + server/subsonic/responses/responses.go | 10 ++++++ tests/mock_scrobble_repo.go | 4 +++ 6 files changed, 88 insertions(+) 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..630d8b837 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -32,3 +32,46 @@ 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) { + 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("s.media_file_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 { + entry := model.MostPlayedEntry{ + MediaFile: *rows[i].MediaFile, + PlayCount: rows[i].PlayCount, + } + var err error + entry.Participants, err = r.getParticipants(rows[i].MediaFile) + if err != nil { + return nil, err + } + entries = append(entries, entry) + } + 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/responses/responses.go b/server/subsonic/responses/responses.go index 7e41a1daa..afa4e9dff 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 ( @@ -446,6 +447,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 +}