Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add the ability to share scrobbles with other users #515

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ dist
*.swp
.tags*
*.test
.idea
7 changes: 5 additions & 2 deletions cmd/gonic/gonic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
73 changes: 73 additions & 0 deletions listenwith/listenwith.go
Original file line number Diff line number Diff line change
@@ -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()
}
68 changes: 68 additions & 0 deletions listenwith/listenwith_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
18 changes: 18 additions & 0 deletions listenwith/util.go
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 31 additions & 0 deletions server/ctrladmin/adminui/pages/home.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,37 @@
</div>
{{ 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"
) }}
<div class="grid grid-cols-[1fr_1fr_auto] gap-2 items-center justify-items-end">
<form class="contents" method="post" action="/admin/start_listen_with_do">
<select name="buddy">
{{ range $candidate := .ListeningCandidates }}<option value="{{ $candidate }}">{{ $candidate }}</option>{{ end }}
</select>
<input type="hidden" name="user" value="{{ .User.Name }}" />
<input type="submit" value="start">
</form>
</div>
<div class="grid grid-cols-[1fr_1fr_auto] gap-2 items-center justify-items-end">
{{ if .ListeningWith }}

<form class="contents" method="post" action="/admin/stop_listen_with_do">

<select name="buddy">
{{ range $buddy := .ListeningWith }} <option value="{{ $buddy }}">{{ $buddy }}</option>{{ end }}
</select>
<input type="submit" value="stop">
</form>

{{ else }}
<p class="font-bold text-red-400">not currently listening with anyone</p>
{{ end }}
</div>
{{ end }}

{{ component "block" (props .
"Icon" "lastfm"
"Name" "last.fm"
Expand Down
9 changes: 8 additions & 1 deletion server/ctrladmin/ctrl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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(),

Expand All @@ -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,
}

Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -284,6 +289,8 @@ type templateData struct {
IsScanning bool
TranscodePreferences []*db.TranscodePreference
TranscodeProfiles []string
ListeningCandidates []string
ListeningWith []string

CurrentLastFMAPIKey string
CurrentLastFMAPISecret string
Expand Down
39 changes: 39 additions & 0 deletions server/ctrladmin/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading