1
0
Fork 0
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:
Philipp Wolfer 2023-11-24 00:07:47 +01:00
parent f94e0f1e85
commit 857661ebf9
No known key found for this signature in database
GPG key ID: 8FDF744D4919943B
76 changed files with 121 additions and 68 deletions

54
internal/backends/auth.go Normal file
View 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
}

View 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)
}

View 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
}

View 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)
}
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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"])
}

View 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"`
}

View 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)
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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"])
}

View 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"`
}

View 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
}
}
]
}

View 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
}
}
]
}

View 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
}

View 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)
}

View 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"`
}

View 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
}

View 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"
]
}
}
]
}
}

View 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"
}
]
}
}

View 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"
}
]
}
}

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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
}

View 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))
}

View 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
}

View 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"
}

View 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"
}
}

View 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"
}

View 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
}

View 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)
}

View 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
}

View 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"])
}

View 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"`
}

View file

@ -0,0 +1,10 @@
{
"status": "success",
"track": {
"artists": [
"Dool"
],
"title": "Oweynagat"
},
"desc": "Scrobbled Oweynagat by Dool"
}

View 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
}
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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"`
}

View 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)
}

View 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
}

View 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)
}

View 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"
}
}

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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
}

View 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
}

View 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)
}

View 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,
}
}