diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index a548909e..7291fb43 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -10,6 +10,7 @@ import ( "io" "log" "net/http" + "sync" "time" "go.senan.xyz/gonic/db" @@ -70,6 +71,51 @@ type Controller struct { tagReader tags.Reader resolveProxyPath ProxyPathResolver + nowPlayingCache *NowPlayingCache +} + +// NowPlayingRecord holds the currently-playing information for a user in memory. +type NowPlayingRecord struct { + TrackID int + Title string + IsDir bool + Album string + Artist string + Username string + Time time.Time + PlayerID int +} + +// NowPlayingCache is a threadsafe in-memory cache of NowPlayingRecord keyed by user ID. +type NowPlayingCache struct { + mu sync.RWMutex + cache map[int]NowPlayingRecord +} + +func NewNowPlayingCache() *NowPlayingCache { + return &NowPlayingCache{ + cache: make(map[int]NowPlayingRecord), + } +} + +func (c *NowPlayingCache) Set(userID int, rec NowPlayingRecord) { + c.mu.Lock() + defer c.mu.Unlock() + c.cache[userID] = rec +} + +// GetRecent returns most recent records with timestamp within the provided duration. +func (c *NowPlayingCache) GetRecent(d time.Duration) []NowPlayingRecord { + cutoff := time.Now().Add(-d) + c.mu.RLock() + defer c.mu.RUnlock() + var out []NowPlayingRecord + for _, r := range c.cache { + if r.Time.After(cutoff) || r.Time.Equal(cutoff) { + out = append(out, r) + } + } + return out } func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPath string, cacheAudioPath string, cacheCoverPath string, jukebox *jukebox.Jukebox, playlistStore *playlist.Store, scrobblers []scrobble.Scrobbler, podcasts *podcast.Podcasts, transcoder transcode.Transcoder, lastFMClient *lastfm.Client, artistInfoCache *artistinfocache.ArtistInfoCache, albumInfoCache *albuminfocache.AlbumInfoCache, tagReader tags.Reader, resolveProxyPath ProxyPathResolver) (*Controller, error) { @@ -93,6 +139,7 @@ func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPa tagReader: tagReader, resolveProxyPath: resolveProxyPath, + nowPlayingCache: NewNowPlayingCache(), } chain := handlerutil.Chain( @@ -133,6 +180,7 @@ func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPa c.Handle("/getSimilarSongs2", chain(resp(c.ServeGetSimilarSongsTwo))) c.Handle("/getLyrics", chain(resp(c.ServeGetLyrics))) c.Handle("/getLyricsBySongId", chain(resp(c.ServeGetLyricsBySongID))) + c.Handle("/getNowPlaying", chain(resp(c.ServeGetNowPlaying))) // raw c.Handle("/getCoverArt", chainRaw(respRaw(c.ServeGetCoverArt))) diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 2a615109..d96dbe43 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "slices" + "sort" "strings" "sync" "time" @@ -787,3 +788,29 @@ func lowerUDecOrHash(in string) string { } return string(lower) } + +func (c *Controller) ServeGetNowPlaying(r *http.Request) *spec.Response { + sub := spec.NewResponse() + sub.NowPlaying = &spec.NowPlaying{} + + records := c.nowPlayingCache.GetRecent(60 * time.Minute) + // sort newest first + sort.Slice(records, func(i, j int) bool { return records[i].Time.After(records[j].Time) }) + + for _, rec := range records { + minutesAgo := int(time.Since(rec.Time).Minutes()) + entry := &spec.NowPlayingEntry{ + ID: rec.TrackID, + Title: rec.Title, + IsDir: rec.IsDir, + Album: rec.Album, + Artist: rec.Artist, + Username: rec.Username, + MinutesAgo: minutesAgo, + PlayerID: rec.PlayerID, + } + sub.NowPlaying.List = append(sub.NowPlaying.List, entry) + } + + return sub +} diff --git a/server/ctrlsubsonic/handlers_common_test.go b/server/ctrlsubsonic/handlers_common_test.go new file mode 100644 index 00000000..2cf696da --- /dev/null +++ b/server/ctrlsubsonic/handlers_common_test.go @@ -0,0 +1,61 @@ +package ctrlsubsonic + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestServeGetNowPlaying_FromCache(t *testing.T) { + t.Parallel() + + now := time.Now().UTC() + + c := Controller{ + nowPlayingCache: NewNowPlayingCache(), + } + + c.nowPlayingCache.Set(1, NowPlayingRecord{ + TrackID: 101, + Title: "Track One", + IsDir: false, + Album: "Album One", + Artist: "Artist One", + Username: "alice", + Time: now.Add(-2 * time.Minute), + PlayerID: 11, + }) + + c.nowPlayingCache.Set(2, NowPlayingRecord{ + TrackID: 202, + Title: "Track Two", + IsDir: false, + Album: "Album Two", + Artist: "Artist Two", + Username: "bob", + Time: now.Add(-30 * time.Minute), + PlayerID: 22, + }) + + resp := c.ServeGetNowPlaying(nil) + require.NotNil(t, resp) + require.NotNil(t, resp.NowPlaying) + list := resp.NowPlaying.List + require.Len(t, list, 2) + + // newest first: alice then bob + first := list[0] + require.Equal(t, 101, first.ID) + require.Equal(t, "Track One", first.Title) + require.Equal(t, "alice", first.Username) + require.Equal(t, 11, first.PlayerID) + require.GreaterOrEqual(t, first.MinutesAgo, 0) + require.LessOrEqual(t, first.MinutesAgo, 5) + second := list[1] + require.Equal(t, 202, second.ID) + require.Equal(t, "Track Two", second.Title) + require.Equal(t, "bob", second.Username) + require.Equal(t, 22, second.PlayerID) + require.GreaterOrEqual(t, second.MinutesAgo, 29) +} diff --git a/server/ctrlsubsonic/handlers_raw.go b/server/ctrlsubsonic/handlers_raw.go index dd77f327..3989f634 100644 --- a/server/ctrlsubsonic/handlers_raw.go +++ b/server/ctrlsubsonic/handlers_raw.go @@ -252,6 +252,32 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R format, _ := params.Get("format") timeOffset, _ := params.GetInt("timeOffset") + // preload Album so we can access album tag/title + if id.Type == specid.Track { + var tr db.Track + if err := c.dbc.Preload("Album").First(&tr, id.Value).Error; err != nil { + log.Printf("now_playing: select track %d: %v", id.Value, err) + } else { + albumTitle := "" + if tr.Album != nil { + albumTitle = tr.Album.TagTitle + } + artist := tr.TagTrackArtist + + rec := NowPlayingRecord{ + TrackID: tr.ID, + Title: tr.TagTitle, + IsDir: false, + Album: albumTitle, + Artist: artist, + Username: user.Name, + Time: time.Now().UTC(), + PlayerID: 0, + } + c.nowPlayingCache.Set(user.ID, rec) + } + } + if format == "raw" { http.ServeFile(w, r, file.AbsPath()) return nil diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index e8c458da..d86f23ea 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -72,6 +72,8 @@ type Response struct { InternetRadioStations *InternetRadioStations `xml:"internetRadioStations" json:"internetRadioStations,omitempty"` Lyrics *Lyrics `xml:"lyrics" json:"lyrics,omitempty"` LyricsList *LyricsList `xml:"lyricsList" json:"lyricsList,omitempty"` + NowPlaying *NowPlaying `xml:"nowPlaying" json:"nowPlaying,omitempty"` + NowPlayingEntry *NowPlayingEntry `xml:"nowPlayingEntry" json:"nowPlayingEntry,omitempty"` } func NewResponse() *Response { @@ -494,6 +496,21 @@ type StructuredLyrics struct { Offset int `xml:"offset,attr,omitempty" json:"offset,omitempty"` } +type NowPlaying struct { + List []*NowPlayingEntry `xml:"entry" json:"entry"` +} + +type NowPlayingEntry struct { + Username string `xml:"username,attr" json:"username"` + MinutesAgo int `xml:"minutesAgo,attr" json:"minutesAgo"` + PlayerID int `xml:"playerId,attr" json:"playerId"` + ID int `xml:"id,attr" json:"id"` + Title string `xml:"title,attr" json:"title"` + IsDir bool `xml:"isDir,attr" json:"isDir"` + Album string `xml:"album,attr" json:"album"` + Artist string `xml:"artist,attr" json:"artist"` +} + type OpenSubsonicExtension struct { Name string `xml:"name,attr" json:"name"` Versions []int `xml:"versions" json:"versions"`