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" +) }} +
not currently listening with anyone
+ {{ end }} +