diff --git a/go.mod b/go.mod index f1d9402..aa7333f 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 + github.com/stretchr/testify v1.8.1 golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 golang.org/x/net v0.7.0 gopkg.in/gormigrate.v1 v1.6.0 @@ -39,6 +40,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/PuerkitoBio/goquery v1.8.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/swag v0.21.1 // indirect @@ -56,10 +58,12 @@ require ( github.com/mmcdole/goxpp v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/crypto v0.6.0 // indirect golang.org/x/image v0.5.0 // indirect golang.org/x/sys v0.5.0 // indirect golang.org/x/text v0.7.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7e8a670..d3ac2d6 100644 --- a/go.sum +++ b/go.sum @@ -142,9 +142,14 @@ github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDF 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -214,3 +219,4 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scrobble/listenbrainz/listenbrainz.go b/scrobble/listenbrainz/listenbrainz.go index b275091..0e5affb 100644 --- a/scrobble/listenbrainz/listenbrainz.go +++ b/scrobble/listenbrainz/listenbrainz.go @@ -27,33 +27,16 @@ var ( ErrListenBrainz = errors.New("listenbrainz error") ) -// https://listenbrainz.readthedocs.io/en/latest/users/json.html#submission-json -type Payload struct { - ListenedAt int `json:"listened_at,omitempty"` - TrackMetadata *TrackMetadata `json:"track_metadata"` +type Scrobbler struct { + httpClient *http.Client } -type AdditionalInfo struct { - TrackNumber int `json:"tracknumber,omitempty"` - TrackMBID string `json:"track_mbid,omitempty"` - RecordingMBID string `json:"recording_mbid,omitempty"` - TrackLength int `json:"track_length,omitempty"` +func NewScrobbler() *Scrobbler { + return &Scrobbler{ + httpClient: http.DefaultClient, + } } -type TrackMetadata struct { - AdditionalInfo *AdditionalInfo `json:"additional_info"` - ArtistName string `json:"artist_name,omitempty"` - TrackName string `json:"track_name,omitempty"` - ReleaseName string `json:"release_name,omitempty"` -} - -type Scrobble struct { - ListenType string `json:"listen_type,omitempty"` - Payload []*Payload `json:"payload"` -} - -type Scrobbler struct{} - func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, submission bool) error { if user.ListenBrainzURL == "" || user.ListenBrainzToken == "" { return nil @@ -96,7 +79,7 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su req, _ := http.NewRequest(http.MethodPost, submitURL, &payloadBuf) req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", authHeader) - resp, err := http.DefaultClient.Do(req) + resp, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("http post: %w", err) } @@ -104,7 +87,7 @@ func (s *Scrobbler) Scrobble(user *db.User, track *db.Track, stamp time.Time, su switch { case resp.StatusCode == http.StatusUnauthorized: - return fmt.Errorf("unathorized: %w", ErrListenBrainz) + return fmt.Errorf("unauthorized: %w", ErrListenBrainz) case resp.StatusCode >= 400: respBytes, _ := httputil.DumpResponse(resp, true) log.Printf("received bad listenbrainz response:\n%s", string(respBytes)) diff --git a/scrobble/listenbrainz/listenbrainz_test.go b/scrobble/listenbrainz/listenbrainz_test.go new file mode 100644 index 0000000..a375d82 --- /dev/null +++ b/scrobble/listenbrainz/listenbrainz_test.go @@ -0,0 +1,148 @@ +package listenbrainz + +import ( + "context" + "crypto/tls" + _ "embed" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.senan.xyz/gonic/db" +) + +func httpClientMock(handler http.Handler) (http.Client, func()) { + server := httptest.NewTLSServer(handler) + client := http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return net.Dial(network, server.Listener.Addr().String()) + }, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec + }, + }, + } + + return client, server.Close +} + +//go:embed testdata/submit_listens_response.json +var submitListensResponse string + +func TestScrobble(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + // arrange + client, close := httpClientMock(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(http.MethodPost, r.Method) + assert.Equal("/1/submit-listens", r.URL.Path) + assert.Equal("application/json", r.Header.Get("Content-Type")) + assert.Equal("Token token1", r.Header.Get("Authorization")) + bodyBytes, err := io.ReadAll(r.Body) + assert.NoError(err) + assert.JSONEq(submitListensResponse, string(bodyBytes)) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"accepted": 1}`)) + })) + defer close() + + scrobbler := Scrobbler{ + httpClient: &client, + } + + // act + err := scrobbler.Scrobble(&db.User{ + ListenBrainzURL: "https://listenbrainz.org", + ListenBrainzToken: "token1", + }, &db.Track{ + Album: &db.Album{ + TagTitle: "album", + }, + TagTitle: "title", + TagTrackArtist: "artist", + TagTrackNumber: 1, + }, time.Unix(1683804525, 0), true) + + // assert + assert.NoError(err) +} + +func TestScrobbleUnauthorized(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + // arrange + client, close := httpClientMock(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(http.MethodPost, r.Method) + assert.Equal("/1/submit-listens", r.URL.Path) + assert.Equal("application/json", r.Header.Get("Content-Type")) + assert.Equal("Token token1", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"code": 401, "error": "Invalid authorization token."}`)) + })) + defer close() + + scrobbler := Scrobbler{ + httpClient: &client, + } + + // act + err := scrobbler.Scrobble(&db.User{ + ListenBrainzURL: "https://listenbrainz.org", + ListenBrainzToken: "token1", + }, &db.Track{ + Album: &db.Album{ + TagTitle: "album", + }, + TagTitle: "title", + TagTrackArtist: "artist", + TagTrackNumber: 1, + }, time.Now(), true) + + // assert + assert.ErrorIs(err, ErrListenBrainz) +} + +func TestScrobbleServerError(t *testing.T) { + t.Parallel() + assert := assert.New(t) + + // arrange + client, close := httpClientMock(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(http.MethodPost, r.Method) + assert.Equal("/1/submit-listens", r.URL.Path) + assert.Equal("application/json", r.Header.Get("Content-Type")) + assert.Equal("Token token1", r.Header.Get("Authorization")) + + w.WriteHeader(http.StatusInternalServerError) + })) + defer close() + + scrobbler := Scrobbler{ + httpClient: &client, + } + + // act + err := scrobbler.Scrobble(&db.User{ + ListenBrainzURL: "https://listenbrainz.org", + ListenBrainzToken: "token1", + }, &db.Track{ + Album: &db.Album{ + TagTitle: "album", + }, + TagTitle: "title", + TagTrackArtist: "artist", + TagTrackNumber: 1, + }, time.Now(), true) + + // assert + assert.ErrorIs(err, ErrListenBrainz) +} diff --git a/scrobble/listenbrainz/model.go b/scrobble/listenbrainz/model.go new file mode 100644 index 0000000..dd7446b --- /dev/null +++ b/scrobble/listenbrainz/model.go @@ -0,0 +1,29 @@ +package listenbrainz + +// https://listenbrainz.readthedocs.io/en/latest/users/json.html#submission-json + +type ( + Payload struct { + ListenedAt int `json:"listened_at,omitempty"` + TrackMetadata *TrackMetadata `json:"track_metadata"` + } + + AdditionalInfo struct { + TrackNumber int `json:"tracknumber,omitempty"` + TrackMBID string `json:"track_mbid,omitempty"` + RecordingMBID string `json:"recording_mbid,omitempty"` + TrackLength int `json:"track_length,omitempty"` + } + + TrackMetadata struct { + AdditionalInfo *AdditionalInfo `json:"additional_info"` + ArtistName string `json:"artist_name,omitempty"` + TrackName string `json:"track_name,omitempty"` + ReleaseName string `json:"release_name,omitempty"` + } + + Scrobble struct { + ListenType string `json:"listen_type,omitempty"` + Payload []*Payload `json:"payload"` + } +) diff --git a/scrobble/listenbrainz/testdata/submit_listens_response.json b/scrobble/listenbrainz/testdata/submit_listens_response.json new file mode 100644 index 0000000..c863327 --- /dev/null +++ b/scrobble/listenbrainz/testdata/submit_listens_response.json @@ -0,0 +1,16 @@ +{ + "listen_type": "single", + "payload": [ + { + "listened_at": 1683804525, + "track_metadata": { + "additional_info": { + "tracknumber": 1 + }, + "artist_name": "artist", + "track_name": "title", + "release_name": "album" + } + } + ] +} \ No newline at end of file diff --git a/server/server.go b/server/server.go index 825947a..f71ca36 100644 --- a/server/server.go +++ b/server/server.go @@ -107,7 +107,7 @@ func New(opts Options) (*Server, error) { PodcastsPath: opts.PodcastPath, CacheAudioPath: opts.CacheAudioPath, CoverCachePath: opts.CoverCachePath, - Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, &listenbrainz.Scrobbler{}}, + Scrobblers: []scrobble.Scrobbler{&lastfm.Scrobbler{DB: opts.DB}, listenbrainz.NewScrobbler()}, Podcasts: podcast, Transcoder: cacheTranscoder, }