mirror of
https://git.sr.ht/~phw/scotty
synced 2025-04-16 01:59:29 +02:00
Initial implementation for Spotify listens export
This commit is contained in:
parent
3ded679d80
commit
3d3685d8bc
11 changed files with 2355 additions and 3 deletions
|
@ -34,6 +34,7 @@ import (
|
|||
"go.uploadedlobster.com/scotty/backends/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/backends/maloja"
|
||||
"go.uploadedlobster.com/scotty/backends/scrobblerlog"
|
||||
"go.uploadedlobster.com/scotty/backends/spotify"
|
||||
"go.uploadedlobster.com/scotty/backends/subsonic"
|
||||
"go.uploadedlobster.com/scotty/models"
|
||||
)
|
||||
|
@ -84,6 +85,7 @@ var knownBackends = map[string]func() models.Backend{
|
|||
"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{} },
|
||||
}
|
||||
|
||||
|
|
80
backends/spotify/client.go
Normal file
80
backends/spotify/client.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
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"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const baseURL = "https://api.spotify.com/v1/"
|
||||
const MaxItemsPerGet = 50
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
}
|
||||
|
||||
func NewClient(conf oauth2.Config, token *oauth2.Token) Client {
|
||||
ctx := context.Background()
|
||||
httpClient := conf.Client(ctx, token)
|
||||
client := resty.NewWithClient(httpClient)
|
||||
client.SetBaseURL(baseURL)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
client.SetRetryCount(5)
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
37
backends/spotify/client_test.go
Normal file
37
backends/spotify/client_test.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uploadedlobster.com/scotty/backends/spotify"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
conf := oauth2.Config{}
|
||||
token := &oauth2.Token{}
|
||||
client := spotify.NewClient(conf, token)
|
||||
assert.IsType(t, spotify.Client{}, client)
|
||||
}
|
99
backends/spotify/models.go
Normal file
99
backends/spotify/models.go
Normal file
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
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 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 {
|
||||
Track Track `json:"track"`
|
||||
PlayedAt string `json:"played_at"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
51
backends/spotify/models_test.go
Normal file
51
backends/spotify/models_test.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
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/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("Viva Emptiness", track1.Album.Name)
|
||||
require.Len(t, track1.Artists, 1)
|
||||
assert.Equal("Katatonia", track1.Artists[0].Name)
|
||||
}
|
168
backends/spotify/spotify.go
Normal file
168
backends/spotify/spotify.go
Normal file
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
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 (
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/models"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/spotify"
|
||||
)
|
||||
|
||||
type SpotifyApiBackend struct {
|
||||
client Client
|
||||
clientId string
|
||||
clientSecret string
|
||||
}
|
||||
|
||||
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) OAuth2Config(redirectUrl string) oauth2.Config {
|
||||
return oauth2.Config{
|
||||
ClientID: b.clientId,
|
||||
ClientSecret: b.clientSecret,
|
||||
Scopes: []string{"user-read-recently-played"},
|
||||
RedirectURL: redirectUrl,
|
||||
Endpoint: spotify.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) OAuth2Setup(redirectUrl string, token *oauth2.Token) error {
|
||||
config := b.OAuth2Config(redirectUrl)
|
||||
b.client = NewClient(config, 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)
|
||||
defer close(progress)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
count := len(result.Items)
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
listens := make(models.ListensList, 0, len(result.Items))
|
||||
|
||||
// Set minTime to the newest returned listen
|
||||
after, err := strconv.ParseInt(result.Cursors.After, 10, 64)
|
||||
if err != nil && after <= minTime.Unix() {
|
||||
err = errors.New("new cursor timestamp did not progress")
|
||||
}
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
}
|
||||
minTime = time.Unix(after, 0)
|
||||
remainingTime := startTime.Sub(minTime)
|
||||
|
||||
for _, listen := range result.Items {
|
||||
listens = append(listens, listen.ToListen())
|
||||
}
|
||||
|
||||
sort.Sort(listens)
|
||||
p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
|
||||
progress <- p
|
||||
results <- models.ListensResult{Listens: listens, OldestTimestamp: oldestTimestamp}
|
||||
}
|
||||
|
||||
progress <- p.Complete()
|
||||
}
|
||||
|
||||
func (l Listen) ToListen() models.Listen {
|
||||
track := l.Track
|
||||
listen := models.Listen{
|
||||
Track: models.Track{
|
||||
TrackName: track.Name,
|
||||
ReleaseName: track.Album.Name,
|
||||
ArtistNames: make([]string, 0, len(track.Artists)),
|
||||
Duration: time.Duration(track.DurationMs * int(time.Millisecond)),
|
||||
TrackNumber: track.TrackNumber,
|
||||
Isrc: track.ExternalIds.ISRC,
|
||||
AdditionalInfo: map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
listen.ListenedAt, _ = time.Parse(time.RFC3339, l.PlayedAt)
|
||||
|
||||
for _, artist := range track.Artists {
|
||||
listen.Track.ArtistNames = append(listen.Track.ArtistNames, artist.Name)
|
||||
}
|
||||
|
||||
info := listen.AdditionalInfo
|
||||
if !l.Track.IsLocal {
|
||||
info["music_service"] = "spotify.com"
|
||||
}
|
||||
|
||||
if track.ExternalUrls.Spotify != "" {
|
||||
info["origin_url"] = track.ExternalUrls.Spotify
|
||||
info["spotify_id"] = track.ExternalUrls.Spotify
|
||||
}
|
||||
|
||||
if track.Album.ExternalUrls.Spotify != "" {
|
||||
info["spotify_album_id"] = track.Album.ExternalUrls.Spotify
|
||||
}
|
||||
|
||||
if len(track.Artists) > 0 {
|
||||
info["spotify_artist_ids"] = extractArtistIds(track.Artists)
|
||||
}
|
||||
|
||||
if len(track.Album.Artists) > 0 {
|
||||
info["spotify_album_artist_ids"] = extractArtistIds(track.Album.Artists)
|
||||
}
|
||||
|
||||
return listen
|
||||
}
|
||||
|
||||
func extractArtistIds(artists []Artist) []string {
|
||||
artistIds := make([]string, len(artists))
|
||||
for i, artist := range artists {
|
||||
artistIds[i] = artist.ExternalUrls.Spotify
|
||||
}
|
||||
return artistIds
|
||||
}
|
58
backends/spotify/spotify_test.go
Normal file
58
backends/spotify/spotify_test.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
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"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/backends/spotify"
|
||||
)
|
||||
|
||||
func TestSpotifyListenToListen(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.ToListen()
|
||||
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, "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"])
|
||||
}
|
458
backends/spotify/testdata/listen.json
vendored
Normal file
458
backends/spotify/testdata/listen.json
vendored
Normal file
|
@ -0,0 +1,458 @@
|
|||
{
|
||||
"track": {
|
||||
"album": {
|
||||
"album_type": "album",
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/101HSR6JTJqe3DBh6rb8kz",
|
||||
"id": "101HSR6JTJqe3DBh6rb8kz",
|
||||
"name": "Dool",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:101HSR6JTJqe3DBh6rb8kz"
|
||||
}
|
||||
],
|
||||
"available_markets": [
|
||||
"AR",
|
||||
"AU",
|
||||
"AT",
|
||||
"BE",
|
||||
"BO",
|
||||
"BR",
|
||||
"BG",
|
||||
"CA",
|
||||
"CL",
|
||||
"CO",
|
||||
"CR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DO",
|
||||
"DE",
|
||||
"EC",
|
||||
"EE",
|
||||
"SV",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"GT",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LT",
|
||||
"LU",
|
||||
"MY",
|
||||
"MT",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NO",
|
||||
"PA",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PL",
|
||||
"PT",
|
||||
"SG",
|
||||
"SK",
|
||||
"ES",
|
||||
"SE",
|
||||
"CH",
|
||||
"TW",
|
||||
"TR",
|
||||
"UY",
|
||||
"US",
|
||||
"GB",
|
||||
"AD",
|
||||
"LI",
|
||||
"MC",
|
||||
"ID",
|
||||
"JP",
|
||||
"TH",
|
||||
"VN",
|
||||
"RO",
|
||||
"IL",
|
||||
"ZA",
|
||||
"SA",
|
||||
"AE",
|
||||
"BH",
|
||||
"QA",
|
||||
"OM",
|
||||
"KW",
|
||||
"EG",
|
||||
"MA",
|
||||
"DZ",
|
||||
"TN",
|
||||
"LB",
|
||||
"JO",
|
||||
"PS",
|
||||
"IN",
|
||||
"BY",
|
||||
"KZ",
|
||||
"MD",
|
||||
"UA",
|
||||
"AL",
|
||||
"BA",
|
||||
"HR",
|
||||
"ME",
|
||||
"MK",
|
||||
"RS",
|
||||
"SI",
|
||||
"KR",
|
||||
"BD",
|
||||
"PK",
|
||||
"LK",
|
||||
"GH",
|
||||
"KE",
|
||||
"NG",
|
||||
"TZ",
|
||||
"UG",
|
||||
"AG",
|
||||
"AM",
|
||||
"BS",
|
||||
"BB",
|
||||
"BZ",
|
||||
"BT",
|
||||
"BW",
|
||||
"BF",
|
||||
"CV",
|
||||
"CW",
|
||||
"DM",
|
||||
"FJ",
|
||||
"GM",
|
||||
"GE",
|
||||
"GD",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"JM",
|
||||
"KI",
|
||||
"LS",
|
||||
"LR",
|
||||
"MW",
|
||||
"MV",
|
||||
"ML",
|
||||
"MH",
|
||||
"FM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NE",
|
||||
"PW",
|
||||
"PG",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SN",
|
||||
"SC",
|
||||
"SL",
|
||||
"SB",
|
||||
"KN",
|
||||
"LC",
|
||||
"VC",
|
||||
"SR",
|
||||
"TL",
|
||||
"TO",
|
||||
"TT",
|
||||
"TV",
|
||||
"VU",
|
||||
"AZ",
|
||||
"BN",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"TD",
|
||||
"KM",
|
||||
"GQ",
|
||||
"SZ",
|
||||
"GA",
|
||||
"GN",
|
||||
"KG",
|
||||
"LA",
|
||||
"MO",
|
||||
"MR",
|
||||
"MN",
|
||||
"NP",
|
||||
"RW",
|
||||
"TG",
|
||||
"UZ",
|
||||
"ZW",
|
||||
"BJ",
|
||||
"MG",
|
||||
"MU",
|
||||
"MZ",
|
||||
"AO",
|
||||
"CI",
|
||||
"DJ",
|
||||
"ZM",
|
||||
"CD",
|
||||
"CG",
|
||||
"IQ",
|
||||
"LY",
|
||||
"TJ",
|
||||
"VE",
|
||||
"ET",
|
||||
"XK"
|
||||
],
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/album/5U1umzRH4EONHWsFgPtRbA"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/albums/5U1umzRH4EONHWsFgPtRbA",
|
||||
"id": "5U1umzRH4EONHWsFgPtRbA",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://i.scdn.co/image/ab67616d0000b273c7b579ace1f3f56381d83aad",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 300,
|
||||
"url": "https://i.scdn.co/image/ab67616d00001e02c7b579ace1f3f56381d83aad",
|
||||
"width": 300
|
||||
},
|
||||
{
|
||||
"height": 64,
|
||||
"url": "https://i.scdn.co/image/ab67616d00004851c7b579ace1f3f56381d83aad",
|
||||
"width": 64
|
||||
}
|
||||
],
|
||||
"name": "Here Now, There Then",
|
||||
"release_date": "2017-02-17",
|
||||
"release_date_precision": "day",
|
||||
"total_tracks": 8,
|
||||
"type": "album",
|
||||
"uri": "spotify:album:5U1umzRH4EONHWsFgPtRbA"
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/101HSR6JTJqe3DBh6rb8kz",
|
||||
"id": "101HSR6JTJqe3DBh6rb8kz",
|
||||
"name": "Dool",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:101HSR6JTJqe3DBh6rb8kz"
|
||||
}
|
||||
],
|
||||
"available_markets": [
|
||||
"AR",
|
||||
"AU",
|
||||
"AT",
|
||||
"BE",
|
||||
"BO",
|
||||
"BR",
|
||||
"BG",
|
||||
"CA",
|
||||
"CL",
|
||||
"CO",
|
||||
"CR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DO",
|
||||
"DE",
|
||||
"EC",
|
||||
"EE",
|
||||
"SV",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"GT",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LT",
|
||||
"LU",
|
||||
"MY",
|
||||
"MT",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NO",
|
||||
"PA",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PL",
|
||||
"PT",
|
||||
"SG",
|
||||
"SK",
|
||||
"ES",
|
||||
"SE",
|
||||
"CH",
|
||||
"TW",
|
||||
"TR",
|
||||
"UY",
|
||||
"US",
|
||||
"GB",
|
||||
"AD",
|
||||
"LI",
|
||||
"MC",
|
||||
"ID",
|
||||
"JP",
|
||||
"TH",
|
||||
"VN",
|
||||
"RO",
|
||||
"IL",
|
||||
"ZA",
|
||||
"SA",
|
||||
"AE",
|
||||
"BH",
|
||||
"QA",
|
||||
"OM",
|
||||
"KW",
|
||||
"EG",
|
||||
"MA",
|
||||
"DZ",
|
||||
"TN",
|
||||
"LB",
|
||||
"JO",
|
||||
"PS",
|
||||
"IN",
|
||||
"BY",
|
||||
"KZ",
|
||||
"MD",
|
||||
"UA",
|
||||
"AL",
|
||||
"BA",
|
||||
"HR",
|
||||
"ME",
|
||||
"MK",
|
||||
"RS",
|
||||
"SI",
|
||||
"KR",
|
||||
"BD",
|
||||
"PK",
|
||||
"LK",
|
||||
"GH",
|
||||
"KE",
|
||||
"NG",
|
||||
"TZ",
|
||||
"UG",
|
||||
"AG",
|
||||
"AM",
|
||||
"BS",
|
||||
"BB",
|
||||
"BZ",
|
||||
"BT",
|
||||
"BW",
|
||||
"BF",
|
||||
"CV",
|
||||
"CW",
|
||||
"DM",
|
||||
"FJ",
|
||||
"GM",
|
||||
"GE",
|
||||
"GD",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"JM",
|
||||
"KI",
|
||||
"LS",
|
||||
"LR",
|
||||
"MW",
|
||||
"MV",
|
||||
"ML",
|
||||
"MH",
|
||||
"FM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NE",
|
||||
"PW",
|
||||
"PG",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SN",
|
||||
"SC",
|
||||
"SL",
|
||||
"SB",
|
||||
"KN",
|
||||
"LC",
|
||||
"VC",
|
||||
"SR",
|
||||
"TL",
|
||||
"TO",
|
||||
"TT",
|
||||
"TV",
|
||||
"VU",
|
||||
"AZ",
|
||||
"BN",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"TD",
|
||||
"KM",
|
||||
"GQ",
|
||||
"SZ",
|
||||
"GA",
|
||||
"GN",
|
||||
"KG",
|
||||
"LA",
|
||||
"MO",
|
||||
"MR",
|
||||
"MN",
|
||||
"NP",
|
||||
"RW",
|
||||
"TG",
|
||||
"UZ",
|
||||
"ZW",
|
||||
"BJ",
|
||||
"MG",
|
||||
"MU",
|
||||
"MZ",
|
||||
"AO",
|
||||
"CI",
|
||||
"DJ",
|
||||
"ZM",
|
||||
"CD",
|
||||
"CG",
|
||||
"IQ",
|
||||
"LY",
|
||||
"TJ",
|
||||
"VE",
|
||||
"ET",
|
||||
"XK"
|
||||
],
|
||||
"disc_number": 1,
|
||||
"duration_ms": 413826,
|
||||
"explicit": false,
|
||||
"external_ids": {
|
||||
"isrc": "DES561620801"
|
||||
},
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/tracks/2JKUgGuXK3dEvyuIJ4Yj2V",
|
||||
"id": "2JKUgGuXK3dEvyuIJ4Yj2V",
|
||||
"is_local": false,
|
||||
"name": "Oweynagat",
|
||||
"popularity": 28,
|
||||
"preview_url": "https://p.scdn.co/mp3-preview/5f01ec09c7a470b9899dc4b4bb427302dc648f24?cid=5433a04d90a946f2a0e5175b1383604a",
|
||||
"track_number": 5,
|
||||
"type": "track",
|
||||
"uri": "spotify:track:2JKUgGuXK3dEvyuIJ4Yj2V"
|
||||
},
|
||||
"played_at": "2023-11-21T15:24:33.361Z",
|
||||
"context": {
|
||||
"type": "playlist",
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/playlist/37i9dQZF1E4odrTa1nmoN6"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/playlists/37i9dQZF1E4odrTa1nmoN6",
|
||||
"uri": "spotify:playlist:37i9dQZF1E4odrTa1nmoN6"
|
||||
}
|
||||
}
|
1385
backends/spotify/testdata/recently-played.json
vendored
Normal file
1385
backends/spotify/testdata/recently-played.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
|
@ -43,15 +43,19 @@ var listensCmd = &cobra.Command{
|
|||
targetName, targetConfig := getConfigFromFlag(cmd, "to")
|
||||
fmt.Printf("Transferring listens from %s to %s...\n", sourceName, targetName)
|
||||
|
||||
// Setup database
|
||||
db, err := storage.New(viper.GetString("database"))
|
||||
cobra.CheckErr(err)
|
||||
|
||||
// Initialize backends
|
||||
exportBackend, err := backends.ResolveBackend[models.ListensExport](sourceConfig)
|
||||
cobra.CheckErr(err)
|
||||
importBackend, err := backends.ResolveBackend[models.ListensImport](targetConfig)
|
||||
cobra.CheckErr(err)
|
||||
|
||||
// Setup database
|
||||
db, err := storage.New(viper.GetString("database"))
|
||||
cobra.CheckErr(err)
|
||||
// Authenticate backends, if needed
|
||||
backends.Authenticate(exportBackend, db, viper.GetViper())
|
||||
backends.Authenticate(importBackend, db, viper.GetViper())
|
||||
|
||||
// Read timestamp
|
||||
timestamp := time.Unix(getInt64FromFlag(cmd, "timestamp"), 0)
|
||||
|
|
|
@ -25,6 +25,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// A listen service backend.
|
||||
|
@ -34,6 +35,15 @@ type Backend interface {
|
|||
FromConfig(config *viper.Viper) Backend
|
||||
}
|
||||
|
||||
type OAuth2Backend interface {
|
||||
Backend
|
||||
// Returns OAuth2 config suitable for this backend
|
||||
OAuth2Config(redirectUrl string) oauth2.Config
|
||||
|
||||
// Setup the OAuth2 client
|
||||
OAuth2Setup(redirectUrl string, token *oauth2.Token) error
|
||||
}
|
||||
|
||||
type ImportBackend interface {
|
||||
Backend
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue