diff --git a/.gitignore b/.gitignore index ace40cd7..7b131e5c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dist *.swp .tags* *.test +.idea diff --git a/cmd/gonic/gonic.go b/cmd/gonic/gonic.go index 4548904c..8579d9c9 100644 --- a/cmd/gonic/gonic.go +++ b/cmd/gonic/gonic.go @@ -39,6 +39,7 @@ import ( "go.senan.xyz/gonic/jukebox" "go.senan.xyz/gonic/lastfm" "go.senan.xyz/gonic/listenbrainz" + "go.senan.xyz/gonic/listenwith" "go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/podcast" "go.senan.xyz/gonic/scanner" @@ -215,6 +216,8 @@ func main() { listenbrainzClient := listenbrainz.NewClient() lastfmClient := lastfm.NewClient(lastfmClientKeySecretFunc) + listenerGraph := listenwith.NewListenerGraph() + playlistStore, err := playlist.NewStore(*confPlaylistsPath) if err != nil { log.Panicf("error creating playlists store: %v", err) @@ -250,11 +253,11 @@ func main() { return url.String() } - ctrlAdmin, err := ctrladmin.New(dbc, sessDB, scannr, podcast, lastfmClient, resolveProxyPath) + ctrlAdmin, err := ctrladmin.New(dbc, sessDB, scannr, podcast, lastfmClient, listenerGraph, resolveProxyPath) if err != nil { log.Panicf("error creating admin controller: %v\n", err) } - ctrlSubsonic, err := ctrlsubsonic.New(dbc, scannr, musicPaths, *confPodcastPath, cacheDirAudio, cacheDirCovers, jukebx, playlistStore, scrobblers, podcast, transcoder, lastfmClient, artistInfoCache, albumInfoCache, resolveProxyPath) + ctrlSubsonic, err := ctrlsubsonic.New(dbc, scannr, musicPaths, *confPodcastPath, cacheDirAudio, cacheDirCovers, jukebx, playlistStore, scrobblers, podcast, transcoder, lastfmClient, listenerGraph, artistInfoCache, albumInfoCache, resolveProxyPath) if err != nil { log.Panicf("error creating subsonic controller: %v\n", err) } diff --git a/go.mod b/go.mod index d9f6ac2b..7cd9354c 100644 --- a/go.mod +++ b/go.mod @@ -23,22 +23,24 @@ require ( github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be - github.com/sentriz/audiotags v0.0.0-20240401192409-5a8ac2a2974f + github.com/sentriz/audiotags v0.0.0-20240509104732-713862dde2f1 github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 github.com/stretchr/testify v1.9.0 go.senan.xyz/flagconf v0.1.8 golang.org/x/net v0.24.0 golang.org/x/sync v0.7.0 - golang.org/x/sys v0.19.0 gopkg.in/gormigrate.v1 v1.6.0 jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 ) +require golang.org/x/sys v0.19.0 // indirect + require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/PuerkitoBio/goquery v1.9.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/gorilla/context v1.1.2 // indirect diff --git a/go.sum b/go.sum index 4f423af3..1baa94aa 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= @@ -128,8 +130,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/sentriz/audiotags v0.0.0-20240401192409-5a8ac2a2974f h1:Yio3vmGw+yf+gzjYLf1plSGEf/1IUTVY45n+qcGJEmk= -github.com/sentriz/audiotags v0.0.0-20240401192409-5a8ac2a2974f/go.mod h1:+pmkMFDEXJuu/u4h2OYJVfYF2qIhXJD7kqvWq6q5Zo0= +github.com/sentriz/audiotags v0.0.0-20240509104732-713862dde2f1 h1:D74XTsZkO7hRb9u/eLvvqIC3bJh582Nx6DL1BXM1k28= +github.com/sentriz/audiotags v0.0.0-20240509104732-713862dde2f1/go.mod h1:+pmkMFDEXJuu/u4h2OYJVfYF2qIhXJD7kqvWq6q5Zo0= github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 h1:sLILANWN76ja66/K4k/mBqJuCjDZaM67w+Ru6rEB0s0= github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981/go.mod h1:Rx8XB1ck+so+41uu9VY1gMKs1CPQ2NTq0pzf+OCCQHo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= diff --git a/listenwith/listenwith.go b/listenwith/listenwith.go new file mode 100644 index 00000000..92c88a9e --- /dev/null +++ b/listenwith/listenwith.go @@ -0,0 +1,73 @@ +package listenwith + +import ( + "sync" + + set "github.com/deckarep/golang-set/v2" + "go.senan.xyz/gonic/db" +) + +type ( + ListenerGroup struct { + buddies sync.Map + inverted sync.Map + } +) + +func NewListenerGraph() *ListenerGroup { + return &ListenerGroup{ + buddies: sync.Map{}, + inverted: sync.Map{}, + } +} + +func (lg *ListenerGroup) AddUser(u *db.User) { + if _, ok := lg.buddies.Load(u.Name); !ok { + lg.buddies.LoadOrStore(u.Name, set.NewSet[string]()) + } +} + +func (lg *ListenerGroup) AddListener(u, l *db.User) { + if _, ok := lg.buddies.Load(u.Name); !ok { + lg.AddUser(u) + } + s, _ := lg.buddies.Load(u.Name) + s.(set.Set[string]).Add(l.Name) + // add to inverted index + if _, ok := lg.inverted.Load(l.Name); !ok { + lg.inverted.LoadOrStore(l.Name, set.NewSet[string]()) + } + s, _ = lg.inverted.Load(l.Name) + s.(set.Set[string]).Add(u.Name) +} + +func (lg *ListenerGroup) RemoveListener(u, l *db.User) { + if _, ok := lg.buddies.Load(u.Name); !ok { + lg.AddUser(u) + } + + s, _ := lg.buddies.Load(u.Name) + s.(set.Set[string]).Remove(l.Name) + // remove from inverted index + if _, ok := lg.inverted.Load(l.Name); !ok { + lg.inverted.LoadOrStore(l.Name, set.NewSet[string]()) + } + s, _ = lg.inverted.Load(l.Name) + s.(set.Set[string]).Remove(u.Name) +} + +func (lg *ListenerGroup) GetListeners(u *db.User) []string { + if _, ok := lg.buddies.Load(u.Name); !ok { + lg.AddUser(u) + } + s, _ := lg.buddies.Load(u.Name) + return s.(set.Set[string]).ToSlice() +} + +func (lg *ListenerGroup) GetInverted(u *db.User) []string { + if _, ok := lg.inverted.Load(u.Name); !ok { + lg.inverted.LoadOrStore(u.Name, set.NewSet[string]()) + } + s, _ := lg.inverted.Load(u.Name) + return s.(set.Set[string]).ToSlice() +} diff --git a/listenwith/listenwith_test.go b/listenwith/listenwith_test.go new file mode 100644 index 00000000..a5d9c87d --- /dev/null +++ b/listenwith/listenwith_test.go @@ -0,0 +1,68 @@ +package listenwith + +import ( + "slices" + "sync" + "testing" + + "go.senan.xyz/gonic/db" +) + +// TestAddUser tests that multiple concurrent calls to add the same user don't +// result in unexpected behavior +func TestAddUser(t *testing.T) { + t.Parallel() + var ( + u = &db.User{Name: "gonic", ID: 0} + b = &db.User{Name: "buddy", ID: 1} + expected = []string{b.Name} + lg = NewListenerGraph() + wg sync.WaitGroup + n = 60 + ) + // We use a WaitGroup here to ensure that some combination of AddUser() and + // AddListener() run before GetListeners() runs. This may seem contrary to + // testing concurrency, but what we are really interested in is whether, + // given a concurrent initialization of a user and a request to add a + // listener to that user, the end result is the same regardless of the order + // these events execute in. We make no guarantees that a read of a user's + // listeners which is concurrent with the write of a user to that list will + // reflect that user being added. In practice the only thing this should + // affect is whether a scrobble which happens concurrently with a user + // registering themselves as a listener will be propagated to that user, + // which is a relatively minor risk. + wg.Add(n) + for i := 1; i <= n; i++ { + go func(i int) { + defer wg.Done() + if i%2 == 0 { + lg.AddUser(u) + } else { + lg.AddListener(u, b) + } + }(i) + } + wg.Wait() + if ss := lg.GetListeners(u); ss != nil && !slices.Equal(ss, expected) { + t.Fatalf("expected concurrent calls of AddUser() to be gracefully managed\nexpected: %s\nactual: %s", expected, lg.GetListeners(u)) + } +} + +// TestAddListener tests that adding a listener updates the Buddies list and the Inverted list +func TestAddListener(t *testing.T) { + t.Parallel() + var ( + u = db.User{Name: "gonic", ID: 0} + b = db.User{Name: "buddy", ID: 1} + expected = []string{b.Name} + inverted = []string{u.Name} + lg = NewListenerGraph() + ) + lg.AddListener(&u, &b) + if !slices.Equal(lg.GetListeners(&u), expected) { + t.Fatalf("expected AddListener() to add a listener\nexpected: %s\nactual: %s", expected, lg.GetListeners(&u)) + } + if !slices.Equal(lg.GetInverted(&b), inverted) { + t.Fatalf("expected AddListener() to update the inverted index\nexpected: %s\nactual: %s", inverted, lg.GetInverted(&b)) + } +} diff --git a/listenwith/util.go b/listenwith/util.go new file mode 100644 index 00000000..d13a5c6d --- /dev/null +++ b/listenwith/util.go @@ -0,0 +1,18 @@ +package listenwith + +import ( + "go.senan.xyz/gonic/db" +) + +// Return a list of usernames with the current user filtered out +func ListeningCandidates(allUsers []*db.User, me string) []string { + var r []string + + for _, u := range allUsers { + if u.Name != me { + r = append(r, u.Name) + } + } + + return r +} diff --git a/server/ctrladmin/adminui/pages/home.tmpl b/server/ctrladmin/adminui/pages/home.tmpl index 134f6075..36ce8951 100644 --- a/server/ctrladmin/adminui/pages/home.tmpl +++ b/server/ctrladmin/adminui/pages/home.tmpl @@ -103,6 +103,37 @@ {{ end }} +{{ component "block" (props . + "Icon" "users" + "Name" "listen with a friend" + "Desc" "if you're listening along while someone else on the server plays music, you can have those tracks scrobble to your account by turning on this feature, then turning it back off after you're done" +) }} +
+
+ + + +
+
+
+ {{ if .ListeningWith }} + +
+ + + +
+ + {{ else }} +

not currently listening with anyone

+ {{ end }} +
+{{ end }} + {{ component "block" (props . "Icon" "lastfm" "Name" "last.fm" diff --git a/server/ctrladmin/ctrl.go b/server/ctrladmin/ctrl.go index 063869f6..06c1baec 100644 --- a/server/ctrladmin/ctrl.go +++ b/server/ctrladmin/ctrl.go @@ -27,6 +27,7 @@ import ( "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/handlerutil" "go.senan.xyz/gonic/lastfm" + "go.senan.xyz/gonic/listenwith" "go.senan.xyz/gonic/podcast" "go.senan.xyz/gonic/scanner" "go.senan.xyz/gonic/server/ctrladmin/adminui" @@ -47,12 +48,13 @@ type Controller struct { scanner *scanner.Scanner podcasts *podcast.Podcasts lastfmClient *lastfm.Client + listenerGroup *listenwith.ListenerGroup resolveProxyPath ProxyPathResolver } type ProxyPathResolver func(in string) string -func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts *podcast.Podcasts, lastfmClient *lastfm.Client, resolveProxyPath ProxyPathResolver) (*Controller, error) { +func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts *podcast.Podcasts, lastfmClient *lastfm.Client, listenerGroup *listenwith.ListenerGroup, resolveProxyPath ProxyPathResolver) (*Controller, error) { c := Controller{ ServeMux: http.NewServeMux(), @@ -61,6 +63,7 @@ func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts scanner: scanner, podcasts: podcasts, lastfmClient: lastfmClient, + listenerGroup: listenerGroup, resolveProxyPath: resolveProxyPath, } @@ -100,6 +103,8 @@ func New(dbc *db.DB, sessDB *gormstore.Store, scanner *scanner.Scanner, podcasts c.Handle("/unlink_listenbrainz_do", userChain(resp(c.ServeUnlinkListenBrainzDo))) c.Handle("/create_transcode_pref_do", userChain(resp(c.ServeCreateTranscodePrefDo))) c.Handle("/delete_transcode_pref_do", userChain(resp(c.ServeDeleteTranscodePrefDo))) + c.Handle("/start_listen_with_do", userChain(resp(c.ServeStartListenWithDo))) + c.Handle("/stop_listen_with_do", userChain(resp(c.ServeStopListenWithDo))) // admin routes (if session is valid, and is admin) c.Handle("/create_user", adminChain(resp(c.ServeCreateUser))) @@ -284,6 +289,8 @@ type templateData struct { IsScanning bool TranscodePreferences []*db.TranscodePreference TranscodeProfiles []string + ListeningCandidates []string + ListeningWith []string CurrentLastFMAPIKey string CurrentLastFMAPISecret string diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index cb081983..f6000795 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -19,6 +19,7 @@ import ( "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/handlerutil" "go.senan.xyz/gonic/listenbrainz" + "go.senan.xyz/gonic/listenwith" "go.senan.xyz/gonic/scanner" "go.senan.xyz/gonic/transcode" ) @@ -69,6 +70,18 @@ func (c *Controller) ServeHome(r *http.Request) *Response { data.TranscodeProfiles = append(data.TranscodeProfiles, profile) } sort.Strings(data.TranscodeProfiles) + // listening with box + var allUsers []*db.User + a := c.dbc.DB + a.Find(&allUsers) + data.ListeningCandidates = listenwith.ListeningCandidates(allUsers, user.Name) + if c.listenerGroup.GetInverted(user) == nil { + data.ListeningWith = []string{} + } else { + data.ListeningWith = c.listenerGroup.GetInverted(user) + } + sort.Strings(data.ListeningCandidates) + sort.Strings(data.ListeningWith) // podcasts box c.dbc.Find(&data.Podcasts) @@ -580,6 +593,32 @@ func (c *Controller) ServeInternetRadioStationDeleteDo(r *http.Request) *Respons } } +func (c *Controller) ServeStartListenWithDo(r *http.Request) *Response { + user := r.Context().Value(CtxUser).(*db.User) + + buddyUser := c.dbc.GetUserByName(r.FormValue("buddy")) + log.Println("adding listener", user.Name, "to user", buddyUser.Name) + c.listenerGroup.AddListener(buddyUser, user) + log.Println("buddies list for", buddyUser.Name, "-", c.listenerGroup.GetListeners(buddyUser)) + + return &Response{ + redirect: "/admin/home", + } +} + +func (c *Controller) ServeStopListenWithDo(r *http.Request) *Response { + user := r.Context().Value(CtxUser).(*db.User) + + buddyUser := c.dbc.GetUserByName(r.FormValue("buddy")) + log.Println("removing listener", user.Name, "to user", buddyUser.Name) + c.listenerGroup.RemoveListener(buddyUser, user) + log.Println("buddies list for", buddyUser.Name, "-", c.listenerGroup.GetListeners(buddyUser)) + + return &Response{ + redirect: "/admin/home", + } +} + func getAvatarFile(r *http.Request) ([]byte, error) { err := r.ParseMultipartForm(10 << 20) // keep up to 10MB in memory if err != nil { diff --git a/server/ctrlsubsonic/ctrl.go b/server/ctrlsubsonic/ctrl.go index 12d0bab2..35fe4891 100644 --- a/server/ctrlsubsonic/ctrl.go +++ b/server/ctrlsubsonic/ctrl.go @@ -18,6 +18,7 @@ import ( "go.senan.xyz/gonic/infocache/artistinfocache" "go.senan.xyz/gonic/jukebox" "go.senan.xyz/gonic/lastfm" + "go.senan.xyz/gonic/listenwith" "go.senan.xyz/gonic/playlist" "go.senan.xyz/gonic/podcast" "go.senan.xyz/gonic/scanner" @@ -64,12 +65,13 @@ type Controller struct { podcasts *podcast.Podcasts transcoder transcode.Transcoder lastFMClient *lastfm.Client + listenerGroup *listenwith.ListenerGroup artistInfoCache *artistinfocache.ArtistInfoCache albumInfoCache *albuminfocache.AlbumInfoCache resolveProxyPath ProxyPathResolver } -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, resolveProxyPath ProxyPathResolver) (*Controller, error) { +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, listenerGroup *listenwith.ListenerGroup, artistInfoCache *artistinfocache.ArtistInfoCache, albumInfoCache *albuminfocache.AlbumInfoCache, resolveProxyPath ProxyPathResolver) (*Controller, error) { c := Controller{ ServeMux: http.NewServeMux(), @@ -85,6 +87,7 @@ func New(dbc *db.DB, scannr *scanner.Scanner, musicPaths []MusicPath, podcastsPa podcasts: podcasts, transcoder: transcoder, lastFMClient: lastFMClient, + listenerGroup: listenerGroup, artistInfoCache: artistInfoCache, albumInfoCache: albumInfoCache, resolveProxyPath: resolveProxyPath, diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 90ef5df6..94e23db4 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -117,6 +117,26 @@ func (c *Controller) ServeScrobble(r *http.Request) *spec.Response { scrobbleErrs[i] = err } }(i) + // If current user has any buddies, try and scrobble them too + if c.listenerGroup.GetListeners(user) == nil { + c.listenerGroup.AddUser(user) + } + if len(c.listenerGroup.GetListeners(user)) != 0 { + wg.Add(1) + go func(i int) { + defer wg.Done() + for _, b := range c.listenerGroup.GetListeners(user) { + bu := c.dbc.GetUserByName(b) + if bu == nil { // Attempt to get user by name failed + continue + } + err = c.scrobblers[i].Scrobble(*bu, scrobbleTrack, optStamp, optSubmission) + if err != nil { + log.Printf("error submitting for buddy \"%s\": %v", b, err) + } + } + }(i) + } } wg.Wait()