mirror of
https://git.sr.ht/~phw/scotty
synced 2025-04-29 21:27:05 +02:00
Restructured code, moved all modules into internal
For now all modules are considered internal. This might change later
This commit is contained in:
parent
f94e0f1e85
commit
857661ebf9
76 changed files with 121 additions and 68 deletions
internal/backends
auth.goauth_test.gobackends.gobackends_test.go
deezer
dump
funkwhale
jspf
listenbrainz
maloja
process.goscrobblerlog
spotify
subsonic
tokensource.go
54
internal/backends/auth.go
Normal file
54
internal/backends/auth.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package backends
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"go.uploadedlobster.com/scotty/internal/storage"
|
||||
)
|
||||
|
||||
func BuildRedirectURL(config *viper.Viper, backend string) (*url.URL, error) {
|
||||
callbackHost, _ := strings.CutSuffix(config.GetString("oauth-host"), "/")
|
||||
if callbackHost == "" {
|
||||
callbackHost = "127.0.0.1:2369"
|
||||
}
|
||||
callbackPath := "/callback/" + backend
|
||||
return url.Parse("http://" + callbackHost + callbackPath)
|
||||
}
|
||||
|
||||
func Authenticate(service string, backend models.Backend, db storage.Database, config *viper.Viper) (bool, error) {
|
||||
authenticator, auth := backend.(models.OAuth2Authenticator)
|
||||
if auth {
|
||||
redirectURL, err := BuildRedirectURL(config, backend.Name())
|
||||
if err != nil {
|
||||
return auth, err
|
||||
}
|
||||
token, err := db.GetOAuth2Token(service)
|
||||
if err != nil {
|
||||
return auth, err
|
||||
}
|
||||
conf := authenticator.OAuth2Strategy(redirectURL).Config()
|
||||
tokenSource := NewDatabaseTokenSource(db, service, &conf, token)
|
||||
authenticator.OAuth2Setup(tokenSource)
|
||||
}
|
||||
return auth, nil
|
||||
}
|
44
internal/backends/auth_test.go
Normal file
44
internal/backends/auth_test.go
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package backends_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends"
|
||||
)
|
||||
|
||||
func TestBuildRedirectURL(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("oauth-host", "localhost:1234/")
|
||||
url, err := backends.BuildRedirectURL(config, "foo")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "localhost:1234", url.Host)
|
||||
assert.Equal(t, "/callback/foo", url.Path)
|
||||
}
|
||||
|
||||
func TestBuildRedirectURLDefaultHost(t *testing.T) {
|
||||
config := viper.New()
|
||||
url, err := backends.BuildRedirectURL(config, "foo")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "127.0.0.1:2369", url.Host)
|
||||
assert.Equal(t, "/callback/foo", url.Path)
|
||||
}
|
145
internal/backends/backends.go
Normal file
145
internal/backends/backends.go
Normal file
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package backends
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/deezer"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/dump"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/funkwhale"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/jspf"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/maloja"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
type BackendInfo struct {
|
||||
Name string
|
||||
ExportCapabilities []Capability
|
||||
ImportCapabilities []Capability
|
||||
}
|
||||
|
||||
type Capability = string
|
||||
|
||||
func ResolveBackend[T interface{}](config *viper.Viper) (T, error) {
|
||||
backendName, backend, err := resolveBackend(config)
|
||||
var result T
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
implements, interfaceName := ImplementsInterface[T](&backend)
|
||||
if implements {
|
||||
result = backend.(T)
|
||||
} else {
|
||||
err = errors.New(
|
||||
fmt.Sprintf("Backend %s does not implement %s", backendName, interfaceName))
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func GetBackends() []BackendInfo {
|
||||
backends := make([]BackendInfo, 0)
|
||||
for name, backendFunc := range knownBackends {
|
||||
backend := backendFunc()
|
||||
info := BackendInfo{
|
||||
Name: name,
|
||||
ExportCapabilities: getExportCapabilities(backend),
|
||||
ImportCapabilities: getImportCapabilities(backend),
|
||||
}
|
||||
backends = append(backends, info)
|
||||
}
|
||||
|
||||
return backends
|
||||
}
|
||||
|
||||
var knownBackends = map[string]func() models.Backend{
|
||||
"deezer": func() models.Backend { return &deezer.DeezerApiBackend{} },
|
||||
"dump": func() models.Backend { return &dump.DumpBackend{} },
|
||||
"funkwhale": func() models.Backend { return &funkwhale.FunkwhaleApiBackend{} },
|
||||
"jspf": func() models.Backend { return &jspf.JSPFBackend{} },
|
||||
"listenbrainz": func() models.Backend { return &listenbrainz.ListenBrainzApiBackend{} },
|
||||
"maloja": func() models.Backend { return &maloja.MalojaApiBackend{} },
|
||||
"scrobbler-log": func() models.Backend { return &scrobblerlog.ScrobblerLogBackend{} },
|
||||
"spotify": func() models.Backend { return &spotify.SpotifyApiBackend{} },
|
||||
"subsonic": func() models.Backend { return &subsonic.SubsonicApiBackend{} },
|
||||
}
|
||||
|
||||
func resolveBackend(config *viper.Viper) (string, models.Backend, error) {
|
||||
backendName := config.GetString("backend")
|
||||
backendType := knownBackends[backendName]
|
||||
if backendType == nil {
|
||||
return backendName, nil, fmt.Errorf("Unknown backend %s", backendName)
|
||||
}
|
||||
return backendName, backendType().FromConfig(config), nil
|
||||
}
|
||||
|
||||
func ImplementsInterface[T interface{}](backend *models.Backend) (bool, string) {
|
||||
expectedInterface := reflect.TypeOf((*T)(nil)).Elem()
|
||||
implements := backend != nil && reflect.TypeOf(*backend).Implements(expectedInterface)
|
||||
return implements, expectedInterface.Name()
|
||||
}
|
||||
|
||||
func getExportCapabilities(backend models.Backend) []string {
|
||||
caps := make([]Capability, 0)
|
||||
var name string
|
||||
var found bool
|
||||
name, found = checkCapability[models.ListensExport](backend, "export")
|
||||
if found {
|
||||
caps = append(caps, name)
|
||||
}
|
||||
name, found = checkCapability[models.LovesExport](backend, "export")
|
||||
if found {
|
||||
caps = append(caps, name)
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
func getImportCapabilities(backend models.Backend) []Capability {
|
||||
caps := make([]Capability, 0)
|
||||
var name string
|
||||
var found bool
|
||||
name, found = checkCapability[models.ListensImport](backend, "import")
|
||||
if found {
|
||||
caps = append(caps, name)
|
||||
}
|
||||
name, found = checkCapability[models.LovesImport](backend, "import")
|
||||
if found {
|
||||
caps = append(caps, name)
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
func checkCapability[T interface{}](backend models.Backend, suffix string) (string, bool) {
|
||||
implements, name := ImplementsInterface[T](&backend)
|
||||
if implements {
|
||||
cap, found := strings.CutSuffix(strings.ToLower(name), suffix)
|
||||
if found {
|
||||
return cap, found
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
109
internal/backends/backends_test.go
Normal file
109
internal/backends/backends_test.go
Normal file
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package backends_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uploadedlobster.com/scotty/internal/backends"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/dump"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/funkwhale"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/jspf"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/maloja"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
func TestResolveBackend(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("backend", "dump")
|
||||
backend, err := backends.ResolveBackend[models.ListensImport](config)
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, &dump.DumpBackend{}, backend)
|
||||
}
|
||||
|
||||
func TestResolveBackendUnknown(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("backend", "foo")
|
||||
_, err := backends.ResolveBackend[models.ListensImport](config)
|
||||
assert.EqualError(t, err, "Unknown backend foo")
|
||||
}
|
||||
|
||||
func TestResolveBackendInvalidInterface(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("backend", "dump")
|
||||
_, err := backends.ResolveBackend[models.ListensExport](config)
|
||||
assert.EqualError(t, err, "Backend dump does not implement ListensExport")
|
||||
}
|
||||
|
||||
func TestGetBackends(t *testing.T) {
|
||||
backends := backends.GetBackends()
|
||||
for _, info := range backends {
|
||||
if info.Name == "dump" {
|
||||
assert.Containsf(t, info.ImportCapabilities, "listens",
|
||||
"ImportCapabilities for \"dump\" expected to contain \"listens\"")
|
||||
assert.Emptyf(t, info.ExportCapabilities,
|
||||
"ExportCapabilities for \"dump\" expected to be empty")
|
||||
return // Finish the test
|
||||
}
|
||||
}
|
||||
|
||||
// If we got here the "dump" backend was not included
|
||||
t.Errorf("GetBackends() did not return expected bacend \"dump\"")
|
||||
}
|
||||
|
||||
func TestImplementsInterfaces(t *testing.T) {
|
||||
expectInterface[models.ListensImport](t, &dump.DumpBackend{})
|
||||
expectInterface[models.LovesImport](t, &dump.DumpBackend{})
|
||||
|
||||
expectInterface[models.ListensExport](t, &funkwhale.FunkwhaleApiBackend{})
|
||||
// expectInterface[models.ListensImport](t, &funkwhale.FunkwhaleApiBackend{})
|
||||
expectInterface[models.LovesExport](t, &funkwhale.FunkwhaleApiBackend{})
|
||||
// expectInterface[models.LovesImport](t, &funkwhale.FunkwhaleApiBackend{})
|
||||
|
||||
// expectInterface[models.ListensExport](t, &jspf.JSPFBackend{})
|
||||
// expectInterface[models.ListensImport](t, &jspf.JSPFBackend{})
|
||||
// expectInterface[models.LovesExport](t, &jspf.JSPFBackend{})
|
||||
expectInterface[models.LovesImport](t, &jspf.JSPFBackend{})
|
||||
|
||||
expectInterface[models.ListensExport](t, &listenbrainz.ListenBrainzApiBackend{})
|
||||
// expectInterface[models.ListensImport](t, &listenbrainz.ListenBrainzApiBackend{})
|
||||
expectInterface[models.LovesExport](t, &listenbrainz.ListenBrainzApiBackend{})
|
||||
expectInterface[models.LovesImport](t, &listenbrainz.ListenBrainzApiBackend{})
|
||||
|
||||
expectInterface[models.ListensExport](t, &maloja.MalojaApiBackend{})
|
||||
expectInterface[models.ListensImport](t, &maloja.MalojaApiBackend{})
|
||||
|
||||
expectInterface[models.ListensExport](t, &scrobblerlog.ScrobblerLogBackend{})
|
||||
expectInterface[models.ListensImport](t, &scrobblerlog.ScrobblerLogBackend{})
|
||||
|
||||
expectInterface[models.LovesExport](t, &subsonic.SubsonicApiBackend{})
|
||||
// expectInterface[models.LovesImport](t, &subsonic.SubsonicApiBackend{})
|
||||
}
|
||||
|
||||
func expectInterface[T interface{}](t *testing.T, backend models.Backend) {
|
||||
ok, name := backends.ImplementsInterface[T](&backend)
|
||||
if !ok {
|
||||
t.Errorf("%v expected to implement %v", reflect.TypeOf(backend).Name(), name)
|
||||
}
|
||||
}
|
83
internal/backends/deezer/auth.go
Normal file
83
internal/backends/deezer/auth.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uploadedlobster.com/scotty/internal/auth"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type deezerStrategy struct {
|
||||
conf oauth2.Config
|
||||
}
|
||||
|
||||
func (s deezerStrategy) Config() oauth2.Config {
|
||||
return s.conf
|
||||
}
|
||||
|
||||
func (s deezerStrategy) AuthCodeURL(verifier string, state string) string {
|
||||
return s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
||||
}
|
||||
|
||||
func (s deezerStrategy) ExchangeToken(code auth.CodeResponse, verifier string) (*oauth2.Token, error) {
|
||||
// Deezer has a non-standard token exchange, expecting all parameters in the URL's query
|
||||
req, err := http.NewRequest(http.MethodGet, s.conf.Endpoint.TokenURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Add("app_id", s.conf.ClientID)
|
||||
q.Add("secret", s.conf.ClientSecret)
|
||||
q.Add("code", code.Code)
|
||||
q.Add("output", "json")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := deezerToken{}
|
||||
if err = json.Unmarshal(reqBody, &token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token.Token(), nil
|
||||
}
|
||||
|
||||
type deezerToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int64 `json:"expires"`
|
||||
}
|
||||
|
||||
func (t deezerToken) Token() *oauth2.Token {
|
||||
token := &oauth2.Token{AccessToken: t.AccessToken}
|
||||
if t.ExpiresIn > 0 {
|
||||
token.Expiry = time.Now().Add(time.Duration(t.ExpiresIn * time.Second.Nanoseconds()))
|
||||
}
|
||||
return token
|
||||
}
|
89
internal/backends/deezer/client.go
Normal file
89
internal/backends/deezer/client.go
Normal file
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const baseURL = "https://api.deezer.com/"
|
||||
const MaxItemsPerGet = 1000
|
||||
const DefaultRateLimitWaitSeconds = 5
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
token oauth2.TokenSource
|
||||
}
|
||||
|
||||
func NewClient(token oauth2.TokenSource) Client {
|
||||
client := resty.New()
|
||||
client.SetBaseURL(baseURL)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
client.SetRetryCount(5)
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) UserHistory(offset int, limit int) (result HistoryResult, err error) {
|
||||
const path = "/user/me/history"
|
||||
return listRequest[HistoryResult](c, path, offset, limit)
|
||||
}
|
||||
|
||||
func (c Client) UserTracks(offset int, limit int) (TracksResult, error) {
|
||||
const path = "/user/me/tracks"
|
||||
return listRequest[TracksResult](c, path, offset, limit)
|
||||
}
|
||||
|
||||
func (c Client) setToken(req *resty.Request) error {
|
||||
tok, err := c.token.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.SetQueryParam("access_token", tok.AccessToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) {
|
||||
request := c.HttpClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"index": strconv.Itoa(offset),
|
||||
"limit": strconv.Itoa(limit),
|
||||
}).
|
||||
SetResult(&result)
|
||||
c.setToken(request)
|
||||
response, err := request.Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(response.String())
|
||||
} else if result.Error() != nil {
|
||||
err = errors.New(result.Error().Message)
|
||||
}
|
||||
return
|
||||
}
|
92
internal/backends/deezer/client_test.go
Normal file
92
internal/backends/deezer/client_test.go
Normal file
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package deezer_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/deezer"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||
client := deezer.NewClient(token)
|
||||
assert.IsType(t, deezer.Client{}, client)
|
||||
}
|
||||
|
||||
func TestGetUserHistory(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||
client := deezer.NewClient(token)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://api.deezer.com/user/me/history",
|
||||
"testdata/user-history.json")
|
||||
|
||||
result, err := client.UserHistory(0, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(12, result.Total)
|
||||
require.Len(t, result.Tracks, 2)
|
||||
track1 := result.Tracks[0]
|
||||
assert.Equal(int64(1700753817), track1.Timestamp)
|
||||
assert.Equal("New Divide", track1.Track.Title)
|
||||
assert.Equal("Linkin Park", track1.Track.Artist.Name)
|
||||
}
|
||||
|
||||
func TestGetUserTracks(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||
client := deezer.NewClient(token)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://api.deezer.com/user/me/tracks",
|
||||
"testdata/user-tracks.json")
|
||||
|
||||
result, err := client.UserTracks(0, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(4, result.Total)
|
||||
require.Len(t, result.Tracks, 2)
|
||||
track1 := result.Tracks[0]
|
||||
assert.Equal(int64(1700743848), track1.AddedAt)
|
||||
assert.Equal("Never Take Me Alive", track1.Track.Title)
|
||||
assert.Equal("Outland", track1.Track.Album.Title)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpmock.RegisterResponder("GET", url, responder)
|
||||
}
|
236
internal/backends/deezer/deezer.go
Normal file
236
internal/backends/deezer/deezer.go
Normal file
|
@ -0,0 +1,236 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/auth"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type DeezerApiBackend struct {
|
||||
client Client
|
||||
clientId string
|
||||
clientSecret string
|
||||
}
|
||||
|
||||
func (b *DeezerApiBackend) Name() string { return "deezer" }
|
||||
|
||||
func (b *DeezerApiBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
b.clientId = config.GetString("client-id")
|
||||
b.clientSecret = config.GetString("client-secret")
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *DeezerApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
||||
conf := oauth2.Config{
|
||||
ClientID: b.clientId,
|
||||
ClientSecret: b.clientSecret,
|
||||
Scopes: []string{
|
||||
"offline_access,basic_access,listening_history",
|
||||
},
|
||||
RedirectURL: redirectUrl.String(),
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://connect.deezer.com/oauth/auth.php",
|
||||
TokenURL: "https://connect.deezer.com/oauth/access_token.php",
|
||||
},
|
||||
}
|
||||
|
||||
return deezerStrategy{conf: conf}
|
||||
}
|
||||
|
||||
func (b *DeezerApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
|
||||
b.client = NewClient(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DeezerApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||
// Choose a high offset, we attempt to search the loves backwards starting
|
||||
// at the oldest one.
|
||||
offset := math.MaxInt32
|
||||
perPage := MaxItemsPerGet
|
||||
|
||||
defer close(results)
|
||||
|
||||
p := models.Progress{Total: int64(perPage)}
|
||||
var totalCount int
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.UserHistory(offset, perPage)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
// The offset was higher then the actual number of tracks. Adjust the offset
|
||||
// and continue.
|
||||
if offset >= result.Total {
|
||||
p.Total = int64(result.Total)
|
||||
totalCount = result.Total
|
||||
offset = result.Total - perPage
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
count := len(result.Tracks)
|
||||
if count == 0 {
|
||||
break out
|
||||
}
|
||||
|
||||
listens := make(models.ListensList, 0, perPage)
|
||||
for _, track := range result.Tracks {
|
||||
listen := track.AsListen()
|
||||
if listen.ListenedAt.Unix() > oldestTimestamp.Unix() {
|
||||
listens = append(listens, listen)
|
||||
} else {
|
||||
totalCount -= 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(listens)
|
||||
results <- models.ListensResult{Listens: listens, Total: totalCount}
|
||||
p.Elapsed += int64(count)
|
||||
progress <- p
|
||||
|
||||
if offset <= 0 {
|
||||
// This was the last request, no further results
|
||||
break out
|
||||
}
|
||||
|
||||
offset -= perPage
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
progress <- p.Complete()
|
||||
}
|
||||
|
||||
func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||
// Choose a high offset, we attempt to search the loves backwards starting
|
||||
// at the oldest one.
|
||||
offset := math.MaxInt32
|
||||
perPage := MaxItemsPerGet
|
||||
|
||||
defer close(results)
|
||||
|
||||
p := models.Progress{Total: int64(perPage)}
|
||||
var totalCount int
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.UserTracks(offset, perPage)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
// The offset was higher then the actual number of tracks. Adjust the offset
|
||||
// and continue.
|
||||
if offset >= result.Total {
|
||||
p.Total = int64(result.Total)
|
||||
totalCount = result.Total
|
||||
offset = result.Total - perPage
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
count := len(result.Tracks)
|
||||
if count == 0 {
|
||||
break out
|
||||
}
|
||||
|
||||
loves := make(models.LovesList, 0, perPage)
|
||||
for _, track := range result.Tracks {
|
||||
love := track.AsLove()
|
||||
if love.Created.Unix() > oldestTimestamp.Unix() {
|
||||
loves = append(loves, love)
|
||||
} else {
|
||||
totalCount -= 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(loves)
|
||||
results <- models.LovesResult{Loves: loves, Total: totalCount}
|
||||
p.Elapsed += int64(count)
|
||||
progress <- p
|
||||
|
||||
if offset <= 0 {
|
||||
// This was the last request, no further results
|
||||
break out
|
||||
}
|
||||
|
||||
offset -= perPage
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
progress <- p.Complete()
|
||||
}
|
||||
|
||||
func (t Listen) AsListen() models.Listen {
|
||||
love := models.Listen{
|
||||
ListenedAt: time.Unix(t.Timestamp, 0),
|
||||
Track: t.Track.AsTrack(),
|
||||
}
|
||||
|
||||
return love
|
||||
}
|
||||
|
||||
func (t LovedTrack) AsLove() models.Love {
|
||||
love := models.Love{
|
||||
Created: time.Unix(t.AddedAt, 0),
|
||||
Track: t.Track.AsTrack(),
|
||||
}
|
||||
|
||||
return love
|
||||
}
|
||||
|
||||
func (t Track) AsTrack() models.Track {
|
||||
track := models.Track{
|
||||
TrackName: t.Title,
|
||||
ReleaseName: t.Album.Title,
|
||||
ArtistNames: []string{t.Artist.Name},
|
||||
Duration: time.Duration(t.Duration * int(time.Second)),
|
||||
AdditionalInfo: map[string]any{},
|
||||
}
|
||||
|
||||
info := track.AdditionalInfo
|
||||
info["music_service"] = "deezer.com"
|
||||
info["origin_url"] = t.Link
|
||||
info["deezer_id"] = t.Link
|
||||
info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/track/%v", t.Album.Id)
|
||||
info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/track/%v", t.Artist.Id)
|
||||
|
||||
return track
|
||||
}
|
70
internal/backends/deezer/deezer_test.go
Normal file
70
internal/backends/deezer/deezer_test.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package deezer_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/deezer"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("client-id", "someclientid")
|
||||
config.Set("client-secret", "someclientsecret")
|
||||
backend := (&deezer.DeezerApiBackend{}).FromConfig(config)
|
||||
assert.IsType(t, &deezer.DeezerApiBackend{}, backend)
|
||||
}
|
||||
|
||||
func TestListenAsListen(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/listen.json")
|
||||
require.NoError(t, err)
|
||||
track := deezer.Listen{}
|
||||
err = json.Unmarshal(data, &track)
|
||||
require.NoError(t, err)
|
||||
listen := track.AsListen()
|
||||
assert.Equal(t, time.Unix(1700753817, 0), listen.ListenedAt)
|
||||
assert.Equal(t, time.Duration(268*time.Second), listen.Duration)
|
||||
assert.Equal(t, "New Divide", listen.TrackName)
|
||||
assert.Equal(t, "New Divide (Int'l DMD Maxi)", listen.ReleaseName)
|
||||
assert.Equal(t, "Linkin Park", listen.ArtistName())
|
||||
assert.Equal(t, "deezer.com", listen.AdditionalInfo["music_service"])
|
||||
assert.Equal(t, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["origin_url"])
|
||||
assert.Equal(t, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["deezer_id"])
|
||||
}
|
||||
|
||||
func TestLovedTrackAsLove(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/track.json")
|
||||
require.NoError(t, err)
|
||||
track := deezer.LovedTrack{}
|
||||
err = json.Unmarshal(data, &track)
|
||||
require.NoError(t, err)
|
||||
love := track.AsLove()
|
||||
assert.Equal(t, time.Unix(1700743848, 0), love.Created)
|
||||
assert.Equal(t, time.Duration(255*time.Second), love.Duration)
|
||||
assert.Equal(t, "Never Take Me Alive", love.TrackName)
|
||||
assert.Equal(t, "Outland", love.ReleaseName)
|
||||
assert.Equal(t, "Spear Of Destiny", love.ArtistName())
|
||||
assert.Equal(t, "deezer.com", love.AdditionalInfo["music_service"])
|
||||
assert.Equal(t, "https://www.deezer.com/track/3265090", love.AdditionalInfo["origin_url"])
|
||||
assert.Equal(t, "https://www.deezer.com/track/3265090", love.AdditionalInfo["deezer_id"])
|
||||
}
|
90
internal/backends/deezer/models.go
Normal file
90
internal/backends/deezer/models.go
Normal file
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package deezer
|
||||
|
||||
type Result interface {
|
||||
Error() *Error
|
||||
}
|
||||
|
||||
type BaseResult struct {
|
||||
Err *Error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r BaseResult) Error() *Error {
|
||||
return r.Err
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
// {"error":{"type":"OAuthException","message":"Invalid OAuth access token.","code":300}}
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type TracksResult struct {
|
||||
BaseResult
|
||||
Next string `json:"next"`
|
||||
Previous string `json:"prev"`
|
||||
Total int `json:"total"`
|
||||
Tracks []LovedTrack `json:"data"`
|
||||
}
|
||||
|
||||
type HistoryResult struct {
|
||||
BaseResult
|
||||
Next string `json:"next"`
|
||||
Previous string `json:"prev"`
|
||||
Total int `json:"total"`
|
||||
Tracks []Listen `json:"data"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Id int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
TitleVersion string `json:"title_version"`
|
||||
Duration int `json:"duration"`
|
||||
Rank int `json:"rank"`
|
||||
Readable bool `json:"readable"`
|
||||
Explicit bool `json:"explicit_lyrics"`
|
||||
Album Album `json:"album"`
|
||||
Artist Artist `json:"artist"`
|
||||
}
|
||||
|
||||
type Listen struct {
|
||||
Track
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type LovedTrack struct {
|
||||
Track
|
||||
AddedAt int64 `json:"time_add"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
Id int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
TrackList string `json:"tracklist"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Id int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Link string `json:"link"`
|
||||
Name string `json:"name"`
|
||||
}
|
65
internal/backends/deezer/models_test.go
Normal file
65
internal/backends/deezer/models_test.go
Normal file
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package deezer_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/deezer"
|
||||
)
|
||||
|
||||
func TestUserTracksResult(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/user-tracks.json")
|
||||
require.NoError(t, err)
|
||||
result := deezer.TracksResult{}
|
||||
err = json.Unmarshal(data, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(4, result.Total)
|
||||
assert.Equal("https://api.deezer.com/user/me/tracks?limit=2&index=2",
|
||||
result.Next)
|
||||
require.Len(t, result.Tracks, 2)
|
||||
track1 := result.Tracks[0]
|
||||
assert.Equal(int64(1700743848), track1.AddedAt)
|
||||
assert.Equal("Never Take Me Alive", track1.Title)
|
||||
assert.Equal("Outland", track1.Album.Title)
|
||||
assert.Equal("Spear Of Destiny", track1.Artist.Name)
|
||||
}
|
||||
|
||||
func TestUserHistoryResult(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/user-history.json")
|
||||
require.NoError(t, err)
|
||||
result := deezer.HistoryResult{}
|
||||
err = json.Unmarshal(data, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(12, result.Total)
|
||||
assert.Equal("https://api.deezer.com/user/me/history?limit=2&index=2",
|
||||
result.Next)
|
||||
require.Len(t, result.Tracks, 2)
|
||||
track1 := result.Tracks[0]
|
||||
assert.Equal(int64(1700753817), track1.Timestamp)
|
||||
assert.Equal("New Divide", track1.Title)
|
||||
assert.Equal("https://www.deezer.com/album/1346960", track1.Album.Link)
|
||||
assert.Equal("Linkin Park", track1.Artist.Name)
|
||||
assert.Equal("https://www.deezer.com/artist/92", track1.Artist.Link)
|
||||
}
|
37
internal/backends/deezer/testdata/listen.json
vendored
Normal file
37
internal/backends/deezer/testdata/listen.json
vendored
Normal file
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"id": 14631511,
|
||||
"readable": true,
|
||||
"title": "New Divide",
|
||||
"title_short": "New Divide",
|
||||
"title_version": "",
|
||||
"link": "https:\/\/www.deezer.com\/track\/14631511",
|
||||
"duration": 268,
|
||||
"rank": 530579,
|
||||
"explicit_lyrics": false,
|
||||
"explicit_content_lyrics": 6,
|
||||
"explicit_content_cover": 0,
|
||||
"preview": "https:\/\/cdns-preview-7.dzcdn.net\/stream\/c-7174e274381557670dda8d4851f4c854-6.mp3",
|
||||
"md5_image": "cb21d305beb247a8f7c79998a96779d4",
|
||||
"timestamp": 1700753817,
|
||||
"artist": {
|
||||
"id": 92,
|
||||
"name": "Linkin Park",
|
||||
"link": "https:\/\/www.deezer.com\/artist\/92",
|
||||
"tracklist": "https:\/\/api.deezer.com\/artist\/92\/top?limit=50",
|
||||
"type": "artist"
|
||||
},
|
||||
"album": {
|
||||
"id": 1346960,
|
||||
"title": "New Divide (Int'l DMD Maxi)",
|
||||
"link": "https:\/\/www.deezer.com\/album\/1346960",
|
||||
"cover": "https:\/\/api.deezer.com\/album\/1346960\/image",
|
||||
"cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/56x56-000000-80-0-0.jpg",
|
||||
"cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/250x250-000000-80-0-0.jpg",
|
||||
"cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/500x500-000000-80-0-0.jpg",
|
||||
"cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/1000x1000-000000-80-0-0.jpg",
|
||||
"md5_image": "cb21d305beb247a8f7c79998a96779d4",
|
||||
"tracklist": "https:\/\/api.deezer.com\/album\/1346960\/tracks",
|
||||
"type": "album"
|
||||
},
|
||||
"type": "track"
|
||||
}
|
37
internal/backends/deezer/testdata/track.json
vendored
Normal file
37
internal/backends/deezer/testdata/track.json
vendored
Normal file
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"id": 3265090,
|
||||
"readable": true,
|
||||
"title": "Never Take Me Alive",
|
||||
"link": "https:\/\/www.deezer.com\/track\/3265090",
|
||||
"duration": 255,
|
||||
"rank": 72294,
|
||||
"explicit_lyrics": false,
|
||||
"explicit_content_lyrics": 0,
|
||||
"explicit_content_cover": 0,
|
||||
"md5_image": "193e4db0eb58117978059acbffe79e93",
|
||||
"time_add": 1700743848,
|
||||
"album": {
|
||||
"id": 311576,
|
||||
"title": "Outland",
|
||||
"cover": "https:\/\/api.deezer.com\/album\/311576\/image",
|
||||
"cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/56x56-000000-80-0-0.jpg",
|
||||
"cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/250x250-000000-80-0-0.jpg",
|
||||
"cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/500x500-000000-80-0-0.jpg",
|
||||
"cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/1000x1000-000000-80-0-0.jpg",
|
||||
"md5_image": "193e4db0eb58117978059acbffe79e93",
|
||||
"tracklist": "https:\/\/api.deezer.com\/album\/311576\/tracks",
|
||||
"type": "album"
|
||||
},
|
||||
"artist": {
|
||||
"id": 94057,
|
||||
"name": "Spear Of Destiny",
|
||||
"picture": "https:\/\/api.deezer.com\/artist\/94057\/image",
|
||||
"picture_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/56x56-000000-80-0-0.jpg",
|
||||
"picture_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/250x250-000000-80-0-0.jpg",
|
||||
"picture_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/500x500-000000-80-0-0.jpg",
|
||||
"picture_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/1000x1000-000000-80-0-0.jpg",
|
||||
"tracklist": "https:\/\/api.deezer.com\/artist\/94057\/top?limit=50",
|
||||
"type": "artist"
|
||||
},
|
||||
"type": "track"
|
||||
}
|
80
internal/backends/deezer/testdata/user-history.json
vendored
Normal file
80
internal/backends/deezer/testdata/user-history.json
vendored
Normal file
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 14631511,
|
||||
"readable": true,
|
||||
"title": "New Divide",
|
||||
"title_short": "New Divide",
|
||||
"title_version": "",
|
||||
"link": "https:\/\/www.deezer.com\/track\/14631511",
|
||||
"duration": 268,
|
||||
"rank": 530579,
|
||||
"explicit_lyrics": false,
|
||||
"explicit_content_lyrics": 6,
|
||||
"explicit_content_cover": 0,
|
||||
"preview": "https:\/\/cdns-preview-7.dzcdn.net\/stream\/c-7174e274381557670dda8d4851f4c854-6.mp3",
|
||||
"md5_image": "cb21d305beb247a8f7c79998a96779d4",
|
||||
"timestamp": 1700753817,
|
||||
"artist": {
|
||||
"id": 92,
|
||||
"name": "Linkin Park",
|
||||
"link": "https:\/\/www.deezer.com\/artist\/92",
|
||||
"tracklist": "https:\/\/api.deezer.com\/artist\/92\/top?limit=50",
|
||||
"type": "artist"
|
||||
},
|
||||
"album": {
|
||||
"id": 1346960,
|
||||
"title": "New Divide (Int'l DMD Maxi)",
|
||||
"link": "https:\/\/www.deezer.com\/album\/1346960",
|
||||
"cover": "https:\/\/api.deezer.com\/album\/1346960\/image",
|
||||
"cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/56x56-000000-80-0-0.jpg",
|
||||
"cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/250x250-000000-80-0-0.jpg",
|
||||
"cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/500x500-000000-80-0-0.jpg",
|
||||
"cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/cb21d305beb247a8f7c79998a96779d4\/1000x1000-000000-80-0-0.jpg",
|
||||
"md5_image": "cb21d305beb247a8f7c79998a96779d4",
|
||||
"tracklist": "https:\/\/api.deezer.com\/album\/1346960\/tracks",
|
||||
"type": "album"
|
||||
},
|
||||
"type": "track"
|
||||
},
|
||||
{
|
||||
"id": 3819908,
|
||||
"readable": true,
|
||||
"title": "Duality",
|
||||
"title_short": "Duality",
|
||||
"title_version": "",
|
||||
"link": "https:\/\/www.deezer.com\/track\/3819908",
|
||||
"duration": 252,
|
||||
"rank": 811964,
|
||||
"explicit_lyrics": false,
|
||||
"explicit_content_lyrics": 6,
|
||||
"explicit_content_cover": 0,
|
||||
"preview": "https:\/\/cdns-preview-e.dzcdn.net\/stream\/c-eaebaf19e890ee649f519c1b47f551b8-12.mp3",
|
||||
"md5_image": "35b093d22fe1539003d5d18dd8f309eb",
|
||||
"timestamp": 1700753544,
|
||||
"artist": {
|
||||
"id": 117,
|
||||
"name": "Slipknot",
|
||||
"link": "https:\/\/www.deezer.com\/artist\/117",
|
||||
"tracklist": "https:\/\/api.deezer.com\/artist\/117\/top?limit=50",
|
||||
"type": "artist"
|
||||
},
|
||||
"album": {
|
||||
"id": 356130,
|
||||
"title": "Vol. 3: The Subliminal Verses",
|
||||
"link": "https:\/\/www.deezer.com\/album\/356130",
|
||||
"cover": "https:\/\/api.deezer.com\/album\/356130\/image",
|
||||
"cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/35b093d22fe1539003d5d18dd8f309eb\/56x56-000000-80-0-0.jpg",
|
||||
"cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/35b093d22fe1539003d5d18dd8f309eb\/250x250-000000-80-0-0.jpg",
|
||||
"cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/35b093d22fe1539003d5d18dd8f309eb\/500x500-000000-80-0-0.jpg",
|
||||
"cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/35b093d22fe1539003d5d18dd8f309eb\/1000x1000-000000-80-0-0.jpg",
|
||||
"md5_image": "35b093d22fe1539003d5d18dd8f309eb",
|
||||
"tracklist": "https:\/\/api.deezer.com\/album\/356130\/tracks",
|
||||
"type": "album"
|
||||
},
|
||||
"type": "track"
|
||||
}
|
||||
],
|
||||
"total": 12,
|
||||
"next": "https:\/\/api.deezer.com\/user\/me\/history?limit=2&index=2"
|
||||
}
|
80
internal/backends/deezer/testdata/user-tracks.json
vendored
Normal file
80
internal/backends/deezer/testdata/user-tracks.json
vendored
Normal file
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 3265090,
|
||||
"readable": true,
|
||||
"title": "Never Take Me Alive",
|
||||
"link": "https:\/\/www.deezer.com\/track\/3265090",
|
||||
"duration": 255,
|
||||
"rank": 72294,
|
||||
"explicit_lyrics": false,
|
||||
"explicit_content_lyrics": 0,
|
||||
"explicit_content_cover": 0,
|
||||
"md5_image": "193e4db0eb58117978059acbffe79e93",
|
||||
"time_add": 1700743848,
|
||||
"album": {
|
||||
"id": 311576,
|
||||
"title": "Outland",
|
||||
"cover": "https:\/\/api.deezer.com\/album\/311576\/image",
|
||||
"cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/56x56-000000-80-0-0.jpg",
|
||||
"cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/250x250-000000-80-0-0.jpg",
|
||||
"cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/500x500-000000-80-0-0.jpg",
|
||||
"cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/193e4db0eb58117978059acbffe79e93\/1000x1000-000000-80-0-0.jpg",
|
||||
"md5_image": "193e4db0eb58117978059acbffe79e93",
|
||||
"tracklist": "https:\/\/api.deezer.com\/album\/311576\/tracks",
|
||||
"type": "album"
|
||||
},
|
||||
"artist": {
|
||||
"id": 94057,
|
||||
"name": "Spear Of Destiny",
|
||||
"picture": "https:\/\/api.deezer.com\/artist\/94057\/image",
|
||||
"picture_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/56x56-000000-80-0-0.jpg",
|
||||
"picture_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/250x250-000000-80-0-0.jpg",
|
||||
"picture_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/500x500-000000-80-0-0.jpg",
|
||||
"picture_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/3fc9b790e88636a03d58f1fe5e74d37c\/1000x1000-000000-80-0-0.jpg",
|
||||
"tracklist": "https:\/\/api.deezer.com\/artist\/94057\/top?limit=50",
|
||||
"type": "artist"
|
||||
},
|
||||
"type": "track"
|
||||
},
|
||||
{
|
||||
"id": 2510418,
|
||||
"readable": true,
|
||||
"title": "Voodoo Lady",
|
||||
"link": "https:\/\/www.deezer.com\/track\/2510418",
|
||||
"duration": 259,
|
||||
"rank": 196860,
|
||||
"explicit_lyrics": true,
|
||||
"explicit_content_lyrics": 1,
|
||||
"explicit_content_cover": 2,
|
||||
"md5_image": "ca459f264d682177d1c8f7620100a8bc",
|
||||
"time_add": 1700747083,
|
||||
"album": {
|
||||
"id": 246602,
|
||||
"title": "The Distance To Here",
|
||||
"cover": "https:\/\/api.deezer.com\/album\/246602\/image",
|
||||
"cover_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/ca459f264d682177d1c8f7620100a8bc\/56x56-000000-80-0-0.jpg",
|
||||
"cover_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/ca459f264d682177d1c8f7620100a8bc\/250x250-000000-80-0-0.jpg",
|
||||
"cover_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/ca459f264d682177d1c8f7620100a8bc\/500x500-000000-80-0-0.jpg",
|
||||
"cover_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/cover\/ca459f264d682177d1c8f7620100a8bc\/1000x1000-000000-80-0-0.jpg",
|
||||
"md5_image": "ca459f264d682177d1c8f7620100a8bc",
|
||||
"tracklist": "https:\/\/api.deezer.com\/album\/246602\/tracks",
|
||||
"type": "album"
|
||||
},
|
||||
"artist": {
|
||||
"id": 168,
|
||||
"name": "LIVE",
|
||||
"picture": "https:\/\/api.deezer.com\/artist\/168\/image",
|
||||
"picture_small": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/5d102eed174da4b807345125b3d955ef\/56x56-000000-80-0-0.jpg",
|
||||
"picture_medium": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/5d102eed174da4b807345125b3d955ef\/250x250-000000-80-0-0.jpg",
|
||||
"picture_big": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/5d102eed174da4b807345125b3d955ef\/500x500-000000-80-0-0.jpg",
|
||||
"picture_xl": "https:\/\/e-cdns-images.dzcdn.net\/images\/artist\/5d102eed174da4b807345125b3d955ef\/1000x1000-000000-80-0-0.jpg",
|
||||
"tracklist": "https:\/\/api.deezer.com\/artist\/168\/top?limit=50",
|
||||
"type": "artist"
|
||||
},
|
||||
"type": "track"
|
||||
}
|
||||
],
|
||||
"total": 4,
|
||||
"next": "https:\/\/api.deezer.com\/user\/me\/tracks?limit=2&index=2"
|
||||
}
|
57
internal/backends/dump/dump.go
Normal file
57
internal/backends/dump/dump.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package dump
|
||||
|
||||
import (
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
type DumpBackend struct{}
|
||||
|
||||
func (b *DumpBackend) Name() string { return "dump" }
|
||||
|
||||
func (b *DumpBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *DumpBackend) StartImport() error { return nil }
|
||||
func (b *DumpBackend) FinishImport() error { return nil }
|
||||
|
||||
func (b *DumpBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
for _, listen := range export.Listens {
|
||||
importResult.UpdateTimestamp(listen.ListenedAt)
|
||||
importResult.ImportCount += 1
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
// fmt.Printf("🎶 %v: \"%v\" by %v (%v)\n",
|
||||
// listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMbid)
|
||||
}
|
||||
|
||||
return importResult, nil
|
||||
}
|
||||
|
||||
func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
for _, love := range export.Loves {
|
||||
importResult.UpdateTimestamp(love.Created)
|
||||
importResult.ImportCount += 1
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
// fmt.Printf("❤️ %v: \"%v\" by %v (%v)\n",
|
||||
// love.Created, love.TrackName, love.ArtistName(), love.RecordingMbid)
|
||||
}
|
||||
|
||||
return importResult, nil
|
||||
}
|
110
internal/backends/funkwhale/client.go
Normal file
110
internal/backends/funkwhale/client.go
Normal file
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package funkwhale
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
const MaxItemsPerGet = 50
|
||||
const DefaultRateLimitWaitSeconds = 5
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
token string
|
||||
}
|
||||
|
||||
func NewClient(serverUrl string, token string) Client {
|
||||
client := resty.New()
|
||||
client.SetBaseURL(serverUrl)
|
||||
client.SetAuthScheme("Bearer")
|
||||
client.SetAuthToken(token)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
|
||||
// Handle rate limiting (see https://docs.funkwhale.audio/developer/api/rate-limit.html)
|
||||
client.SetRetryCount(5)
|
||||
client.AddRetryCondition(
|
||||
func(r *resty.Response, err error) bool {
|
||||
code := r.StatusCode()
|
||||
return code == http.StatusTooManyRequests || code >= http.StatusInternalServerError
|
||||
},
|
||||
)
|
||||
client.SetRetryMaxWaitTime(time.Duration(1 * time.Minute))
|
||||
client.SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) {
|
||||
var err error
|
||||
var retryAfter int = DefaultRateLimitWaitSeconds
|
||||
if resp.StatusCode() == http.StatusTooManyRequests {
|
||||
retryAfter, err = strconv.Atoi(resp.Header().Get("Retry-After"))
|
||||
if err != nil {
|
||||
retryAfter = DefaultRateLimitWaitSeconds
|
||||
}
|
||||
}
|
||||
return time.Duration(retryAfter * int(time.Second)), err
|
||||
})
|
||||
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) GetHistoryListenings(user string, page int, perPage int) (result ListeningsResult, err error) {
|
||||
const path = "/api/v1/history/listenings"
|
||||
response, err := c.HttpClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"username": user,
|
||||
"page": strconv.Itoa(page),
|
||||
"page_size": strconv.Itoa(perPage),
|
||||
"ordering": "-creation_date",
|
||||
}).
|
||||
SetResult(&result).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(response.String())
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksResult, err error) {
|
||||
const path = "/api/v1/favorites/tracks"
|
||||
response, err := c.HttpClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"page": strconv.Itoa(page),
|
||||
"page_size": strconv.Itoa(perPage),
|
||||
"ordering": "-creation_date",
|
||||
}).
|
||||
SetResult(&result).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(response.String())
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
98
internal/backends/funkwhale/client_test.go
Normal file
98
internal/backends/funkwhale/client_test.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package funkwhale_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/funkwhale"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
serverUrl := "https://funkwhale.example.com"
|
||||
token := "foobar123"
|
||||
client := funkwhale.NewClient(serverUrl, token)
|
||||
assert.Equal(t, serverUrl, client.HttpClient.BaseURL)
|
||||
assert.Equal(t, token, client.HttpClient.Token)
|
||||
}
|
||||
|
||||
func TestGetHistoryListenings(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
serverUrl := "https://funkwhale.example.com"
|
||||
token := "thetoken"
|
||||
client := funkwhale.NewClient(serverUrl, token)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://funkwhale.example.com/api/v1/history/listenings",
|
||||
"testdata/listenings.json")
|
||||
|
||||
result, err := client.GetHistoryListenings("outsidecontext", 0, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(2204, result.Count)
|
||||
require.Len(t, result.Results, 2)
|
||||
listen1 := result.Results[0]
|
||||
assert.Equal("2023-11-09T23:59:29.022005Z", listen1.CreationDate)
|
||||
assert.Equal("Way to Eden", listen1.Track.Title)
|
||||
assert.Equal("Hazeshuttle", listen1.Track.Album.Title)
|
||||
assert.Equal("Hazeshuttle", listen1.Track.Artist.Name)
|
||||
assert.Equal("phw", listen1.User.UserName)
|
||||
}
|
||||
|
||||
func TestGetFavoriteTracks(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
token := "thetoken"
|
||||
serverUrl := "https://funkwhale.example.com"
|
||||
client := funkwhale.NewClient(serverUrl, token)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://funkwhale.example.com/api/v1/favorites/tracks",
|
||||
"testdata/favorite-tracks.json")
|
||||
|
||||
result, err := client.GetFavoriteTracks(0, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(76, result.Count)
|
||||
require.Len(t, result.Results, 2)
|
||||
fav1 := result.Results[0]
|
||||
assert.Equal("2023-11-05T20:32:32.339738Z", fav1.CreationDate)
|
||||
assert.Equal("Reign", fav1.Track.Title)
|
||||
assert.Equal("Home Economics", fav1.Track.Album.Title)
|
||||
assert.Equal("Prinzhorn Dance School", fav1.Track.Artist.Name)
|
||||
assert.Equal("phw", fav1.User.UserName)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpmock.RegisterResponder("GET", url, responder)
|
||||
}
|
195
internal/backends/funkwhale/funkwhale.go
Normal file
195
internal/backends/funkwhale/funkwhale.go
Normal file
|
@ -0,0 +1,195 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package funkwhale
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
const FunkwhaleClientName = "Funkwhale"
|
||||
|
||||
type FunkwhaleApiBackend struct {
|
||||
client Client
|
||||
username string
|
||||
}
|
||||
|
||||
func (b *FunkwhaleApiBackend) Name() string { return "funkwhale" }
|
||||
|
||||
func (b *FunkwhaleApiBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
b.client = NewClient(
|
||||
config.GetString("server-url"),
|
||||
config.GetString("token"),
|
||||
)
|
||||
b.username = config.GetString("username")
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||
page := 1
|
||||
perPage := MaxItemsPerGet
|
||||
|
||||
defer close(results)
|
||||
|
||||
// We need to gather the full list of listens in order to sort them
|
||||
listens := make(models.ListensList, 0, 2*perPage)
|
||||
p := models.Progress{Total: int64(perPage)}
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.GetHistoryListenings(b.username, page, perPage)
|
||||
if err != nil {
|
||||
results <- models.ListensResult{Error: err}
|
||||
}
|
||||
|
||||
count := len(result.Results)
|
||||
if count == 0 {
|
||||
break out
|
||||
}
|
||||
|
||||
for _, fwListen := range result.Results {
|
||||
listen := fwListen.AsListen()
|
||||
if listen.ListenedAt.Unix() > oldestTimestamp.Unix() {
|
||||
p.Elapsed += 1
|
||||
listens = append(listens, listen)
|
||||
} else {
|
||||
break out
|
||||
}
|
||||
}
|
||||
|
||||
if result.Next == "" {
|
||||
// No further results
|
||||
p.Total = p.Elapsed
|
||||
p.Total -= int64(perPage - count)
|
||||
break out
|
||||
}
|
||||
|
||||
p.Total += int64(perPage)
|
||||
progress <- p
|
||||
page += 1
|
||||
}
|
||||
|
||||
sort.Sort(listens)
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Listens: listens}
|
||||
}
|
||||
|
||||
func (b *FunkwhaleApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||
page := 1
|
||||
perPage := MaxItemsPerGet
|
||||
|
||||
defer close(results)
|
||||
|
||||
// We need to gather the full list of listens in order to sort them
|
||||
loves := make(models.LovesList, 0, 2*perPage)
|
||||
p := models.Progress{Total: int64(perPage)}
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.GetFavoriteTracks(page, perPage)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
count := len(result.Results)
|
||||
if count == 0 {
|
||||
break out
|
||||
}
|
||||
|
||||
for _, favorite := range result.Results {
|
||||
love := favorite.AsLove()
|
||||
if love.Created.Unix() > oldestTimestamp.Unix() {
|
||||
p.Elapsed += 1
|
||||
loves = append(loves, love)
|
||||
} else {
|
||||
break out
|
||||
}
|
||||
}
|
||||
|
||||
if result.Next == "" {
|
||||
// No further results
|
||||
break out
|
||||
}
|
||||
|
||||
p.Total += int64(perPage)
|
||||
progress <- p
|
||||
page += 1
|
||||
}
|
||||
|
||||
sort.Sort(loves)
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Loves: loves}
|
||||
}
|
||||
|
||||
func (l Listening) AsListen() models.Listen {
|
||||
listen := models.Listen{
|
||||
UserName: l.User.UserName,
|
||||
Track: l.Track.AsTrack(),
|
||||
}
|
||||
|
||||
listenedAt, err := time.Parse(time.RFC3339, l.CreationDate)
|
||||
if err == nil {
|
||||
listen.ListenedAt = listenedAt
|
||||
}
|
||||
|
||||
return listen
|
||||
}
|
||||
|
||||
func (f FavoriteTrack) AsLove() models.Love {
|
||||
track := f.Track.AsTrack()
|
||||
love := models.Love{
|
||||
UserName: f.User.UserName,
|
||||
RecordingMbid: track.RecordingMbid,
|
||||
Track: track,
|
||||
}
|
||||
|
||||
created, err := time.Parse(time.RFC3339, f.CreationDate)
|
||||
if err == nil {
|
||||
love.Created = created
|
||||
}
|
||||
|
||||
return love
|
||||
}
|
||||
|
||||
func (t Track) AsTrack() models.Track {
|
||||
recordingMbid := models.MBID(t.RecordingMbid)
|
||||
track := models.Track{
|
||||
TrackName: t.Title,
|
||||
ReleaseName: t.Album.Title,
|
||||
ArtistNames: []string{t.Artist.Name},
|
||||
TrackNumber: t.Position,
|
||||
DiscNumber: t.DiscNumber,
|
||||
RecordingMbid: recordingMbid,
|
||||
ReleaseMbid: models.MBID(t.Album.ReleaseMbid),
|
||||
ArtistMbids: []models.MBID{models.MBID(t.Artist.ArtistMbid)},
|
||||
Tags: t.Tags,
|
||||
AdditionalInfo: map[string]any{
|
||||
"media_player": FunkwhaleClientName,
|
||||
},
|
||||
}
|
||||
|
||||
if len(t.Uploads) > 0 {
|
||||
track.Duration = time.Duration(t.Uploads[0].Duration * int(time.Second))
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
126
internal/backends/funkwhale/funkwhale_test.go
Normal file
126
internal/backends/funkwhale/funkwhale_test.go
Normal file
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package funkwhale_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/funkwhale"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("token", "thetoken")
|
||||
backend := (&funkwhale.FunkwhaleApiBackend{}).FromConfig(config)
|
||||
assert.IsType(t, &funkwhale.FunkwhaleApiBackend{}, backend)
|
||||
}
|
||||
|
||||
func TestFunkwhaleListeningAsListen(t *testing.T) {
|
||||
fwListen := funkwhale.Listening{
|
||||
CreationDate: "2023-11-09T23:59:29.022005Z",
|
||||
User: funkwhale.User{
|
||||
UserName: "outsidecontext",
|
||||
},
|
||||
Track: funkwhale.Track{
|
||||
Title: "Oweynagat",
|
||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
Position: 5,
|
||||
DiscNumber: 1,
|
||||
Tags: []string{"foo", "bar"},
|
||||
Artist: funkwhale.Artist{
|
||||
Name: "Dool",
|
||||
ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||
},
|
||||
Album: funkwhale.Album{
|
||||
Title: "Here Now, There Then",
|
||||
ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||
},
|
||||
Uploads: []funkwhale.Upload{
|
||||
{
|
||||
Duration: 414,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
listen := fwListen.AsListen()
|
||||
assert := assert.New(t)
|
||||
assert.Equal(time.Unix(1699574369, 0).Unix(), listen.ListenedAt.Unix())
|
||||
assert.Equal(fwListen.User.UserName, listen.UserName)
|
||||
assert.Equal(time.Duration(414*time.Second), listen.Duration)
|
||||
assert.Equal(fwListen.Track.Title, listen.TrackName)
|
||||
assert.Equal(fwListen.Track.Album.Title, listen.ReleaseName)
|
||||
assert.Equal([]string{fwListen.Track.Artist.Name}, listen.ArtistNames)
|
||||
assert.Equal(fwListen.Track.Position, listen.Track.TrackNumber)
|
||||
assert.Equal(fwListen.Track.DiscNumber, listen.Track.DiscNumber)
|
||||
assert.Equal(fwListen.Track.Tags, listen.Track.Tags)
|
||||
// assert.Equal(backends.FunkwhaleClientName, listen.AdditionalInfo["disc_number"])
|
||||
assert.Equal(models.MBID(fwListen.Track.RecordingMbid), listen.RecordingMbid)
|
||||
assert.Equal(models.MBID(fwListen.Track.Album.ReleaseMbid), listen.ReleaseMbid)
|
||||
assert.Equal(models.MBID(fwListen.Track.Artist.ArtistMbid), listen.ArtistMbids[0])
|
||||
assert.Equal(funkwhale.FunkwhaleClientName, listen.AdditionalInfo["media_player"])
|
||||
}
|
||||
|
||||
func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) {
|
||||
favorite := funkwhale.FavoriteTrack{
|
||||
CreationDate: "2023-11-09T23:59:29.022005Z",
|
||||
User: funkwhale.User{
|
||||
UserName: "outsidecontext",
|
||||
},
|
||||
Track: funkwhale.Track{
|
||||
Title: "Oweynagat",
|
||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
Position: 5,
|
||||
DiscNumber: 1,
|
||||
Tags: []string{"foo", "bar"},
|
||||
Artist: funkwhale.Artist{
|
||||
Name: "Dool",
|
||||
ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||
},
|
||||
Album: funkwhale.Album{
|
||||
Title: "Here Now, There Then",
|
||||
ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||
},
|
||||
Uploads: []funkwhale.Upload{
|
||||
{
|
||||
Duration: 414,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
love := favorite.AsLove()
|
||||
assert := assert.New(t)
|
||||
assert.Equal(time.Unix(1699574369, 0).Unix(), love.Created.Unix())
|
||||
assert.Equal(favorite.User.UserName, love.UserName)
|
||||
assert.Equal(time.Duration(414*time.Second), love.Duration)
|
||||
assert.Equal(favorite.Track.Title, love.TrackName)
|
||||
assert.Equal(favorite.Track.Album.Title, love.ReleaseName)
|
||||
assert.Equal([]string{favorite.Track.Artist.Name}, love.ArtistNames)
|
||||
assert.Equal(favorite.Track.Position, love.Track.TrackNumber)
|
||||
assert.Equal(favorite.Track.DiscNumber, love.Track.DiscNumber)
|
||||
assert.Equal(favorite.Track.Tags, love.Track.Tags)
|
||||
assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.RecordingMbid)
|
||||
assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.Track.RecordingMbid)
|
||||
assert.Equal(models.MBID(favorite.Track.Album.ReleaseMbid), love.ReleaseMbid)
|
||||
require.Len(t, love.Track.ArtistMbids, 1)
|
||||
assert.Equal(models.MBID(favorite.Track.Artist.ArtistMbid), love.ArtistMbids[0])
|
||||
assert.Equal(funkwhale.FunkwhaleClientName, love.AdditionalInfo["media_player"])
|
||||
}
|
87
internal/backends/funkwhale/models.go
Normal file
87
internal/backends/funkwhale/models.go
Normal file
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package funkwhale
|
||||
|
||||
type ListeningsResult struct {
|
||||
Count int `json:"count"`
|
||||
Previous string `json:"previous"`
|
||||
Next string `json:"next"`
|
||||
Results []Listening `json:"results"`
|
||||
}
|
||||
|
||||
type Listening struct {
|
||||
Id int `json:"int"`
|
||||
User User `json:"user"`
|
||||
Track Track `json:"track"`
|
||||
CreationDate string `json:"creation_date"`
|
||||
}
|
||||
|
||||
type FavoriteTracksResult struct {
|
||||
Count int `json:"count"`
|
||||
Previous string `json:"previous"`
|
||||
Next string `json:"next"`
|
||||
Results []FavoriteTrack `json:"results"`
|
||||
}
|
||||
|
||||
type FavoriteTrack struct {
|
||||
Id int `json:"int"`
|
||||
User User `json:"user"`
|
||||
Track Track `json:"track"`
|
||||
CreationDate string `json:"creation_date"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Id int `json:"int"`
|
||||
Artist Artist `json:"artist"`
|
||||
Album Album `json:"album"`
|
||||
Title string `json:"title"`
|
||||
Position int `json:"position"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
RecordingMbid string `json:"mbid"`
|
||||
Tags []string `json:"tags"`
|
||||
Uploads []Upload `json:"uploads"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Id int `json:"int"`
|
||||
Name string `json:"name"`
|
||||
ArtistMbid string `json:"mbid"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
Id int `json:"int"`
|
||||
Title string `json:"title"`
|
||||
AlbumArtist Artist `json:"artist"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TrackCount int `json:"track_count"`
|
||||
ReleaseMbid string `json:"mbid"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Id int `json:"int"`
|
||||
UserName string `json:"username"`
|
||||
}
|
||||
|
||||
type Upload struct {
|
||||
UUID string `json:"uuid"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
261
internal/backends/funkwhale/testdata/favorite-tracks.json
vendored
Normal file
261
internal/backends/funkwhale/testdata/favorite-tracks.json
vendored
Normal file
|
@ -0,0 +1,261 @@
|
|||
{
|
||||
"count": 76,
|
||||
"next": "https://music.uploadedlobster.com/api/v1/favorites/tracks/?page=2&page_size=2",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 80,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "phw",
|
||||
"name": "",
|
||||
"date_joined": "2020-11-13T16:22:52.464109Z",
|
||||
"avatar": {
|
||||
"uuid": "8f87ca98-fe9e-4f7e-aa54-aa8276c25fd4",
|
||||
"size": 262868,
|
||||
"mimetype": "image/png",
|
||||
"creation_date": "2021-08-30T12:02:20.962405Z",
|
||||
"urls": {
|
||||
"source": null,
|
||||
"original": "https://music.uploadedlobster.com/media/attachments/4b/e2/c3/canned-ape.png",
|
||||
"medium_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/4b/e2/c3/canned-ape-crop-c0-5__0-5-200x200.png",
|
||||
"large_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/4b/e2/c3/canned-ape-crop-c0-5__0-5-600x600.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
"track": {
|
||||
"artist": {
|
||||
"id": 211,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/artists/92ab65ce-95d4-405f-a162-959e9a69ec3e",
|
||||
"mbid": "a1f2450a-c076-4a0d-ac9c-764bfc4225f7",
|
||||
"name": "Prinzhorn Dance School",
|
||||
"creation_date": "2020-11-14T08:27:27.964479Z",
|
||||
"modification_date": "2020-11-14T08:27:27.964602Z",
|
||||
"is_local": true,
|
||||
"content_category": "music",
|
||||
"description": null,
|
||||
"attachment_cover": null,
|
||||
"channel": null
|
||||
},
|
||||
"album": {
|
||||
"id": 237,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/albums/d301df18-c0b0-4608-8c68-bba86a65eabc",
|
||||
"mbid": "7cb1093c-bfa5-4ffc-b3ac-943fb5c7f39f",
|
||||
"title": "Home Economics",
|
||||
"artist": {
|
||||
"id": 211,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/artists/92ab65ce-95d4-405f-a162-959e9a69ec3e",
|
||||
"mbid": "a1f2450a-c076-4a0d-ac9c-764bfc4225f7",
|
||||
"name": "Prinzhorn Dance School",
|
||||
"creation_date": "2020-11-14T08:27:27.964479Z",
|
||||
"modification_date": "2020-11-14T08:27:27.964602Z",
|
||||
"is_local": true,
|
||||
"content_category": "music",
|
||||
"description": null,
|
||||
"attachment_cover": null,
|
||||
"channel": null
|
||||
},
|
||||
"release_date": "2015-06-08",
|
||||
"cover": {
|
||||
"uuid": "e2022441-b120-431d-912c-aa930c668cd5",
|
||||
"size": 279103,
|
||||
"mimetype": "image/jpeg",
|
||||
"creation_date": "2023-03-30T07:51:33.366860Z",
|
||||
"urls": {
|
||||
"source": null,
|
||||
"original": "https://music.uploadedlobster.com/media/attachments/98/0a/af/attachment_cover-d301df18-c0b0-4608-8c68-bba86a65eabc.jpg",
|
||||
"medium_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/98/0a/af/attachment_cover-d301df18-c0b0-4608-8c68-bba86a65eabc-crop-c0-5__0-5-200x200-95.jpg",
|
||||
"large_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/98/0a/af/attachment_cover-d301df18-c0b0-4608-8c68-bba86a65eabc-crop-c0-5__0-5-600x600-95.jpg"
|
||||
}
|
||||
},
|
||||
"creation_date": "2020-11-14T08:27:28.805848Z",
|
||||
"is_local": true,
|
||||
"tracks_count": 6
|
||||
},
|
||||
"uploads": [
|
||||
{
|
||||
"uuid": "6647a0d0-5119-4dd0-bf1f-dfc64a1d956c",
|
||||
"listen_url": "/api/v1/listen/b4cb522f-ce1b-49fb-81b8-0cc0d1cb5495/?upload=6647a0d0-5119-4dd0-bf1f-dfc64a1d956c",
|
||||
"size": 11147671,
|
||||
"duration": 271,
|
||||
"bitrate": 320000,
|
||||
"mimetype": "audio/mpeg",
|
||||
"extension": "mp3",
|
||||
"is_local": true
|
||||
}
|
||||
],
|
||||
"listen_url": "/api/v1/listen/b4cb522f-ce1b-49fb-81b8-0cc0d1cb5495/",
|
||||
"tags": [],
|
||||
"attributed_to": {
|
||||
"fid": "https://music.uploadedlobster.com/federation/actors/phw",
|
||||
"url": null,
|
||||
"creation_date": "2020-11-13T16:54:45.182645Z",
|
||||
"summary": null,
|
||||
"preferred_username": "phw",
|
||||
"name": "phw",
|
||||
"last_fetch_date": "2020-11-13T16:54:45.182661Z",
|
||||
"domain": "music.uploadedlobster.com",
|
||||
"type": "Person",
|
||||
"manually_approves_followers": false,
|
||||
"full_username": "phw@music.uploadedlobster.com",
|
||||
"is_local": true
|
||||
},
|
||||
"id": 2833,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/tracks/b4cb522f-ce1b-49fb-81b8-0cc0d1cb5495",
|
||||
"mbid": "b59cf4e7-caee-4019-a844-79d2c58d4dff",
|
||||
"title": "Reign",
|
||||
"creation_date": "2020-11-14T08:27:28.954529Z",
|
||||
"is_local": true,
|
||||
"position": 1,
|
||||
"disc_number": 1,
|
||||
"downloads_count": 3,
|
||||
"copyright": null,
|
||||
"license": null,
|
||||
"cover": null,
|
||||
"is_playable": true
|
||||
},
|
||||
"creation_date": "2023-11-05T20:32:32.339738Z",
|
||||
"actor": {
|
||||
"fid": "https://music.uploadedlobster.com/federation/actors/phw",
|
||||
"url": null,
|
||||
"creation_date": "2020-11-13T16:54:45.182645Z",
|
||||
"summary": null,
|
||||
"preferred_username": "phw",
|
||||
"name": "phw",
|
||||
"last_fetch_date": "2020-11-13T16:54:45.182661Z",
|
||||
"domain": "music.uploadedlobster.com",
|
||||
"type": "Person",
|
||||
"manually_approves_followers": false,
|
||||
"full_username": "phw@music.uploadedlobster.com",
|
||||
"is_local": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 79,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "phw",
|
||||
"name": "",
|
||||
"date_joined": "2020-11-13T16:22:52.464109Z",
|
||||
"avatar": {
|
||||
"uuid": "8f87ca98-fe9e-4f7e-aa54-aa8276c25fd4",
|
||||
"size": 262868,
|
||||
"mimetype": "image/png",
|
||||
"creation_date": "2021-08-30T12:02:20.962405Z",
|
||||
"urls": {
|
||||
"source": null,
|
||||
"original": "https://music.uploadedlobster.com/media/attachments/4b/e2/c3/canned-ape.png",
|
||||
"medium_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/4b/e2/c3/canned-ape-crop-c0-5__0-5-200x200.png",
|
||||
"large_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/4b/e2/c3/canned-ape-crop-c0-5__0-5-600x600.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
"track": {
|
||||
"artist": {
|
||||
"id": 3800,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/artists/2ef92f34-f4bc-4bf5-85de-a6847ea51a62",
|
||||
"mbid": "055b6082-b9cc-4688-85c4-8153c0ef2d70",
|
||||
"name": "Crippled Black Phoenix",
|
||||
"creation_date": "2023-07-19T06:39:22.078159Z",
|
||||
"modification_date": "2023-07-19T06:39:22.078238Z",
|
||||
"is_local": true,
|
||||
"content_category": "music",
|
||||
"description": null,
|
||||
"attachment_cover": null,
|
||||
"channel": null
|
||||
},
|
||||
"album": {
|
||||
"id": 2684,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/albums/138bd960-8e94-4794-adaa-51de5fba33c3",
|
||||
"mbid": "97509ec0-93cc-47ca-9033-1ac27678d799",
|
||||
"title": "Ellengæst",
|
||||
"artist": {
|
||||
"id": 3800,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/artists/2ef92f34-f4bc-4bf5-85de-a6847ea51a62",
|
||||
"mbid": "055b6082-b9cc-4688-85c4-8153c0ef2d70",
|
||||
"name": "Crippled Black Phoenix",
|
||||
"creation_date": "2023-07-19T06:39:22.078159Z",
|
||||
"modification_date": "2023-07-19T06:39:22.078238Z",
|
||||
"is_local": true,
|
||||
"content_category": "music",
|
||||
"description": null,
|
||||
"attachment_cover": null,
|
||||
"channel": null
|
||||
},
|
||||
"release_date": "2020-11-04",
|
||||
"cover": {
|
||||
"uuid": "2c191778-0c1c-467e-9bf1-9949e3d98507",
|
||||
"size": 129633,
|
||||
"mimetype": "image/jpeg",
|
||||
"creation_date": "2023-07-19T06:39:22.102600Z",
|
||||
"urls": {
|
||||
"source": null,
|
||||
"original": "https://music.uploadedlobster.com/media/attachments/e5/d6/bf/attachment_cover-138bd960-8e94-4794-adaa-51de5fba33c3.jpg",
|
||||
"medium_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/e5/d6/bf/attachment_cover-138bd960-8e94-4794-adaa-51de5fba33c3-crop-c0-5__0-5-200x200-95.jpg",
|
||||
"large_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/e5/d6/bf/attachment_cover-138bd960-8e94-4794-adaa-51de5fba33c3-crop-c0-5__0-5-600x600-95.jpg"
|
||||
}
|
||||
},
|
||||
"creation_date": "2023-07-19T06:39:22.094428Z",
|
||||
"is_local": true,
|
||||
"tracks_count": 8
|
||||
},
|
||||
"uploads": [
|
||||
{
|
||||
"uuid": "f28de29b-6928-4879-a9da-32dcf9dd5ee4",
|
||||
"listen_url": "/api/v1/listen/9263d5c0-dee3-4273-beef-7585e1d0041a/?upload=f28de29b-6928-4879-a9da-32dcf9dd5ee4",
|
||||
"size": 12065231,
|
||||
"duration": 491,
|
||||
"bitrate": 0,
|
||||
"mimetype": "audio/opus",
|
||||
"extension": "opus",
|
||||
"is_local": true
|
||||
}
|
||||
],
|
||||
"listen_url": "/api/v1/listen/9263d5c0-dee3-4273-beef-7585e1d0041a/",
|
||||
"tags": [],
|
||||
"attributed_to": {
|
||||
"fid": "https://music.uploadedlobster.com/federation/actors/phw",
|
||||
"url": null,
|
||||
"creation_date": "2020-11-13T16:54:45.182645Z",
|
||||
"summary": null,
|
||||
"preferred_username": "phw",
|
||||
"name": "phw",
|
||||
"last_fetch_date": "2020-11-13T16:54:45.182661Z",
|
||||
"domain": "music.uploadedlobster.com",
|
||||
"type": "Person",
|
||||
"manually_approves_followers": false,
|
||||
"full_username": "phw@music.uploadedlobster.com",
|
||||
"is_local": true
|
||||
},
|
||||
"id": 28095,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/tracks/9263d5c0-dee3-4273-beef-7585e1d0041a",
|
||||
"mbid": "14d612f0-4022-4adc-8cef-87a569e2d65c",
|
||||
"title": "Lost",
|
||||
"creation_date": "2023-07-19T06:39:22.459072Z",
|
||||
"is_local": true,
|
||||
"position": 2,
|
||||
"disc_number": 1,
|
||||
"downloads_count": 2,
|
||||
"copyright": null,
|
||||
"license": null,
|
||||
"cover": null,
|
||||
"is_playable": true
|
||||
},
|
||||
"creation_date": "2023-10-25T16:14:36.112517Z",
|
||||
"actor": {
|
||||
"fid": "https://music.uploadedlobster.com/federation/actors/phw",
|
||||
"url": null,
|
||||
"creation_date": "2020-11-13T16:54:45.182645Z",
|
||||
"summary": null,
|
||||
"preferred_username": "phw",
|
||||
"name": "phw",
|
||||
"last_fetch_date": "2020-11-13T16:54:45.182661Z",
|
||||
"domain": "music.uploadedlobster.com",
|
||||
"type": "Person",
|
||||
"manually_approves_followers": false,
|
||||
"full_username": "phw@music.uploadedlobster.com",
|
||||
"is_local": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
261
internal/backends/funkwhale/testdata/listenings.json
vendored
Normal file
261
internal/backends/funkwhale/testdata/listenings.json
vendored
Normal file
|
@ -0,0 +1,261 @@
|
|||
{
|
||||
"count": 2204,
|
||||
"next": "https://music.uploadedlobster.com/api/v1/history/listenings/?page=2&page_size=2&username=phw",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 2204,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "phw",
|
||||
"name": "",
|
||||
"date_joined": "2020-11-13T16:22:52.464109Z",
|
||||
"avatar": {
|
||||
"uuid": "8f87ca98-fe9e-4f7e-aa54-aa8276c25fd4",
|
||||
"size": 262868,
|
||||
"mimetype": "image/png",
|
||||
"creation_date": "2021-08-30T12:02:20.962405Z",
|
||||
"urls": {
|
||||
"source": null,
|
||||
"original": "https://music.uploadedlobster.com/media/attachments/4b/e2/c3/canned-ape.png",
|
||||
"medium_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/4b/e2/c3/canned-ape-crop-c0-5__0-5-200x200.png",
|
||||
"large_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/4b/e2/c3/canned-ape-crop-c0-5__0-5-600x600.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
"track": {
|
||||
"artist": {
|
||||
"id": 3824,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/artists/0c0fc2f3-becd-4b24-a2c4-04523cbd5934",
|
||||
"mbid": "54292079-790c-4e99-bf8d-12efa29fa3e9",
|
||||
"name": "Hazeshuttle",
|
||||
"creation_date": "2023-10-27T16:35:36.458347Z",
|
||||
"modification_date": "2023-10-27T16:35:36.458426Z",
|
||||
"is_local": true,
|
||||
"content_category": "music",
|
||||
"description": null,
|
||||
"attachment_cover": null,
|
||||
"channel": null
|
||||
},
|
||||
"album": {
|
||||
"id": 2700,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/albums/ff6bb1d6-4334-4280-95a1-0d49c64c8c7d",
|
||||
"mbid": "6d0ee27f-dc9f-4dab-8d7d-f4dcd14dc54a",
|
||||
"title": "Hazeshuttle",
|
||||
"artist": {
|
||||
"id": 3824,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/artists/0c0fc2f3-becd-4b24-a2c4-04523cbd5934",
|
||||
"mbid": "54292079-790c-4e99-bf8d-12efa29fa3e9",
|
||||
"name": "Hazeshuttle",
|
||||
"creation_date": "2023-10-27T16:35:36.458347Z",
|
||||
"modification_date": "2023-10-27T16:35:36.458426Z",
|
||||
"is_local": true,
|
||||
"content_category": "music",
|
||||
"description": null,
|
||||
"attachment_cover": null,
|
||||
"channel": null
|
||||
},
|
||||
"release_date": "2023-04-01",
|
||||
"cover": {
|
||||
"uuid": "d91a26cd-6132-4762-9f33-dba9b78ece69",
|
||||
"size": 143159,
|
||||
"mimetype": "image/jpeg",
|
||||
"creation_date": "2023-10-27T16:35:36.473913Z",
|
||||
"urls": {
|
||||
"source": null,
|
||||
"original": "https://music.uploadedlobster.com/media/attachments/5e/4d/7f/attachment_cover-ff6bb1d6-4334-4280-95a1-0d49c64c8c7d.jpg",
|
||||
"medium_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/5e/4d/7f/attachment_cover-ff6bb1d6-4334-4280-95a1-0d49c64c8c7d-crop-c0-5__0-5-200x200-95.jpg",
|
||||
"large_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/5e/4d/7f/attachment_cover-ff6bb1d6-4334-4280-95a1-0d49c64c8c7d-crop-c0-5__0-5-600x600-95.jpg"
|
||||
}
|
||||
},
|
||||
"creation_date": "2023-10-27T16:35:36.468407Z",
|
||||
"is_local": true,
|
||||
"tracks_count": 5
|
||||
},
|
||||
"uploads": [
|
||||
{
|
||||
"uuid": "4d3ef919-8683-42f0-bb1a-edbec258006a",
|
||||
"listen_url": "/api/v1/listen/a7fc119f-67c2-4a64-9609-d5dc3d42e3ee/?upload=4d3ef919-8683-42f0-bb1a-edbec258006a",
|
||||
"size": 13837685,
|
||||
"duration": 567,
|
||||
"bitrate": 0,
|
||||
"mimetype": "audio/opus",
|
||||
"extension": "opus",
|
||||
"is_local": true
|
||||
}
|
||||
],
|
||||
"listen_url": "/api/v1/listen/a7fc119f-67c2-4a64-9609-d5dc3d42e3ee/",
|
||||
"tags": [],
|
||||
"attributed_to": {
|
||||
"fid": "https://music.uploadedlobster.com/federation/actors/phw",
|
||||
"url": null,
|
||||
"creation_date": "2020-11-13T16:54:45.182645Z",
|
||||
"summary": null,
|
||||
"preferred_username": "phw",
|
||||
"name": "phw",
|
||||
"last_fetch_date": "2020-11-13T16:54:45.182661Z",
|
||||
"domain": "music.uploadedlobster.com",
|
||||
"type": "Person",
|
||||
"manually_approves_followers": false,
|
||||
"full_username": "phw@music.uploadedlobster.com",
|
||||
"is_local": true
|
||||
},
|
||||
"id": 28224,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/tracks/a7fc119f-67c2-4a64-9609-d5dc3d42e3ee",
|
||||
"mbid": "db8488cb-d665-4853-8e7e-970e7c2d9225",
|
||||
"title": "Way to Eden",
|
||||
"creation_date": "2023-10-27T16:35:36.661199Z",
|
||||
"is_local": true,
|
||||
"position": 2,
|
||||
"disc_number": 1,
|
||||
"downloads_count": 2,
|
||||
"copyright": null,
|
||||
"license": null,
|
||||
"cover": null,
|
||||
"is_playable": true
|
||||
},
|
||||
"creation_date": "2023-11-09T23:59:29.022005Z",
|
||||
"actor": {
|
||||
"fid": "https://music.uploadedlobster.com/federation/actors/phw",
|
||||
"url": null,
|
||||
"creation_date": "2020-11-13T16:54:45.182645Z",
|
||||
"summary": null,
|
||||
"preferred_username": "phw",
|
||||
"name": "phw",
|
||||
"last_fetch_date": "2020-11-13T16:54:45.182661Z",
|
||||
"domain": "music.uploadedlobster.com",
|
||||
"type": "Person",
|
||||
"manually_approves_followers": false,
|
||||
"full_username": "phw@music.uploadedlobster.com",
|
||||
"is_local": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2203,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "phw",
|
||||
"name": "",
|
||||
"date_joined": "2020-11-13T16:22:52.464109Z",
|
||||
"avatar": {
|
||||
"uuid": "8f87ca98-fe9e-4f7e-aa54-aa8276c25fd4",
|
||||
"size": 262868,
|
||||
"mimetype": "image/png",
|
||||
"creation_date": "2021-08-30T12:02:20.962405Z",
|
||||
"urls": {
|
||||
"source": null,
|
||||
"original": "https://music.uploadedlobster.com/media/attachments/4b/e2/c3/canned-ape.png",
|
||||
"medium_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/4b/e2/c3/canned-ape-crop-c0-5__0-5-200x200.png",
|
||||
"large_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/4b/e2/c3/canned-ape-crop-c0-5__0-5-600x600.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
"track": {
|
||||
"artist": {
|
||||
"id": 3824,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/artists/0c0fc2f3-becd-4b24-a2c4-04523cbd5934",
|
||||
"mbid": "54292079-790c-4e99-bf8d-12efa29fa3e9",
|
||||
"name": "Hazeshuttle",
|
||||
"creation_date": "2023-10-27T16:35:36.458347Z",
|
||||
"modification_date": "2023-10-27T16:35:36.458426Z",
|
||||
"is_local": true,
|
||||
"content_category": "music",
|
||||
"description": null,
|
||||
"attachment_cover": null,
|
||||
"channel": null
|
||||
},
|
||||
"album": {
|
||||
"id": 2700,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/albums/ff6bb1d6-4334-4280-95a1-0d49c64c8c7d",
|
||||
"mbid": "6d0ee27f-dc9f-4dab-8d7d-f4dcd14dc54a",
|
||||
"title": "Hazeshuttle",
|
||||
"artist": {
|
||||
"id": 3824,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/artists/0c0fc2f3-becd-4b24-a2c4-04523cbd5934",
|
||||
"mbid": "54292079-790c-4e99-bf8d-12efa29fa3e9",
|
||||
"name": "Hazeshuttle",
|
||||
"creation_date": "2023-10-27T16:35:36.458347Z",
|
||||
"modification_date": "2023-10-27T16:35:36.458426Z",
|
||||
"is_local": true,
|
||||
"content_category": "music",
|
||||
"description": null,
|
||||
"attachment_cover": null,
|
||||
"channel": null
|
||||
},
|
||||
"release_date": "2023-04-01",
|
||||
"cover": {
|
||||
"uuid": "d91a26cd-6132-4762-9f33-dba9b78ece69",
|
||||
"size": 143159,
|
||||
"mimetype": "image/jpeg",
|
||||
"creation_date": "2023-10-27T16:35:36.473913Z",
|
||||
"urls": {
|
||||
"source": null,
|
||||
"original": "https://music.uploadedlobster.com/media/attachments/5e/4d/7f/attachment_cover-ff6bb1d6-4334-4280-95a1-0d49c64c8c7d.jpg",
|
||||
"medium_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/5e/4d/7f/attachment_cover-ff6bb1d6-4334-4280-95a1-0d49c64c8c7d-crop-c0-5__0-5-200x200-95.jpg",
|
||||
"large_square_crop": "https://music.uploadedlobster.com/media/__sized__/attachments/5e/4d/7f/attachment_cover-ff6bb1d6-4334-4280-95a1-0d49c64c8c7d-crop-c0-5__0-5-600x600-95.jpg"
|
||||
}
|
||||
},
|
||||
"creation_date": "2023-10-27T16:35:36.468407Z",
|
||||
"is_local": true,
|
||||
"tracks_count": 5
|
||||
},
|
||||
"uploads": [
|
||||
{
|
||||
"uuid": "32a6e065-cfb3-4bd5-a1a8-08416e2211f2",
|
||||
"listen_url": "/api/v1/listen/24c207d8-abeb-4541-829f-cf6c6a04a44a/?upload=32a6e065-cfb3-4bd5-a1a8-08416e2211f2",
|
||||
"size": 24121224,
|
||||
"duration": 1007,
|
||||
"bitrate": 0,
|
||||
"mimetype": "audio/opus",
|
||||
"extension": "opus",
|
||||
"is_local": true
|
||||
}
|
||||
],
|
||||
"listen_url": "/api/v1/listen/24c207d8-abeb-4541-829f-cf6c6a04a44a/",
|
||||
"tags": [],
|
||||
"attributed_to": {
|
||||
"fid": "https://music.uploadedlobster.com/federation/actors/phw",
|
||||
"url": null,
|
||||
"creation_date": "2020-11-13T16:54:45.182645Z",
|
||||
"summary": null,
|
||||
"preferred_username": "phw",
|
||||
"name": "phw",
|
||||
"last_fetch_date": "2020-11-13T16:54:45.182661Z",
|
||||
"domain": "music.uploadedlobster.com",
|
||||
"type": "Person",
|
||||
"manually_approves_followers": false,
|
||||
"full_username": "phw@music.uploadedlobster.com",
|
||||
"is_local": true
|
||||
},
|
||||
"id": 28223,
|
||||
"fid": "https://music.uploadedlobster.com/federation/music/tracks/24c207d8-abeb-4541-829f-cf6c6a04a44a",
|
||||
"mbid": "ba49cada-9873-4bdb-9506-533cb63372c8",
|
||||
"title": "Homosativa",
|
||||
"creation_date": "2023-10-27T16:35:36.559260Z",
|
||||
"is_local": true,
|
||||
"position": 1,
|
||||
"disc_number": 1,
|
||||
"downloads_count": 1,
|
||||
"copyright": null,
|
||||
"license": null,
|
||||
"cover": null,
|
||||
"is_playable": true
|
||||
},
|
||||
"creation_date": "2023-11-09T23:42:42.345349Z",
|
||||
"actor": {
|
||||
"fid": "https://music.uploadedlobster.com/federation/actors/phw",
|
||||
"url": null,
|
||||
"creation_date": "2020-11-13T16:54:45.182645Z",
|
||||
"summary": null,
|
||||
"preferred_username": "phw",
|
||||
"name": "phw",
|
||||
"last_fetch_date": "2020-11-13T16:54:45.182661Z",
|
||||
"domain": "music.uploadedlobster.com",
|
||||
"type": "Person",
|
||||
"manually_approves_followers": false,
|
||||
"full_username": "phw@music.uploadedlobster.com",
|
||||
"is_local": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
164
internal/backends/jspf/jspf.go
Normal file
164
internal/backends/jspf/jspf.go
Normal file
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package jspf
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
type JSPFBackend struct {
|
||||
filePath string
|
||||
title string
|
||||
creator string
|
||||
identifier string
|
||||
tracks []Track
|
||||
}
|
||||
|
||||
func (b *JSPFBackend) Name() string { return "jspf" }
|
||||
|
||||
func (b *JSPFBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
b.filePath = config.GetString("file-path")
|
||||
b.title = config.GetString("title")
|
||||
b.creator = config.GetString("username")
|
||||
b.identifier = config.GetString("identifier")
|
||||
b.tracks = make([]Track, 0)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *JSPFBackend) StartImport() error { return nil }
|
||||
func (b *JSPFBackend) FinishImport() error {
|
||||
err := b.writeJSPF(b.tracks)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
for _, listen := range export.Listens {
|
||||
track := listenAsTrack(listen)
|
||||
b.tracks = append(b.tracks, track)
|
||||
importResult.ImportCount += 1
|
||||
importResult.UpdateTimestamp(listen.ListenedAt)
|
||||
}
|
||||
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
return importResult, nil
|
||||
}
|
||||
|
||||
func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
for _, love := range export.Loves {
|
||||
track := loveAsTrack(love)
|
||||
b.tracks = append(b.tracks, track)
|
||||
importResult.ImportCount += 1
|
||||
importResult.UpdateTimestamp(love.Created)
|
||||
}
|
||||
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
return importResult, nil
|
||||
}
|
||||
|
||||
func listenAsTrack(l models.Listen) Track {
|
||||
l.FillAdditionalInfo()
|
||||
track := trackAsTrack(l.Track)
|
||||
extension := makeMusicBrainzExtension(l.Track)
|
||||
extension.AddedAt = l.ListenedAt
|
||||
extension.AddedBy = l.UserName
|
||||
track.Extension[MusicBrainzTrackExtensionId] = extension
|
||||
|
||||
if l.RecordingMbid != "" {
|
||||
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid))
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
func loveAsTrack(l models.Love) Track {
|
||||
l.FillAdditionalInfo()
|
||||
track := trackAsTrack(l.Track)
|
||||
extension := makeMusicBrainzExtension(l.Track)
|
||||
extension.AddedAt = l.Created
|
||||
extension.AddedBy = l.UserName
|
||||
track.Extension[MusicBrainzTrackExtensionId] = extension
|
||||
|
||||
if l.RecordingMbid != "" {
|
||||
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid))
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
func trackAsTrack(t models.Track) Track {
|
||||
track := Track{
|
||||
Title: t.TrackName,
|
||||
Album: t.ReleaseName,
|
||||
Creator: t.ArtistName(),
|
||||
TrackNum: t.TrackNumber,
|
||||
Extension: map[string]any{},
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
func makeMusicBrainzExtension(t models.Track) MusicBrainzTrackExtension {
|
||||
extension := MusicBrainzTrackExtension{
|
||||
AdditionalMetadata: t.AdditionalInfo,
|
||||
ArtistIdentifiers: make([]string, len(t.ArtistMbids)),
|
||||
}
|
||||
|
||||
for i, mbid := range t.ArtistMbids {
|
||||
extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid)
|
||||
}
|
||||
|
||||
if t.ReleaseMbid != "" {
|
||||
extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMbid)
|
||||
}
|
||||
|
||||
// The tracknumber tag would be redundant
|
||||
delete(extension.AdditionalMetadata, "tracknumber")
|
||||
|
||||
return extension
|
||||
}
|
||||
|
||||
func (b JSPFBackend) writeJSPF(tracks []Track) error {
|
||||
playlist := JSPF{
|
||||
Playlist: Playlist{
|
||||
Title: b.title,
|
||||
Creator: b.creator,
|
||||
Identifier: b.identifier,
|
||||
Date: time.Now(),
|
||||
Tracks: tracks,
|
||||
},
|
||||
}
|
||||
|
||||
file, err := os.Create(b.filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
jspfJson, err := json.MarshalIndent(playlist, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = file.Write(jspfJson)
|
||||
return err
|
||||
}
|
36
internal/backends/jspf/jspf_test.go
Normal file
36
internal/backends/jspf/jspf_test.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package jspf_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("file-path", "/foo/bar.jspf")
|
||||
config.Set("title", "My Playlist")
|
||||
config.Set("username", "outsidecontext")
|
||||
config.Set("identifier", "http://example.com/playlist1")
|
||||
backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(config)
|
||||
assert.IsType(t, &scrobblerlog.ScrobblerLogBackend{}, backend)
|
||||
}
|
75
internal/backends/jspf/models.go
Normal file
75
internal/backends/jspf/models.go
Normal file
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package jspf
|
||||
|
||||
import "time"
|
||||
|
||||
// See https://xspf.org/jspf
|
||||
type JSPF struct {
|
||||
Playlist Playlist `json:"playlist"`
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Creator string `json:"creator,omitempty"`
|
||||
Annotation string `json:"annotation,omitempty"`
|
||||
Info string `json:"info,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Identifier string `json:"identifier,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Date time.Time `json:"date,omitempty"`
|
||||
License string `json:"license,omitempty"`
|
||||
Attribution []Attribution `json:"attribution,omitempty"`
|
||||
Links []Link `json:"link,omitempty"`
|
||||
Meta []Meta `json:"meta,omitempty"`
|
||||
Extension map[string]any `json:"extension,omitempty"`
|
||||
Tracks []Track `json:"track"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Location []string `json:"location,omitempty"`
|
||||
Identifier []string `json:"identifier,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Creator string `json:"creator,omitempty"`
|
||||
Annotation string `json:"annotation,omitempty"`
|
||||
Info string `json:"info,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
TrackNum int `json:"trackNum,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Links []Link `json:"link,omitempty"`
|
||||
Meta []Meta `json:"meta,omitempty"`
|
||||
Extension map[string]any `json:"extension,omitempty"`
|
||||
}
|
||||
|
||||
type Attribution map[string]string
|
||||
|
||||
type Link map[string]string
|
||||
|
||||
type Meta map[string]string
|
||||
|
||||
// Extension for "https://musicbrainz.org/doc/jspf#track"
|
||||
// as used by ListenBrainz.
|
||||
const MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track"
|
||||
|
||||
type MusicBrainzTrackExtension struct {
|
||||
AddedAt time.Time `json:"added_at,omitempty"`
|
||||
AddedBy string `json:"added_by,omitempty"`
|
||||
ReleaseIdentifier string `json:"release_identifier,omitempty"`
|
||||
ArtistIdentifiers []string `json:"artist_identifiers,omitempty"`
|
||||
AdditionalMetadata map[string]any `json:"additional_metadata,omitempty"`
|
||||
}
|
106
internal/backends/jspf/models_test.go
Normal file
106
internal/backends/jspf/models_test.go
Normal file
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package jspf_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/jspf"
|
||||
)
|
||||
|
||||
func TestUnmarshalSimple(t *testing.T) {
|
||||
data, err := readSampleJson("testdata/simple.jspf")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
playlist := data.Playlist
|
||||
assert.Equal("Two Songs From Thriller", playlist.Title)
|
||||
assert.Equal("MJ Fan", playlist.Creator)
|
||||
require.Len(t, playlist.Tracks, 2)
|
||||
track1 := playlist.Tracks[0]
|
||||
require.Len(t, track1.Location, 1)
|
||||
assert.Equal("http://example.com/billiejean.mp3", track1.Location[0])
|
||||
assert.Equal("Billie Jean", track1.Title)
|
||||
assert.Equal("Michael Jackson", track1.Creator)
|
||||
assert.Equal("Thriller", track1.Album)
|
||||
}
|
||||
|
||||
func TestUnmarshalComprehensive(t *testing.T) {
|
||||
data, err := readSampleJson("testdata/comprehensive.jspf")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
playlist := data.Playlist
|
||||
assert.Equal("http://example.com/", playlist.License)
|
||||
require.Len(t, playlist.Attribution, 2)
|
||||
assert.Equal("http://example.com/", playlist.Attribution[0]["identifier"])
|
||||
assert.Equal("http://example.com/", playlist.Attribution[1]["location"])
|
||||
require.Len(t, playlist.Meta, 2)
|
||||
assert.Equal("345", playlist.Meta[1]["http://example.com/rel/2/"])
|
||||
require.Len(t, playlist.Links, 2)
|
||||
assert.Equal("http://example.com/body/1/", playlist.Links[0]["http://example.com/rel/1/"])
|
||||
}
|
||||
|
||||
func TestUnmarshalListenBrainzPlaylist(t *testing.T) {
|
||||
data, err := readSampleJson("testdata/lb-playlist.jspf")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
playlist := data.Playlist
|
||||
assert.Equal(
|
||||
"https://listenbrainz.org/playlist/96485e27-967a-492a-9d04-c5a819baa2f3",
|
||||
playlist.Identifier)
|
||||
expectedPlaylistDate, err := time.Parse(time.RFC3339, "2023-07-04T21:03:52.317148+00:00")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(expectedPlaylistDate, playlist.Date)
|
||||
assert.NotNil(playlist.Extension["https://musicbrainz.org/doc/jspf#playlist"])
|
||||
|
||||
require.Len(t, playlist.Tracks, 2)
|
||||
track1 := playlist.Tracks[0]
|
||||
assert.Equal(
|
||||
"https://musicbrainz.org/recording/3f2bdbbd-063e-478c-a394-6da0cb303302",
|
||||
track1.Identifier[0])
|
||||
fmt.Printf("Ext: %v\n", track1.Extension["https://musicbrainz.org/doc/jspf#track"])
|
||||
extension := track1.Extension["https://musicbrainz.org/doc/jspf#track"].(map[string]any)
|
||||
assert.NotNil(extension)
|
||||
assert.Equal("outsidecontext", extension["added_by"])
|
||||
}
|
||||
|
||||
func readSampleJson(path string) (jspf.JSPF, error) {
|
||||
var result jspf.JSPF
|
||||
jsonFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer jsonFile.Close()
|
||||
|
||||
byteValue, err := io.ReadAll(jsonFile)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(byteValue, &result)
|
||||
return result, err
|
||||
}
|
91
internal/backends/jspf/testdata/comprehensive.jspf
vendored
Normal file
91
internal/backends/jspf/testdata/comprehensive.jspf
vendored
Normal file
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
"playlist": {
|
||||
"title": "JSPF example",
|
||||
"creator": "Name of playlist author",
|
||||
"annotation": "Super playlist",
|
||||
"info": "http://example.com/",
|
||||
"location": "http://example.com/",
|
||||
"identifier": "http://example.com/",
|
||||
"image": "http://example.com/",
|
||||
"date": "2005-01-08T17:10:47-05:00",
|
||||
"license": "http://example.com/",
|
||||
"attribution": [
|
||||
{
|
||||
"identifier": "http://example.com/"
|
||||
},
|
||||
{
|
||||
"location": "http://example.com/"
|
||||
}
|
||||
],
|
||||
"link": [
|
||||
{
|
||||
"http://example.com/rel/1/": "http://example.com/body/1/"
|
||||
},
|
||||
{
|
||||
"http://example.com/rel/2/": "http://example.com/body/2/"
|
||||
}
|
||||
],
|
||||
"meta": [
|
||||
{
|
||||
"http://example.com/rel/1/": "my meta 14"
|
||||
},
|
||||
{
|
||||
"http://example.com/rel/2/": "345"
|
||||
}
|
||||
],
|
||||
"extension": {
|
||||
"http://example.com/app/1/": [
|
||||
"ARBITRARY_EXTENSION_BODY",
|
||||
{}
|
||||
],
|
||||
"http://example.com/app/2/": [
|
||||
"ARBITRARY_EXTENSION_BODY"
|
||||
]
|
||||
},
|
||||
"track": [
|
||||
{
|
||||
"location": [
|
||||
"http://example.com/1.ogg",
|
||||
"http://example.com/2.mp3"
|
||||
],
|
||||
"identifier": [
|
||||
"http://example.com/1/",
|
||||
"http://example.com/2/"
|
||||
],
|
||||
"title": "Track title",
|
||||
"creator": "Artist name",
|
||||
"annotation": "Some text",
|
||||
"info": "http://example.com/",
|
||||
"image": "http://example.com/",
|
||||
"album": "Album name",
|
||||
"trackNum": 1,
|
||||
"duration": 0,
|
||||
"link": [
|
||||
{
|
||||
"http://example.com/rel/1/": "http://example.com/body/1/"
|
||||
},
|
||||
{
|
||||
"http://example.com/rel/2/": "http://example.com/body/2/"
|
||||
}
|
||||
],
|
||||
"meta": [
|
||||
{
|
||||
"http://example.com/rel/1/": "my meta 14"
|
||||
},
|
||||
{
|
||||
"http://example.com/rel/2/": "345"
|
||||
}
|
||||
],
|
||||
"extension": {
|
||||
"http://example.com/app/1/": [
|
||||
"ARBITRARY_EXTENSION_BODY",
|
||||
{}
|
||||
],
|
||||
"http://example.com/app/2/": [
|
||||
"ARBITRARY_EXTENSION_BODY"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
58
internal/backends/jspf/testdata/lb-playlist.jspf
vendored
Normal file
58
internal/backends/jspf/testdata/lb-playlist.jspf
vendored
Normal file
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"playlist": {
|
||||
"creator": "outsidecontext",
|
||||
"date": "2023-07-04T21:03:52.317148+00:00",
|
||||
"extension": {
|
||||
"https://musicbrainz.org/doc/jspf#playlist": {
|
||||
"creator": "outsidecontext",
|
||||
"last_modified_at": "2023-07-10T10:03:48.833282+00:00",
|
||||
"public": false
|
||||
}
|
||||
},
|
||||
"identifier": "https://listenbrainz.org/playlist/96485e27-967a-492a-9d04-c5a819baa2f3",
|
||||
"title": "Fundst\u00fccke",
|
||||
"track": [
|
||||
{
|
||||
"creator": "Airghoul feat. Priest",
|
||||
"extension": {
|
||||
"https://musicbrainz.org/doc/jspf#track": {
|
||||
"added_at": "2023-07-04T21:05:59.492439+00:00",
|
||||
"added_by": "outsidecontext",
|
||||
"additional_metadata": {
|
||||
"caa_id": 32981136309,
|
||||
"caa_release_mbid": "fb7d69d6-0b4b-4f99-a77a-c3a0d786b52c"
|
||||
},
|
||||
"artist_identifiers": [
|
||||
"https://musicbrainz.org/artist/554a5819-6c3f-4734-ae4c-11eabb7ca2e0",
|
||||
"https://musicbrainz.org/artist/56ff293f-ec9a-4741-9d14-0537c4fb8f97"
|
||||
]
|
||||
}
|
||||
},
|
||||
"identifier": [
|
||||
"https://musicbrainz.org/recording/3f2bdbbd-063e-478c-a394-6da0cb303302"
|
||||
],
|
||||
"title": "Orange Forest"
|
||||
},
|
||||
{
|
||||
"creator": "Crippled Black Phoenix",
|
||||
"extension": {
|
||||
"https://musicbrainz.org/doc/jspf#track": {
|
||||
"added_at": "2023-07-10T10:03:48.833330+00:00",
|
||||
"added_by": "outsidecontext",
|
||||
"additional_metadata": {
|
||||
"caa_id": 28699084533,
|
||||
"caa_release_mbid": "92e7cf6c-e626-4409-81b0-0fcb8a0c3699"
|
||||
},
|
||||
"artist_identifiers": [
|
||||
"https://musicbrainz.org/artist/055b6082-b9cc-4688-85c4-8153c0ef2d70"
|
||||
]
|
||||
}
|
||||
},
|
||||
"identifier": [
|
||||
"https://musicbrainz.org/recording/14d612f0-4022-4adc-8cef-87a569e2d65c"
|
||||
],
|
||||
"title": "Lost"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
24
internal/backends/jspf/testdata/simple.jspf
vendored
Normal file
24
internal/backends/jspf/testdata/simple.jspf
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"playlist": {
|
||||
"title": "Two Songs From Thriller",
|
||||
"creator": "MJ Fan",
|
||||
"track": [
|
||||
{
|
||||
"location": [
|
||||
"http://example.com/billiejean.mp3"
|
||||
],
|
||||
"title": "Billie Jean",
|
||||
"creator": "Michael Jackson",
|
||||
"album": "Thriller"
|
||||
},
|
||||
{
|
||||
"location": [
|
||||
"http://example.com/thegirlismine.mp3"
|
||||
],
|
||||
"title": "The Girl Is Mine",
|
||||
"creator": "Michael Jackson",
|
||||
"album": "Thriller"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
173
internal/backends/listenbrainz/client.go
Normal file
173
internal/backends/listenbrainz/client.go
Normal file
|
@ -0,0 +1,173 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
const listenBrainzBaseURL = "https://api.listenbrainz.org/1/"
|
||||
|
||||
const (
|
||||
DefaultItemsPerGet = 25
|
||||
MaxItemsPerGet = 1000
|
||||
MaxListensPerRequest = 1000
|
||||
DefaultRateLimitWaitSeconds = 5
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
MaxResults int
|
||||
}
|
||||
|
||||
func NewClient(token string) Client {
|
||||
client := resty.New()
|
||||
client.SetBaseURL(listenBrainzBaseURL)
|
||||
client.SetAuthScheme("Token")
|
||||
client.SetAuthToken(token)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
|
||||
// Handle rate limiting (see https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#rate-limiting)
|
||||
client.SetRetryCount(5)
|
||||
client.AddRetryCondition(
|
||||
func(r *resty.Response, err error) bool {
|
||||
code := r.StatusCode()
|
||||
return code == http.StatusTooManyRequests || code >= http.StatusInternalServerError
|
||||
},
|
||||
)
|
||||
client.SetRetryMaxWaitTime(time.Duration(1 * time.Minute))
|
||||
client.SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) {
|
||||
var err error
|
||||
var retryAfter int = DefaultRateLimitWaitSeconds
|
||||
if resp.StatusCode() == http.StatusTooManyRequests {
|
||||
retryAfter, err = strconv.Atoi(resp.Header().Get("X-RateLimit-Reset-In"))
|
||||
if err != nil {
|
||||
retryAfter = DefaultRateLimitWaitSeconds
|
||||
}
|
||||
}
|
||||
return time.Duration(retryAfter * int(time.Second)), err
|
||||
})
|
||||
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
MaxResults: DefaultItemsPerGet,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
|
||||
const path = "/user/{username}/listens"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
SetPathParam("username", user).
|
||||
SetQueryParams(map[string]string{
|
||||
"max_ts": strconv.FormatInt(maxTime.Unix(), 10),
|
||||
"min_ts": strconv.FormatInt(minTime.Unix(), 10),
|
||||
"count": strconv.Itoa(c.MaxResults),
|
||||
}).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) {
|
||||
const path = "/submit-listens"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
SetBody(listens).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Post(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) {
|
||||
const path = "/feedback/user/{username}/get-feedback"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
SetPathParam("username", user).
|
||||
SetQueryParams(map[string]string{
|
||||
"status": strconv.Itoa(status),
|
||||
"offset": strconv.Itoa(offset),
|
||||
"count": strconv.Itoa(c.MaxResults),
|
||||
"metadata": "true",
|
||||
}).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) {
|
||||
const path = "/feedback/recording-feedback"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
SetBody(feedback).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Post(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) {
|
||||
const path = "/metadata/lookup"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"recording_name": recordingName,
|
||||
"artist_name": artistName,
|
||||
}).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
166
internal/backends/listenbrainz/client_test.go
Normal file
166
internal/backends/listenbrainz/client_test.go
Normal file
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package listenbrainz_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
token := "foobar123"
|
||||
client := listenbrainz.NewClient(token)
|
||||
assert.Equal(t, token, client.HttpClient.Token)
|
||||
assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults)
|
||||
}
|
||||
|
||||
func TestGetListens(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
client.MaxResults = 2
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://api.listenbrainz.org/1/user/outsidecontext/listens",
|
||||
"testdata/listens.json")
|
||||
|
||||
result, err := client.GetListens("outsidecontext", time.Now(), time.Now().Add(-2*time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(2, result.Payload.Count)
|
||||
require.Len(t, result.Payload.Listens, 2)
|
||||
assert.Equal("Shadowplay", result.Payload.Listens[0].TrackMetadata.TrackName)
|
||||
}
|
||||
|
||||
func TestSubmitListens(t *testing.T) {
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
||||
Status: "ok",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url := "https://api.listenbrainz.org/1/submit-listens"
|
||||
httpmock.RegisterResponder("POST", url, responder)
|
||||
|
||||
listens := listenbrainz.ListenSubmission{
|
||||
ListenType: listenbrainz.Import,
|
||||
Payload: []listenbrainz.Listen{
|
||||
{
|
||||
ListenedAt: time.Now().Unix(),
|
||||
TrackMetadata: listenbrainz.Track{
|
||||
TrackName: "Oweynagat",
|
||||
ArtistName: "Dool",
|
||||
},
|
||||
},
|
||||
{
|
||||
ListenedAt: time.Now().Add(-2 * time.Minute).Unix(),
|
||||
TrackMetadata: listenbrainz.Track{
|
||||
TrackName: "Say Just Words",
|
||||
ArtistName: "Paradise Lost",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
result, err := client.SubmitListens(listens)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "ok", result.Status)
|
||||
}
|
||||
|
||||
func TestGetFeedback(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
client.MaxResults = 2
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback",
|
||||
"testdata/feedback.json")
|
||||
|
||||
result, err := client.GetFeedback("outsidecontext", 1, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(2, result.Count)
|
||||
assert.Equal(302, result.TotalCount)
|
||||
assert.Equal(3, result.Offset)
|
||||
require.Len(t, result.Feedback, 2)
|
||||
assert.Equal("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", result.Feedback[0].RecordingMbid)
|
||||
}
|
||||
|
||||
func TestSendFeedback(t *testing.T) {
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
||||
Status: "ok",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url := "https://api.listenbrainz.org/1/feedback/recording-feedback"
|
||||
httpmock.RegisterResponder("POST", url, responder)
|
||||
|
||||
feedback := listenbrainz.Feedback{
|
||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
Score: 1,
|
||||
}
|
||||
result, err := client.SendFeedback(feedback)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "ok", result.Status)
|
||||
}
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://api.listenbrainz.org/1/metadata/lookup",
|
||||
"testdata/lookup.json")
|
||||
|
||||
result, err := client.Lookup("Paradise Lost", "Say Just Words")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal("Say Just Words", result.RecordingName)
|
||||
assert.Equal("Paradise Lost", result.ArtistCreditName)
|
||||
assert.Equal("569436a1-234a-44bc-a370-8f4d252bef21", result.RecordingMbid)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpmock.RegisterResponder("GET", url, responder)
|
||||
}
|
290
internal/backends/listenbrainz/listenbrainz.go
Normal file
290
internal/backends/listenbrainz/listenbrainz.go
Normal file
|
@ -0,0 +1,290 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
type ListenBrainzApiBackend struct {
|
||||
client Client
|
||||
username string
|
||||
existingMbids map[string]bool
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" }
|
||||
|
||||
func (b *ListenBrainzApiBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
b.client = NewClient(config.GetString("token"))
|
||||
b.client.MaxResults = MaxItemsPerGet
|
||||
b.username = config.GetString("username")
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) StartImport() error { return nil }
|
||||
func (b *ListenBrainzApiBackend) FinishImport() error { return nil }
|
||||
|
||||
func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||
startTime := time.Now()
|
||||
maxTime := startTime
|
||||
minTime := time.Unix(0, 0)
|
||||
|
||||
totalDuration := startTime.Sub(oldestTimestamp)
|
||||
|
||||
defer close(results)
|
||||
|
||||
// FIXME: Optimize by fetching the listens in reverse listen time order
|
||||
listens := make(models.ListensList, 0, 2*MaxItemsPerGet)
|
||||
p := models.Progress{Total: int64(totalDuration.Seconds())}
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.GetListens(b.username, maxTime, minTime)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
count := len(result.Payload.Listens)
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Set maxTime to the oldest returned listen
|
||||
maxTime = time.Unix(result.Payload.Listens[count-1].ListenedAt, 0)
|
||||
remainingTime := maxTime.Sub(oldestTimestamp)
|
||||
|
||||
for _, listen := range result.Payload.Listens {
|
||||
if listen.ListenedAt > oldestTimestamp.Unix() {
|
||||
listens = append(listens, listen.AsListen())
|
||||
} else {
|
||||
// result contains listens older then oldestTimestamp,
|
||||
// we can stop requesting more
|
||||
p.Total = int64(startTime.Sub(time.Unix(listen.ListenedAt, 0)).Seconds())
|
||||
break out
|
||||
}
|
||||
}
|
||||
|
||||
p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
|
||||
progress <- p
|
||||
}
|
||||
|
||||
sort.Sort(listens)
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Listens: listens, OldestTimestamp: oldestTimestamp}
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
total := len(export.Listens)
|
||||
for i := 0; i < total; i += MaxListensPerRequest {
|
||||
listens := export.Listens[i:min(i+MaxItemsPerGet, total)]
|
||||
count := len(listens)
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
submission := ListenSubmission{
|
||||
ListenType: Import,
|
||||
Payload: make([]Listen, 0, count),
|
||||
}
|
||||
|
||||
for _, l := range listens {
|
||||
l.FillAdditionalInfo()
|
||||
listen := Listen{
|
||||
ListenedAt: l.ListenedAt.Unix(),
|
||||
TrackMetadata: Track{
|
||||
TrackName: l.TrackName,
|
||||
ReleaseName: l.ReleaseName,
|
||||
ArtistName: l.ArtistName(),
|
||||
AdditionalInfo: l.AdditionalInfo,
|
||||
},
|
||||
}
|
||||
listen.TrackMetadata.AdditionalInfo["submission_client"] = "Scotty"
|
||||
submission.Payload = append(submission.Payload, listen)
|
||||
}
|
||||
|
||||
_, err := b.client.SubmitListens(submission)
|
||||
if err != nil {
|
||||
return importResult, err
|
||||
}
|
||||
|
||||
importResult.UpdateTimestamp(listens[count-1].ListenedAt)
|
||||
importResult.ImportCount += count
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
}
|
||||
|
||||
return importResult, nil
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||
offset := 0
|
||||
defer close(results)
|
||||
loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
|
||||
p := models.Progress{}
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.GetFeedback(b.username, 1, offset)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
count := len(result.Feedback)
|
||||
if count == 0 {
|
||||
break out
|
||||
}
|
||||
|
||||
for _, feedback := range result.Feedback {
|
||||
love := feedback.AsLove()
|
||||
if love.Created.Unix() > oldestTimestamp.Unix() {
|
||||
loves = append(loves, love)
|
||||
p.Elapsed += 1
|
||||
progress <- p
|
||||
} else {
|
||||
break out
|
||||
}
|
||||
}
|
||||
|
||||
p.Total = int64(result.TotalCount)
|
||||
p.Elapsed += int64(count)
|
||||
|
||||
offset += MaxItemsPerGet
|
||||
}
|
||||
|
||||
sort.Sort(loves)
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Loves: loves}
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
if len(b.existingMbids) == 0 {
|
||||
existingLovesChan := make(chan models.LovesResult)
|
||||
go b.ExportLoves(time.Unix(0, 0), existingLovesChan, progress)
|
||||
existingLoves := <-existingLovesChan
|
||||
if existingLoves.Error != nil {
|
||||
return importResult, existingLoves.Error
|
||||
}
|
||||
|
||||
// TODO: Store MBIDs directly
|
||||
b.existingMbids = make(map[string]bool, len(existingLoves.Loves))
|
||||
for _, love := range existingLoves.Loves {
|
||||
b.existingMbids[string(love.RecordingMbid)] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, love := range export.Loves {
|
||||
recordingMbid := string(love.RecordingMbid)
|
||||
|
||||
if recordingMbid == "" {
|
||||
lookup, err := b.client.Lookup(love.TrackName, love.ArtistName())
|
||||
if err == nil {
|
||||
recordingMbid = lookup.RecordingMbid
|
||||
}
|
||||
}
|
||||
|
||||
if recordingMbid != "" {
|
||||
ok := false
|
||||
errMsg := ""
|
||||
if b.existingMbids[recordingMbid] {
|
||||
ok = true
|
||||
} else {
|
||||
resp, err := b.client.SendFeedback(Feedback{
|
||||
RecordingMbid: recordingMbid,
|
||||
Score: 1,
|
||||
})
|
||||
ok = err == nil && resp.Status == "ok"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
importResult.UpdateTimestamp(love.Created)
|
||||
importResult.ImportCount += 1
|
||||
} else {
|
||||
msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v",
|
||||
love.TrackName, love.ArtistName(), errMsg)
|
||||
importResult.ImportErrors = append(importResult.ImportErrors, msg)
|
||||
}
|
||||
}
|
||||
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
}
|
||||
|
||||
return importResult, nil
|
||||
}
|
||||
|
||||
func (lbListen Listen) AsListen() models.Listen {
|
||||
listen := models.Listen{
|
||||
ListenedAt: time.Unix(lbListen.ListenedAt, 0),
|
||||
UserName: lbListen.UserName,
|
||||
Track: lbListen.TrackMetadata.AsTrack(),
|
||||
}
|
||||
return listen
|
||||
}
|
||||
|
||||
func (f Feedback) AsLove() models.Love {
|
||||
recordingMbid := models.MBID(f.RecordingMbid)
|
||||
track := f.TrackMetadata
|
||||
if track == nil {
|
||||
track = &Track{}
|
||||
}
|
||||
love := models.Love{
|
||||
UserName: f.UserName,
|
||||
RecordingMbid: recordingMbid,
|
||||
Created: time.Unix(f.Created, 0),
|
||||
Track: track.AsTrack(),
|
||||
}
|
||||
|
||||
if love.Track.RecordingMbid == "" {
|
||||
love.Track.RecordingMbid = love.RecordingMbid
|
||||
}
|
||||
|
||||
return love
|
||||
}
|
||||
|
||||
func (t Track) AsTrack() models.Track {
|
||||
track := models.Track{
|
||||
TrackName: t.TrackName,
|
||||
ReleaseName: t.ReleaseName,
|
||||
ArtistNames: []string{t.ArtistName},
|
||||
Duration: t.Duration(),
|
||||
TrackNumber: t.TrackNumber(),
|
||||
DiscNumber: t.DiscNumber(),
|
||||
RecordingMbid: models.MBID(t.RecordingMbid()),
|
||||
ReleaseMbid: models.MBID(t.ReleaseMbid()),
|
||||
ReleaseGroupMbid: models.MBID(t.ReleaseGroupMbid()),
|
||||
ISRC: t.ISRC(),
|
||||
AdditionalInfo: t.AdditionalInfo,
|
||||
}
|
||||
|
||||
if t.MbidMapping != nil && len(track.ArtistMbids) == 0 {
|
||||
for _, artistMbid := range t.MbidMapping.ArtistMbids {
|
||||
track.ArtistMbids = append(track.ArtistMbids, models.MBID(artistMbid))
|
||||
}
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
120
internal/backends/listenbrainz/listenbrainz_test.go
Normal file
120
internal/backends/listenbrainz/listenbrainz_test.go
Normal file
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package listenbrainz_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("token", "thetoken")
|
||||
backend := (&listenbrainz.ListenBrainzApiBackend{}).FromConfig(config)
|
||||
assert.IsType(t, &listenbrainz.ListenBrainzApiBackend{}, backend)
|
||||
}
|
||||
|
||||
func TestListenBrainzListenAsListen(t *testing.T) {
|
||||
lbListen := listenbrainz.Listen{
|
||||
ListenedAt: 1699289873,
|
||||
UserName: "outsidecontext",
|
||||
TrackMetadata: listenbrainz.Track{
|
||||
TrackName: "The Track",
|
||||
ArtistName: "Dool",
|
||||
ReleaseName: "Here Now, There Then",
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration_ms": 413787,
|
||||
"foo": "bar",
|
||||
"isrc": "DES561620801",
|
||||
"tracknumber": 5,
|
||||
"discnumber": 1,
|
||||
"recording_mbid": "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
"release_group_mbid": "80aca1ee-aa51-41be-9f75-024710d92ff4",
|
||||
"release_mbid": "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||
},
|
||||
},
|
||||
}
|
||||
listen := lbListen.AsListen()
|
||||
assert.Equal(t, time.Unix(1699289873, 0), listen.ListenedAt)
|
||||
assert.Equal(t, lbListen.UserName, listen.UserName)
|
||||
assert.Equal(t, time.Duration(413787*time.Millisecond), listen.Duration)
|
||||
assert.Equal(t, lbListen.TrackMetadata.TrackName, listen.TrackName)
|
||||
assert.Equal(t, lbListen.TrackMetadata.ReleaseName, listen.ReleaseName)
|
||||
assert.Equal(t, []string{lbListen.TrackMetadata.ArtistName}, listen.ArtistNames)
|
||||
assert.Equal(t, 5, listen.TrackNumber)
|
||||
assert.Equal(t, 1, listen.DiscNumber)
|
||||
assert.Equal(t, models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMbid)
|
||||
assert.Equal(t, models.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMbid)
|
||||
assert.Equal(t, models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMbid)
|
||||
assert.Equal(t, "DES561620801", listen.ISRC)
|
||||
assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"])
|
||||
}
|
||||
|
||||
func TestListenBrainzFeedbackAsLove(t *testing.T) {
|
||||
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
|
||||
releaseMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
|
||||
artistMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
|
||||
feedback := listenbrainz.Feedback{
|
||||
Created: 1699859066,
|
||||
RecordingMbid: recordingMbid,
|
||||
Score: 1,
|
||||
UserName: "ousidecontext",
|
||||
TrackMetadata: &listenbrainz.Track{
|
||||
TrackName: "Oweynagat",
|
||||
ArtistName: "Dool",
|
||||
ReleaseName: "Here Now, There Then",
|
||||
MbidMapping: &listenbrainz.MbidMapping{
|
||||
RecordingMbid: recordingMbid,
|
||||
ReleaseMbid: releaseMbid,
|
||||
ArtistMbids: []string{artistMbid},
|
||||
},
|
||||
},
|
||||
}
|
||||
love := feedback.AsLove()
|
||||
assert := assert.New(t)
|
||||
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
|
||||
assert.Equal(feedback.UserName, love.UserName)
|
||||
assert.Equal(feedback.TrackMetadata.TrackName, love.TrackName)
|
||||
assert.Equal(feedback.TrackMetadata.ReleaseName, love.ReleaseName)
|
||||
assert.Equal([]string{feedback.TrackMetadata.ArtistName}, love.ArtistNames)
|
||||
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
|
||||
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
|
||||
assert.Equal(models.MBID(releaseMbid), love.Track.ReleaseMbid)
|
||||
require.Len(t, love.Track.ArtistMbids, 1)
|
||||
assert.Equal(models.MBID(artistMbid), love.Track.ArtistMbids[0])
|
||||
}
|
||||
|
||||
func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
|
||||
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
|
||||
feedback := listenbrainz.Feedback{
|
||||
Created: 1699859066,
|
||||
RecordingMbid: recordingMbid,
|
||||
Score: 1,
|
||||
}
|
||||
love := feedback.AsLove()
|
||||
assert := assert.New(t)
|
||||
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
|
||||
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
|
||||
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
|
||||
assert.Empty(love.Track.TrackName)
|
||||
}
|
239
internal/backends/listenbrainz/models.go
Normal file
239
internal/backends/listenbrainz/models.go
Normal file
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/constraints"
|
||||
)
|
||||
|
||||
type GetListensResult struct {
|
||||
Payload GetListenPayload `json:"payload"`
|
||||
}
|
||||
|
||||
type GetListenPayload struct {
|
||||
Count int `json:"count"`
|
||||
UserName string `json:"user_id"`
|
||||
LatestListenTimestamp int64 `json:"latest_listen_ts"`
|
||||
Listens []Listen `json:"listens"`
|
||||
}
|
||||
|
||||
type listenType string
|
||||
|
||||
const (
|
||||
PlayingNow listenType = "playing_now"
|
||||
Single listenType = "single"
|
||||
Import listenType = "import"
|
||||
)
|
||||
|
||||
type ListenSubmission struct {
|
||||
ListenType listenType `json:"listen_type"`
|
||||
Payload []Listen `json:"payload"`
|
||||
}
|
||||
|
||||
type Listen struct {
|
||||
InsertedAt int64 `json:"inserted_at,omitempty"`
|
||||
ListenedAt int64 `json:"listened_at"`
|
||||
RecordingMsid string `json:"recording_msid,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
TrackMetadata Track `json:"track_metadata"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
ReleaseName string `json:"release_name,omitempty"`
|
||||
AdditionalInfo map[string]any `json:"additional_info,omitempty"`
|
||||
MbidMapping *MbidMapping `json:"mbid_mapping,omitempty"`
|
||||
}
|
||||
|
||||
type MbidMapping struct {
|
||||
RecordingName string `json:"recording_name,omitempty"`
|
||||
RecordingMbid string `json:"recording_mbid,omitempty"`
|
||||
ReleaseMbid string `json:"release_mbid,omitempty"`
|
||||
ArtistMbids []string `json:"artist_mbids,omitempty"`
|
||||
Artists []Artist `json:"artists,omitempty"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
ArtistCreditName string `json:"artist_credit_name,omitempty"`
|
||||
ArtistMbid string `json:"artist_mbid,omitempty"`
|
||||
JoinPhrase string `json:"join_phrase,omitempty"`
|
||||
}
|
||||
|
||||
type GetFeedbackResult struct {
|
||||
Count int `json:"count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Offset int `json:"offset"`
|
||||
Feedback []Feedback `json:"feedback"`
|
||||
}
|
||||
|
||||
type Feedback struct {
|
||||
Created int64 `json:"created,omitempty"`
|
||||
RecordingMbid string `json:"recording_mbid,omitempty"`
|
||||
RecordingMsid string `json:"recording_msid,omitempty"`
|
||||
Score int `json:"score,omitempty"`
|
||||
TrackMetadata *Track `json:"track_metadata,omitempty"`
|
||||
UserName string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type LookupResult struct {
|
||||
ArtistCreditName string `json:"artist_credit_name"`
|
||||
ReleaseName string `json:"release_name"`
|
||||
RecordingName string `json:"recording_name"`
|
||||
RecordingMbid string `json:"recording_mbid"`
|
||||
ReleaseMbid string `json:"release_mbid"`
|
||||
ArtistMbids []string `json:"artist_mbids"`
|
||||
}
|
||||
|
||||
type StatusResult struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type ErrorResult struct {
|
||||
Code int `json:"code"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (t Track) Duration() time.Duration {
|
||||
info := t.AdditionalInfo
|
||||
millisecondsF, ok := tryGetFloat[float64](info, "duration_ms")
|
||||
if ok {
|
||||
return time.Duration(int64(millisecondsF * float64(time.Millisecond)))
|
||||
}
|
||||
|
||||
millisecondsI, ok := tryGetInteger[int64](info, "duration_ms")
|
||||
if ok {
|
||||
return time.Duration(millisecondsI * int64(time.Millisecond))
|
||||
}
|
||||
|
||||
secondsF, ok := tryGetFloat[float64](info, "duration")
|
||||
if ok {
|
||||
return time.Duration(int64(secondsF * float64(time.Second)))
|
||||
}
|
||||
|
||||
secondsI, ok := tryGetInteger[int64](info, "duration")
|
||||
if ok {
|
||||
return time.Duration(secondsI * int64(time.Second))
|
||||
}
|
||||
|
||||
return time.Duration(0)
|
||||
}
|
||||
|
||||
func (t Track) TrackNumber() int {
|
||||
value, ok := tryGetInteger[int](t.AdditionalInfo, "tracknumber")
|
||||
if ok {
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t Track) DiscNumber() int {
|
||||
value, ok := tryGetInteger[int](t.AdditionalInfo, "discnumber")
|
||||
if ok {
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t Track) ISRC() string {
|
||||
return tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc")
|
||||
}
|
||||
|
||||
func (t Track) RecordingMbid() string {
|
||||
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid")
|
||||
if mbid == "" && t.MbidMapping != nil {
|
||||
return t.MbidMapping.RecordingMbid
|
||||
} else {
|
||||
return mbid
|
||||
}
|
||||
}
|
||||
|
||||
func (t Track) ReleaseMbid() string {
|
||||
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid")
|
||||
if mbid == "" && t.MbidMapping != nil {
|
||||
return t.MbidMapping.ReleaseMbid
|
||||
} else {
|
||||
return mbid
|
||||
}
|
||||
}
|
||||
|
||||
func (t Track) ReleaseGroupMbid() string {
|
||||
return tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid")
|
||||
}
|
||||
|
||||
func tryGetValueOrEmpty[T any](dict map[string]any, key string) T {
|
||||
var result T
|
||||
value, ok := dict[key].(T)
|
||||
if ok {
|
||||
result = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func tryGetFloat[T constraints.Float](dict map[string]any, key string) (T, bool) {
|
||||
valueFloat64, ok := dict[key].(float64)
|
||||
if ok {
|
||||
return T(valueFloat64), ok
|
||||
}
|
||||
|
||||
valueFloat32, ok := dict[key].(float32)
|
||||
if ok {
|
||||
return T(valueFloat32), ok
|
||||
}
|
||||
|
||||
valueStr, ok := dict[key].(string)
|
||||
if ok {
|
||||
valueFloat64, err := strconv.ParseFloat(valueStr, 64)
|
||||
if err == nil {
|
||||
return T(valueFloat64), ok
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func tryGetInteger[T constraints.Integer](dict map[string]any, key string) (T, bool) {
|
||||
valueInt64, ok := dict[key].(int64)
|
||||
if ok {
|
||||
return T(valueInt64), ok
|
||||
}
|
||||
|
||||
valueInt32, ok := dict[key].(int32)
|
||||
if ok {
|
||||
return T(valueInt32), ok
|
||||
}
|
||||
|
||||
valueInt, ok := dict[key].(int)
|
||||
if ok {
|
||||
return T(valueInt), ok
|
||||
}
|
||||
|
||||
valueFloat, ok := tryGetFloat[float64](dict, key)
|
||||
if ok {
|
||||
return T(valueFloat), ok
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
183
internal/backends/listenbrainz/models_test.go
Normal file
183
internal/backends/listenbrainz/models_test.go
Normal file
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package listenbrainz_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
)
|
||||
|
||||
func TestTrackDurationMillisecondsInt(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration_ms": 528235,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528235*time.Millisecond), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationMillisecondsInt64(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration_ms": int64(528235),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528235*time.Millisecond), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationMillisecondsFloat(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration_ms": 528235.0,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528235*time.Millisecond), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationSecondsInt(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration": 528,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528*time.Second), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationSecondsInt64(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration": int64(528),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528*time.Second), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationSecondsFloat(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration": 528.235,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528235*time.Millisecond), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationSecondsString(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"duration": "528.235",
|
||||
},
|
||||
}
|
||||
assert.Equal(t, time.Duration(528235*time.Millisecond), track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackDurationEmpty(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{},
|
||||
}
|
||||
assert.Empty(t, track.Duration())
|
||||
}
|
||||
|
||||
func TestTrackTrackNumber(t *testing.T) {
|
||||
expected := 7
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"tracknumber": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.TrackNumber())
|
||||
}
|
||||
|
||||
func TestTrackDiscNumber(t *testing.T) {
|
||||
expected := 7
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"discnumber": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.DiscNumber())
|
||||
}
|
||||
|
||||
func TestTrackTrackNumberString(t *testing.T) {
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"tracknumber": "12",
|
||||
},
|
||||
}
|
||||
assert.Equal(t, 12, track.TrackNumber())
|
||||
}
|
||||
|
||||
func TestTrackIsrc(t *testing.T) {
|
||||
expected := "TCAEJ1934417"
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"isrc": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.ISRC())
|
||||
}
|
||||
|
||||
func TestTrackRecordingMbid(t *testing.T) {
|
||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"recording_mbid": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.RecordingMbid())
|
||||
}
|
||||
|
||||
func TestTrackReleaseMbid(t *testing.T) {
|
||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"release_mbid": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.ReleaseMbid())
|
||||
}
|
||||
|
||||
func TestReleaseGroupMbid(t *testing.T) {
|
||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"release_group_mbid": expected,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.ReleaseGroupMbid())
|
||||
}
|
||||
|
||||
func TestMarshalPartialFeedback(t *testing.T) {
|
||||
feedback := listenbrainz.Feedback{
|
||||
Created: 1699859066,
|
||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
}
|
||||
b, err := json.Marshal(feedback)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"{\"created\":1699859066,\"recording_mbid\":\"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12\"}",
|
||||
string(b))
|
||||
}
|
63
internal/backends/listenbrainz/testdata/feedback.json
vendored
Normal file
63
internal/backends/listenbrainz/testdata/feedback.json
vendored
Normal file
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"count": 2,
|
||||
"feedback": [
|
||||
{
|
||||
"created": 1699859066,
|
||||
"recording_mbid": "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
"recording_msid": null,
|
||||
"score": 1,
|
||||
"track_metadata": {
|
||||
"artist_name": "Dool",
|
||||
"mbid_mapping": {
|
||||
"artist_mbids": [
|
||||
"24412926-c7bd-48e8-afad-8a285b42e131"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"artist_credit_name": "Dool",
|
||||
"artist_mbid": "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||
"join_phrase": ""
|
||||
}
|
||||
],
|
||||
"caa_id": 15991300316,
|
||||
"caa_release_mbid": "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||
"recording_mbid": "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
"release_mbid": "aa1ea1ac-7ec4-4542-a494-105afbfe547d"
|
||||
},
|
||||
"release_name": "Here Now, There Then",
|
||||
"track_name": "Oweynagat"
|
||||
},
|
||||
"user_id": "outsidecontext"
|
||||
},
|
||||
{
|
||||
"created": 1698911509,
|
||||
"recording_mbid": "ba49cada-9873-4bdb-9506-533cb63372c8",
|
||||
"recording_msid": null,
|
||||
"score": 1,
|
||||
"track_metadata": {
|
||||
"artist_name": "Hazeshuttle",
|
||||
"mbid_mapping": {
|
||||
"artist_mbids": [
|
||||
"54292079-790c-4e99-bf8d-12efa29fa3e9"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"artist_credit_name": "Hazeshuttle",
|
||||
"artist_mbid": "54292079-790c-4e99-bf8d-12efa29fa3e9",
|
||||
"join_phrase": ""
|
||||
}
|
||||
],
|
||||
"caa_id": 35325252352,
|
||||
"caa_release_mbid": "6d0ee27f-dc9f-4dab-8d7d-f4dcd14dc54a",
|
||||
"recording_mbid": "ba49cada-9873-4bdb-9506-533cb63372c8",
|
||||
"release_mbid": "6d0ee27f-dc9f-4dab-8d7d-f4dcd14dc54a"
|
||||
},
|
||||
"release_name": "Hazeshuttle",
|
||||
"track_name": "Homosativa"
|
||||
},
|
||||
"user_id": "outsidecontext"
|
||||
}
|
||||
],
|
||||
"offset": 3,
|
||||
"total_count": 302
|
||||
}
|
53
internal/backends/listenbrainz/testdata/listen.json
vendored
Normal file
53
internal/backends/listenbrainz/testdata/listen.json
vendored
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"inserted_at": 1700580352,
|
||||
"listened_at": 1700580273,
|
||||
"recording_msid": "0a3144ea-f85c-4238-b0e3-e3d7a422df9d",
|
||||
"track_metadata": {
|
||||
"additional_info": {
|
||||
"artist_names": [
|
||||
"Dool"
|
||||
],
|
||||
"discnumber": 1,
|
||||
"duration_ms": 413826,
|
||||
"isrc": "DES561620801",
|
||||
"music_service": "spotify.com",
|
||||
"origin_url": "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V",
|
||||
"recording_msid": "0a3144ea-f85c-4238-b0e3-e3d7a422df9d",
|
||||
"release_artist_name": "Dool",
|
||||
"release_artist_names": [
|
||||
"Dool"
|
||||
],
|
||||
"spotify_album_artist_ids": [
|
||||
"https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"
|
||||
],
|
||||
"spotify_album_id": "https://open.spotify.com/album/5U1umzRH4EONHWsFgPtRbA",
|
||||
"spotify_artist_ids": [
|
||||
"https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"
|
||||
],
|
||||
"spotify_id": "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V",
|
||||
"submission_client": "listenbrainz",
|
||||
"tracknumber": 5
|
||||
},
|
||||
"artist_name": "Dool",
|
||||
"mbid_mapping": {
|
||||
"artist_mbids": [
|
||||
"24412926-c7bd-48e8-afad-8a285b42e131"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"artist_credit_name": "Dool",
|
||||
"artist_mbid": "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||
"join_phrase": ""
|
||||
}
|
||||
],
|
||||
"caa_id": 15991300316,
|
||||
"caa_release_mbid": "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||
"recording_mbid": "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
"recording_name": "Oweynagat",
|
||||
"release_mbid": "aa1ea1ac-7ec4-4542-a494-105afbfe547d"
|
||||
},
|
||||
"release_name": "Here Now, There Then",
|
||||
"track_name": "Oweynagat"
|
||||
},
|
||||
"user_name": "outsidecontext"
|
||||
}
|
115
internal/backends/listenbrainz/testdata/listens.json
vendored
Normal file
115
internal/backends/listenbrainz/testdata/listens.json
vendored
Normal file
|
@ -0,0 +1,115 @@
|
|||
{
|
||||
"payload": {
|
||||
"count": 2,
|
||||
"latest_listen_ts": 1699718723,
|
||||
"listens": [
|
||||
{
|
||||
"inserted_at": 1699719320,
|
||||
"listened_at": 1699718723,
|
||||
"recording_msid": "94794568-ddd5-43be-a770-b6da011c6872",
|
||||
"track_metadata": {
|
||||
"additional_info": {
|
||||
"artist_names": [
|
||||
"Joy Division"
|
||||
],
|
||||
"discnumber": 1,
|
||||
"duration_ms": 242933,
|
||||
"isrc": "NLEM80819612",
|
||||
"music_service": "spotify.com",
|
||||
"origin_url": "https://open.spotify.com/track/4pzYKPOjn1ITfEanoWIvrn",
|
||||
"recording_msid": "94794568-ddd5-43be-a770-b6da011c6872",
|
||||
"release_artist_name": "Warsaw",
|
||||
"release_artist_names": [
|
||||
"Warsaw"
|
||||
],
|
||||
"spotify_album_artist_ids": [
|
||||
"https://open.spotify.com/artist/0SS65FajB9S7ZILHdNOCsp"
|
||||
],
|
||||
"spotify_album_id": "https://open.spotify.com/album/3kDMRpbBe5eFMMo1pSYFhN",
|
||||
"spotify_artist_ids": [
|
||||
"https://open.spotify.com/artist/432R46LaYsJZV2Gmc4jUV5"
|
||||
],
|
||||
"spotify_id": "https://open.spotify.com/track/4pzYKPOjn1ITfEanoWIvrn",
|
||||
"submission_client": "listenbrainz",
|
||||
"tracknumber": 1
|
||||
},
|
||||
"artist_name": "Joy Division",
|
||||
"mbid_mapping": {
|
||||
"artist_mbids": [
|
||||
"9a58fda3-f4ed-4080-a3a5-f457aac9fcdd"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"artist_credit_name": "Joy Division",
|
||||
"artist_mbid": "9a58fda3-f4ed-4080-a3a5-f457aac9fcdd",
|
||||
"join_phrase": ""
|
||||
}
|
||||
],
|
||||
"caa_id": 3880053972,
|
||||
"caa_release_mbid": "d2f506bb-cfb5-327e-b8d6-cf4036c77cfa",
|
||||
"recording_mbid": "17ddd699-a35f-4f80-8064-9a807ad2799f",
|
||||
"recording_name": "Shadowplay",
|
||||
"release_mbid": "d2f506bb-cfb5-327e-b8d6-cf4036c77cfa"
|
||||
},
|
||||
"release_name": "Warsaw",
|
||||
"track_name": "Shadowplay"
|
||||
},
|
||||
"user_name": "outsidecontext"
|
||||
},
|
||||
{
|
||||
"inserted_at": 1699718945,
|
||||
"listened_at": 1699718480,
|
||||
"recording_msid": "5b6a3471-8f22-414b-a061-e45627ed26b8",
|
||||
"track_metadata": {
|
||||
"additional_info": {
|
||||
"artist_names": [
|
||||
"SubRosa"
|
||||
],
|
||||
"discnumber": 1,
|
||||
"duration_ms": 350760,
|
||||
"isrc": "USN681110018",
|
||||
"music_service": "spotify.com",
|
||||
"origin_url": "https://open.spotify.com/track/0L0oz4yFk5hMmo52qAUQRF",
|
||||
"recording_msid": "5b6a3471-8f22-414b-a061-e45627ed26b8",
|
||||
"release_artist_name": "SubRosa",
|
||||
"release_artist_names": [
|
||||
"SubRosa"
|
||||
],
|
||||
"spotify_album_artist_ids": [
|
||||
"https://open.spotify.com/artist/4hAqIOkN2Q4apnbcOUUb7h"
|
||||
],
|
||||
"spotify_album_id": "https://open.spotify.com/album/3mYNFe9G85URf09SmoX2sB",
|
||||
"spotify_artist_ids": [
|
||||
"https://open.spotify.com/artist/4hAqIOkN2Q4apnbcOUUb7h"
|
||||
],
|
||||
"spotify_id": "https://open.spotify.com/track/0L0oz4yFk5hMmo52qAUQRF",
|
||||
"submission_client": "listenbrainz",
|
||||
"tracknumber": 1
|
||||
},
|
||||
"artist_name": "SubRosa",
|
||||
"mbid_mapping": {
|
||||
"artist_mbids": [
|
||||
"aa1c41d7-7836-42d0-8e0e-b5d565767db6"
|
||||
],
|
||||
"artists": [
|
||||
{
|
||||
"artist_credit_name": "SubRosa",
|
||||
"artist_mbid": "aa1c41d7-7836-42d0-8e0e-b5d565767db6",
|
||||
"join_phrase": ""
|
||||
}
|
||||
],
|
||||
"caa_id": 6163307004,
|
||||
"caa_release_mbid": "eb5dec80-ec5d-49a3-a622-3c02eefa0774",
|
||||
"recording_mbid": "c2374d60-7bfa-44ef-b5dc-f7bc6004b4a7",
|
||||
"recording_name": "Borrowed Time, Borrowed Eyes",
|
||||
"release_mbid": "9fba6ca8-4acb-44a9-951a-6c1fb8511443"
|
||||
},
|
||||
"release_name": "No Help for the Mighty Ones",
|
||||
"track_name": "Borrowed Time, Borrowed Eyes"
|
||||
},
|
||||
"user_name": "outsidecontext"
|
||||
}
|
||||
],
|
||||
"user_id": "outsidecontext"
|
||||
}
|
||||
}
|
10
internal/backends/listenbrainz/testdata/lookup.json
vendored
Normal file
10
internal/backends/listenbrainz/testdata/lookup.json
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"artist_credit_name": "Paradise Lost",
|
||||
"artist_mbids": [
|
||||
"10bf95b6-30e3-44f1-817f-45762cdc0de0"
|
||||
],
|
||||
"recording_mbid": "569436a1-234a-44bc-a370-8f4d252bef21",
|
||||
"recording_name": "Say Just Words",
|
||||
"release_mbid": "90b2d144-e5f3-3192-9da5-0d72d67c61be",
|
||||
"release_name": "One Second"
|
||||
}
|
79
internal/backends/maloja/client.go
Normal file
79
internal/backends/maloja/client.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package maloja
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
const MaxItemsPerGet = 1000
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
token string
|
||||
}
|
||||
|
||||
func NewClient(serverUrl string, token string) Client {
|
||||
client := resty.New()
|
||||
client.SetBaseURL(serverUrl)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
client.SetRetryCount(5)
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) {
|
||||
const path = "/apis/mlj_1/scrobbles"
|
||||
response, err := c.HttpClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"page": strconv.Itoa(page),
|
||||
"perpage": strconv.Itoa(perPage),
|
||||
}).
|
||||
SetResult(&result).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(response.String())
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err error) {
|
||||
const path = "/apis/mlj_1/newscrobble"
|
||||
scrobble.Key = c.token
|
||||
response, err := c.HttpClient.R().
|
||||
SetBody(scrobble).
|
||||
SetResult(&result).
|
||||
Post(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(response.String())
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
91
internal/backends/maloja/client_test.go
Normal file
91
internal/backends/maloja/client_test.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package maloja_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/maloja"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
serverUrl := "https://maloja.example.com"
|
||||
token := "foobar123"
|
||||
client := maloja.NewClient(serverUrl, token)
|
||||
assert.Equal(t, serverUrl, client.HttpClient.BaseURL)
|
||||
}
|
||||
|
||||
func TestGetScrobbles(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
serverUrl := "https://maloja.example.com"
|
||||
token := "thetoken"
|
||||
client := maloja.NewClient(serverUrl, token)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://maloja.example.com/apis/mlj_1/scrobbles",
|
||||
"testdata/scrobbles.json")
|
||||
|
||||
result, err := client.GetScrobbles(0, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
require.Len(t, result.List, 2)
|
||||
assert.Equal("Way to Eden", result.List[0].Track.Title)
|
||||
assert.Equal(int64(558), result.List[0].Duration)
|
||||
}
|
||||
|
||||
func TestNewScrobble(t *testing.T) {
|
||||
server := "https://maloja.example.com"
|
||||
client := maloja.NewClient(server, "thetoken")
|
||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url := server + "/apis/mlj_1/newscrobble"
|
||||
httpmock.RegisterResponder("POST", url, responder)
|
||||
|
||||
scrobble := maloja.NewScrobble{
|
||||
Title: "Oweynagat",
|
||||
Artist: "Dool",
|
||||
Time: 1699574369,
|
||||
}
|
||||
result, err := client.NewScrobble(scrobble)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "success", result.Status)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpmock.RegisterResponder("GET", url, responder)
|
||||
}
|
138
internal/backends/maloja/maloja.go
Normal file
138
internal/backends/maloja/maloja.go
Normal file
|
@ -0,0 +1,138 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package maloja
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
type MalojaApiBackend struct {
|
||||
client Client
|
||||
nofix bool
|
||||
}
|
||||
|
||||
func (b *MalojaApiBackend) Name() string { return "maloja" }
|
||||
|
||||
func (b *MalojaApiBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
b.client = NewClient(
|
||||
config.GetString("server-url"),
|
||||
config.GetString("token"),
|
||||
)
|
||||
b.nofix = config.GetBool("nofix")
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *MalojaApiBackend) StartImport() error { return nil }
|
||||
func (b *MalojaApiBackend) FinishImport() error { return nil }
|
||||
|
||||
func (b *MalojaApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||
page := 0
|
||||
perPage := MaxItemsPerGet
|
||||
|
||||
defer close(results)
|
||||
|
||||
// We need to gather the full list of listens in order to sort them
|
||||
listens := make(models.ListensList, 0, 2*perPage)
|
||||
p := models.Progress{Total: int64(perPage)}
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.GetScrobbles(page, perPage)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
count := len(result.List)
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, scrobble := range result.List {
|
||||
if scrobble.ListenedAt > oldestTimestamp.Unix() {
|
||||
p.Elapsed += 1
|
||||
listens = append(listens, scrobble.AsListen())
|
||||
} else {
|
||||
break out
|
||||
}
|
||||
}
|
||||
|
||||
p.Total += int64(perPage)
|
||||
progress <- p
|
||||
page += 1
|
||||
}
|
||||
|
||||
sort.Sort(listens)
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Listens: listens}
|
||||
}
|
||||
|
||||
func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
for _, listen := range export.Listens {
|
||||
scrobble := NewScrobble{
|
||||
Title: listen.TrackName,
|
||||
Artists: listen.ArtistNames,
|
||||
Album: listen.ReleaseName,
|
||||
Duration: int64(listen.PlaybackDuration.Seconds()),
|
||||
Length: int64(listen.Duration.Seconds()),
|
||||
Time: listen.ListenedAt.Unix(),
|
||||
Nofix: b.nofix,
|
||||
}
|
||||
|
||||
resp, err := b.client.NewScrobble(scrobble)
|
||||
if err != nil {
|
||||
return importResult, err
|
||||
} else if resp.Status != "success" {
|
||||
return importResult, errors.New(resp.Error.Description)
|
||||
}
|
||||
|
||||
importResult.UpdateTimestamp(listen.ListenedAt)
|
||||
importResult.ImportCount += 1
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
}
|
||||
|
||||
return importResult, nil
|
||||
}
|
||||
|
||||
func (s Scrobble) AsListen() models.Listen {
|
||||
track := s.Track
|
||||
listen := models.Listen{
|
||||
ListenedAt: time.Unix(s.ListenedAt, 0),
|
||||
PlaybackDuration: time.Duration(s.Duration * int64(time.Second)),
|
||||
Track: models.Track{
|
||||
TrackName: track.Title,
|
||||
ReleaseName: track.Album.Title,
|
||||
ArtistNames: track.Artists,
|
||||
Duration: time.Duration(track.Length * int64(time.Second)),
|
||||
AdditionalInfo: map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
client, found := strings.CutPrefix(s.Origin, "client:")
|
||||
if found {
|
||||
listen.AdditionalInfo["media_player"] = client
|
||||
}
|
||||
|
||||
return listen
|
||||
}
|
56
internal/backends/maloja/maloja_test.go
Normal file
56
internal/backends/maloja/maloja_test.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package maloja_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/maloja"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("token", "thetoken")
|
||||
backend := (&maloja.MalojaApiBackend{}).FromConfig(config)
|
||||
assert.IsType(t, &maloja.MalojaApiBackend{}, backend)
|
||||
}
|
||||
|
||||
func TestScrobbleAsListen(t *testing.T) {
|
||||
scrobble := maloja.Scrobble{
|
||||
ListenedAt: 1699289873,
|
||||
Track: maloja.Track{
|
||||
Title: "Oweynagat",
|
||||
Album: maloja.Album{
|
||||
Title: "Here Now, There Then",
|
||||
},
|
||||
Artists: []string{"Dool"},
|
||||
Length: 414,
|
||||
},
|
||||
Origin: "client:Funkwhale",
|
||||
}
|
||||
listen := scrobble.AsListen()
|
||||
assert := assert.New(t)
|
||||
assert.Equal(time.Unix(1699289873, 0), listen.ListenedAt)
|
||||
assert.Equal(time.Duration(414*time.Second), listen.Duration)
|
||||
assert.Equal(scrobble.Track.Title, listen.TrackName)
|
||||
assert.Equal(scrobble.Track.Album.Title, listen.ReleaseName)
|
||||
assert.Equal(scrobble.Track.Artists, listen.ArtistNames)
|
||||
assert.Equal("Funkwhale", listen.AdditionalInfo["media_player"])
|
||||
}
|
86
internal/backends/maloja/models.go
Normal file
86
internal/backends/maloja/models.go
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package maloja
|
||||
|
||||
type GenericResult struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type GetScrobblesResult struct {
|
||||
GenericResult
|
||||
List []Scrobble `json:"list"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type NewScrobbleResult struct {
|
||||
GenericResult
|
||||
Track Track `json:"track"`
|
||||
Description string `json:"desc"`
|
||||
Error Error `json:"error"`
|
||||
}
|
||||
|
||||
type Scrobble struct {
|
||||
ListenedAt int64 `json:"time"`
|
||||
Duration int64 `json:"duration"`
|
||||
// Maloja sets Origin to the name of the API key
|
||||
// prefixed with "client:"
|
||||
Origin string `json:"origin"`
|
||||
Track Track `json:"track"`
|
||||
}
|
||||
|
||||
type NewScrobble struct {
|
||||
Key string `json:"key"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Artists []string `json:"artists,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtists []string `json:"albumartists,omitempty"`
|
||||
Duration int64 `json:"duration,omitempty"`
|
||||
Length int64 `json:"length,omitempty"`
|
||||
Time int64 `json:"time,omitempty"`
|
||||
Nofix bool `json:"nofix,omitempty"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Title string `json:"title"`
|
||||
Artists []string `json:"artists"`
|
||||
Album Album `json:"album"`
|
||||
Length int64 `json:"length"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
Title string `json:"albumtitle"`
|
||||
Artists []string `json:"artists"`
|
||||
}
|
||||
|
||||
type Pagination struct {
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perpage"`
|
||||
NextPage string `json:"next_page"`
|
||||
PrevPage string `json:"prev_page"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
Description string `json:"desc"`
|
||||
}
|
10
internal/backends/maloja/testdata/newscrobble-result.json
vendored
Normal file
10
internal/backends/maloja/testdata/newscrobble-result.json
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"status": "success",
|
||||
"track": {
|
||||
"artists": [
|
||||
"Dool"
|
||||
],
|
||||
"title": "Oweynagat"
|
||||
},
|
||||
"desc": "Scrobbled Oweynagat by Dool"
|
||||
}
|
47
internal/backends/maloja/testdata/scrobbles.json
vendored
Normal file
47
internal/backends/maloja/testdata/scrobbles.json
vendored
Normal file
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"list": [
|
||||
{
|
||||
"time": 1699574369,
|
||||
"track": {
|
||||
"artists": [
|
||||
"Hazeshuttle"
|
||||
],
|
||||
"title": "Way to Eden",
|
||||
"album": {
|
||||
"artists": [
|
||||
"Hazeshuttle"
|
||||
],
|
||||
"albumtitle": "Hazeshuttle"
|
||||
},
|
||||
"length": 567
|
||||
},
|
||||
"duration": 558,
|
||||
"origin": "client:Funkwhale"
|
||||
},
|
||||
{
|
||||
"time": 1699573362,
|
||||
"track": {
|
||||
"artists": [
|
||||
"Hazeshuttle"
|
||||
],
|
||||
"title": "Homosativa",
|
||||
"album": {
|
||||
"artists": [
|
||||
"Hazeshuttle"
|
||||
],
|
||||
"albumtitle": "Hazeshuttle"
|
||||
},
|
||||
"length": 1007
|
||||
},
|
||||
"duration": null,
|
||||
"origin": "client:Funkwhale"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 0,
|
||||
"perpage": 2,
|
||||
"next_page": "/apis/mlj_1/scrobbles?page=1&perpage=2",
|
||||
"prev_page": null
|
||||
}
|
||||
}
|
110
internal/backends/process.go
Normal file
110
internal/backends/process.go
Normal file
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package backends
|
||||
|
||||
import "go.uploadedlobster.com/scotty/internal/models"
|
||||
|
||||
func ProcessListensImports(importer models.ListensImport, results chan models.ListensResult, out chan models.ImportResult, progress chan models.Progress) {
|
||||
defer close(out)
|
||||
defer close(progress)
|
||||
result := models.ImportResult{}
|
||||
|
||||
err := importer.StartImport()
|
||||
if err != nil {
|
||||
handleError(result, err, out, progress)
|
||||
return
|
||||
}
|
||||
|
||||
for exportResult := range results {
|
||||
if exportResult.Error != nil {
|
||||
handleError(result, exportResult.Error, out, progress)
|
||||
return
|
||||
}
|
||||
|
||||
if exportResult.Total > 0 {
|
||||
result.TotalCount = exportResult.Total
|
||||
} else {
|
||||
result.TotalCount += len(exportResult.Listens)
|
||||
}
|
||||
importResult, err := importer.ImportListens(exportResult, result, progress)
|
||||
if err != nil {
|
||||
handleError(importResult, err, out, progress)
|
||||
return
|
||||
}
|
||||
|
||||
result.Update(importResult)
|
||||
progress <- models.Progress{}.FromImportResult(result)
|
||||
}
|
||||
|
||||
err = importer.FinishImport()
|
||||
if err != nil {
|
||||
handleError(result, err, out, progress)
|
||||
return
|
||||
}
|
||||
|
||||
progress <- models.Progress{}.FromImportResult(result).Complete()
|
||||
out <- result
|
||||
}
|
||||
|
||||
func ProcessLovesImports(importer models.LovesImport, results chan models.LovesResult, out chan models.ImportResult, progress chan models.Progress) {
|
||||
defer close(out)
|
||||
defer close(progress)
|
||||
result := models.ImportResult{}
|
||||
|
||||
err := importer.StartImport()
|
||||
if err != nil {
|
||||
handleError(result, err, out, progress)
|
||||
return
|
||||
}
|
||||
|
||||
for exportResult := range results {
|
||||
if exportResult.Error != nil {
|
||||
handleError(result, exportResult.Error, out, progress)
|
||||
return
|
||||
}
|
||||
|
||||
if exportResult.Total > 0 {
|
||||
result.TotalCount = exportResult.Total
|
||||
} else {
|
||||
result.TotalCount += len(exportResult.Loves)
|
||||
}
|
||||
importResult, err := importer.ImportLoves(exportResult, result, progress)
|
||||
if err != nil {
|
||||
handleError(importResult, err, out, progress)
|
||||
return
|
||||
}
|
||||
|
||||
result.Update(importResult)
|
||||
progress <- models.Progress{}.FromImportResult(result)
|
||||
}
|
||||
|
||||
err = importer.FinishImport()
|
||||
if err != nil {
|
||||
handleError(result, err, out, progress)
|
||||
return
|
||||
}
|
||||
|
||||
progress <- models.Progress{}.FromImportResult(result).Complete()
|
||||
out <- result
|
||||
}
|
||||
|
||||
func handleError(result models.ImportResult, err error, out chan models.ImportResult, progress chan models.Progress) {
|
||||
result.Error = err
|
||||
progress <- models.Progress{}.FromImportResult(result).Complete()
|
||||
out <- result
|
||||
}
|
212
internal/backends/scrobblerlog/parser.go
Normal file
212
internal/backends/scrobblerlog/parser.go
Normal file
|
@ -0,0 +1,212 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package scrobblerlog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
type ScrobblerLog struct {
|
||||
Timezone string
|
||||
Client string
|
||||
Listens models.ListensList
|
||||
}
|
||||
|
||||
func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) {
|
||||
result := ScrobblerLog{
|
||||
Listens: make(models.ListensList, 0),
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(data)
|
||||
err := ReadHeader(reader, &result)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
tsvReader := csv.NewReader(reader)
|
||||
tsvReader.Comma = '\t'
|
||||
// Row length is often flexible
|
||||
tsvReader.FieldsPerRecord = -1
|
||||
|
||||
for {
|
||||
// A row is:
|
||||
// artistName releaseName trackName trackNumber duration rating timestamp recordingMbid
|
||||
row, err := tsvReader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// fmt.Printf("row: %v\n", row)
|
||||
|
||||
// We consider only the last field (recording MBID) optional
|
||||
if len(row) < 7 {
|
||||
line, _ := tsvReader.FieldPos(0)
|
||||
return result, errors.New(fmt.Sprintf(
|
||||
"Invalid record in scrobblerlog line %v", line))
|
||||
}
|
||||
|
||||
rating := row[5]
|
||||
if !includeSkipped && rating == "S" {
|
||||
continue
|
||||
}
|
||||
|
||||
client := strings.Split(result.Client, " ")[0]
|
||||
listen, err := rowToListen(row, client)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.Listens = append(result.Listens, listen)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func Write(data io.Writer, listens models.ListensList) (lastTimestamp time.Time, err error) {
|
||||
tsvWriter := csv.NewWriter(data)
|
||||
tsvWriter.Comma = '\t'
|
||||
|
||||
for _, listen := range listens {
|
||||
if listen.ListenedAt.Unix() > lastTimestamp.Unix() {
|
||||
lastTimestamp = listen.ListenedAt
|
||||
}
|
||||
|
||||
// A row is:
|
||||
// artistName releaseName trackName trackNumber duration rating timestamp recordingMbid
|
||||
rating, ok := listen.AdditionalInfo["rockbox_rating"].(string)
|
||||
if !ok || rating == "" {
|
||||
rating = "L"
|
||||
}
|
||||
tsvWriter.Write([]string{
|
||||
listen.ArtistName(),
|
||||
listen.ReleaseName,
|
||||
listen.TrackName,
|
||||
strconv.Itoa(listen.TrackNumber),
|
||||
strconv.Itoa(int(listen.Duration.Seconds())),
|
||||
rating,
|
||||
strconv.Itoa(int(listen.ListenedAt.Unix())),
|
||||
string(listen.RecordingMbid),
|
||||
})
|
||||
}
|
||||
|
||||
tsvWriter.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
func ReadHeader(reader *bufio.Reader, log *ScrobblerLog) error {
|
||||
// Skip header
|
||||
for i := 0; i < 3; i++ {
|
||||
line, _, err := reader.ReadLine()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(line) == 0 || line[0] != '#' {
|
||||
err = errors.New(fmt.Sprintf("Unexpected header (line %v)", i))
|
||||
} else {
|
||||
text := string(line)
|
||||
if i == 0 && !strings.HasPrefix(text, "#AUDIOSCROBBLER/1") {
|
||||
err = errors.New(fmt.Sprintf("Not a scrobbler log file"))
|
||||
}
|
||||
|
||||
timezone, found := strings.CutPrefix(text, "#TZ/")
|
||||
if strings.HasPrefix(text, "#TZ/") {
|
||||
log.Timezone = timezone
|
||||
}
|
||||
|
||||
client, found := strings.CutPrefix(text, "#CLIENT/")
|
||||
if found {
|
||||
log.Client = client
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteHeader(writer io.Writer, log *ScrobblerLog) error {
|
||||
headers := []string{
|
||||
"#AUDIOSCROBBLER/1.1\n",
|
||||
"#TZ/" + log.Timezone + "\n",
|
||||
"#CLIENT/" + log.Client + "\n",
|
||||
}
|
||||
for _, line := range headers {
|
||||
_, err := writer.Write([]byte(line))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rowToListen(row []string, client string) (models.Listen, error) {
|
||||
var listen models.Listen
|
||||
trackNumber, err := strconv.Atoi(row[3])
|
||||
if err != nil {
|
||||
return listen, err
|
||||
}
|
||||
|
||||
duration, err := strconv.Atoi(row[4])
|
||||
if err != nil {
|
||||
return listen, err
|
||||
}
|
||||
|
||||
timestamp, err := strconv.Atoi(row[6])
|
||||
if err != nil {
|
||||
return listen, err
|
||||
}
|
||||
|
||||
listen = models.Listen{
|
||||
Track: models.Track{
|
||||
ArtistNames: []string{row[0]},
|
||||
ReleaseName: row[1],
|
||||
TrackName: row[2],
|
||||
TrackNumber: trackNumber,
|
||||
Duration: time.Duration(duration * int(time.Second)),
|
||||
AdditionalInfo: models.AdditionalInfo{
|
||||
"rockbox_rating": row[5],
|
||||
"media_player": client,
|
||||
},
|
||||
},
|
||||
ListenedAt: time.Unix(int64(timestamp), 0),
|
||||
}
|
||||
|
||||
if len(row) > 7 {
|
||||
listen.Track.RecordingMbid = models.MBID(row[7])
|
||||
}
|
||||
|
||||
return listen, nil
|
||||
}
|
128
internal/backends/scrobblerlog/parser_test.go
Normal file
128
internal/backends/scrobblerlog/parser_test.go
Normal file
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package scrobblerlog_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
var testScrobblerLog = `#AUDIOSCROBBLER/1.1
|
||||
#TZ/UNKNOWN
|
||||
#CLIENT/Rockbox sansaclipplus $Revision$
|
||||
Özcan Deniz Ses ve Ayrilik Sevdanin rengi (sipacik) byMrTurkey 5 306 L 1260342084
|
||||
Özcan Deniz Hediye 2@V@7 Bir Dudaktan 1 210 L 1260342633
|
||||
KOMPROMAT Traum und Existenz Possession 1 220 L 1260357290 d66b1084-b2ae-4661-8382-5d0c1c484b6d
|
||||
Kraftwerk Trans-Europe Express The Hall of Mirrors 2 474 S 1260358000 385ba9e9-626d-4750-a607-58e541dca78e
|
||||
Teeth Agency You Don't Have To Live In Pain Wolfs Jam 2 107 L 1260359404 1262beaf-19f8-4534-b9ed-7eef9ca8e83f
|
||||
`
|
||||
|
||||
func TestParser(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
data := bytes.NewBufferString(testScrobblerLog)
|
||||
result, err := scrobblerlog.Parse(data, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal("UNKNOWN", result.Timezone)
|
||||
assert.Equal("Rockbox sansaclipplus $Revision$", result.Client)
|
||||
assert.Len(result.Listens, 5)
|
||||
listen1 := result.Listens[0]
|
||||
assert.Equal("Özcan Deniz", listen1.ArtistName())
|
||||
assert.Equal("Ses ve Ayrilik", listen1.ReleaseName)
|
||||
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", listen1.TrackName)
|
||||
assert.Equal(5, listen1.TrackNumber)
|
||||
assert.Equal(time.Duration(306*time.Second), listen1.Duration)
|
||||
assert.Equal("L", listen1.AdditionalInfo["rockbox_rating"])
|
||||
assert.Equal(time.Unix(1260342084, 0), listen1.ListenedAt)
|
||||
assert.Equal(models.MBID(""), listen1.RecordingMbid)
|
||||
listen4 := result.Listens[3]
|
||||
assert.Equal("S", listen4.AdditionalInfo["rockbox_rating"])
|
||||
assert.Equal(models.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), listen4.RecordingMbid)
|
||||
}
|
||||
|
||||
func TestParserExcludeSkipped(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
data := bytes.NewBufferString(testScrobblerLog)
|
||||
result, err := scrobblerlog.Parse(data, false)
|
||||
require.NoError(t, err)
|
||||
assert.Len(result.Listens, 4)
|
||||
listen4 := result.Listens[3]
|
||||
assert.Equal("L", listen4.AdditionalInfo["rockbox_rating"])
|
||||
assert.Equal(models.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMbid)
|
||||
}
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
data := make([]byte, 0, 10)
|
||||
buffer := bytes.NewBuffer(data)
|
||||
log := scrobblerlog.ScrobblerLog{
|
||||
Timezone: "Unknown",
|
||||
Client: "Rockbox foo $Revision$",
|
||||
Listens: []models.Listen{
|
||||
{
|
||||
ListenedAt: time.Unix(1699572072, 0),
|
||||
Track: models.Track{
|
||||
ArtistNames: []string{"Prinzhorn Dance School"},
|
||||
ReleaseName: "Home Economics",
|
||||
TrackName: "Reign",
|
||||
TrackNumber: 1,
|
||||
Duration: 271 * time.Second,
|
||||
RecordingMbid: models.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
|
||||
AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := scrobblerlog.WriteHeader(buffer, &log)
|
||||
require.NoError(t, err)
|
||||
lastTimestamp, err := scrobblerlog.Write(buffer, log.Listens)
|
||||
require.NoError(t, err)
|
||||
result := string(buffer.Bytes())
|
||||
lines := strings.Split(result, "\n")
|
||||
assert.Equal(5, len(lines))
|
||||
assert.Equal("#AUDIOSCROBBLER/1.1", lines[0])
|
||||
assert.Equal("#TZ/Unknown", lines[1])
|
||||
assert.Equal("#CLIENT/Rockbox foo $Revision$", lines[2])
|
||||
assert.Equal(
|
||||
"Prinzhorn Dance School\tHome Economics\tReign\t1\t271\tL\t1699572072\tb59cf4e7-caee-4019-a844-79d2c58d4dff",
|
||||
lines[3])
|
||||
assert.Equal("", lines[4])
|
||||
assert.Equal(time.Unix(1699572072, 0), lastTimestamp)
|
||||
}
|
||||
|
||||
func TestReadHeader(t *testing.T) {
|
||||
data := bytes.NewBufferString(testScrobblerLog)
|
||||
reader := bufio.NewReader(data)
|
||||
log := scrobblerlog.ScrobblerLog{}
|
||||
err := scrobblerlog.ReadHeader(reader, &log)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, log.Timezone, "UNKNOWN")
|
||||
assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$")
|
||||
assert.Empty(t, log.Listens)
|
||||
}
|
136
internal/backends/scrobblerlog/scrobblerlog.go
Normal file
136
internal/backends/scrobblerlog/scrobblerlog.go
Normal file
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package scrobblerlog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
type ScrobblerLogBackend struct {
|
||||
filePath string
|
||||
includeSkipped bool
|
||||
append bool
|
||||
file *os.File
|
||||
log ScrobblerLog
|
||||
}
|
||||
|
||||
func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" }
|
||||
|
||||
func (b *ScrobblerLogBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
b.filePath = config.GetString("file-path")
|
||||
b.includeSkipped = config.GetBool("include-skipped")
|
||||
b.append = true
|
||||
if config.IsSet("append") {
|
||||
b.append = config.GetBool("append")
|
||||
}
|
||||
b.log = ScrobblerLog{
|
||||
Timezone: "UNKNOWN",
|
||||
Client: "Rockbox unknown $Revision$",
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ScrobblerLogBackend) StartImport() error {
|
||||
flags := os.O_RDWR | os.O_CREATE
|
||||
if !b.append {
|
||||
flags |= os.O_TRUNC
|
||||
}
|
||||
file, err := os.OpenFile(b.filePath, flags, 0666)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.append {
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
if stat.Size() == 0 {
|
||||
// Zero length file, treat as a new file
|
||||
b.append = false
|
||||
} else {
|
||||
// Verify existing file is a scrobbler log
|
||||
reader := bufio.NewReader(file)
|
||||
err = ReadHeader(reader, &b.log)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
file.Seek(0, 2)
|
||||
}
|
||||
}
|
||||
|
||||
if !b.append {
|
||||
err = WriteHeader(file, &b.log)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
b.file = file
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *ScrobblerLogBackend) FinishImport() error {
|
||||
return b.file.Close()
|
||||
}
|
||||
|
||||
func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||
defer close(results)
|
||||
file, err := os.Open(b.filePath)
|
||||
if err != nil {
|
||||
progress <- models.Progress{}.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
log, err := Parse(file, b.includeSkipped)
|
||||
if err != nil {
|
||||
progress <- models.Progress{}.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
listens := log.Listens.NewerThan(oldestTimestamp)
|
||||
sort.Sort(listens)
|
||||
progress <- models.Progress{Elapsed: int64(len(listens))}.Complete()
|
||||
results <- models.ListensResult{Listens: listens}
|
||||
}
|
||||
|
||||
func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
lastTimestamp, err := Write(b.file, export.Listens)
|
||||
if err != nil {
|
||||
return importResult, err
|
||||
}
|
||||
|
||||
importResult.UpdateTimestamp(lastTimestamp)
|
||||
importResult.ImportCount += len(export.Listens)
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
|
||||
return importResult, nil
|
||||
}
|
32
internal/backends/scrobblerlog/scrobblerlog_test.go
Normal file
32
internal/backends/scrobblerlog/scrobblerlog_test.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package scrobblerlog_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("token", "thetoken")
|
||||
backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(config)
|
||||
assert.IsType(t, &scrobblerlog.ScrobblerLogBackend{}, backend)
|
||||
}
|
114
internal/backends/spotify/client.go
Normal file
114
internal/backends/spotify/client.go
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const baseURL = "https://api.spotify.com/v1/"
|
||||
const MaxItemsPerGet = 50
|
||||
const DefaultRateLimitWaitSeconds = 5
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
}
|
||||
|
||||
func NewClient(token oauth2.TokenSource) Client {
|
||||
ctx := context.Background()
|
||||
httpClient := oauth2.NewClient(ctx, token)
|
||||
client := resty.NewWithClient(httpClient)
|
||||
client.SetBaseURL(baseURL)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
client.SetRetryCount(5)
|
||||
client.AddRetryCondition(
|
||||
func(r *resty.Response, err error) bool {
|
||||
code := r.StatusCode()
|
||||
return code == http.StatusTooManyRequests || code >= http.StatusInternalServerError
|
||||
},
|
||||
)
|
||||
client.SetRetryMaxWaitTime(time.Duration(1 * time.Minute))
|
||||
client.SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) {
|
||||
var err error
|
||||
var retryAfter int = DefaultRateLimitWaitSeconds
|
||||
if resp.StatusCode() == http.StatusTooManyRequests {
|
||||
retryAfter, err = strconv.Atoi(resp.Header().Get("Retry-After"))
|
||||
if err != nil {
|
||||
retryAfter = DefaultRateLimitWaitSeconds
|
||||
}
|
||||
}
|
||||
return time.Duration(retryAfter * int(time.Second)), err
|
||||
})
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) RecentlyPlayedAfter(after time.Time, limit int) (RecentlyPlayedResult, error) {
|
||||
return c.recentlyPlayed(&after, nil, limit)
|
||||
}
|
||||
|
||||
func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlayedResult, error) {
|
||||
return c.recentlyPlayed(nil, &before, limit)
|
||||
}
|
||||
|
||||
func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) {
|
||||
const path = "/me/player/recently-played"
|
||||
request := c.HttpClient.R().
|
||||
SetQueryParam("limit", strconv.Itoa(limit)).
|
||||
SetResult(&result)
|
||||
if after != nil {
|
||||
request.SetQueryParam("after", strconv.FormatInt(after.Unix(), 10))
|
||||
} else if before != nil {
|
||||
request.SetQueryParam("before", strconv.FormatInt(before.Unix(), 10))
|
||||
}
|
||||
response, err := request.Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(response.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) {
|
||||
const path = "/me/tracks"
|
||||
response, err := c.HttpClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"offset": strconv.Itoa(offset),
|
||||
"limit": strconv.Itoa(limit),
|
||||
}).
|
||||
SetResult(&result).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(response.String())
|
||||
}
|
||||
return
|
||||
}
|
90
internal/backends/spotify/client_test.go
Normal file
90
internal/backends/spotify/client_test.go
Normal file
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package spotify_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||
client := spotify.NewClient(token)
|
||||
assert.IsType(t, spotify.Client{}, client)
|
||||
}
|
||||
|
||||
func TestRecentlyPlayedAfter(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := spotify.NewClient(nil)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://api.spotify.com/v1/me/player/recently-played",
|
||||
"testdata/recently-played.json")
|
||||
|
||||
result, err := client.RecentlyPlayedAfter(time.Now(), 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(3, result.Limit)
|
||||
require.Len(t, result.Items, 3)
|
||||
listen1 := result.Items[0]
|
||||
assert.Equal("2023-11-21T15:00:07.229Z", listen1.PlayedAt)
|
||||
assert.Equal("Evidence", listen1.Track.Name)
|
||||
assert.Equal("Viva Emptiness", listen1.Track.Album.Name)
|
||||
}
|
||||
|
||||
func TestGetUserTracks(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := spotify.NewClient(nil)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://api.spotify.com/v1/me/tracks",
|
||||
"testdata/user-tracks.json")
|
||||
|
||||
result, err := client.UserTracks(0, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(1243, result.Total)
|
||||
require.Len(t, result.Items, 2)
|
||||
track1 := result.Items[0]
|
||||
assert.Equal("2022-02-13T21:46:08Z", track1.AddedAt)
|
||||
assert.Equal("Death to the Holy", track1.Track.Name)
|
||||
assert.Equal("Zeal & Ardor", track1.Track.Album.Name)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpmock.RegisterResponder("GET", url, responder)
|
||||
}
|
114
internal/backends/spotify/models.go
Normal file
114
internal/backends/spotify/models.go
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package spotify
|
||||
|
||||
type TracksResult struct {
|
||||
Href string `json:"href"`
|
||||
Limit int `json:"limit"`
|
||||
Next string `json:"next"`
|
||||
Previous string `json:"previous"`
|
||||
Offset int `json:"offset"`
|
||||
Total int `json:"total"`
|
||||
Items []SavedTrack `json:"items"`
|
||||
}
|
||||
|
||||
type SavedTrack struct {
|
||||
AddedAt string `json:"added_at"`
|
||||
Track Track `json:"track"`
|
||||
}
|
||||
|
||||
type RecentlyPlayedResult struct {
|
||||
Href string `json:"href"`
|
||||
Limit int `json:"limit"`
|
||||
Next string `json:"next"`
|
||||
Cursors Cursors `json:"cursors"`
|
||||
Items []Listen `json:"items"`
|
||||
}
|
||||
|
||||
type Cursors struct {
|
||||
After string `json:"after"`
|
||||
Before string `json:"before"`
|
||||
}
|
||||
|
||||
type Listen struct {
|
||||
PlayedAt string `json:"played_at"`
|
||||
Track Track `json:"track"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Uri string `json:"uri"`
|
||||
Type string `json:"type"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Explicit bool `json:"explicit"`
|
||||
IsLocal bool `json:"is_local"`
|
||||
Popularity int `json:"popularity"`
|
||||
ExternalIds ExternalIds `json:"external_ids"`
|
||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
||||
Album Album `json:"album"`
|
||||
Artists []Artist `json:"artists"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Uri string `json:"uri"`
|
||||
Type string `json:"type"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ReleaseDatePrecision string `json:"release_date_precision"`
|
||||
AlbumType string `json:"album_type"`
|
||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
||||
Artists []Artist `json:"artists"`
|
||||
Images []Image `json:"images"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Uri string `json:"uri"`
|
||||
Type string `json:"type"`
|
||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
||||
}
|
||||
|
||||
type ExternalIds struct {
|
||||
ISRC string `json:"isrc"`
|
||||
EAN string `json:"ean"`
|
||||
UPC string `json:"upc"`
|
||||
}
|
||||
|
||||
type ExternalUrls struct {
|
||||
Spotify string `json:"spotify"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
Url string `json:"url"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
}
|
53
internal/backends/spotify/models_test.go
Normal file
53
internal/backends/spotify/models_test.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package spotify_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||
)
|
||||
|
||||
func TestRecentlyPlayedResult(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/recently-played.json")
|
||||
require.NoError(t, err)
|
||||
result := spotify.RecentlyPlayedResult{}
|
||||
err = json.Unmarshal(data, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(3, result.Limit)
|
||||
assert.Equal("1700578807229", result.Cursors.After)
|
||||
require.Len(t, result.Items, 3)
|
||||
track1 := result.Items[0].Track
|
||||
assert.Equal("Evidence", track1.Name)
|
||||
assert.Equal(11, track1.TrackNumber)
|
||||
assert.Equal(1, track1.DiscNumber)
|
||||
assert.Equal("Viva Emptiness", track1.Album.Name)
|
||||
require.Len(t, track1.Artists, 1)
|
||||
assert.Equal("Katatonia", track1.Artists[0].Name)
|
||||
}
|
282
internal/backends/spotify/spotify.go
Normal file
282
internal/backends/spotify/spotify.go
Normal file
|
@ -0,0 +1,282 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/auth"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/spotify"
|
||||
)
|
||||
|
||||
type SpotifyApiBackend struct {
|
||||
client Client
|
||||
clientId string
|
||||
clientSecret string
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) Name() string { return "spotify" }
|
||||
|
||||
func (b *SpotifyApiBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
b.clientId = config.GetString("client-id")
|
||||
b.clientSecret = config.GetString("client-secret")
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
||||
conf := oauth2.Config{
|
||||
ClientID: b.clientId,
|
||||
ClientSecret: b.clientSecret,
|
||||
Scopes: []string{
|
||||
"user-read-currently-playing",
|
||||
"user-read-recently-played",
|
||||
"user-library-read",
|
||||
"user-library-modify",
|
||||
},
|
||||
RedirectURL: redirectUrl.String(),
|
||||
Endpoint: spotify.Endpoint,
|
||||
}
|
||||
|
||||
return auth.NewStandardStrategy(conf)
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config {
|
||||
return oauth2.Config{
|
||||
ClientID: b.clientId,
|
||||
ClientSecret: b.clientSecret,
|
||||
Scopes: []string{
|
||||
"user-read-currently-playing",
|
||||
"user-read-recently-played",
|
||||
"user-library-read",
|
||||
"user-library-modify",
|
||||
},
|
||||
RedirectURL: redirectUrl.String(),
|
||||
Endpoint: spotify.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
|
||||
b.client = NewClient(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||
startTime := time.Now()
|
||||
minTime := oldestTimestamp
|
||||
|
||||
totalDuration := startTime.Sub(oldestTimestamp)
|
||||
|
||||
defer close(results)
|
||||
|
||||
p := models.Progress{Total: int64(totalDuration.Seconds())}
|
||||
|
||||
for {
|
||||
result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
if result.Cursors.After == "" {
|
||||
break
|
||||
}
|
||||
|
||||
// Set minTime to the newest returned listen
|
||||
after, err := strconv.ParseInt(result.Cursors.After, 10, 64)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
} else if after <= minTime.Unix() {
|
||||
// new cursor timestamp did not progress
|
||||
break
|
||||
}
|
||||
minTime = time.Unix(after, 0)
|
||||
remainingTime := startTime.Sub(minTime)
|
||||
|
||||
count := len(result.Items)
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
listens := make(models.ListensList, 0, len(result.Items))
|
||||
|
||||
for _, listen := range result.Items {
|
||||
l := listen.AsListen()
|
||||
if l.ListenedAt.Unix() > oldestTimestamp.Unix() {
|
||||
listens = append(listens, l)
|
||||
} else {
|
||||
// result contains listens older then oldestTimestamp,
|
||||
// we can stop requesting more
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(listens)
|
||||
p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
|
||||
progress <- p
|
||||
results <- models.ListensResult{Listens: listens, OldestTimestamp: minTime}
|
||||
}
|
||||
|
||||
results <- models.ListensResult{OldestTimestamp: minTime}
|
||||
progress <- p.Complete()
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||
// Choose a high offset, we attempt to search the loves backwards starting
|
||||
// at the oldest one.
|
||||
offset := math.MaxInt32
|
||||
perPage := MaxItemsPerGet
|
||||
|
||||
defer close(results)
|
||||
|
||||
p := models.Progress{Total: int64(perPage)}
|
||||
var totalCount int
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.UserTracks(offset, perPage)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
// The offset was higher then the actual number of tracks. Adjust the offset
|
||||
// and continue.
|
||||
if offset >= result.Total {
|
||||
p.Total = int64(result.Total)
|
||||
totalCount = result.Total
|
||||
offset = result.Total - perPage
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
count := len(result.Items)
|
||||
if count == 0 {
|
||||
break out
|
||||
}
|
||||
|
||||
loves := make(models.LovesList, 0, perPage)
|
||||
for _, track := range result.Items {
|
||||
love := track.AsLove()
|
||||
if love.Created.Unix() > oldestTimestamp.Unix() {
|
||||
loves = append(loves, love)
|
||||
} else {
|
||||
totalCount -= 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(loves)
|
||||
results <- models.LovesResult{Loves: loves, Total: totalCount}
|
||||
p.Elapsed += int64(count)
|
||||
progress <- p
|
||||
|
||||
if offset <= 0 {
|
||||
// This was the last request, no further results
|
||||
break out
|
||||
}
|
||||
|
||||
offset -= perPage
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
progress <- p.Complete()
|
||||
}
|
||||
|
||||
func (l Listen) AsListen() models.Listen {
|
||||
listenedAt, _ := time.Parse(time.RFC3339, l.PlayedAt)
|
||||
listen := models.Listen{
|
||||
ListenedAt: listenedAt,
|
||||
Track: l.Track.AsTrack(),
|
||||
}
|
||||
|
||||
return listen
|
||||
}
|
||||
|
||||
func (t SavedTrack) AsLove() models.Love {
|
||||
addedAt, _ := time.Parse(time.RFC3339, t.AddedAt)
|
||||
love := models.Love{
|
||||
Created: addedAt,
|
||||
Track: t.Track.AsTrack(),
|
||||
}
|
||||
|
||||
return love
|
||||
}
|
||||
|
||||
func (t Track) AsTrack() models.Track {
|
||||
track := models.Track{
|
||||
TrackName: t.Name,
|
||||
ReleaseName: t.Album.Name,
|
||||
ArtistNames: make([]string, 0, len(t.Artists)),
|
||||
Duration: time.Duration(t.DurationMs * int(time.Millisecond)),
|
||||
TrackNumber: t.TrackNumber,
|
||||
DiscNumber: t.DiscNumber,
|
||||
ISRC: t.ExternalIds.ISRC,
|
||||
AdditionalInfo: map[string]any{},
|
||||
}
|
||||
|
||||
for _, artist := range t.Artists {
|
||||
track.ArtistNames = append(track.ArtistNames, artist.Name)
|
||||
}
|
||||
|
||||
info := track.AdditionalInfo
|
||||
if !t.IsLocal {
|
||||
info["music_service"] = "spotify.com"
|
||||
}
|
||||
|
||||
if t.ExternalUrls.Spotify != "" {
|
||||
info["origin_url"] = t.ExternalUrls.Spotify
|
||||
info["spotify_id"] = t.ExternalUrls.Spotify
|
||||
}
|
||||
|
||||
if t.Album.ExternalUrls.Spotify != "" {
|
||||
info["spotify_album_id"] = t.Album.ExternalUrls.Spotify
|
||||
}
|
||||
|
||||
if len(t.Artists) > 0 {
|
||||
info["spotify_artist_ids"] = extractArtistIds(t.Artists)
|
||||
}
|
||||
|
||||
if len(t.Album.Artists) > 0 {
|
||||
info["spotify_album_artist_ids"] = extractArtistIds(t.Album.Artists)
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
func extractArtistIds(artists []Artist) []string {
|
||||
artistIds := make([]string, len(artists))
|
||||
for i, artist := range artists {
|
||||
artistIds[i] = artist.ExternalUrls.Spotify
|
||||
}
|
||||
return artistIds
|
||||
}
|
76
internal/backends/spotify/spotify_test.go
Normal file
76
internal/backends/spotify/spotify_test.go
Normal file
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package spotify_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("client-id", "someclientid")
|
||||
config.Set("client-secret", "someclientsecret")
|
||||
backend := (&spotify.SpotifyApiBackend{}).FromConfig(config)
|
||||
assert.IsType(t, &spotify.SpotifyApiBackend{}, backend)
|
||||
}
|
||||
|
||||
func TestSpotifyListenAsListen(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/listen.json")
|
||||
require.NoError(t, err)
|
||||
spListen := spotify.Listen{}
|
||||
err = json.Unmarshal(data, &spListen)
|
||||
require.NoError(t, err)
|
||||
listen := spListen.AsListen()
|
||||
listenedAt, _ := time.Parse(time.RFC3339, "2023-11-21T15:24:33.361Z")
|
||||
assert.Equal(t, listenedAt, listen.ListenedAt)
|
||||
assert.Equal(t, time.Duration(413826*time.Millisecond), listen.Duration)
|
||||
assert.Equal(t, "Oweynagat", listen.TrackName)
|
||||
assert.Equal(t, "Here Now, There Then", listen.ReleaseName)
|
||||
assert.Equal(t, []string{"Dool"}, listen.ArtistNames)
|
||||
assert.Equal(t, 5, listen.TrackNumber)
|
||||
assert.Equal(t, 1, listen.DiscNumber)
|
||||
assert.Equal(t, "DES561620801", listen.ISRC)
|
||||
info := listen.AdditionalInfo
|
||||
assert.Equal(t, "spotify.com", info["music_service"])
|
||||
assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["origin_url"])
|
||||
assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["spotify_id"])
|
||||
assert.Equal(t, "https://open.spotify.com/album/5U1umzRH4EONHWsFgPtRbA", info["spotify_album_id"])
|
||||
assert.Equal(t, []string{"https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"}, info["spotify_artist_ids"])
|
||||
assert.Equal(t, []string{"https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"}, info["spotify_album_artist_ids"])
|
||||
}
|
||||
|
||||
func TestSavedTrackAsLove(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/track.json")
|
||||
require.NoError(t, err)
|
||||
track := spotify.SavedTrack{}
|
||||
err = json.Unmarshal(data, &track)
|
||||
require.NoError(t, err)
|
||||
love := track.AsLove()
|
||||
created, _ := time.Parse(time.RFC3339, "2022-02-13T21:46:08Z")
|
||||
assert.Equal(t, created, love.Created)
|
||||
assert.Equal(t, time.Duration(187680*time.Millisecond), love.Duration)
|
||||
assert.Equal(t, "Death to the Holy", love.TrackName)
|
||||
}
|
458
internal/backends/spotify/testdata/listen.json
vendored
Normal file
458
internal/backends/spotify/testdata/listen.json
vendored
Normal file
|
@ -0,0 +1,458 @@
|
|||
{
|
||||
"track": {
|
||||
"album": {
|
||||
"album_type": "album",
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/101HSR6JTJqe3DBh6rb8kz",
|
||||
"id": "101HSR6JTJqe3DBh6rb8kz",
|
||||
"name": "Dool",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:101HSR6JTJqe3DBh6rb8kz"
|
||||
}
|
||||
],
|
||||
"available_markets": [
|
||||
"AR",
|
||||
"AU",
|
||||
"AT",
|
||||
"BE",
|
||||
"BO",
|
||||
"BR",
|
||||
"BG",
|
||||
"CA",
|
||||
"CL",
|
||||
"CO",
|
||||
"CR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DO",
|
||||
"DE",
|
||||
"EC",
|
||||
"EE",
|
||||
"SV",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"GT",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LT",
|
||||
"LU",
|
||||
"MY",
|
||||
"MT",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NO",
|
||||
"PA",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PL",
|
||||
"PT",
|
||||
"SG",
|
||||
"SK",
|
||||
"ES",
|
||||
"SE",
|
||||
"CH",
|
||||
"TW",
|
||||
"TR",
|
||||
"UY",
|
||||
"US",
|
||||
"GB",
|
||||
"AD",
|
||||
"LI",
|
||||
"MC",
|
||||
"ID",
|
||||
"JP",
|
||||
"TH",
|
||||
"VN",
|
||||
"RO",
|
||||
"IL",
|
||||
"ZA",
|
||||
"SA",
|
||||
"AE",
|
||||
"BH",
|
||||
"QA",
|
||||
"OM",
|
||||
"KW",
|
||||
"EG",
|
||||
"MA",
|
||||
"DZ",
|
||||
"TN",
|
||||
"LB",
|
||||
"JO",
|
||||
"PS",
|
||||
"IN",
|
||||
"BY",
|
||||
"KZ",
|
||||
"MD",
|
||||
"UA",
|
||||
"AL",
|
||||
"BA",
|
||||
"HR",
|
||||
"ME",
|
||||
"MK",
|
||||
"RS",
|
||||
"SI",
|
||||
"KR",
|
||||
"BD",
|
||||
"PK",
|
||||
"LK",
|
||||
"GH",
|
||||
"KE",
|
||||
"NG",
|
||||
"TZ",
|
||||
"UG",
|
||||
"AG",
|
||||
"AM",
|
||||
"BS",
|
||||
"BB",
|
||||
"BZ",
|
||||
"BT",
|
||||
"BW",
|
||||
"BF",
|
||||
"CV",
|
||||
"CW",
|
||||
"DM",
|
||||
"FJ",
|
||||
"GM",
|
||||
"GE",
|
||||
"GD",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"JM",
|
||||
"KI",
|
||||
"LS",
|
||||
"LR",
|
||||
"MW",
|
||||
"MV",
|
||||
"ML",
|
||||
"MH",
|
||||
"FM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NE",
|
||||
"PW",
|
||||
"PG",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SN",
|
||||
"SC",
|
||||
"SL",
|
||||
"SB",
|
||||
"KN",
|
||||
"LC",
|
||||
"VC",
|
||||
"SR",
|
||||
"TL",
|
||||
"TO",
|
||||
"TT",
|
||||
"TV",
|
||||
"VU",
|
||||
"AZ",
|
||||
"BN",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"TD",
|
||||
"KM",
|
||||
"GQ",
|
||||
"SZ",
|
||||
"GA",
|
||||
"GN",
|
||||
"KG",
|
||||
"LA",
|
||||
"MO",
|
||||
"MR",
|
||||
"MN",
|
||||
"NP",
|
||||
"RW",
|
||||
"TG",
|
||||
"UZ",
|
||||
"ZW",
|
||||
"BJ",
|
||||
"MG",
|
||||
"MU",
|
||||
"MZ",
|
||||
"AO",
|
||||
"CI",
|
||||
"DJ",
|
||||
"ZM",
|
||||
"CD",
|
||||
"CG",
|
||||
"IQ",
|
||||
"LY",
|
||||
"TJ",
|
||||
"VE",
|
||||
"ET",
|
||||
"XK"
|
||||
],
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/album/5U1umzRH4EONHWsFgPtRbA"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/albums/5U1umzRH4EONHWsFgPtRbA",
|
||||
"id": "5U1umzRH4EONHWsFgPtRbA",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://i.scdn.co/image/ab67616d0000b273c7b579ace1f3f56381d83aad",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 300,
|
||||
"url": "https://i.scdn.co/image/ab67616d00001e02c7b579ace1f3f56381d83aad",
|
||||
"width": 300
|
||||
},
|
||||
{
|
||||
"height": 64,
|
||||
"url": "https://i.scdn.co/image/ab67616d00004851c7b579ace1f3f56381d83aad",
|
||||
"width": 64
|
||||
}
|
||||
],
|
||||
"name": "Here Now, There Then",
|
||||
"release_date": "2017-02-17",
|
||||
"release_date_precision": "day",
|
||||
"total_tracks": 8,
|
||||
"type": "album",
|
||||
"uri": "spotify:album:5U1umzRH4EONHWsFgPtRbA"
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/101HSR6JTJqe3DBh6rb8kz",
|
||||
"id": "101HSR6JTJqe3DBh6rb8kz",
|
||||
"name": "Dool",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:101HSR6JTJqe3DBh6rb8kz"
|
||||
}
|
||||
],
|
||||
"available_markets": [
|
||||
"AR",
|
||||
"AU",
|
||||
"AT",
|
||||
"BE",
|
||||
"BO",
|
||||
"BR",
|
||||
"BG",
|
||||
"CA",
|
||||
"CL",
|
||||
"CO",
|
||||
"CR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DO",
|
||||
"DE",
|
||||
"EC",
|
||||
"EE",
|
||||
"SV",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"GT",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LT",
|
||||
"LU",
|
||||
"MY",
|
||||
"MT",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NO",
|
||||
"PA",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PL",
|
||||
"PT",
|
||||
"SG",
|
||||
"SK",
|
||||
"ES",
|
||||
"SE",
|
||||
"CH",
|
||||
"TW",
|
||||
"TR",
|
||||
"UY",
|
||||
"US",
|
||||
"GB",
|
||||
"AD",
|
||||
"LI",
|
||||
"MC",
|
||||
"ID",
|
||||
"JP",
|
||||
"TH",
|
||||
"VN",
|
||||
"RO",
|
||||
"IL",
|
||||
"ZA",
|
||||
"SA",
|
||||
"AE",
|
||||
"BH",
|
||||
"QA",
|
||||
"OM",
|
||||
"KW",
|
||||
"EG",
|
||||
"MA",
|
||||
"DZ",
|
||||
"TN",
|
||||
"LB",
|
||||
"JO",
|
||||
"PS",
|
||||
"IN",
|
||||
"BY",
|
||||
"KZ",
|
||||
"MD",
|
||||
"UA",
|
||||
"AL",
|
||||
"BA",
|
||||
"HR",
|
||||
"ME",
|
||||
"MK",
|
||||
"RS",
|
||||
"SI",
|
||||
"KR",
|
||||
"BD",
|
||||
"PK",
|
||||
"LK",
|
||||
"GH",
|
||||
"KE",
|
||||
"NG",
|
||||
"TZ",
|
||||
"UG",
|
||||
"AG",
|
||||
"AM",
|
||||
"BS",
|
||||
"BB",
|
||||
"BZ",
|
||||
"BT",
|
||||
"BW",
|
||||
"BF",
|
||||
"CV",
|
||||
"CW",
|
||||
"DM",
|
||||
"FJ",
|
||||
"GM",
|
||||
"GE",
|
||||
"GD",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"JM",
|
||||
"KI",
|
||||
"LS",
|
||||
"LR",
|
||||
"MW",
|
||||
"MV",
|
||||
"ML",
|
||||
"MH",
|
||||
"FM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NE",
|
||||
"PW",
|
||||
"PG",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SN",
|
||||
"SC",
|
||||
"SL",
|
||||
"SB",
|
||||
"KN",
|
||||
"LC",
|
||||
"VC",
|
||||
"SR",
|
||||
"TL",
|
||||
"TO",
|
||||
"TT",
|
||||
"TV",
|
||||
"VU",
|
||||
"AZ",
|
||||
"BN",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"TD",
|
||||
"KM",
|
||||
"GQ",
|
||||
"SZ",
|
||||
"GA",
|
||||
"GN",
|
||||
"KG",
|
||||
"LA",
|
||||
"MO",
|
||||
"MR",
|
||||
"MN",
|
||||
"NP",
|
||||
"RW",
|
||||
"TG",
|
||||
"UZ",
|
||||
"ZW",
|
||||
"BJ",
|
||||
"MG",
|
||||
"MU",
|
||||
"MZ",
|
||||
"AO",
|
||||
"CI",
|
||||
"DJ",
|
||||
"ZM",
|
||||
"CD",
|
||||
"CG",
|
||||
"IQ",
|
||||
"LY",
|
||||
"TJ",
|
||||
"VE",
|
||||
"ET",
|
||||
"XK"
|
||||
],
|
||||
"disc_number": 1,
|
||||
"duration_ms": 413826,
|
||||
"explicit": false,
|
||||
"external_ids": {
|
||||
"isrc": "DES561620801"
|
||||
},
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/tracks/2JKUgGuXK3dEvyuIJ4Yj2V",
|
||||
"id": "2JKUgGuXK3dEvyuIJ4Yj2V",
|
||||
"is_local": false,
|
||||
"name": "Oweynagat",
|
||||
"popularity": 28,
|
||||
"preview_url": "https://p.scdn.co/mp3-preview/5f01ec09c7a470b9899dc4b4bb427302dc648f24?cid=5433a04d90a946f2a0e5175b1383604a",
|
||||
"track_number": 5,
|
||||
"type": "track",
|
||||
"uri": "spotify:track:2JKUgGuXK3dEvyuIJ4Yj2V"
|
||||
},
|
||||
"played_at": "2023-11-21T15:24:33.361Z",
|
||||
"context": {
|
||||
"type": "playlist",
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/playlist/37i9dQZF1E4odrTa1nmoN6"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/playlists/37i9dQZF1E4odrTa1nmoN6",
|
||||
"uri": "spotify:playlist:37i9dQZF1E4odrTa1nmoN6"
|
||||
}
|
||||
}
|
1385
internal/backends/spotify/testdata/recently-played.json
vendored
Normal file
1385
internal/backends/spotify/testdata/recently-played.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
80
internal/backends/spotify/testdata/track.json
vendored
Normal file
80
internal/backends/spotify/testdata/track.json
vendored
Normal file
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"added_at": "2022-02-13T21:46:08Z",
|
||||
"track": {
|
||||
"album": {
|
||||
"album_type": "album",
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"id": "6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"name": "Zeal & Ardor",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
}
|
||||
],
|
||||
"available_markets": [],
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/album/34u4cq27YP6837IcmzTTgX"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/albums/34u4cq27YP6837IcmzTTgX",
|
||||
"id": "34u4cq27YP6837IcmzTTgX",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://i.scdn.co/image/ab67616d0000b2731fedf861c139b6d8bebdc840",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 300,
|
||||
"url": "https://i.scdn.co/image/ab67616d00001e021fedf861c139b6d8bebdc840",
|
||||
"width": 300
|
||||
},
|
||||
{
|
||||
"height": 64,
|
||||
"url": "https://i.scdn.co/image/ab67616d000048511fedf861c139b6d8bebdc840",
|
||||
"width": 64
|
||||
}
|
||||
],
|
||||
"name": "Zeal & Ardor",
|
||||
"release_date": "2022-02-11",
|
||||
"release_date_precision": "day",
|
||||
"total_tracks": 14,
|
||||
"type": "album",
|
||||
"uri": "spotify:album:34u4cq27YP6837IcmzTTgX"
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"id": "6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"name": "Zeal & Ardor",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
}
|
||||
],
|
||||
"available_markets": [],
|
||||
"disc_number": 1,
|
||||
"duration_ms": 187680,
|
||||
"explicit": false,
|
||||
"external_ids": {
|
||||
"isrc": "CH8092101707"
|
||||
},
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/track/07K2e1PXNra3Zd5SGCsLuZ"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/tracks/07K2e1PXNra3Zd5SGCsLuZ",
|
||||
"id": "07K2e1PXNra3Zd5SGCsLuZ",
|
||||
"is_local": false,
|
||||
"name": "Death to the Holy",
|
||||
"popularity": 0,
|
||||
"preview_url": null,
|
||||
"track_number": 3,
|
||||
"type": "track",
|
||||
"uri": "spotify:track:07K2e1PXNra3Zd5SGCsLuZ"
|
||||
}
|
||||
}
|
540
internal/backends/spotify/testdata/user-tracks.json
vendored
Normal file
540
internal/backends/spotify/testdata/user-tracks.json
vendored
Normal file
|
@ -0,0 +1,540 @@
|
|||
{
|
||||
"href": "https://api.spotify.com/v1/me/tracks?offset=143&limit=2&locale=de,en-US;q=0.7,en;q=0.3",
|
||||
"items": [
|
||||
{
|
||||
"added_at": "2022-02-13T21:46:08Z",
|
||||
"track": {
|
||||
"album": {
|
||||
"album_type": "album",
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"id": "6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"name": "Zeal & Ardor",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
}
|
||||
],
|
||||
"available_markets": [],
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/album/34u4cq27YP6837IcmzTTgX"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/albums/34u4cq27YP6837IcmzTTgX",
|
||||
"id": "34u4cq27YP6837IcmzTTgX",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://i.scdn.co/image/ab67616d0000b2731fedf861c139b6d8bebdc840",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 300,
|
||||
"url": "https://i.scdn.co/image/ab67616d00001e021fedf861c139b6d8bebdc840",
|
||||
"width": 300
|
||||
},
|
||||
{
|
||||
"height": 64,
|
||||
"url": "https://i.scdn.co/image/ab67616d000048511fedf861c139b6d8bebdc840",
|
||||
"width": 64
|
||||
}
|
||||
],
|
||||
"name": "Zeal & Ardor",
|
||||
"release_date": "2022-02-11",
|
||||
"release_date_precision": "day",
|
||||
"total_tracks": 14,
|
||||
"type": "album",
|
||||
"uri": "spotify:album:34u4cq27YP6837IcmzTTgX"
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"id": "6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"name": "Zeal & Ardor",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
}
|
||||
],
|
||||
"available_markets": [],
|
||||
"disc_number": 1,
|
||||
"duration_ms": 187680,
|
||||
"explicit": false,
|
||||
"external_ids": {
|
||||
"isrc": "CH8092101707"
|
||||
},
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/track/07K2e1PXNra3Zd5SGCsLuZ"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/tracks/07K2e1PXNra3Zd5SGCsLuZ",
|
||||
"id": "07K2e1PXNra3Zd5SGCsLuZ",
|
||||
"is_local": false,
|
||||
"name": "Death to the Holy",
|
||||
"popularity": 0,
|
||||
"preview_url": null,
|
||||
"track_number": 3,
|
||||
"type": "track",
|
||||
"uri": "spotify:track:07K2e1PXNra3Zd5SGCsLuZ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"added_at": "2022-02-13T21:34:31Z",
|
||||
"track": {
|
||||
"album": {
|
||||
"album_type": "single",
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/7FsZ5HKdtDFJ1xmK6NICBO"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/7FsZ5HKdtDFJ1xmK6NICBO",
|
||||
"id": "7FsZ5HKdtDFJ1xmK6NICBO",
|
||||
"name": "Wucan",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:7FsZ5HKdtDFJ1xmK6NICBO"
|
||||
}
|
||||
],
|
||||
"available_markets": [
|
||||
"AR",
|
||||
"AU",
|
||||
"AT",
|
||||
"BE",
|
||||
"BO",
|
||||
"BR",
|
||||
"BG",
|
||||
"CA",
|
||||
"CL",
|
||||
"CO",
|
||||
"CR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DO",
|
||||
"DE",
|
||||
"EC",
|
||||
"EE",
|
||||
"SV",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"GT",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LT",
|
||||
"LU",
|
||||
"MY",
|
||||
"MT",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NO",
|
||||
"PA",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PL",
|
||||
"PT",
|
||||
"SG",
|
||||
"SK",
|
||||
"ES",
|
||||
"SE",
|
||||
"CH",
|
||||
"TW",
|
||||
"TR",
|
||||
"UY",
|
||||
"US",
|
||||
"GB",
|
||||
"AD",
|
||||
"LI",
|
||||
"MC",
|
||||
"ID",
|
||||
"JP",
|
||||
"TH",
|
||||
"VN",
|
||||
"RO",
|
||||
"IL",
|
||||
"ZA",
|
||||
"SA",
|
||||
"AE",
|
||||
"BH",
|
||||
"QA",
|
||||
"OM",
|
||||
"KW",
|
||||
"EG",
|
||||
"MA",
|
||||
"DZ",
|
||||
"TN",
|
||||
"LB",
|
||||
"JO",
|
||||
"PS",
|
||||
"IN",
|
||||
"BY",
|
||||
"KZ",
|
||||
"MD",
|
||||
"UA",
|
||||
"AL",
|
||||
"BA",
|
||||
"HR",
|
||||
"ME",
|
||||
"MK",
|
||||
"RS",
|
||||
"SI",
|
||||
"KR",
|
||||
"BD",
|
||||
"PK",
|
||||
"LK",
|
||||
"GH",
|
||||
"KE",
|
||||
"NG",
|
||||
"TZ",
|
||||
"UG",
|
||||
"AG",
|
||||
"AM",
|
||||
"BS",
|
||||
"BB",
|
||||
"BZ",
|
||||
"BT",
|
||||
"BW",
|
||||
"BF",
|
||||
"CV",
|
||||
"CW",
|
||||
"DM",
|
||||
"FJ",
|
||||
"GM",
|
||||
"GE",
|
||||
"GD",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"JM",
|
||||
"KI",
|
||||
"LS",
|
||||
"LR",
|
||||
"MW",
|
||||
"MV",
|
||||
"ML",
|
||||
"MH",
|
||||
"FM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NE",
|
||||
"PW",
|
||||
"PG",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SN",
|
||||
"SC",
|
||||
"SL",
|
||||
"SB",
|
||||
"KN",
|
||||
"LC",
|
||||
"VC",
|
||||
"SR",
|
||||
"TL",
|
||||
"TO",
|
||||
"TT",
|
||||
"TV",
|
||||
"VU",
|
||||
"AZ",
|
||||
"BN",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"TD",
|
||||
"KM",
|
||||
"GQ",
|
||||
"SZ",
|
||||
"GA",
|
||||
"GN",
|
||||
"KG",
|
||||
"LA",
|
||||
"MO",
|
||||
"MR",
|
||||
"MN",
|
||||
"NP",
|
||||
"RW",
|
||||
"TG",
|
||||
"UZ",
|
||||
"ZW",
|
||||
"BJ",
|
||||
"MG",
|
||||
"MU",
|
||||
"MZ",
|
||||
"AO",
|
||||
"CI",
|
||||
"DJ",
|
||||
"ZM",
|
||||
"CD",
|
||||
"CG",
|
||||
"IQ",
|
||||
"LY",
|
||||
"TJ",
|
||||
"VE",
|
||||
"ET",
|
||||
"XK"
|
||||
],
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/album/11XibdriTL2mncbfoWLwv7"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/albums/11XibdriTL2mncbfoWLwv7",
|
||||
"id": "11XibdriTL2mncbfoWLwv7",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://i.scdn.co/image/ab67616d0000b2734f84c9e19c3a309cd123c6e5",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 300,
|
||||
"url": "https://i.scdn.co/image/ab67616d00001e024f84c9e19c3a309cd123c6e5",
|
||||
"width": 300
|
||||
},
|
||||
{
|
||||
"height": 64,
|
||||
"url": "https://i.scdn.co/image/ab67616d000048514f84c9e19c3a309cd123c6e5",
|
||||
"width": 64
|
||||
}
|
||||
],
|
||||
"name": "Night to Fall",
|
||||
"release_date": "2018-10-22",
|
||||
"release_date_precision": "day",
|
||||
"total_tracks": 1,
|
||||
"type": "album",
|
||||
"uri": "spotify:album:11XibdriTL2mncbfoWLwv7"
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/7FsZ5HKdtDFJ1xmK6NICBO"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/7FsZ5HKdtDFJ1xmK6NICBO",
|
||||
"id": "7FsZ5HKdtDFJ1xmK6NICBO",
|
||||
"name": "Wucan",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:7FsZ5HKdtDFJ1xmK6NICBO"
|
||||
}
|
||||
],
|
||||
"available_markets": [
|
||||
"AR",
|
||||
"AU",
|
||||
"AT",
|
||||
"BE",
|
||||
"BO",
|
||||
"BR",
|
||||
"BG",
|
||||
"CA",
|
||||
"CL",
|
||||
"CO",
|
||||
"CR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DO",
|
||||
"DE",
|
||||
"EC",
|
||||
"EE",
|
||||
"SV",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"GT",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LT",
|
||||
"LU",
|
||||
"MY",
|
||||
"MT",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NO",
|
||||
"PA",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PL",
|
||||
"PT",
|
||||
"SG",
|
||||
"SK",
|
||||
"ES",
|
||||
"SE",
|
||||
"CH",
|
||||
"TW",
|
||||
"TR",
|
||||
"UY",
|
||||
"US",
|
||||
"GB",
|
||||
"AD",
|
||||
"LI",
|
||||
"MC",
|
||||
"ID",
|
||||
"JP",
|
||||
"TH",
|
||||
"VN",
|
||||
"RO",
|
||||
"IL",
|
||||
"ZA",
|
||||
"SA",
|
||||
"AE",
|
||||
"BH",
|
||||
"QA",
|
||||
"OM",
|
||||
"KW",
|
||||
"EG",
|
||||
"MA",
|
||||
"DZ",
|
||||
"TN",
|
||||
"LB",
|
||||
"JO",
|
||||
"PS",
|
||||
"IN",
|
||||
"BY",
|
||||
"KZ",
|
||||
"MD",
|
||||
"UA",
|
||||
"AL",
|
||||
"BA",
|
||||
"HR",
|
||||
"ME",
|
||||
"MK",
|
||||
"RS",
|
||||
"SI",
|
||||
"KR",
|
||||
"BD",
|
||||
"PK",
|
||||
"LK",
|
||||
"GH",
|
||||
"KE",
|
||||
"NG",
|
||||
"TZ",
|
||||
"UG",
|
||||
"AG",
|
||||
"AM",
|
||||
"BS",
|
||||
"BB",
|
||||
"BZ",
|
||||
"BT",
|
||||
"BW",
|
||||
"BF",
|
||||
"CV",
|
||||
"CW",
|
||||
"DM",
|
||||
"FJ",
|
||||
"GM",
|
||||
"GE",
|
||||
"GD",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"JM",
|
||||
"KI",
|
||||
"LS",
|
||||
"LR",
|
||||
"MW",
|
||||
"MV",
|
||||
"ML",
|
||||
"MH",
|
||||
"FM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NE",
|
||||
"PW",
|
||||
"PG",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SN",
|
||||
"SC",
|
||||
"SL",
|
||||
"SB",
|
||||
"KN",
|
||||
"LC",
|
||||
"VC",
|
||||
"SR",
|
||||
"TL",
|
||||
"TO",
|
||||
"TT",
|
||||
"TV",
|
||||
"VU",
|
||||
"AZ",
|
||||
"BN",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"TD",
|
||||
"KM",
|
||||
"GQ",
|
||||
"SZ",
|
||||
"GA",
|
||||
"GN",
|
||||
"KG",
|
||||
"LA",
|
||||
"MO",
|
||||
"MR",
|
||||
"MN",
|
||||
"NP",
|
||||
"RW",
|
||||
"TG",
|
||||
"UZ",
|
||||
"ZW",
|
||||
"BJ",
|
||||
"MG",
|
||||
"MU",
|
||||
"MZ",
|
||||
"AO",
|
||||
"CI",
|
||||
"DJ",
|
||||
"ZM",
|
||||
"CD",
|
||||
"CG",
|
||||
"IQ",
|
||||
"LY",
|
||||
"TJ",
|
||||
"VE",
|
||||
"ET",
|
||||
"XK"
|
||||
],
|
||||
"disc_number": 1,
|
||||
"duration_ms": 248372,
|
||||
"explicit": false,
|
||||
"external_ids": {
|
||||
"isrc": "DEMV91805509"
|
||||
},
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/track/30YxzMczS77DCXIWiXSzSK"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/tracks/30YxzMczS77DCXIWiXSzSK",
|
||||
"id": "30YxzMczS77DCXIWiXSzSK",
|
||||
"is_local": false,
|
||||
"name": "Night to Fall",
|
||||
"popularity": 29,
|
||||
"preview_url": "https://p.scdn.co/mp3-preview/9d3edae25510e9f78194a04625a0773ee1f0db65?cid=5433a04d90a946f2a0e5175b1383604a",
|
||||
"track_number": 1,
|
||||
"type": "track",
|
||||
"uri": "spotify:track:30YxzMczS77DCXIWiXSzSK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"limit": 2,
|
||||
"next": "https://api.spotify.com/v1/me/tracks?offset=145&limit=2&locale=de,en-US;q=0.7,en;q=0.3",
|
||||
"offset": 143,
|
||||
"previous": "https://api.spotify.com/v1/me/tracks?offset=141&limit=2&locale=de,en-US;q=0.7,en;q=0.3",
|
||||
"total": 1243
|
||||
}
|
97
internal/backends/subsonic/subsonic.go
Normal file
97
internal/backends/subsonic/subsonic.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/delucks/go-subsonic"
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
type SubsonicApiBackend struct {
|
||||
client subsonic.Client
|
||||
password string
|
||||
}
|
||||
|
||||
func (b *SubsonicApiBackend) Name() string { return "subsonic" }
|
||||
|
||||
func (b *SubsonicApiBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
b.client = subsonic.Client{
|
||||
Client: &http.Client{},
|
||||
BaseUrl: config.GetString("server-url"),
|
||||
User: config.GetString("username"),
|
||||
ClientName: "Scotty",
|
||||
}
|
||||
b.password = config.GetString("token")
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||
defer close(results)
|
||||
err := b.client.Authenticate(b.password)
|
||||
if err != nil {
|
||||
progress <- models.Progress{}.Complete()
|
||||
results <- models.LovesResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
starred, err := b.client.GetStarred2(map[string]string{})
|
||||
if err != nil {
|
||||
progress <- models.Progress{}.Complete()
|
||||
results <- models.LovesResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
progress <- models.Progress{Elapsed: int64(len(starred.Song))}.Complete()
|
||||
results <- models.LovesResult{Loves: b.filterSongs(starred.Song, oldestTimestamp)}
|
||||
}
|
||||
|
||||
func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestamp time.Time) models.LovesList {
|
||||
loves := make(models.LovesList, len(songs))
|
||||
for i, song := range songs {
|
||||
love := SongAsLove(*song, b.client.User)
|
||||
if love.Created.Unix() > oldestTimestamp.Unix() {
|
||||
loves[i] = love
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(loves)
|
||||
return loves
|
||||
}
|
||||
|
||||
func SongAsLove(song subsonic.Child, username string) models.Love {
|
||||
love := models.Love{
|
||||
UserName: username,
|
||||
Created: song.Starred,
|
||||
Track: models.Track{
|
||||
TrackName: song.Title,
|
||||
ReleaseName: song.Album,
|
||||
ArtistNames: []string{song.Artist},
|
||||
TrackNumber: song.Track,
|
||||
DiscNumber: song.DiscNumber,
|
||||
Tags: []string{song.Genre},
|
||||
AdditionalInfo: map[string]any{},
|
||||
Duration: time.Duration(song.Duration * int(time.Second)),
|
||||
},
|
||||
}
|
||||
|
||||
return love
|
||||
}
|
60
internal/backends/subsonic/subsonic_test.go
Normal file
60
internal/backends/subsonic/subsonic_test.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package subsonic_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
go_subsonic "github.com/delucks/go-subsonic"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("server-url", "https://subsonic.example.com")
|
||||
config.Set("token", "thetoken")
|
||||
backend := (&subsonic.SubsonicApiBackend{}).FromConfig(config)
|
||||
assert.IsType(t, &subsonic.SubsonicApiBackend{}, backend)
|
||||
}
|
||||
|
||||
func TestSongToLove(t *testing.T) {
|
||||
user := "outsidecontext"
|
||||
song := go_subsonic.Child{
|
||||
Starred: time.Unix(1699574369, 0),
|
||||
Title: "Oweynagat",
|
||||
Album: "Here Now, There Then",
|
||||
Artist: "Dool",
|
||||
Duration: 414,
|
||||
Genre: "psychedelic rock",
|
||||
DiscNumber: 1,
|
||||
Track: 5,
|
||||
}
|
||||
love := subsonic.SongAsLove(song, user)
|
||||
assert := assert.New(t)
|
||||
assert.Equal(time.Unix(1699574369, 0).Unix(), love.Created.Unix())
|
||||
assert.Equal(user, love.UserName)
|
||||
assert.Equal(time.Duration(414*time.Second), love.Duration)
|
||||
assert.Equal(song.Title, love.TrackName)
|
||||
assert.Equal(song.Album, love.ReleaseName)
|
||||
assert.Equal([]string{song.Artist}, love.ArtistNames)
|
||||
assert.Equal(song.Track, love.Track.TrackNumber)
|
||||
assert.Equal(song.DiscNumber, love.Track.DiscNumber)
|
||||
assert.Equal([]string{song.Genre}, love.Track.Tags)
|
||||
}
|
70
internal/backends/tokensource.go
Normal file
70
internal/backends/tokensource.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package backends
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uploadedlobster.com/scotty/internal/storage"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type databaseTokenSource struct {
|
||||
new oauth2.TokenSource
|
||||
db storage.Database
|
||||
service string
|
||||
tok *oauth2.Token
|
||||
}
|
||||
|
||||
func (s *databaseTokenSource) Token() (tok *oauth2.Token, err error) {
|
||||
tok = s.tok
|
||||
if tok == nil {
|
||||
tok, _ = s.loadToken()
|
||||
}
|
||||
|
||||
if tok != nil && tok.Valid() {
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
if tok, err = s.new.Token(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.saveToken(tok)
|
||||
s.tok = tok
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tok, err
|
||||
}
|
||||
|
||||
func (c *databaseTokenSource) saveToken(tok *oauth2.Token) error {
|
||||
return c.db.SetOAuth2Token(c.service, tok)
|
||||
}
|
||||
|
||||
func (c *databaseTokenSource) loadToken() (*oauth2.Token, error) {
|
||||
return c.db.GetOAuth2Token(c.service)
|
||||
}
|
||||
|
||||
func NewDatabaseTokenSource(db storage.Database, service string, config *oauth2.Config, tok *oauth2.Token) oauth2.TokenSource {
|
||||
return &databaseTokenSource{
|
||||
db: db,
|
||||
new: config.TokenSource(context.Background(), tok),
|
||||
service: service,
|
||||
tok: tok,
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue