Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions server/ctrlsubsonic/ctrl.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"log"
"net/http"
"sync"
"time"

"go.senan.xyz/gonic/db"
Expand Down Expand Up @@ -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) {
Expand All @@ -93,6 +139,7 @@ func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPa
tagReader: tagReader,

resolveProxyPath: resolveProxyPath,
nowPlayingCache: NewNowPlayingCache(),
}

chain := handlerutil.Chain(
Expand Down Expand Up @@ -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)))
Expand Down
27 changes: 27 additions & 0 deletions server/ctrlsubsonic/handlers_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -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
}
61 changes: 61 additions & 0 deletions server/ctrlsubsonic/handlers_common_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
26 changes: 26 additions & 0 deletions server/ctrlsubsonic/handlers_raw.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions server/ctrlsubsonic/spec/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"`
Expand Down