mirror of
https://git.sr.ht/~phw/scotty
synced 2025-04-30 05:37:05 +02:00
Restructured code, moved all modules into internal
For now all modules are considered internal. This might change later
This commit is contained in:
parent
f94e0f1e85
commit
857661ebf9
76 changed files with 121 additions and 68 deletions
114
internal/backends/spotify/client.go
Normal file
114
internal/backends/spotify/client.go
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const baseURL = "https://api.spotify.com/v1/"
|
||||
const MaxItemsPerGet = 50
|
||||
const DefaultRateLimitWaitSeconds = 5
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
}
|
||||
|
||||
func NewClient(token oauth2.TokenSource) Client {
|
||||
ctx := context.Background()
|
||||
httpClient := oauth2.NewClient(ctx, token)
|
||||
client := resty.NewWithClient(httpClient)
|
||||
client.SetBaseURL(baseURL)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
client.SetRetryCount(5)
|
||||
client.AddRetryCondition(
|
||||
func(r *resty.Response, err error) bool {
|
||||
code := r.StatusCode()
|
||||
return code == http.StatusTooManyRequests || code >= http.StatusInternalServerError
|
||||
},
|
||||
)
|
||||
client.SetRetryMaxWaitTime(time.Duration(1 * time.Minute))
|
||||
client.SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) {
|
||||
var err error
|
||||
var retryAfter int = DefaultRateLimitWaitSeconds
|
||||
if resp.StatusCode() == http.StatusTooManyRequests {
|
||||
retryAfter, err = strconv.Atoi(resp.Header().Get("Retry-After"))
|
||||
if err != nil {
|
||||
retryAfter = DefaultRateLimitWaitSeconds
|
||||
}
|
||||
}
|
||||
return time.Duration(retryAfter * int(time.Second)), err
|
||||
})
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) RecentlyPlayedAfter(after time.Time, limit int) (RecentlyPlayedResult, error) {
|
||||
return c.recentlyPlayed(&after, nil, limit)
|
||||
}
|
||||
|
||||
func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlayedResult, error) {
|
||||
return c.recentlyPlayed(nil, &before, limit)
|
||||
}
|
||||
|
||||
func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) {
|
||||
const path = "/me/player/recently-played"
|
||||
request := c.HttpClient.R().
|
||||
SetQueryParam("limit", strconv.Itoa(limit)).
|
||||
SetResult(&result)
|
||||
if after != nil {
|
||||
request.SetQueryParam("after", strconv.FormatInt(after.Unix(), 10))
|
||||
} else if before != nil {
|
||||
request.SetQueryParam("before", strconv.FormatInt(before.Unix(), 10))
|
||||
}
|
||||
response, err := request.Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(response.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) {
|
||||
const path = "/me/tracks"
|
||||
response, err := c.HttpClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"offset": strconv.Itoa(offset),
|
||||
"limit": strconv.Itoa(limit),
|
||||
}).
|
||||
SetResult(&result).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
err = errors.New(response.String())
|
||||
}
|
||||
return
|
||||
}
|
90
internal/backends/spotify/client_test.go
Normal file
90
internal/backends/spotify/client_test.go
Normal file
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package spotify_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jarcoal/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||
client := spotify.NewClient(token)
|
||||
assert.IsType(t, spotify.Client{}, client)
|
||||
}
|
||||
|
||||
func TestRecentlyPlayedAfter(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := spotify.NewClient(nil)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://api.spotify.com/v1/me/player/recently-played",
|
||||
"testdata/recently-played.json")
|
||||
|
||||
result, err := client.RecentlyPlayedAfter(time.Now(), 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(3, result.Limit)
|
||||
require.Len(t, result.Items, 3)
|
||||
listen1 := result.Items[0]
|
||||
assert.Equal("2023-11-21T15:00:07.229Z", listen1.PlayedAt)
|
||||
assert.Equal("Evidence", listen1.Track.Name)
|
||||
assert.Equal("Viva Emptiness", listen1.Track.Album.Name)
|
||||
}
|
||||
|
||||
func TestGetUserTracks(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := spotify.NewClient(nil)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
"https://api.spotify.com/v1/me/tracks",
|
||||
"testdata/user-tracks.json")
|
||||
|
||||
result, err := client.UserTracks(0, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(1243, result.Total)
|
||||
require.Len(t, result.Items, 2)
|
||||
track1 := result.Items[0]
|
||||
assert.Equal("2022-02-13T21:46:08Z", track1.AddedAt)
|
||||
assert.Equal("Death to the Holy", track1.Track.Name)
|
||||
assert.Equal("Zeal & Ardor", track1.Track.Album.Name)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpmock.RegisterResponder("GET", url, responder)
|
||||
}
|
114
internal/backends/spotify/models.go
Normal file
114
internal/backends/spotify/models.go
Normal file
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package spotify
|
||||
|
||||
type TracksResult struct {
|
||||
Href string `json:"href"`
|
||||
Limit int `json:"limit"`
|
||||
Next string `json:"next"`
|
||||
Previous string `json:"previous"`
|
||||
Offset int `json:"offset"`
|
||||
Total int `json:"total"`
|
||||
Items []SavedTrack `json:"items"`
|
||||
}
|
||||
|
||||
type SavedTrack struct {
|
||||
AddedAt string `json:"added_at"`
|
||||
Track Track `json:"track"`
|
||||
}
|
||||
|
||||
type RecentlyPlayedResult struct {
|
||||
Href string `json:"href"`
|
||||
Limit int `json:"limit"`
|
||||
Next string `json:"next"`
|
||||
Cursors Cursors `json:"cursors"`
|
||||
Items []Listen `json:"items"`
|
||||
}
|
||||
|
||||
type Cursors struct {
|
||||
After string `json:"after"`
|
||||
Before string `json:"before"`
|
||||
}
|
||||
|
||||
type Listen struct {
|
||||
PlayedAt string `json:"played_at"`
|
||||
Track Track `json:"track"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Uri string `json:"uri"`
|
||||
Type string `json:"type"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Explicit bool `json:"explicit"`
|
||||
IsLocal bool `json:"is_local"`
|
||||
Popularity int `json:"popularity"`
|
||||
ExternalIds ExternalIds `json:"external_ids"`
|
||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
||||
Album Album `json:"album"`
|
||||
Artists []Artist `json:"artists"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Uri string `json:"uri"`
|
||||
Type string `json:"type"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ReleaseDatePrecision string `json:"release_date_precision"`
|
||||
AlbumType string `json:"album_type"`
|
||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
||||
Artists []Artist `json:"artists"`
|
||||
Images []Image `json:"images"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Uri string `json:"uri"`
|
||||
Type string `json:"type"`
|
||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
||||
}
|
||||
|
||||
type ExternalIds struct {
|
||||
ISRC string `json:"isrc"`
|
||||
EAN string `json:"ean"`
|
||||
UPC string `json:"upc"`
|
||||
}
|
||||
|
||||
type ExternalUrls struct {
|
||||
Spotify string `json:"spotify"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
Url string `json:"url"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
}
|
53
internal/backends/spotify/models_test.go
Normal file
53
internal/backends/spotify/models_test.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package spotify_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||
)
|
||||
|
||||
func TestRecentlyPlayedResult(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/recently-played.json")
|
||||
require.NoError(t, err)
|
||||
result := spotify.RecentlyPlayedResult{}
|
||||
err = json.Unmarshal(data, &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert := assert.New(t)
|
||||
assert.Equal(3, result.Limit)
|
||||
assert.Equal("1700578807229", result.Cursors.After)
|
||||
require.Len(t, result.Items, 3)
|
||||
track1 := result.Items[0].Track
|
||||
assert.Equal("Evidence", track1.Name)
|
||||
assert.Equal(11, track1.TrackNumber)
|
||||
assert.Equal(1, track1.DiscNumber)
|
||||
assert.Equal("Viva Emptiness", track1.Album.Name)
|
||||
require.Len(t, track1.Artists, 1)
|
||||
assert.Equal("Katatonia", track1.Artists[0].Name)
|
||||
}
|
282
internal/backends/spotify/spotify.go
Normal file
282
internal/backends/spotify/spotify.go
Normal file
|
@ -0,0 +1,282 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"go.uploadedlobster.com/scotty/internal/auth"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/spotify"
|
||||
)
|
||||
|
||||
type SpotifyApiBackend struct {
|
||||
client Client
|
||||
clientId string
|
||||
clientSecret string
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) Name() string { return "spotify" }
|
||||
|
||||
func (b *SpotifyApiBackend) FromConfig(config *viper.Viper) models.Backend {
|
||||
b.clientId = config.GetString("client-id")
|
||||
b.clientSecret = config.GetString("client-secret")
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
||||
conf := oauth2.Config{
|
||||
ClientID: b.clientId,
|
||||
ClientSecret: b.clientSecret,
|
||||
Scopes: []string{
|
||||
"user-read-currently-playing",
|
||||
"user-read-recently-played",
|
||||
"user-library-read",
|
||||
"user-library-modify",
|
||||
},
|
||||
RedirectURL: redirectUrl.String(),
|
||||
Endpoint: spotify.Endpoint,
|
||||
}
|
||||
|
||||
return auth.NewStandardStrategy(conf)
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config {
|
||||
return oauth2.Config{
|
||||
ClientID: b.clientId,
|
||||
ClientSecret: b.clientSecret,
|
||||
Scopes: []string{
|
||||
"user-read-currently-playing",
|
||||
"user-read-recently-played",
|
||||
"user-library-read",
|
||||
"user-library-modify",
|
||||
},
|
||||
RedirectURL: redirectUrl.String(),
|
||||
Endpoint: spotify.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
|
||||
b.client = NewClient(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||
startTime := time.Now()
|
||||
minTime := oldestTimestamp
|
||||
|
||||
totalDuration := startTime.Sub(oldestTimestamp)
|
||||
|
||||
defer close(results)
|
||||
|
||||
p := models.Progress{Total: int64(totalDuration.Seconds())}
|
||||
|
||||
for {
|
||||
result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
if result.Cursors.After == "" {
|
||||
break
|
||||
}
|
||||
|
||||
// Set minTime to the newest returned listen
|
||||
after, err := strconv.ParseInt(result.Cursors.After, 10, 64)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
} else if after <= minTime.Unix() {
|
||||
// new cursor timestamp did not progress
|
||||
break
|
||||
}
|
||||
minTime = time.Unix(after, 0)
|
||||
remainingTime := startTime.Sub(minTime)
|
||||
|
||||
count := len(result.Items)
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
listens := make(models.ListensList, 0, len(result.Items))
|
||||
|
||||
for _, listen := range result.Items {
|
||||
l := listen.AsListen()
|
||||
if l.ListenedAt.Unix() > oldestTimestamp.Unix() {
|
||||
listens = append(listens, l)
|
||||
} else {
|
||||
// result contains listens older then oldestTimestamp,
|
||||
// we can stop requesting more
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(listens)
|
||||
p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
|
||||
progress <- p
|
||||
results <- models.ListensResult{Listens: listens, OldestTimestamp: minTime}
|
||||
}
|
||||
|
||||
results <- models.ListensResult{OldestTimestamp: minTime}
|
||||
progress <- p.Complete()
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||
// Choose a high offset, we attempt to search the loves backwards starting
|
||||
// at the oldest one.
|
||||
offset := math.MaxInt32
|
||||
perPage := MaxItemsPerGet
|
||||
|
||||
defer close(results)
|
||||
|
||||
p := models.Progress{Total: int64(perPage)}
|
||||
var totalCount int
|
||||
|
||||
out:
|
||||
for {
|
||||
result, err := b.client.UserTracks(offset, perPage)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
// The offset was higher then the actual number of tracks. Adjust the offset
|
||||
// and continue.
|
||||
if offset >= result.Total {
|
||||
p.Total = int64(result.Total)
|
||||
totalCount = result.Total
|
||||
offset = result.Total - perPage
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
count := len(result.Items)
|
||||
if count == 0 {
|
||||
break out
|
||||
}
|
||||
|
||||
loves := make(models.LovesList, 0, perPage)
|
||||
for _, track := range result.Items {
|
||||
love := track.AsLove()
|
||||
if love.Created.Unix() > oldestTimestamp.Unix() {
|
||||
loves = append(loves, love)
|
||||
} else {
|
||||
totalCount -= 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(loves)
|
||||
results <- models.LovesResult{Loves: loves, Total: totalCount}
|
||||
p.Elapsed += int64(count)
|
||||
progress <- p
|
||||
|
||||
if offset <= 0 {
|
||||
// This was the last request, no further results
|
||||
break out
|
||||
}
|
||||
|
||||
offset -= perPage
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
progress <- p.Complete()
|
||||
}
|
||||
|
||||
func (l Listen) AsListen() models.Listen {
|
||||
listenedAt, _ := time.Parse(time.RFC3339, l.PlayedAt)
|
||||
listen := models.Listen{
|
||||
ListenedAt: listenedAt,
|
||||
Track: l.Track.AsTrack(),
|
||||
}
|
||||
|
||||
return listen
|
||||
}
|
||||
|
||||
func (t SavedTrack) AsLove() models.Love {
|
||||
addedAt, _ := time.Parse(time.RFC3339, t.AddedAt)
|
||||
love := models.Love{
|
||||
Created: addedAt,
|
||||
Track: t.Track.AsTrack(),
|
||||
}
|
||||
|
||||
return love
|
||||
}
|
||||
|
||||
func (t Track) AsTrack() models.Track {
|
||||
track := models.Track{
|
||||
TrackName: t.Name,
|
||||
ReleaseName: t.Album.Name,
|
||||
ArtistNames: make([]string, 0, len(t.Artists)),
|
||||
Duration: time.Duration(t.DurationMs * int(time.Millisecond)),
|
||||
TrackNumber: t.TrackNumber,
|
||||
DiscNumber: t.DiscNumber,
|
||||
ISRC: t.ExternalIds.ISRC,
|
||||
AdditionalInfo: map[string]any{},
|
||||
}
|
||||
|
||||
for _, artist := range t.Artists {
|
||||
track.ArtistNames = append(track.ArtistNames, artist.Name)
|
||||
}
|
||||
|
||||
info := track.AdditionalInfo
|
||||
if !t.IsLocal {
|
||||
info["music_service"] = "spotify.com"
|
||||
}
|
||||
|
||||
if t.ExternalUrls.Spotify != "" {
|
||||
info["origin_url"] = t.ExternalUrls.Spotify
|
||||
info["spotify_id"] = t.ExternalUrls.Spotify
|
||||
}
|
||||
|
||||
if t.Album.ExternalUrls.Spotify != "" {
|
||||
info["spotify_album_id"] = t.Album.ExternalUrls.Spotify
|
||||
}
|
||||
|
||||
if len(t.Artists) > 0 {
|
||||
info["spotify_artist_ids"] = extractArtistIds(t.Artists)
|
||||
}
|
||||
|
||||
if len(t.Album.Artists) > 0 {
|
||||
info["spotify_album_artist_ids"] = extractArtistIds(t.Album.Artists)
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
func extractArtistIds(artists []Artist) []string {
|
||||
artistIds := make([]string, len(artists))
|
||||
for i, artist := range artists {
|
||||
artistIds[i] = artist.ExternalUrls.Spotify
|
||||
}
|
||||
return artistIds
|
||||
}
|
76
internal/backends/spotify/spotify_test.go
Normal file
76
internal/backends/spotify/spotify_test.go
Normal file
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
This file is part of Scotty.
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with
|
||||
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package spotify_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
config := viper.New()
|
||||
config.Set("client-id", "someclientid")
|
||||
config.Set("client-secret", "someclientsecret")
|
||||
backend := (&spotify.SpotifyApiBackend{}).FromConfig(config)
|
||||
assert.IsType(t, &spotify.SpotifyApiBackend{}, backend)
|
||||
}
|
||||
|
||||
func TestSpotifyListenAsListen(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/listen.json")
|
||||
require.NoError(t, err)
|
||||
spListen := spotify.Listen{}
|
||||
err = json.Unmarshal(data, &spListen)
|
||||
require.NoError(t, err)
|
||||
listen := spListen.AsListen()
|
||||
listenedAt, _ := time.Parse(time.RFC3339, "2023-11-21T15:24:33.361Z")
|
||||
assert.Equal(t, listenedAt, listen.ListenedAt)
|
||||
assert.Equal(t, time.Duration(413826*time.Millisecond), listen.Duration)
|
||||
assert.Equal(t, "Oweynagat", listen.TrackName)
|
||||
assert.Equal(t, "Here Now, There Then", listen.ReleaseName)
|
||||
assert.Equal(t, []string{"Dool"}, listen.ArtistNames)
|
||||
assert.Equal(t, 5, listen.TrackNumber)
|
||||
assert.Equal(t, 1, listen.DiscNumber)
|
||||
assert.Equal(t, "DES561620801", listen.ISRC)
|
||||
info := listen.AdditionalInfo
|
||||
assert.Equal(t, "spotify.com", info["music_service"])
|
||||
assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["origin_url"])
|
||||
assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["spotify_id"])
|
||||
assert.Equal(t, "https://open.spotify.com/album/5U1umzRH4EONHWsFgPtRbA", info["spotify_album_id"])
|
||||
assert.Equal(t, []string{"https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"}, info["spotify_artist_ids"])
|
||||
assert.Equal(t, []string{"https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"}, info["spotify_album_artist_ids"])
|
||||
}
|
||||
|
||||
func TestSavedTrackAsLove(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/track.json")
|
||||
require.NoError(t, err)
|
||||
track := spotify.SavedTrack{}
|
||||
err = json.Unmarshal(data, &track)
|
||||
require.NoError(t, err)
|
||||
love := track.AsLove()
|
||||
created, _ := time.Parse(time.RFC3339, "2022-02-13T21:46:08Z")
|
||||
assert.Equal(t, created, love.Created)
|
||||
assert.Equal(t, time.Duration(187680*time.Millisecond), love.Duration)
|
||||
assert.Equal(t, "Death to the Holy", love.TrackName)
|
||||
}
|
458
internal/backends/spotify/testdata/listen.json
vendored
Normal file
458
internal/backends/spotify/testdata/listen.json
vendored
Normal file
|
@ -0,0 +1,458 @@
|
|||
{
|
||||
"track": {
|
||||
"album": {
|
||||
"album_type": "album",
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/101HSR6JTJqe3DBh6rb8kz",
|
||||
"id": "101HSR6JTJqe3DBh6rb8kz",
|
||||
"name": "Dool",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:101HSR6JTJqe3DBh6rb8kz"
|
||||
}
|
||||
],
|
||||
"available_markets": [
|
||||
"AR",
|
||||
"AU",
|
||||
"AT",
|
||||
"BE",
|
||||
"BO",
|
||||
"BR",
|
||||
"BG",
|
||||
"CA",
|
||||
"CL",
|
||||
"CO",
|
||||
"CR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DO",
|
||||
"DE",
|
||||
"EC",
|
||||
"EE",
|
||||
"SV",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"GT",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LT",
|
||||
"LU",
|
||||
"MY",
|
||||
"MT",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NO",
|
||||
"PA",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PL",
|
||||
"PT",
|
||||
"SG",
|
||||
"SK",
|
||||
"ES",
|
||||
"SE",
|
||||
"CH",
|
||||
"TW",
|
||||
"TR",
|
||||
"UY",
|
||||
"US",
|
||||
"GB",
|
||||
"AD",
|
||||
"LI",
|
||||
"MC",
|
||||
"ID",
|
||||
"JP",
|
||||
"TH",
|
||||
"VN",
|
||||
"RO",
|
||||
"IL",
|
||||
"ZA",
|
||||
"SA",
|
||||
"AE",
|
||||
"BH",
|
||||
"QA",
|
||||
"OM",
|
||||
"KW",
|
||||
"EG",
|
||||
"MA",
|
||||
"DZ",
|
||||
"TN",
|
||||
"LB",
|
||||
"JO",
|
||||
"PS",
|
||||
"IN",
|
||||
"BY",
|
||||
"KZ",
|
||||
"MD",
|
||||
"UA",
|
||||
"AL",
|
||||
"BA",
|
||||
"HR",
|
||||
"ME",
|
||||
"MK",
|
||||
"RS",
|
||||
"SI",
|
||||
"KR",
|
||||
"BD",
|
||||
"PK",
|
||||
"LK",
|
||||
"GH",
|
||||
"KE",
|
||||
"NG",
|
||||
"TZ",
|
||||
"UG",
|
||||
"AG",
|
||||
"AM",
|
||||
"BS",
|
||||
"BB",
|
||||
"BZ",
|
||||
"BT",
|
||||
"BW",
|
||||
"BF",
|
||||
"CV",
|
||||
"CW",
|
||||
"DM",
|
||||
"FJ",
|
||||
"GM",
|
||||
"GE",
|
||||
"GD",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"JM",
|
||||
"KI",
|
||||
"LS",
|
||||
"LR",
|
||||
"MW",
|
||||
"MV",
|
||||
"ML",
|
||||
"MH",
|
||||
"FM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NE",
|
||||
"PW",
|
||||
"PG",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SN",
|
||||
"SC",
|
||||
"SL",
|
||||
"SB",
|
||||
"KN",
|
||||
"LC",
|
||||
"VC",
|
||||
"SR",
|
||||
"TL",
|
||||
"TO",
|
||||
"TT",
|
||||
"TV",
|
||||
"VU",
|
||||
"AZ",
|
||||
"BN",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"TD",
|
||||
"KM",
|
||||
"GQ",
|
||||
"SZ",
|
||||
"GA",
|
||||
"GN",
|
||||
"KG",
|
||||
"LA",
|
||||
"MO",
|
||||
"MR",
|
||||
"MN",
|
||||
"NP",
|
||||
"RW",
|
||||
"TG",
|
||||
"UZ",
|
||||
"ZW",
|
||||
"BJ",
|
||||
"MG",
|
||||
"MU",
|
||||
"MZ",
|
||||
"AO",
|
||||
"CI",
|
||||
"DJ",
|
||||
"ZM",
|
||||
"CD",
|
||||
"CG",
|
||||
"IQ",
|
||||
"LY",
|
||||
"TJ",
|
||||
"VE",
|
||||
"ET",
|
||||
"XK"
|
||||
],
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/album/5U1umzRH4EONHWsFgPtRbA"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/albums/5U1umzRH4EONHWsFgPtRbA",
|
||||
"id": "5U1umzRH4EONHWsFgPtRbA",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://i.scdn.co/image/ab67616d0000b273c7b579ace1f3f56381d83aad",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 300,
|
||||
"url": "https://i.scdn.co/image/ab67616d00001e02c7b579ace1f3f56381d83aad",
|
||||
"width": 300
|
||||
},
|
||||
{
|
||||
"height": 64,
|
||||
"url": "https://i.scdn.co/image/ab67616d00004851c7b579ace1f3f56381d83aad",
|
||||
"width": 64
|
||||
}
|
||||
],
|
||||
"name": "Here Now, There Then",
|
||||
"release_date": "2017-02-17",
|
||||
"release_date_precision": "day",
|
||||
"total_tracks": 8,
|
||||
"type": "album",
|
||||
"uri": "spotify:album:5U1umzRH4EONHWsFgPtRbA"
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/101HSR6JTJqe3DBh6rb8kz"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/101HSR6JTJqe3DBh6rb8kz",
|
||||
"id": "101HSR6JTJqe3DBh6rb8kz",
|
||||
"name": "Dool",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:101HSR6JTJqe3DBh6rb8kz"
|
||||
}
|
||||
],
|
||||
"available_markets": [
|
||||
"AR",
|
||||
"AU",
|
||||
"AT",
|
||||
"BE",
|
||||
"BO",
|
||||
"BR",
|
||||
"BG",
|
||||
"CA",
|
||||
"CL",
|
||||
"CO",
|
||||
"CR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DO",
|
||||
"DE",
|
||||
"EC",
|
||||
"EE",
|
||||
"SV",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"GT",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LT",
|
||||
"LU",
|
||||
"MY",
|
||||
"MT",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NO",
|
||||
"PA",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PL",
|
||||
"PT",
|
||||
"SG",
|
||||
"SK",
|
||||
"ES",
|
||||
"SE",
|
||||
"CH",
|
||||
"TW",
|
||||
"TR",
|
||||
"UY",
|
||||
"US",
|
||||
"GB",
|
||||
"AD",
|
||||
"LI",
|
||||
"MC",
|
||||
"ID",
|
||||
"JP",
|
||||
"TH",
|
||||
"VN",
|
||||
"RO",
|
||||
"IL",
|
||||
"ZA",
|
||||
"SA",
|
||||
"AE",
|
||||
"BH",
|
||||
"QA",
|
||||
"OM",
|
||||
"KW",
|
||||
"EG",
|
||||
"MA",
|
||||
"DZ",
|
||||
"TN",
|
||||
"LB",
|
||||
"JO",
|
||||
"PS",
|
||||
"IN",
|
||||
"BY",
|
||||
"KZ",
|
||||
"MD",
|
||||
"UA",
|
||||
"AL",
|
||||
"BA",
|
||||
"HR",
|
||||
"ME",
|
||||
"MK",
|
||||
"RS",
|
||||
"SI",
|
||||
"KR",
|
||||
"BD",
|
||||
"PK",
|
||||
"LK",
|
||||
"GH",
|
||||
"KE",
|
||||
"NG",
|
||||
"TZ",
|
||||
"UG",
|
||||
"AG",
|
||||
"AM",
|
||||
"BS",
|
||||
"BB",
|
||||
"BZ",
|
||||
"BT",
|
||||
"BW",
|
||||
"BF",
|
||||
"CV",
|
||||
"CW",
|
||||
"DM",
|
||||
"FJ",
|
||||
"GM",
|
||||
"GE",
|
||||
"GD",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"JM",
|
||||
"KI",
|
||||
"LS",
|
||||
"LR",
|
||||
"MW",
|
||||
"MV",
|
||||
"ML",
|
||||
"MH",
|
||||
"FM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NE",
|
||||
"PW",
|
||||
"PG",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SN",
|
||||
"SC",
|
||||
"SL",
|
||||
"SB",
|
||||
"KN",
|
||||
"LC",
|
||||
"VC",
|
||||
"SR",
|
||||
"TL",
|
||||
"TO",
|
||||
"TT",
|
||||
"TV",
|
||||
"VU",
|
||||
"AZ",
|
||||
"BN",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"TD",
|
||||
"KM",
|
||||
"GQ",
|
||||
"SZ",
|
||||
"GA",
|
||||
"GN",
|
||||
"KG",
|
||||
"LA",
|
||||
"MO",
|
||||
"MR",
|
||||
"MN",
|
||||
"NP",
|
||||
"RW",
|
||||
"TG",
|
||||
"UZ",
|
||||
"ZW",
|
||||
"BJ",
|
||||
"MG",
|
||||
"MU",
|
||||
"MZ",
|
||||
"AO",
|
||||
"CI",
|
||||
"DJ",
|
||||
"ZM",
|
||||
"CD",
|
||||
"CG",
|
||||
"IQ",
|
||||
"LY",
|
||||
"TJ",
|
||||
"VE",
|
||||
"ET",
|
||||
"XK"
|
||||
],
|
||||
"disc_number": 1,
|
||||
"duration_ms": 413826,
|
||||
"explicit": false,
|
||||
"external_ids": {
|
||||
"isrc": "DES561620801"
|
||||
},
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/tracks/2JKUgGuXK3dEvyuIJ4Yj2V",
|
||||
"id": "2JKUgGuXK3dEvyuIJ4Yj2V",
|
||||
"is_local": false,
|
||||
"name": "Oweynagat",
|
||||
"popularity": 28,
|
||||
"preview_url": "https://p.scdn.co/mp3-preview/5f01ec09c7a470b9899dc4b4bb427302dc648f24?cid=5433a04d90a946f2a0e5175b1383604a",
|
||||
"track_number": 5,
|
||||
"type": "track",
|
||||
"uri": "spotify:track:2JKUgGuXK3dEvyuIJ4Yj2V"
|
||||
},
|
||||
"played_at": "2023-11-21T15:24:33.361Z",
|
||||
"context": {
|
||||
"type": "playlist",
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/playlist/37i9dQZF1E4odrTa1nmoN6"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/playlists/37i9dQZF1E4odrTa1nmoN6",
|
||||
"uri": "spotify:playlist:37i9dQZF1E4odrTa1nmoN6"
|
||||
}
|
||||
}
|
1385
internal/backends/spotify/testdata/recently-played.json
vendored
Normal file
1385
internal/backends/spotify/testdata/recently-played.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
80
internal/backends/spotify/testdata/track.json
vendored
Normal file
80
internal/backends/spotify/testdata/track.json
vendored
Normal file
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"added_at": "2022-02-13T21:46:08Z",
|
||||
"track": {
|
||||
"album": {
|
||||
"album_type": "album",
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"id": "6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"name": "Zeal & Ardor",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
}
|
||||
],
|
||||
"available_markets": [],
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/album/34u4cq27YP6837IcmzTTgX"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/albums/34u4cq27YP6837IcmzTTgX",
|
||||
"id": "34u4cq27YP6837IcmzTTgX",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://i.scdn.co/image/ab67616d0000b2731fedf861c139b6d8bebdc840",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 300,
|
||||
"url": "https://i.scdn.co/image/ab67616d00001e021fedf861c139b6d8bebdc840",
|
||||
"width": 300
|
||||
},
|
||||
{
|
||||
"height": 64,
|
||||
"url": "https://i.scdn.co/image/ab67616d000048511fedf861c139b6d8bebdc840",
|
||||
"width": 64
|
||||
}
|
||||
],
|
||||
"name": "Zeal & Ardor",
|
||||
"release_date": "2022-02-11",
|
||||
"release_date_precision": "day",
|
||||
"total_tracks": 14,
|
||||
"type": "album",
|
||||
"uri": "spotify:album:34u4cq27YP6837IcmzTTgX"
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"id": "6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"name": "Zeal & Ardor",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
}
|
||||
],
|
||||
"available_markets": [],
|
||||
"disc_number": 1,
|
||||
"duration_ms": 187680,
|
||||
"explicit": false,
|
||||
"external_ids": {
|
||||
"isrc": "CH8092101707"
|
||||
},
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/track/07K2e1PXNra3Zd5SGCsLuZ"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/tracks/07K2e1PXNra3Zd5SGCsLuZ",
|
||||
"id": "07K2e1PXNra3Zd5SGCsLuZ",
|
||||
"is_local": false,
|
||||
"name": "Death to the Holy",
|
||||
"popularity": 0,
|
||||
"preview_url": null,
|
||||
"track_number": 3,
|
||||
"type": "track",
|
||||
"uri": "spotify:track:07K2e1PXNra3Zd5SGCsLuZ"
|
||||
}
|
||||
}
|
540
internal/backends/spotify/testdata/user-tracks.json
vendored
Normal file
540
internal/backends/spotify/testdata/user-tracks.json
vendored
Normal file
|
@ -0,0 +1,540 @@
|
|||
{
|
||||
"href": "https://api.spotify.com/v1/me/tracks?offset=143&limit=2&locale=de,en-US;q=0.7,en;q=0.3",
|
||||
"items": [
|
||||
{
|
||||
"added_at": "2022-02-13T21:46:08Z",
|
||||
"track": {
|
||||
"album": {
|
||||
"album_type": "album",
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"id": "6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"name": "Zeal & Ardor",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
}
|
||||
],
|
||||
"available_markets": [],
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/album/34u4cq27YP6837IcmzTTgX"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/albums/34u4cq27YP6837IcmzTTgX",
|
||||
"id": "34u4cq27YP6837IcmzTTgX",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://i.scdn.co/image/ab67616d0000b2731fedf861c139b6d8bebdc840",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 300,
|
||||
"url": "https://i.scdn.co/image/ab67616d00001e021fedf861c139b6d8bebdc840",
|
||||
"width": 300
|
||||
},
|
||||
{
|
||||
"height": 64,
|
||||
"url": "https://i.scdn.co/image/ab67616d000048511fedf861c139b6d8bebdc840",
|
||||
"width": 64
|
||||
}
|
||||
],
|
||||
"name": "Zeal & Ardor",
|
||||
"release_date": "2022-02-11",
|
||||
"release_date_precision": "day",
|
||||
"total_tracks": 14,
|
||||
"type": "album",
|
||||
"uri": "spotify:album:34u4cq27YP6837IcmzTTgX"
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"id": "6yCjbLFZ9qAnWfsy9ujm5Y",
|
||||
"name": "Zeal & Ardor",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:6yCjbLFZ9qAnWfsy9ujm5Y"
|
||||
}
|
||||
],
|
||||
"available_markets": [],
|
||||
"disc_number": 1,
|
||||
"duration_ms": 187680,
|
||||
"explicit": false,
|
||||
"external_ids": {
|
||||
"isrc": "CH8092101707"
|
||||
},
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/track/07K2e1PXNra3Zd5SGCsLuZ"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/tracks/07K2e1PXNra3Zd5SGCsLuZ",
|
||||
"id": "07K2e1PXNra3Zd5SGCsLuZ",
|
||||
"is_local": false,
|
||||
"name": "Death to the Holy",
|
||||
"popularity": 0,
|
||||
"preview_url": null,
|
||||
"track_number": 3,
|
||||
"type": "track",
|
||||
"uri": "spotify:track:07K2e1PXNra3Zd5SGCsLuZ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"added_at": "2022-02-13T21:34:31Z",
|
||||
"track": {
|
||||
"album": {
|
||||
"album_type": "single",
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/7FsZ5HKdtDFJ1xmK6NICBO"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/7FsZ5HKdtDFJ1xmK6NICBO",
|
||||
"id": "7FsZ5HKdtDFJ1xmK6NICBO",
|
||||
"name": "Wucan",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:7FsZ5HKdtDFJ1xmK6NICBO"
|
||||
}
|
||||
],
|
||||
"available_markets": [
|
||||
"AR",
|
||||
"AU",
|
||||
"AT",
|
||||
"BE",
|
||||
"BO",
|
||||
"BR",
|
||||
"BG",
|
||||
"CA",
|
||||
"CL",
|
||||
"CO",
|
||||
"CR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DO",
|
||||
"DE",
|
||||
"EC",
|
||||
"EE",
|
||||
"SV",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"GT",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LT",
|
||||
"LU",
|
||||
"MY",
|
||||
"MT",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NO",
|
||||
"PA",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PL",
|
||||
"PT",
|
||||
"SG",
|
||||
"SK",
|
||||
"ES",
|
||||
"SE",
|
||||
"CH",
|
||||
"TW",
|
||||
"TR",
|
||||
"UY",
|
||||
"US",
|
||||
"GB",
|
||||
"AD",
|
||||
"LI",
|
||||
"MC",
|
||||
"ID",
|
||||
"JP",
|
||||
"TH",
|
||||
"VN",
|
||||
"RO",
|
||||
"IL",
|
||||
"ZA",
|
||||
"SA",
|
||||
"AE",
|
||||
"BH",
|
||||
"QA",
|
||||
"OM",
|
||||
"KW",
|
||||
"EG",
|
||||
"MA",
|
||||
"DZ",
|
||||
"TN",
|
||||
"LB",
|
||||
"JO",
|
||||
"PS",
|
||||
"IN",
|
||||
"BY",
|
||||
"KZ",
|
||||
"MD",
|
||||
"UA",
|
||||
"AL",
|
||||
"BA",
|
||||
"HR",
|
||||
"ME",
|
||||
"MK",
|
||||
"RS",
|
||||
"SI",
|
||||
"KR",
|
||||
"BD",
|
||||
"PK",
|
||||
"LK",
|
||||
"GH",
|
||||
"KE",
|
||||
"NG",
|
||||
"TZ",
|
||||
"UG",
|
||||
"AG",
|
||||
"AM",
|
||||
"BS",
|
||||
"BB",
|
||||
"BZ",
|
||||
"BT",
|
||||
"BW",
|
||||
"BF",
|
||||
"CV",
|
||||
"CW",
|
||||
"DM",
|
||||
"FJ",
|
||||
"GM",
|
||||
"GE",
|
||||
"GD",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"JM",
|
||||
"KI",
|
||||
"LS",
|
||||
"LR",
|
||||
"MW",
|
||||
"MV",
|
||||
"ML",
|
||||
"MH",
|
||||
"FM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NE",
|
||||
"PW",
|
||||
"PG",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SN",
|
||||
"SC",
|
||||
"SL",
|
||||
"SB",
|
||||
"KN",
|
||||
"LC",
|
||||
"VC",
|
||||
"SR",
|
||||
"TL",
|
||||
"TO",
|
||||
"TT",
|
||||
"TV",
|
||||
"VU",
|
||||
"AZ",
|
||||
"BN",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"TD",
|
||||
"KM",
|
||||
"GQ",
|
||||
"SZ",
|
||||
"GA",
|
||||
"GN",
|
||||
"KG",
|
||||
"LA",
|
||||
"MO",
|
||||
"MR",
|
||||
"MN",
|
||||
"NP",
|
||||
"RW",
|
||||
"TG",
|
||||
"UZ",
|
||||
"ZW",
|
||||
"BJ",
|
||||
"MG",
|
||||
"MU",
|
||||
"MZ",
|
||||
"AO",
|
||||
"CI",
|
||||
"DJ",
|
||||
"ZM",
|
||||
"CD",
|
||||
"CG",
|
||||
"IQ",
|
||||
"LY",
|
||||
"TJ",
|
||||
"VE",
|
||||
"ET",
|
||||
"XK"
|
||||
],
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/album/11XibdriTL2mncbfoWLwv7"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/albums/11XibdriTL2mncbfoWLwv7",
|
||||
"id": "11XibdriTL2mncbfoWLwv7",
|
||||
"images": [
|
||||
{
|
||||
"height": 640,
|
||||
"url": "https://i.scdn.co/image/ab67616d0000b2734f84c9e19c3a309cd123c6e5",
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"height": 300,
|
||||
"url": "https://i.scdn.co/image/ab67616d00001e024f84c9e19c3a309cd123c6e5",
|
||||
"width": 300
|
||||
},
|
||||
{
|
||||
"height": 64,
|
||||
"url": "https://i.scdn.co/image/ab67616d000048514f84c9e19c3a309cd123c6e5",
|
||||
"width": 64
|
||||
}
|
||||
],
|
||||
"name": "Night to Fall",
|
||||
"release_date": "2018-10-22",
|
||||
"release_date_precision": "day",
|
||||
"total_tracks": 1,
|
||||
"type": "album",
|
||||
"uri": "spotify:album:11XibdriTL2mncbfoWLwv7"
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/artist/7FsZ5HKdtDFJ1xmK6NICBO"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/artists/7FsZ5HKdtDFJ1xmK6NICBO",
|
||||
"id": "7FsZ5HKdtDFJ1xmK6NICBO",
|
||||
"name": "Wucan",
|
||||
"type": "artist",
|
||||
"uri": "spotify:artist:7FsZ5HKdtDFJ1xmK6NICBO"
|
||||
}
|
||||
],
|
||||
"available_markets": [
|
||||
"AR",
|
||||
"AU",
|
||||
"AT",
|
||||
"BE",
|
||||
"BO",
|
||||
"BR",
|
||||
"BG",
|
||||
"CA",
|
||||
"CL",
|
||||
"CO",
|
||||
"CR",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DK",
|
||||
"DO",
|
||||
"DE",
|
||||
"EC",
|
||||
"EE",
|
||||
"SV",
|
||||
"FI",
|
||||
"FR",
|
||||
"GR",
|
||||
"GT",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IE",
|
||||
"IT",
|
||||
"LV",
|
||||
"LT",
|
||||
"LU",
|
||||
"MY",
|
||||
"MT",
|
||||
"MX",
|
||||
"NL",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NO",
|
||||
"PA",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PL",
|
||||
"PT",
|
||||
"SG",
|
||||
"SK",
|
||||
"ES",
|
||||
"SE",
|
||||
"CH",
|
||||
"TW",
|
||||
"TR",
|
||||
"UY",
|
||||
"US",
|
||||
"GB",
|
||||
"AD",
|
||||
"LI",
|
||||
"MC",
|
||||
"ID",
|
||||
"JP",
|
||||
"TH",
|
||||
"VN",
|
||||
"RO",
|
||||
"IL",
|
||||
"ZA",
|
||||
"SA",
|
||||
"AE",
|
||||
"BH",
|
||||
"QA",
|
||||
"OM",
|
||||
"KW",
|
||||
"EG",
|
||||
"MA",
|
||||
"DZ",
|
||||
"TN",
|
||||
"LB",
|
||||
"JO",
|
||||
"PS",
|
||||
"IN",
|
||||
"BY",
|
||||
"KZ",
|
||||
"MD",
|
||||
"UA",
|
||||
"AL",
|
||||
"BA",
|
||||
"HR",
|
||||
"ME",
|
||||
"MK",
|
||||
"RS",
|
||||
"SI",
|
||||
"KR",
|
||||
"BD",
|
||||
"PK",
|
||||
"LK",
|
||||
"GH",
|
||||
"KE",
|
||||
"NG",
|
||||
"TZ",
|
||||
"UG",
|
||||
"AG",
|
||||
"AM",
|
||||
"BS",
|
||||
"BB",
|
||||
"BZ",
|
||||
"BT",
|
||||
"BW",
|
||||
"BF",
|
||||
"CV",
|
||||
"CW",
|
||||
"DM",
|
||||
"FJ",
|
||||
"GM",
|
||||
"GE",
|
||||
"GD",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"JM",
|
||||
"KI",
|
||||
"LS",
|
||||
"LR",
|
||||
"MW",
|
||||
"MV",
|
||||
"ML",
|
||||
"MH",
|
||||
"FM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NE",
|
||||
"PW",
|
||||
"PG",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SN",
|
||||
"SC",
|
||||
"SL",
|
||||
"SB",
|
||||
"KN",
|
||||
"LC",
|
||||
"VC",
|
||||
"SR",
|
||||
"TL",
|
||||
"TO",
|
||||
"TT",
|
||||
"TV",
|
||||
"VU",
|
||||
"AZ",
|
||||
"BN",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"TD",
|
||||
"KM",
|
||||
"GQ",
|
||||
"SZ",
|
||||
"GA",
|
||||
"GN",
|
||||
"KG",
|
||||
"LA",
|
||||
"MO",
|
||||
"MR",
|
||||
"MN",
|
||||
"NP",
|
||||
"RW",
|
||||
"TG",
|
||||
"UZ",
|
||||
"ZW",
|
||||
"BJ",
|
||||
"MG",
|
||||
"MU",
|
||||
"MZ",
|
||||
"AO",
|
||||
"CI",
|
||||
"DJ",
|
||||
"ZM",
|
||||
"CD",
|
||||
"CG",
|
||||
"IQ",
|
||||
"LY",
|
||||
"TJ",
|
||||
"VE",
|
||||
"ET",
|
||||
"XK"
|
||||
],
|
||||
"disc_number": 1,
|
||||
"duration_ms": 248372,
|
||||
"explicit": false,
|
||||
"external_ids": {
|
||||
"isrc": "DEMV91805509"
|
||||
},
|
||||
"external_urls": {
|
||||
"spotify": "https://open.spotify.com/track/30YxzMczS77DCXIWiXSzSK"
|
||||
},
|
||||
"href": "https://api.spotify.com/v1/tracks/30YxzMczS77DCXIWiXSzSK",
|
||||
"id": "30YxzMczS77DCXIWiXSzSK",
|
||||
"is_local": false,
|
||||
"name": "Night to Fall",
|
||||
"popularity": 29,
|
||||
"preview_url": "https://p.scdn.co/mp3-preview/9d3edae25510e9f78194a04625a0773ee1f0db65?cid=5433a04d90a946f2a0e5175b1383604a",
|
||||
"track_number": 1,
|
||||
"type": "track",
|
||||
"uri": "spotify:track:30YxzMczS77DCXIWiXSzSK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"limit": 2,
|
||||
"next": "https://api.spotify.com/v1/me/tracks?offset=145&limit=2&locale=de,en-US;q=0.7,en;q=0.3",
|
||||
"offset": 143,
|
||||
"previous": "https://api.spotify.com/v1/me/tracks?offset=141&limit=2&locale=de,en-US;q=0.7,en;q=0.3",
|
||||
"total": 1243
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue