Compare commits

..

9 commits

Author SHA1 Message Date
Philipp Wolfer
0a411fe2fa
If locale detection fails fall back to English 2025-04-29 17:25:10 +02:00
Philipp Wolfer
1e91b684cb
Release 0.5.0 2025-04-29 16:16:43 +02:00
Philipp Wolfer
19852be68b
Updated translations 2025-04-29 16:12:42 +02:00
Philipp Wolfer
a6cc8d49ac Translated using Weblate (German)
Currently translated at 100.0% (54 of 54 strings)

Co-authored-by: Philipp Wolfer <phw@uploadedlobster.com>
Translate-URL: https://translate.uploadedlobster.com/projects/scotty/app/de/
Translation: Scotty/app
2025-04-29 14:11:31 +00:00
Philipp Wolfer
a5442b477e
Sync translations with new strings 2025-04-29 16:06:13 +02:00
Philipp Wolfer
90e101080f Translated using Weblate (German)
Currently translated at 100.0% (54 of 54 strings)

Co-authored-by: Philipp Wolfer <phw@uploadedlobster.com>
Translate-URL: https://translate.uploadedlobster.com/projects/scotty/app/de/
Translation: Scotty/app
2025-04-29 13:51:09 +00:00
Philipp Wolfer
dff34b249c
Updated translation files 2025-04-29 15:46:14 +02:00
Philipp Wolfer
bcb1834994
scrobblerlog: use camelcase for constants 2025-04-29 13:29:00 +02:00
Philipp Wolfer
d51c97c648
Code style: All uppercase acronyms URL, ISRC, ID, HTTP 2025-04-29 13:23:41 +02:00
38 changed files with 322 additions and 322 deletions

View file

@ -1,6 +1,6 @@
# Scotty Changelog # Scotty Changelog
## 0.5.0 - (not yet released) ## 0.5.0 - 2025-04-29
- ListenBrainz: handle missing loves metadata in case of merged recordings - ListenBrainz: handle missing loves metadata in case of merged recordings
- ListenBrainz: fix loves import loading all existing loves - ListenBrainz: fix loves import loading all existing loves
- ListenBrainz: fixed progress for loves import - ListenBrainz: fixed progress for loves import

View file

@ -27,7 +27,7 @@ type OAuth2Authenticator interface {
models.Backend models.Backend
// Returns OAuth2 config suitable for this backend // Returns OAuth2 config suitable for this backend
OAuth2Strategy(redirectUrl *url.URL) OAuth2Strategy OAuth2Strategy(redirectURL *url.URL) OAuth2Strategy
// Setup the OAuth2 client // Setup the OAuth2 client
OAuth2Setup(token oauth2.TokenSource) error OAuth2Setup(token oauth2.TokenSource) error

View file

@ -24,14 +24,14 @@ import (
type OAuth2Strategy interface { type OAuth2Strategy interface {
Config() oauth2.Config Config() oauth2.Config
AuthCodeURL(verifier string, state string) AuthUrl AuthCodeURL(verifier string, state string) AuthURL
ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error) ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error)
} }
type AuthUrl struct { type AuthURL struct {
// The URL the user must visit to approve access // The URL the user must visit to approve access
Url string URL string
// Random state string passed on to the callback. // Random state string passed on to the callback.
// Leave empty if the service does not support state. // Leave empty if the service does not support state.
State string State string
@ -56,10 +56,10 @@ func (s StandardStrategy) Config() oauth2.Config {
return s.conf return s.conf
} }
func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthUrl { func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthURL {
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
return AuthUrl{ return AuthURL{
Url: url, URL: url,
State: state, State: state,
Param: "code", Param: "code",
} }

View file

@ -33,10 +33,10 @@ func (s deezerStrategy) Config() oauth2.Config {
return s.conf return s.conf
} }
func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl { func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL {
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
return auth.AuthUrl{ return auth.AuthURL{
Url: url, URL: url,
State: state, State: state,
Param: "code", Param: "code",
} }

View file

@ -36,7 +36,7 @@ const MaxItemsPerGet = 1000
const DefaultRateLimitWaitSeconds = 5 const DefaultRateLimitWaitSeconds = 5
type Client struct { type Client struct {
HttpClient *resty.Client HTTPClient *resty.Client
token oauth2.TokenSource token oauth2.TokenSource
} }
@ -47,7 +47,7 @@ func NewClient(token oauth2.TokenSource) Client {
client.SetHeader("User-Agent", version.UserAgent()) client.SetHeader("User-Agent", version.UserAgent())
client.SetRetryCount(5) client.SetRetryCount(5)
return Client{ return Client{
HttpClient: client, HTTPClient: client,
token: token, token: token,
} }
} }
@ -73,7 +73,7 @@ func (c Client) setToken(req *resty.Request) error {
} }
func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) { func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) {
request := c.HttpClient.R(). request := c.HTTPClient.R().
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"index": strconv.Itoa(offset), "index": strconv.Itoa(offset),
"limit": strconv.Itoa(limit), "limit": strconv.Itoa(limit),

View file

@ -44,7 +44,7 @@ func TestGetUserHistory(t *testing.T) {
token := oauth2.StaticTokenSource(&oauth2.Token{}) token := oauth2.StaticTokenSource(&oauth2.Token{})
client := deezer.NewClient(token) client := deezer.NewClient(token)
setupHttpMock(t, client.HttpClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.deezer.com/user/me/history", "https://api.deezer.com/user/me/history",
"testdata/user-history.json") "testdata/user-history.json")
@ -65,7 +65,7 @@ func TestGetUserTracks(t *testing.T) {
token := oauth2.StaticTokenSource(&oauth2.Token{}) token := oauth2.StaticTokenSource(&oauth2.Token{})
client := deezer.NewClient(token) client := deezer.NewClient(token)
setupHttpMock(t, client.HttpClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.deezer.com/user/me/tracks", "https://api.deezer.com/user/me/tracks",
"testdata/user-tracks.json") "testdata/user-tracks.json")
@ -81,7 +81,7 @@ func TestGetUserTracks(t *testing.T) {
assert.Equal("Outland", track1.Track.Album.Title) assert.Equal("Outland", track1.Track.Album.Title)
} }
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
httpmock.ActivateNonDefault(client) httpmock.ActivateNonDefault(client)
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))

View file

@ -31,7 +31,7 @@ import (
type DeezerApiBackend struct { type DeezerApiBackend struct {
client Client client Client
clientId string clientID string
clientSecret string clientSecret string
} }
@ -50,19 +50,19 @@ func (b *DeezerApiBackend) Options() []models.BackendOption {
} }
func (b *DeezerApiBackend) InitConfig(config *config.ServiceConfig) error { func (b *DeezerApiBackend) InitConfig(config *config.ServiceConfig) error {
b.clientId = config.GetString("client-id") b.clientID = config.GetString("client-id")
b.clientSecret = config.GetString("client-secret") b.clientSecret = config.GetString("client-secret")
return nil return nil
} }
func (b *DeezerApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { func (b *DeezerApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
conf := oauth2.Config{ conf := oauth2.Config{
ClientID: b.clientId, ClientID: b.clientID,
ClientSecret: b.clientSecret, ClientSecret: b.clientSecret,
Scopes: []string{ Scopes: []string{
"offline_access,basic_access,listening_history", "offline_access,basic_access,listening_history",
}, },
RedirectURL: redirectUrl.String(), RedirectURL: redirectURL.String(),
Endpoint: oauth2.Endpoint{ Endpoint: oauth2.Endpoint{
AuthURL: "https://connect.deezer.com/oauth/auth.php", AuthURL: "https://connect.deezer.com/oauth/auth.php",
TokenURL: "https://connect.deezer.com/oauth/access_token.php", TokenURL: "https://connect.deezer.com/oauth/access_token.php",
@ -244,8 +244,8 @@ func (t Track) AsTrack() models.Track {
info["music_service"] = "deezer.com" info["music_service"] = "deezer.com"
info["origin_url"] = t.Link info["origin_url"] = t.Link
info["deezer_id"] = t.Link info["deezer_id"] = t.Link
info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/album/%v", t.Album.Id) info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/album/%v", t.Album.ID)
info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/artist/%v", t.Artist.Id) info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/artist/%v", t.Artist.ID)
return track return track
} }

View file

@ -51,7 +51,7 @@ type HistoryResult struct {
} }
type Track struct { type Track struct {
Id int `json:"id"` ID int `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Link string `json:"link"` Link string `json:"link"`
Title string `json:"title"` Title string `json:"title"`
@ -75,7 +75,7 @@ type LovedTrack struct {
} }
type Album struct { type Album struct {
Id int `json:"id"` ID int `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Link string `json:"link"` Link string `json:"link"`
Title string `json:"title"` Title string `json:"title"`
@ -83,7 +83,7 @@ type Album struct {
} }
type Artist struct { type Artist struct {
Id int `json:"id"` ID int `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Link string `json:"link"` Link string `json:"link"`
Name string `json:"name"` Name string `json:"name"`

View file

@ -33,13 +33,13 @@ import (
const MaxItemsPerGet = 50 const MaxItemsPerGet = 50
type Client struct { type Client struct {
HttpClient *resty.Client HTTPClient *resty.Client
token string token string
} }
func NewClient(serverUrl string, token string) Client { func NewClient(serverURL string, token string) Client {
client := resty.New() client := resty.New()
client.SetBaseURL(serverUrl) client.SetBaseURL(serverURL)
client.SetAuthScheme("Bearer") client.SetAuthScheme("Bearer")
client.SetAuthToken(token) client.SetAuthToken(token)
client.SetHeader("Accept", "application/json") client.SetHeader("Accept", "application/json")
@ -49,14 +49,14 @@ func NewClient(serverUrl string, token string) Client {
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After") ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
return Client{ return Client{
HttpClient: client, HTTPClient: client,
token: token, token: token,
} }
} }
func (c Client) GetHistoryListenings(user string, page int, perPage int) (result ListeningsResult, err error) { func (c Client) GetHistoryListenings(user string, page int, perPage int) (result ListeningsResult, err error) {
const path = "/api/v1/history/listenings" const path = "/api/v1/history/listenings"
response, err := c.HttpClient.R(). response, err := c.HTTPClient.R().
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"username": user, "username": user,
"page": strconv.Itoa(page), "page": strconv.Itoa(page),
@ -75,7 +75,7 @@ func (c Client) GetHistoryListenings(user string, page int, perPage int) (result
func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksResult, err error) { func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksResult, err error) {
const path = "/api/v1/favorites/tracks" const path = "/api/v1/favorites/tracks"
response, err := c.HttpClient.R(). response, err := c.HTTPClient.R().
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"page": strconv.Itoa(page), "page": strconv.Itoa(page),
"page_size": strconv.Itoa(perPage), "page_size": strconv.Itoa(perPage),

View file

@ -32,20 +32,20 @@ import (
) )
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
serverUrl := "https://funkwhale.example.com" serverURL := "https://funkwhale.example.com"
token := "foobar123" token := "foobar123"
client := funkwhale.NewClient(serverUrl, token) client := funkwhale.NewClient(serverURL, token)
assert.Equal(t, serverUrl, client.HttpClient.BaseURL) assert.Equal(t, serverURL, client.HTTPClient.BaseURL)
assert.Equal(t, token, client.HttpClient.Token) assert.Equal(t, token, client.HTTPClient.Token)
} }
func TestGetHistoryListenings(t *testing.T) { func TestGetHistoryListenings(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
serverUrl := "https://funkwhale.example.com" serverURL := "https://funkwhale.example.com"
token := "thetoken" token := "thetoken"
client := funkwhale.NewClient(serverUrl, token) client := funkwhale.NewClient(serverURL, token)
setupHttpMock(t, client.HttpClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://funkwhale.example.com/api/v1/history/listenings", "https://funkwhale.example.com/api/v1/history/listenings",
"testdata/listenings.json") "testdata/listenings.json")
@ -67,9 +67,9 @@ func TestGetFavoriteTracks(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
token := "thetoken" token := "thetoken"
serverUrl := "https://funkwhale.example.com" serverURL := "https://funkwhale.example.com"
client := funkwhale.NewClient(serverUrl, token) client := funkwhale.NewClient(serverURL, token)
setupHttpMock(t, client.HttpClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://funkwhale.example.com/api/v1/favorites/tracks", "https://funkwhale.example.com/api/v1/favorites/tracks",
"testdata/favorite-tracks.json") "testdata/favorite-tracks.json")
@ -87,7 +87,7 @@ func TestGetFavoriteTracks(t *testing.T) {
assert.Equal("phw", fav1.User.UserName) assert.Equal("phw", fav1.User.UserName)
} }
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
httpmock.ActivateNonDefault(client) httpmock.ActivateNonDefault(client)
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))

View file

@ -31,7 +31,7 @@ type ListeningsResult struct {
} }
type Listening struct { type Listening struct {
Id int `json:"int"` ID int `json:"int"`
User User `json:"user"` User User `json:"user"`
Track Track `json:"track"` Track Track `json:"track"`
CreationDate string `json:"creation_date"` CreationDate string `json:"creation_date"`
@ -45,14 +45,14 @@ type FavoriteTracksResult struct {
} }
type FavoriteTrack struct { type FavoriteTrack struct {
Id int `json:"int"` ID int `json:"int"`
User User `json:"user"` User User `json:"user"`
Track Track `json:"track"` Track Track `json:"track"`
CreationDate string `json:"creation_date"` CreationDate string `json:"creation_date"`
} }
type Track struct { type Track struct {
Id int `json:"int"` ID int `json:"int"`
Artist Artist `json:"artist"` Artist Artist `json:"artist"`
Album Album `json:"album"` Album Album `json:"album"`
Title string `json:"title"` Title string `json:"title"`
@ -64,13 +64,13 @@ type Track struct {
} }
type Artist struct { type Artist struct {
Id int `json:"int"` ID int `json:"int"`
Name string `json:"name"` Name string `json:"name"`
ArtistMBID mbtypes.MBID `json:"mbid"` ArtistMBID mbtypes.MBID `json:"mbid"`
} }
type Album struct { type Album struct {
Id int `json:"int"` ID int `json:"int"`
Title string `json:"title"` Title string `json:"title"`
AlbumArtist Artist `json:"artist"` AlbumArtist Artist `json:"artist"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
@ -79,7 +79,7 @@ type Album struct {
} }
type User struct { type User struct {
Id int `json:"int"` ID int `json:"int"`
UserName string `json:"username"` UserName string `json:"username"`
} }

View file

@ -69,7 +69,7 @@ func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error {
Identifier: config.GetString("identifier"), Identifier: config.GetString("identifier"),
Tracks: make([]jspf.Track, 0), Tracks: make([]jspf.Track, 0),
Extension: map[string]any{ Extension: map[string]any{
jspf.MusicBrainzPlaylistExtensionId: jspf.MusicBrainzPlaylistExtension{ jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{
LastModifiedAt: time.Now(), LastModifiedAt: time.Now(),
Public: true, Public: true,
}, },
@ -116,7 +116,7 @@ func listenAsTrack(l models.Listen) jspf.Track {
extension := makeMusicBrainzExtension(l.Track) extension := makeMusicBrainzExtension(l.Track)
extension.AddedAt = l.ListenedAt extension.AddedAt = l.ListenedAt
extension.AddedBy = l.UserName extension.AddedBy = l.UserName
track.Extension[jspf.MusicBrainzTrackExtensionId] = extension track.Extension[jspf.MusicBrainzTrackExtensionID] = extension
if l.RecordingMBID != "" { if l.RecordingMBID != "" {
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMBID)) track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMBID))
@ -131,7 +131,7 @@ func loveAsTrack(l models.Love) jspf.Track {
extension := makeMusicBrainzExtension(l.Track) extension := makeMusicBrainzExtension(l.Track)
extension.AddedAt = l.Created extension.AddedAt = l.Created
extension.AddedBy = l.UserName extension.AddedBy = l.UserName
track.Extension[jspf.MusicBrainzTrackExtensionId] = extension track.Extension[jspf.MusicBrainzTrackExtensionID] = extension
recordingMBID := l.Track.RecordingMBID recordingMBID := l.Track.RecordingMBID
if l.RecordingMBID != "" { if l.RecordingMBID != "" {

View file

@ -25,21 +25,21 @@ import (
type lastfmStrategy struct { type lastfmStrategy struct {
client *lastfm.Api client *lastfm.Api
redirectUrl *url.URL redirectURL *url.URL
} }
func (s lastfmStrategy) Config() oauth2.Config { func (s lastfmStrategy) Config() oauth2.Config {
return oauth2.Config{} return oauth2.Config{}
} }
func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl { func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL {
// Last.fm does not use OAuth2, but the provided authorization flow with // Last.fm does not use OAuth2, but the provided authorization flow with
// callback URL is close enough we can shoehorn it into the existing // callback URL is close enough we can shoehorn it into the existing
// authentication strategy. // authentication strategy.
// TODO: Investigate and use callback-less flow with api.GetAuthTokenUrl(token) // TODO: Investigate and use callback-less flow with api.GetAuthTokenUrl(token)
url := s.client.GetAuthRequestUrl(s.redirectUrl.String()) url := s.client.GetAuthRequestUrl(s.redirectURL.String())
return auth.AuthUrl{ return auth.AuthURL{
Url: url, URL: url,
State: "", // last.fm does not use state State: "", // last.fm does not use state
Param: "token", Param: "token",
} }

View file

@ -62,9 +62,9 @@ func (b *LastfmApiBackend) Options() []models.BackendOption {
} }
func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error { func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error {
clientId := config.GetString("client-id") clientID := config.GetString("client-id")
clientSecret := config.GetString("client-secret") clientSecret := config.GetString("client-secret")
b.client = lastfm.New(clientId, clientSecret) b.client = lastfm.New(clientID, clientSecret)
b.username = config.GetString("username") b.username = config.GetString("username")
return nil return nil
} }
@ -72,10 +72,10 @@ func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error {
func (b *LastfmApiBackend) StartImport() error { return nil } func (b *LastfmApiBackend) StartImport() error { return nil }
func (b *LastfmApiBackend) FinishImport() error { return nil } func (b *LastfmApiBackend) FinishImport() error { return nil }
func (b *LastfmApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { func (b *LastfmApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
return lastfmStrategy{ return lastfmStrategy{
client: b.client, client: b.client,
redirectUrl: redirectUrl, redirectURL: redirectURL,
} }
} }

View file

@ -39,7 +39,7 @@ const (
) )
type Client struct { type Client struct {
HttpClient *resty.Client HTTPClient *resty.Client
MaxResults int MaxResults int
} }
@ -55,7 +55,7 @@ func NewClient(token string) Client {
ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In") ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In")
return Client{ return Client{
HttpClient: client, HTTPClient: client,
MaxResults: DefaultItemsPerGet, MaxResults: DefaultItemsPerGet,
} }
} }
@ -63,7 +63,7 @@ func NewClient(token string) Client {
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) { func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
const path = "/user/{username}/listens" const path = "/user/{username}/listens"
errorResult := ErrorResult{} errorResult := ErrorResult{}
response, err := c.HttpClient.R(). response, err := c.HTTPClient.R().
SetPathParam("username", user). SetPathParam("username", user).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"max_ts": strconv.FormatInt(maxTime.Unix(), 10), "max_ts": strconv.FormatInt(maxTime.Unix(), 10),
@ -84,7 +84,7 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) { func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) {
const path = "/submit-listens" const path = "/submit-listens"
errorResult := ErrorResult{} errorResult := ErrorResult{}
response, err := c.HttpClient.R(). response, err := c.HTTPClient.R().
SetBody(listens). SetBody(listens).
SetResult(&result). SetResult(&result).
SetError(&errorResult). SetError(&errorResult).
@ -100,7 +100,7 @@ func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, er
func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) { func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) {
const path = "/feedback/user/{username}/get-feedback" const path = "/feedback/user/{username}/get-feedback"
errorResult := ErrorResult{} errorResult := ErrorResult{}
response, err := c.HttpClient.R(). response, err := c.HTTPClient.R().
SetPathParam("username", user). SetPathParam("username", user).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"status": strconv.Itoa(status), "status": strconv.Itoa(status),
@ -122,7 +122,7 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed
func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) { func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) {
const path = "/feedback/recording-feedback" const path = "/feedback/recording-feedback"
errorResult := ErrorResult{} errorResult := ErrorResult{}
response, err := c.HttpClient.R(). response, err := c.HTTPClient.R().
SetBody(feedback). SetBody(feedback).
SetResult(&result). SetResult(&result).
SetError(&errorResult). SetError(&errorResult).
@ -138,7 +138,7 @@ func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error)
func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) { func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) {
const path = "/metadata/lookup" const path = "/metadata/lookup"
errorResult := ErrorResult{} errorResult := ErrorResult{}
response, err := c.HttpClient.R(). response, err := c.HTTPClient.R().
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"recording_name": recordingName, "recording_name": recordingName,
"artist_name": artistName, "artist_name": artistName,

View file

@ -36,7 +36,7 @@ import (
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
token := "foobar123" token := "foobar123"
client := listenbrainz.NewClient(token) client := listenbrainz.NewClient(token)
assert.Equal(t, token, client.HttpClient.Token) assert.Equal(t, token, client.HTTPClient.Token)
assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults) assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults)
} }
@ -45,7 +45,7 @@ func TestGetListens(t *testing.T) {
client := listenbrainz.NewClient("thetoken") client := listenbrainz.NewClient("thetoken")
client.MaxResults = 2 client.MaxResults = 2
setupHttpMock(t, client.HttpClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.listenbrainz.org/1/user/outsidecontext/listens", "https://api.listenbrainz.org/1/user/outsidecontext/listens",
"testdata/listens.json") "testdata/listens.json")
@ -62,7 +62,7 @@ func TestGetListens(t *testing.T) {
func TestSubmitListens(t *testing.T) { func TestSubmitListens(t *testing.T) {
client := listenbrainz.NewClient("thetoken") client := listenbrainz.NewClient("thetoken")
httpmock.ActivateNonDefault(client.HttpClient.GetClient()) httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{ responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
Status: "ok", Status: "ok",
@ -103,7 +103,7 @@ func TestGetFeedback(t *testing.T) {
client := listenbrainz.NewClient("thetoken") client := listenbrainz.NewClient("thetoken")
client.MaxResults = 2 client.MaxResults = 2
setupHttpMock(t, client.HttpClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback", "https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback",
"testdata/feedback.json") "testdata/feedback.json")
@ -120,7 +120,7 @@ func TestGetFeedback(t *testing.T) {
func TestSendFeedback(t *testing.T) { func TestSendFeedback(t *testing.T) {
client := listenbrainz.NewClient("thetoken") client := listenbrainz.NewClient("thetoken")
httpmock.ActivateNonDefault(client.HttpClient.GetClient()) httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{ responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
Status: "ok", Status: "ok",
@ -145,7 +145,7 @@ func TestLookup(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
client := listenbrainz.NewClient("thetoken") client := listenbrainz.NewClient("thetoken")
setupHttpMock(t, client.HttpClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.listenbrainz.org/1/metadata/lookup", "https://api.listenbrainz.org/1/metadata/lookup",
"testdata/lookup.json") "testdata/lookup.json")
@ -158,7 +158,7 @@ func TestLookup(t *testing.T) {
assert.Equal(mbtypes.MBID("569436a1-234a-44bc-a370-8f4d252bef21"), result.RecordingMBID) assert.Equal(mbtypes.MBID("569436a1-234a-44bc-a370-8f4d252bef21"), result.RecordingMBID)
} }
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
httpmock.ActivateNonDefault(client) httpmock.ActivateNonDefault(client)
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))

View file

@ -131,7 +131,7 @@ func TestTrackTrackNumberString(t *testing.T) {
assert.Equal(t, 12, track.TrackNumber()) assert.Equal(t, 12, track.TrackNumber())
} }
func TestTrackIsrc(t *testing.T) { func TestTrackISRC(t *testing.T) {
expected := mbtypes.ISRC("TCAEJ1934417") expected := mbtypes.ISRC("TCAEJ1934417")
track := listenbrainz.Track{ track := listenbrainz.Track{
AdditionalInfo: map[string]any{ AdditionalInfo: map[string]any{

View file

@ -32,25 +32,25 @@ import (
const MaxItemsPerGet = 1000 const MaxItemsPerGet = 1000
type Client struct { type Client struct {
HttpClient *resty.Client HTTPClient *resty.Client
token string token string
} }
func NewClient(serverUrl string, token string) Client { func NewClient(serverURL string, token string) Client {
client := resty.New() client := resty.New()
client.SetBaseURL(serverUrl) client.SetBaseURL(serverURL)
client.SetHeader("Accept", "application/json") client.SetHeader("Accept", "application/json")
client.SetHeader("User-Agent", version.UserAgent()) client.SetHeader("User-Agent", version.UserAgent())
client.SetRetryCount(5) client.SetRetryCount(5)
return Client{ return Client{
HttpClient: client, HTTPClient: client,
token: token, token: token,
} }
} }
func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) { func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) {
const path = "/apis/mlj_1/scrobbles" const path = "/apis/mlj_1/scrobbles"
response, err := c.HttpClient.R(). response, err := c.HTTPClient.R().
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"page": strconv.Itoa(page), "page": strconv.Itoa(page),
"perpage": strconv.Itoa(perPage), "perpage": strconv.Itoa(perPage),
@ -68,7 +68,7 @@ func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult,
func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err error) { func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err error) {
const path = "/apis/mlj_1/newscrobble" const path = "/apis/mlj_1/newscrobble"
scrobble.Key = c.token scrobble.Key = c.token
response, err := c.HttpClient.R(). response, err := c.HTTPClient.R().
SetBody(scrobble). SetBody(scrobble).
SetResult(&result). SetResult(&result).
Post(path) Post(path)

View file

@ -32,19 +32,19 @@ import (
) )
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
serverUrl := "https://maloja.example.com" serverURL := "https://maloja.example.com"
token := "foobar123" token := "foobar123"
client := maloja.NewClient(serverUrl, token) client := maloja.NewClient(serverURL, token)
assert.Equal(t, serverUrl, client.HttpClient.BaseURL) assert.Equal(t, serverURL, client.HTTPClient.BaseURL)
} }
func TestGetScrobbles(t *testing.T) { func TestGetScrobbles(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
serverUrl := "https://maloja.example.com" serverURL := "https://maloja.example.com"
token := "thetoken" token := "thetoken"
client := maloja.NewClient(serverUrl, token) client := maloja.NewClient(serverURL, token)
setupHttpMock(t, client.HttpClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://maloja.example.com/apis/mlj_1/scrobbles", "https://maloja.example.com/apis/mlj_1/scrobbles",
"testdata/scrobbles.json") "testdata/scrobbles.json")
@ -60,7 +60,7 @@ func TestGetScrobbles(t *testing.T) {
func TestNewScrobble(t *testing.T) { func TestNewScrobble(t *testing.T) {
server := "https://maloja.example.com" server := "https://maloja.example.com"
client := maloja.NewClient(server, "thetoken") client := maloja.NewClient(server, "thetoken")
httpmock.ActivateNonDefault(client.HttpClient.GetClient()) httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json")) responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json"))
if err != nil { if err != nil {
@ -80,7 +80,7 @@ func TestNewScrobble(t *testing.T) {
assert.Equal(t, "success", result.Status) assert.Equal(t, "success", result.Status)
} }
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
httpmock.ActivateNonDefault(client) httpmock.ActivateNonDefault(client)
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))

View file

@ -76,7 +76,7 @@ func (b *ScrobblerLogBackend) InitConfig(config *config.ServiceConfig) error {
b.log.FallbackTimezone = location b.log.FallbackTimezone = location
} }
b.log = scrobblerlog.ScrobblerLog{ b.log = scrobblerlog.ScrobblerLog{
TZ: scrobblerlog.TZ_UTC, TZ: scrobblerlog.TimezoneUTC,
Client: "Rockbox unknown $Revision$", Client: "Rockbox unknown $Revision$",
} }
return nil return nil
@ -197,7 +197,7 @@ func listenToRecord(listen models.Listen) scrobblerlog.Record {
var rating scrobblerlog.Rating var rating scrobblerlog.Rating
rockboxRating, ok := listen.AdditionalInfo["rockbox_rating"].(string) rockboxRating, ok := listen.AdditionalInfo["rockbox_rating"].(string)
if !ok || rockboxRating == "" { if !ok || rockboxRating == "" {
rating = scrobblerlog.RATING_LISTENED rating = scrobblerlog.RatingListened
} else { } else {
rating = scrobblerlog.Rating(rating) rating = scrobblerlog.Rating(rating)
} }

View file

@ -40,7 +40,7 @@ const (
) )
type Client struct { type Client struct {
HttpClient *resty.Client HTTPClient *resty.Client
} }
func NewClient(token oauth2.TokenSource) Client { func NewClient(token oauth2.TokenSource) Client {
@ -55,7 +55,7 @@ func NewClient(token oauth2.TokenSource) Client {
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After") ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
return Client{ return Client{
HttpClient: client, HTTPClient: client,
} }
} }
@ -69,7 +69,7 @@ func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlaye
func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) { func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) {
const path = "/me/player/recently-played" const path = "/me/player/recently-played"
request := c.HttpClient.R(). request := c.HTTPClient.R().
SetQueryParam("limit", strconv.Itoa(limit)). SetQueryParam("limit", strconv.Itoa(limit)).
SetResult(&result) SetResult(&result)
if after != nil { if after != nil {
@ -87,7 +87,7 @@ func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (
func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) { func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) {
const path = "/me/tracks" const path = "/me/tracks"
response, err := c.HttpClient.R(). response, err := c.HTTPClient.R().
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"offset": strconv.Itoa(offset), "offset": strconv.Itoa(offset),
"limit": strconv.Itoa(limit), "limit": strconv.Itoa(limit),

View file

@ -43,7 +43,7 @@ func TestRecentlyPlayedAfter(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
client := spotify.NewClient(nil) client := spotify.NewClient(nil)
setupHttpMock(t, client.HttpClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.spotify.com/v1/me/player/recently-played", "https://api.spotify.com/v1/me/player/recently-played",
"testdata/recently-played.json") "testdata/recently-played.json")
@ -63,7 +63,7 @@ func TestGetUserTracks(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
client := spotify.NewClient(nil) client := spotify.NewClient(nil)
setupHttpMock(t, client.HttpClient.GetClient(), setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.spotify.com/v1/me/tracks", "https://api.spotify.com/v1/me/tracks",
"testdata/user-tracks.json") "testdata/user-tracks.json")
@ -79,7 +79,7 @@ func TestGetUserTracks(t *testing.T) {
assert.Equal("Zeal & Ardor", track1.Track.Album.Name) assert.Equal("Zeal & Ardor", track1.Track.Album.Name)
} }
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
httpmock.ActivateNonDefault(client) httpmock.ActivateNonDefault(client)
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))

View file

@ -58,7 +58,7 @@ type Listen struct {
} }
type Track struct { type Track struct {
Id string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Href string `json:"href"` Href string `json:"href"`
Uri string `json:"uri"` Uri string `json:"uri"`
@ -69,14 +69,14 @@ type Track struct {
Explicit bool `json:"explicit"` Explicit bool `json:"explicit"`
IsLocal bool `json:"is_local"` IsLocal bool `json:"is_local"`
Popularity int `json:"popularity"` Popularity int `json:"popularity"`
ExternalIds ExternalIds `json:"external_ids"` ExternalIDs ExternalIDs `json:"external_ids"`
ExternalUrls ExternalUrls `json:"external_urls"` ExternalURLs ExternalURLs `json:"external_urls"`
Album Album `json:"album"` Album Album `json:"album"`
Artists []Artist `json:"artists"` Artists []Artist `json:"artists"`
} }
type Album struct { type Album struct {
Id string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Href string `json:"href"` Href string `json:"href"`
Uri string `json:"uri"` Uri string `json:"uri"`
@ -85,32 +85,32 @@ type Album struct {
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
ReleaseDatePrecision string `json:"release_date_precision"` ReleaseDatePrecision string `json:"release_date_precision"`
AlbumType string `json:"album_type"` AlbumType string `json:"album_type"`
ExternalUrls ExternalUrls `json:"external_urls"` ExternalURLs ExternalURLs `json:"external_urls"`
Artists []Artist `json:"artists"` Artists []Artist `json:"artists"`
Images []Image `json:"images"` Images []Image `json:"images"`
} }
type Artist struct { type Artist struct {
Id string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Href string `json:"href"` Href string `json:"href"`
Uri string `json:"uri"` Uri string `json:"uri"`
Type string `json:"type"` Type string `json:"type"`
ExternalUrls ExternalUrls `json:"external_urls"` ExternalURLs ExternalURLs `json:"external_urls"`
} }
type ExternalIds struct { type ExternalIDs struct {
ISRC mbtypes.ISRC `json:"isrc"` ISRC mbtypes.ISRC `json:"isrc"`
EAN string `json:"ean"` EAN string `json:"ean"`
UPC string `json:"upc"` UPC string `json:"upc"`
} }
type ExternalUrls struct { type ExternalURLs struct {
Spotify string `json:"spotify"` Spotify string `json:"spotify"`
} }
type Image struct { type Image struct {
Url string `json:"url"` URL string `json:"url"`
Height int `json:"height"` Height int `json:"height"`
Width int `json:"width"` Width int `json:"width"`
} }

View file

@ -34,7 +34,7 @@ import (
type SpotifyApiBackend struct { type SpotifyApiBackend struct {
client Client client Client
clientId string clientID string
clientSecret string clientSecret string
} }
@ -53,14 +53,14 @@ func (b *SpotifyApiBackend) Options() []models.BackendOption {
} }
func (b *SpotifyApiBackend) InitConfig(config *config.ServiceConfig) error { func (b *SpotifyApiBackend) InitConfig(config *config.ServiceConfig) error {
b.clientId = config.GetString("client-id") b.clientID = config.GetString("client-id")
b.clientSecret = config.GetString("client-secret") b.clientSecret = config.GetString("client-secret")
return nil return nil
} }
func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { func (b *SpotifyApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
conf := oauth2.Config{ conf := oauth2.Config{
ClientID: b.clientId, ClientID: b.clientID,
ClientSecret: b.clientSecret, ClientSecret: b.clientSecret,
Scopes: []string{ Scopes: []string{
"user-read-currently-playing", "user-read-currently-playing",
@ -68,16 +68,16 @@ func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Stra
"user-library-read", "user-library-read",
"user-library-modify", "user-library-modify",
}, },
RedirectURL: redirectUrl.String(), RedirectURL: redirectURL.String(),
Endpoint: spotify.Endpoint, Endpoint: spotify.Endpoint,
} }
return auth.NewStandardStrategy(conf) return auth.NewStandardStrategy(conf)
} }
func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config { func (b *SpotifyApiBackend) OAuth2Config(redirectURL *url.URL) oauth2.Config {
return oauth2.Config{ return oauth2.Config{
ClientID: b.clientId, ClientID: b.clientID,
ClientSecret: b.clientSecret, ClientSecret: b.clientSecret,
Scopes: []string{ Scopes: []string{
"user-read-currently-playing", "user-read-currently-playing",
@ -85,7 +85,7 @@ func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config {
"user-library-read", "user-library-read",
"user-library-modify", "user-library-modify",
}, },
RedirectURL: redirectUrl.String(), RedirectURL: redirectURL.String(),
Endpoint: spotify.Endpoint, Endpoint: spotify.Endpoint,
} }
} }
@ -251,7 +251,7 @@ func (t Track) AsTrack() models.Track {
Duration: time.Duration(t.DurationMs * int(time.Millisecond)), Duration: time.Duration(t.DurationMs * int(time.Millisecond)),
TrackNumber: t.TrackNumber, TrackNumber: t.TrackNumber,
DiscNumber: t.DiscNumber, DiscNumber: t.DiscNumber,
ISRC: t.ExternalIds.ISRC, ISRC: t.ExternalIDs.ISRC,
AdditionalInfo: map[string]any{}, AdditionalInfo: map[string]any{},
} }
@ -264,30 +264,30 @@ func (t Track) AsTrack() models.Track {
info["music_service"] = "spotify.com" info["music_service"] = "spotify.com"
} }
if t.ExternalUrls.Spotify != "" { if t.ExternalURLs.Spotify != "" {
info["origin_url"] = t.ExternalUrls.Spotify info["origin_url"] = t.ExternalURLs.Spotify
info["spotify_id"] = t.ExternalUrls.Spotify info["spotify_id"] = t.ExternalURLs.Spotify
} }
if t.Album.ExternalUrls.Spotify != "" { if t.Album.ExternalURLs.Spotify != "" {
info["spotify_album_id"] = t.Album.ExternalUrls.Spotify info["spotify_album_id"] = t.Album.ExternalURLs.Spotify
} }
if len(t.Artists) > 0 { if len(t.Artists) > 0 {
info["spotify_artist_ids"] = extractArtistIds(t.Artists) info["spotify_artist_ids"] = extractArtistIDs(t.Artists)
} }
if len(t.Album.Artists) > 0 { if len(t.Album.Artists) > 0 {
info["spotify_album_artist_ids"] = extractArtistIds(t.Album.Artists) info["spotify_album_artist_ids"] = extractArtistIDs(t.Album.Artists)
} }
return track return track
} }
func extractArtistIds(artists []Artist) []string { func extractArtistIDs(artists []Artist) []string {
artistIds := make([]string, len(artists)) artistIDs := make([]string, len(artists))
for i, artist := range artists { for i, artist := range artists {
artistIds[i] = artist.ExternalUrls.Spotify artistIDs[i] = artist.ExternalURLs.Spotify
} }
return artistIds return artistIDs
} }

View file

@ -92,8 +92,8 @@ func (i HistoryItem) AsListen() models.Listen {
PlaybackDuration: time.Duration(i.MillisecondsPlayed * int(time.Millisecond)), PlaybackDuration: time.Duration(i.MillisecondsPlayed * int(time.Millisecond)),
UserName: i.UserName, UserName: i.UserName,
} }
if trackUrl, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil { if trackURL, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil {
listen.AdditionalInfo["spotify_id"] = trackUrl listen.AdditionalInfo["spotify_id"] = trackURL
} }
return listen return listen
} }

View file

@ -43,20 +43,20 @@ func AuthenticationFlow(service config.ServiceConfig, backend auth.OAuth2Authent
state := auth.RandomState() state := auth.RandomState()
// Redirect user to consent page to ask for permission specified scopes. // Redirect user to consent page to ask for permission specified scopes.
authUrl := strategy.AuthCodeURL(verifier, state) authURL := strategy.AuthCodeURL(verifier, state)
// Start an HTTP server to listen for the response // Start an HTTP server to listen for the response
responseChan := make(chan auth.CodeResponse) responseChan := make(chan auth.CodeResponse)
auth.RunOauth2CallbackServer(*redirectURL, authUrl.Param, responseChan) auth.RunOauth2CallbackServer(*redirectURL, authURL.Param, responseChan)
// Open the URL // Open the URL
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authUrl.Url)) fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authURL.URL))
err = browser.OpenURL(authUrl.Url) err = browser.OpenURL(authURL.URL)
cobra.CheckErr(err) cobra.CheckErr(err)
// Retrieve the code from the authentication callback // Retrieve the code from the authentication callback
code := <-responseChan code := <-responseChan
if code.State != authUrl.State { if code.State != authURL.State {
cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch")) cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch"))
os.Exit(1) os.Exit(1)
} }

View file

@ -88,7 +88,7 @@ func (c *TransferCmd[E, I, R]) resolveBackends(source string, target string) err
} }
func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp backends.ImportProcessor[R]) error { func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp backends.ImportProcessor[R]) error {
fmt.Println(i18n.Tr("Transferring %s from %s to %s...", c.entity, c.sourceName, c.targetName)) fmt.Println(i18n.Tr("Transferring %s from %s to %s", c.entity, c.sourceName, c.targetName))
// Authenticate backends, if needed // Authenticate backends, if needed
config := viper.GetViper() config := viper.GetViper()

View file

@ -16,11 +16,10 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package i18n package i18n
import ( import (
"log"
"github.com/Xuanwo/go-locale" "github.com/Xuanwo/go-locale"
_ "go.uploadedlobster.com/scotty/internal/translations" _ "go.uploadedlobster.com/scotty/internal/translations"
"golang.org/x/text/language"
"golang.org/x/text/message" "golang.org/x/text/message"
) )
@ -29,7 +28,7 @@ var localizer Localizer
func init() { func init() {
tag, err := locale.Detect() tag, err := locale.Detect()
if err != nil { if err != nil {
log.Fatal(err) tag = language.English
} }
localizer = New(tag) localizer = New(tag)
} }

View file

@ -42,56 +42,56 @@ var messageKeyToIndex = map[string]int{
"\tbackend: %v": 11, "\tbackend: %v": 11,
"\texport: %s": 0, "\texport: %s": 0,
"\timport: %s\n": 1, "\timport: %s\n": 1,
"%v: %v": 47, "%v: %v": 48,
"Aborted": 8, "Aborted": 8,
"Access token": 19, "Access token": 19,
"Access token received, you can use %v now.\n": 33, "Access token received, you can use %v now.\n": 34,
"Append to file": 21, "Append to file": 21,
"Backend": 41, "Backend": 42,
"Check for duplicate listens on import (slower)": 24, "Check for duplicate listens on import (slower)": 24,
"Client ID": 15, "Client ID": 15,
"Client secret": 16, "Client secret": 16,
"Delete the service configuration \"%v\"?": 7, "Delete the service configuration \"%v\"?": 7,
"Directory path": 27, "Directory path": 29,
"Disable auto correction of submitted listens": 25, "Disable auto correction of submitted listens": 26,
"Error: OAuth state mismatch": 32, "Error: OAuth state mismatch": 33,
"Failed reading config: %v": 2, "Failed reading config: %v": 2,
"File path": 20, "File path": 20,
"From timestamp: %v (%v)": 43, "From timestamp: %v (%v)": 44,
"Ignore listens in incognito mode": 28, "Ignore listens in incognito mode": 30,
"Ignore skipped listens": 29, "Ignore skipped listens": 27,
"Ignored duplicate listen %v: \"%v\" by %v (%v)": 53, "Ignored duplicate listen %v: \"%v\" by %v (%v)": 25,
"Import failed, last reported timestamp was %v (%s)": 44, "Import failed, last reported timestamp was %v (%s)": 45,
"Import log:": 46, "Import log:": 47,
"Imported %v of %v %s into %v.": 45, "Imported %v of %v %s into %v.": 46,
"Include skipped listens": 26, "Latest timestamp: %v (%v)": 50,
"Latest timestamp: %v (%v)": 49, "Minimum playback duration for skipped tracks (seconds)": 31,
"Minimum playback duration for skipped tracks (seconds)": 30, "No": 39,
"No": 38,
"Playlist title": 22, "Playlist title": 22,
"Saved service %v using backend %v": 5, "Saved service %v using backend %v": 5,
"Server URL": 17, "Server URL": 17,
"Service": 40, "Service": 41,
"Service \"%v\" deleted\n": 9, "Service \"%v\" deleted\n": 9,
"Service name": 3, "Service name": 3,
"Specify a time zone for the listen timestamps": 28,
"The backend %v requires authentication. Authenticate now?": 6, "The backend %v requires authentication. Authenticate now?": 6,
"Token received, you can close this window now.": 12, "Token received, you can close this window now.": 12,
"Transferring %s from %s to %s...": 42, "Transferring %s from %s to %s…": 43,
"Unique playlist identifier": 23, "Unique playlist identifier": 23,
"Updated service %v using backend %v\n": 10, "Updated service %v using backend %v\n": 10,
"User name": 18, "User name": 18,
"Visit the URL for authorization: %v": 31, "Visit the URL for authorization: %v": 32,
"Yes": 37, "Yes": 38,
"a service with this name already exists": 4, "a service with this name already exists": 4,
"backend %s does not implement %s": 13, "backend %s does not implement %s": 13,
"done": 36, "done": 37,
"exporting": 34, "exporting": 35,
"importing": 35, "importing": 36,
"invalid timestamp string \"%v\"": 48, "invalid timestamp string \"%v\"": 49,
"key must only consist of A-Za-z0-9_-": 51, "key must only consist of A-Za-z0-9_-": 52,
"no configuration file defined, cannot write config": 50, "no configuration file defined, cannot write config": 51,
"no existing service configurations": 39, "no existing service configurations": 40,
"no service configuration \"%v\"": 52, "no service configuration \"%v\"": 53,
"unknown backend \"%s\"": 14, "unknown backend \"%s\"": 14,
} }
@ -103,18 +103,18 @@ var deIndex = []uint32{ // 55 elements
0x000001ac, 0x000001e7, 0x00000213, 0x00000233, 0x000001ac, 0x000001e7, 0x00000213, 0x00000233,
0x0000023d, 0x0000024b, 0x00000256, 0x00000263, 0x0000023d, 0x0000024b, 0x00000256, 0x00000263,
0x00000271, 0x0000027b, 0x0000028e, 0x000002a1, 0x00000271, 0x0000027b, 0x0000028e, 0x000002a1,
0x000002b8, 0x000002ed, 0x00000321, 0x00000342, 0x000002b8, 0x000002ed, 0x00000328, 0x0000035c,
0x00000352, 0x00000378, 0x0000039a, 0x000003d8, 0x0000037e, 0x000003a4, 0x000003b4, 0x000003da,
// Entry 20 - 3F // Entry 20 - 3F
0x000003fe, 0x00000428, 0x00000468, 0x00000473, 0x00000418, 0x00000443, 0x0000046d, 0x000004ad,
0x0000047e, 0x00000485, 0x00000488, 0x0000048d, 0x000004b8, 0x000004c3, 0x000004ca, 0x000004cd,
0x000004b6, 0x000004be, 0x000004c6, 0x000004ef, 0x000004d2, 0x000004fb, 0x00000503, 0x0000050b,
0x0000050d, 0x0000054a, 0x00000575, 0x00000580, 0x00000534, 0x00000552, 0x0000058f, 0x000005ba,
0x0000058d, 0x000005b1, 0x000005d4, 0x00000625, 0x000005c5, 0x000005d2, 0x000005f6, 0x00000619,
0x0000065c, 0x00000683, 0x00000683, 0x0000066a, 0x000006a1, 0x000006c8,
} // Size: 244 bytes } // Size: 244 bytes
const deData string = "" + // Size: 1667 bytes const deData string = "" + // Size: 1736 bytes
"\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" + "\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" +
" %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" + " %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" +
"in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" + "in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" +
@ -123,25 +123,26 @@ const deData string = "" + // Size: 1667 bytes
"\x02Abgebrochen\x04\x00\x01\x0a\x1e\x02Service „%[1]v“ gelöscht\x04\x00" + "\x02Abgebrochen\x04\x00\x01\x0a\x1e\x02Service „%[1]v“ gelöscht\x04\x00" +
"\x01\x0a1\x02Service %[1]v mit dem Backend %[2]v aktualisiert\x04\x01" + "\x01\x0a1\x02Service %[1]v mit dem Backend %[2]v aktualisiert\x04\x01" +
"\x09\x00\x0f\x02Backend: %[1]v\x02Token erhalten, das Fenster kann jetzt" + "\x09\x00\x0f\x02Backend: %[1]v\x02Token erhalten, das Fenster kann jetzt" +
" geschlossen werden.\x02das backend %[1]s implementiert %[2]s nicht\x02u" + " geschlossen werden.\x02das Backend %[1]s implementiert %[2]s nicht\x02u" +
"nbekanntes Backend „%[1]s“\x02Client-ID\x02Client-Secret\x02Server-URL" + "nbekanntes Backend „%[1]s“\x02Client-ID\x02Client-Secret\x02Server-URL" +
"\x02Benutzername\x02Zugriffstoken\x02Dateipfad\x02An Datei anhängen\x02T" + "\x02Benutzername\x02Zugriffstoken\x02Dateipfad\x02An Datei anhängen\x02T" +
"itel der Playlist\x02Eindeutige Playlist-ID\x02Beim Import auf Listen-Du" + "itel der Playlist\x02Eindeutige Playlist-ID\x02Beim Import auf Listen-Du" +
"plikate prüfen (langsamer)\x02Autokorrektur für übermittelte Titel deakt" + "plikate prüfen (langsamer)\x02Listen-Duplikat ignoriert %[1]v: \x22%[2]v" +
"ivieren\x02Übersprungene Titel einbeziehen\x02Verzeichnispfad\x02Listens" + "\x22 von %[3]v (%[4]v)\x02Autokorrektur für übermittelte Titel deaktivie" +
" im Inkognito-Modus ignorieren\x02Übersprungene Listens ignorieren\x02Mi" + "ren\x02Übersprungene Listens ignorieren\x02Zeitzone für den Abspiel-Zeit" +
"nimale Wiedergabedauer für übersprungene Titel (Sekunden)\x02URL für Aut" + "stempel\x02Verzeichnispfad\x02Listens im Inkognito-Modus ignorieren\x02M" +
"orisierung öffnen: %[1]v\x02Fehler: OAuth-State stimmt nicht überein\x04" + "inimale Wiedergabedauer für übersprungene Titel (Sekunden)\x02Zur Anmeld" +
"\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwendet werd" + "ung folgende URL aufrufen: %[1]v\x02Fehler: OAuth-State stimmt nicht übe" +
"en.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine bestehe" + "rein\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwen" +
"nden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %[1]s von" + "det werden.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine" +
" %[2]s nach %[3]s...\x02Ab Zeitstempel: %[1]v (%[2]v)\x02Import fehlgesc" + " bestehenden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %" +
"hlagen, letzter Zeitstempel war %[1]v (%[2]s)\x02%[1]v von %[2]v %[3]s i" + "[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[2]v)\x02Import fe" +
"n %[4]v importiert.\x02Importlog:\x02%[1]v: %[2]v\x02ungültiger Zeitstem" + "hlgeschlagen, letzter Zeitstempel war %[1]v (%[2]s)\x02%[1]v von %[2]v %" +
"pel „%[1]v“\x02Letzter Zeitstempel: %[1]v (%[2]v)\x02keine Konfiguration" + "[3]s in %[4]v importiert.\x02Importlog:\x02%[1]v: %[2]v\x02ungültiger Ze" +
"sdatei definiert, Konfiguration kann nicht geschrieben werden\x02Schlüss" + "itstempel „%[1]v“\x02Letzter Zeitstempel: %[1]v (%[2]v)\x02keine Konfigu" +
"el darf nur die Zeichen A-Za-z0-9_- beinhalten\x02keine Servicekonfigura" + "rationsdatei definiert, Konfiguration kann nicht geschrieben werden\x02S" +
"tion „%[1]v“" "chlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten\x02keine Servicekon" +
"figuration „%[1]v“"
var enIndex = []uint32{ // 55 elements var enIndex = []uint32{ // 55 elements
// Entry 0 - 1F // Entry 0 - 1F
@ -151,18 +152,18 @@ var enIndex = []uint32{ // 55 elements
0x00000170, 0x0000019f, 0x000001c6, 0x000001de, 0x00000170, 0x0000019f, 0x000001c6, 0x000001de,
0x000001e8, 0x000001f6, 0x00000201, 0x0000020b, 0x000001e8, 0x000001f6, 0x00000201, 0x0000020b,
0x00000218, 0x00000222, 0x00000231, 0x00000240, 0x00000218, 0x00000222, 0x00000231, 0x00000240,
0x0000025b, 0x0000028a, 0x000002b7, 0x000002cf, 0x0000025b, 0x0000028a, 0x000002c3, 0x000002f0,
0x000002de, 0x000002ff, 0x00000316, 0x0000034d, 0x00000307, 0x00000335, 0x00000344, 0x00000365,
// Entry 20 - 3F // Entry 20 - 3F
0x00000374, 0x00000390, 0x000003c3, 0x000003cd, 0x0000039c, 0x000003c3, 0x000003df, 0x00000412,
0x000003d7, 0x000003dc, 0x000003e0, 0x000003e3, 0x0000041c, 0x00000426, 0x0000042b, 0x0000042f,
0x00000406, 0x0000040e, 0x00000416, 0x00000440, 0x00000432, 0x00000455, 0x0000045d, 0x00000465,
0x0000045e, 0x00000497, 0x000004c1, 0x000004cd, 0x0000048f, 0x000004ad, 0x000004e6, 0x00000510,
0x000004da, 0x000004fb, 0x0000051b, 0x0000054e, 0x0000051c, 0x00000529, 0x0000054a, 0x0000056a,
0x00000573, 0x00000594, 0x000005cd, 0x0000059d, 0x000005c2, 0x000005e3,
} // Size: 244 bytes } // Size: 244 bytes
const enData string = "" + // Size: 1485 bytes const enData string = "" + // Size: 1507 bytes
"\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" + "\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" +
" %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" + " %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" +
" this name already exists\x02Saved service %[1]v using backend %[2]v\x02" + " this name already exists\x02Saved service %[1]v using backend %[2]v\x02" +
@ -174,19 +175,19 @@ const enData string = "" + // Size: 1485 bytes
"ent %[2]s\x02unknown backend \x22%[1]s\x22\x02Client ID\x02Client secret" + "ent %[2]s\x02unknown backend \x22%[1]s\x22\x02Client ID\x02Client secret" +
"\x02Server URL\x02User name\x02Access token\x02File path\x02Append to fi" + "\x02Server URL\x02User name\x02Access token\x02File path\x02Append to fi" +
"le\x02Playlist title\x02Unique playlist identifier\x02Check for duplicat" + "le\x02Playlist title\x02Unique playlist identifier\x02Check for duplicat" +
"e listens on import (slower)\x02Disable auto correction of submitted lis" + "e listens on import (slower)\x02Ignored duplicate listen %[1]v: \x22%[2]" +
"tens\x02Include skipped listens\x02Directory path\x02Ignore listens in i" + "v\x22 by %[3]v (%[4]v)\x02Disable auto correction of submitted listens" +
"ncognito mode\x02Ignore skipped listens\x02Minimum playback duration for" + "\x02Ignore skipped listens\x02Specify a time zone for the listen timesta" +
" skipped tracks (seconds)\x02Visit the URL for authorization: %[1]v\x02E" + "mps\x02Directory path\x02Ignore listens in incognito mode\x02Minimum pla" +
"rror: OAuth state mismatch\x04\x00\x01\x0a.\x02Access token received, yo" + "yback duration for skipped tracks (seconds)\x02Visit the URL for authori" +
"u can use %[1]v now.\x02exporting\x02importing\x02done\x02Yes\x02No\x02n" + "zation: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Access " +
"o existing service configurations\x02Service\x02Backend\x02Transferring " + "token received, you can use %[1]v now.\x02exporting\x02importing\x02done" +
"%[1]s from %[2]s to %[3]s...\x02From timestamp: %[1]v (%[2]v)\x02Import " + "\x02Yes\x02No\x02no existing service configurations\x02Service\x02Backen" +
"failed, last reported timestamp was %[1]v (%[2]s)\x02Imported %[1]v of %" + "d\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestamp: %[1]v (%" +
"[2]v %[3]s into %[4]v.\x02Import log:\x02%[1]v: %[2]v\x02invalid timesta" + "[2]v)\x02Import failed, last reported timestamp was %[1]v (%[2]s)\x02Imp" +
"mp string \x22%[1]v\x22\x02Latest timestamp: %[1]v (%[2]v)\x02no configu" + "orted %[1]v of %[2]v %[3]s into %[4]v.\x02Import log:\x02%[1]v: %[2]v" +
"ration file defined, cannot write config\x02key must only consist of A-Z" + "\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: %[1]v (%" +
"a-z0-9_-\x02no service configuration \x22%[1]v\x22\x02Ignored duplicate " + "[2]v)\x02no configuration file defined, cannot write config\x02key must " +
"listen %[1]v: \x22%[2]v\x22 by %[3]v (%[4]v)" "only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]v\x22"
// Total table size 3640 bytes (3KiB); checksum: 719A868A // Total table size 3731 bytes (3KiB); checksum: F7951710

View file

@ -175,7 +175,7 @@
{ {
"id": "backend {Backend} does not implement {InterfaceName}", "id": "backend {Backend} does not implement {InterfaceName}",
"message": "backend {Backend} does not implement {InterfaceName}", "message": "backend {Backend} does not implement {InterfaceName}",
"translation": "das backend {Backend} implementiert {InterfaceName} nicht", "translation": "das Backend {Backend} implementiert {InterfaceName} nicht",
"placeholders": [ "placeholders": [
{ {
"id": "Backend", "id": "Backend",
@ -261,9 +261,9 @@
"translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)" "translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)"
}, },
{ {
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
"translation": "Listen-Duplikat ignoriert {ListenedAt}: „{TrackName}“ von {ArtistName} ({RecordingMbid})", "translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})",
"placeholders": [ "placeholders": [
{ {
"id": "ListenedAt", "id": "ListenedAt",
@ -290,12 +290,12 @@
"expr": "l.ArtistName()" "expr": "l.ArtistName()"
}, },
{ {
"id": "RecordingMbid", "id": "RecordingMBID",
"string": "%[4]v", "string": "%[4]v",
"type": "go.uploadedlobster.com/scotty/internal/models.MBID", "type": "go.uploadedlobster.com/mbtypes.MBID",
"underlyingType": "string", "underlyingType": "string",
"argNum": 4, "argNum": 4,
"expr": "l.RecordingMbid" "expr": "l.RecordingMBID"
} }
] ]
}, },
@ -305,9 +305,14 @@
"translation": "Autokorrektur für übermittelte Titel deaktivieren" "translation": "Autokorrektur für übermittelte Titel deaktivieren"
}, },
{ {
"id": "Include skipped listens", "id": "Ignore skipped listens",
"message": "Include skipped listens", "message": "Ignore skipped listens",
"translation": "Übersprungene Titel einbeziehen" "translation": "Übersprungene Listens ignorieren"
},
{
"id": "Specify a time zone for the listen timestamps",
"message": "Specify a time zone for the listen timestamps",
"translation": "Zeitzone für den Abspiel-Zeitstempel"
}, },
{ {
"id": "Directory path", "id": "Directory path",
@ -319,28 +324,23 @@
"message": "Ignore listens in incognito mode", "message": "Ignore listens in incognito mode",
"translation": "Listens im Inkognito-Modus ignorieren" "translation": "Listens im Inkognito-Modus ignorieren"
}, },
{
"id": "Ignore skipped listens",
"message": "Ignore skipped listens",
"translation": "Übersprungene Listens ignorieren"
},
{ {
"id": "Minimum playback duration for skipped tracks (seconds)", "id": "Minimum playback duration for skipped tracks (seconds)",
"message": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)",
"translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)" "translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)"
}, },
{ {
"id": "Visit the URL for authorization: {Url}", "id": "Visit the URL for authorization: {URL}",
"message": "Visit the URL for authorization: {Url}", "message": "Visit the URL for authorization: {URL}",
"translation": "URL für Autorisierung öffnen: {Url}", "translation": "Zur Anmeldung folgende URL aufrufen: {URL}",
"placeholders": [ "placeholders": [
{ {
"id": "Url", "id": "URL",
"string": "%[1]v", "string": "%[1]v",
"type": "string", "type": "string",
"underlyingType": "string", "underlyingType": "string",
"argNum": 1, "argNum": 1,
"expr": "authUrl.Url" "expr": "authURL.URL"
} }
] ]
}, },
@ -411,9 +411,9 @@
"translation": "Backend" "translation": "Backend"
}, },
{ {
"id": "Transferring {Entity} from {SourceName} to {TargetName}...", "id": "Transferring {Entity} from {SourceName} to {TargetName}",
"message": "Transferring {Entity} from {SourceName} to {TargetName}...", "message": "Transferring {Entity} from {SourceName} to {TargetName}",
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...", "translation": "Übertrage {Entity} von {SourceName} nach {TargetName}",
"placeholders": [ "placeholders": [
{ {
"id": "Entity", "id": "Entity",

View file

@ -175,7 +175,7 @@
{ {
"id": "backend {Backend} does not implement {InterfaceName}", "id": "backend {Backend} does not implement {InterfaceName}",
"message": "backend {Backend} does not implement {InterfaceName}", "message": "backend {Backend} does not implement {InterfaceName}",
"translation": "das backend {Backend} implementiert {InterfaceName} nicht", "translation": "das Backend {Backend} implementiert {InterfaceName} nicht",
"placeholders": [ "placeholders": [
{ {
"id": "Backend", "id": "Backend",
@ -263,7 +263,7 @@
{ {
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
"translation": "", "translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})",
"placeholders": [ "placeholders": [
{ {
"id": "ListenedAt", "id": "ListenedAt",
@ -305,9 +305,14 @@
"translation": "Autokorrektur für übermittelte Titel deaktivieren" "translation": "Autokorrektur für übermittelte Titel deaktivieren"
}, },
{ {
"id": "Include skipped listens", "id": "Ignore skipped listens",
"message": "Include skipped listens", "message": "Ignore skipped listens",
"translation": "Übersprungene Titel einbeziehen" "translation": "Übersprungene Listens ignorieren"
},
{
"id": "Specify a time zone for the listen timestamps",
"message": "Specify a time zone for the listen timestamps",
"translation": "Zeitzone für den Abspiel-Zeitstempel"
}, },
{ {
"id": "Directory path", "id": "Directory path",
@ -319,28 +324,23 @@
"message": "Ignore listens in incognito mode", "message": "Ignore listens in incognito mode",
"translation": "Listens im Inkognito-Modus ignorieren" "translation": "Listens im Inkognito-Modus ignorieren"
}, },
{
"id": "Ignore skipped listens",
"message": "Ignore skipped listens",
"translation": "Übersprungene Listens ignorieren"
},
{ {
"id": "Minimum playback duration for skipped tracks (seconds)", "id": "Minimum playback duration for skipped tracks (seconds)",
"message": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)",
"translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)" "translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)"
}, },
{ {
"id": "Visit the URL for authorization: {Url}", "id": "Visit the URL for authorization: {URL}",
"message": "Visit the URL for authorization: {Url}", "message": "Visit the URL for authorization: {URL}",
"translation": "URL für Autorisierung öffnen: {Url}", "translation": "Zur Anmeldung folgende URL aufrufen: {URL}",
"placeholders": [ "placeholders": [
{ {
"id": "Url", "id": "URL",
"string": "%[1]v", "string": "%[1]v",
"type": "string", "type": "string",
"underlyingType": "string", "underlyingType": "string",
"argNum": 1, "argNum": 1,
"expr": "authUrl.Url" "expr": "authURL.URL"
} }
] ]
}, },
@ -411,9 +411,9 @@
"translation": "Backend" "translation": "Backend"
}, },
{ {
"id": "Transferring {Entity} from {SourceName} to {TargetName}...", "id": "Transferring {Entity} from {SourceName} to {TargetName}",
"message": "Transferring {Entity} from {SourceName} to {TargetName}...", "message": "Transferring {Entity} from {SourceName} to {TargetName}",
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...", "translation": "Übertrage {Entity} von {SourceName} nach {TargetName}",
"placeholders": [ "placeholders": [
{ {
"id": "Entity", "id": "Entity",

View file

@ -311,9 +311,9 @@
"fuzzy": true "fuzzy": true
}, },
{ {
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", "translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"placeholders": [ "placeholders": [
{ {
@ -341,12 +341,12 @@
"expr": "l.ArtistName()" "expr": "l.ArtistName()"
}, },
{ {
"id": "RecordingMbid", "id": "RecordingMBID",
"string": "%[4]v", "string": "%[4]v",
"type": "go.uploadedlobster.com/scotty/internal/models.MBID", "type": "go.uploadedlobster.com/mbtypes.MBID",
"underlyingType": "string", "underlyingType": "string",
"argNum": 4, "argNum": 4,
"expr": "l.RecordingMbid" "expr": "l.RecordingMBID"
} }
], ],
"fuzzy": true "fuzzy": true
@ -359,9 +359,16 @@
"fuzzy": true "fuzzy": true
}, },
{ {
"id": "Include skipped listens", "id": "Ignore skipped listens",
"message": "Include skipped listens", "message": "Ignore skipped listens",
"translation": "Include skipped listens", "translation": "Ignore skipped listens",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Specify a time zone for the listen timestamps",
"message": "Specify a time zone for the listen timestamps",
"translation": "Specify a time zone for the listen timestamps",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"fuzzy": true "fuzzy": true
}, },
@ -379,13 +386,6 @@
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"fuzzy": true "fuzzy": true
}, },
{
"id": "Ignore skipped listens",
"message": "Ignore skipped listens",
"translation": "Ignore skipped listens",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{ {
"id": "Minimum playback duration for skipped tracks (seconds)", "id": "Minimum playback duration for skipped tracks (seconds)",
"message": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)",
@ -394,18 +394,18 @@
"fuzzy": true "fuzzy": true
}, },
{ {
"id": "Visit the URL for authorization: {Url}", "id": "Visit the URL for authorization: {URL}",
"message": "Visit the URL for authorization: {Url}", "message": "Visit the URL for authorization: {URL}",
"translation": "Visit the URL for authorization: {Url}", "translation": "Visit the URL for authorization: {URL}",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"placeholders": [ "placeholders": [
{ {
"id": "Url", "id": "URL",
"string": "%[1]v", "string": "%[1]v",
"type": "string", "type": "string",
"underlyingType": "string", "underlyingType": "string",
"argNum": 1, "argNum": 1,
"expr": "authUrl.Url" "expr": "authURL.URL"
} }
], ],
"fuzzy": true "fuzzy": true
@ -491,9 +491,9 @@
"fuzzy": true "fuzzy": true
}, },
{ {
"id": "Transferring {Entity} from {SourceName} to {TargetName}...", "id": "Transferring {Entity} from {SourceName} to {TargetName}",
"message": "Transferring {Entity} from {SourceName} to {TargetName}...", "message": "Transferring {Entity} from {SourceName} to {TargetName}",
"translation": "Transferring {Entity} from {SourceName} to {TargetName}...", "translation": "Transferring {Entity} from {SourceName} to {TargetName}",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"placeholders": [ "placeholders": [
{ {

View file

@ -359,9 +359,16 @@
"fuzzy": true "fuzzy": true
}, },
{ {
"id": "Include skipped listens", "id": "Ignore skipped listens",
"message": "Include skipped listens", "message": "Ignore skipped listens",
"translation": "Include skipped listens", "translation": "Ignore skipped listens",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Specify a time zone for the listen timestamps",
"message": "Specify a time zone for the listen timestamps",
"translation": "Specify a time zone for the listen timestamps",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"fuzzy": true "fuzzy": true
}, },
@ -379,13 +386,6 @@
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"fuzzy": true "fuzzy": true
}, },
{
"id": "Ignore skipped listens",
"message": "Ignore skipped listens",
"translation": "Ignore skipped listens",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{ {
"id": "Minimum playback duration for skipped tracks (seconds)", "id": "Minimum playback duration for skipped tracks (seconds)",
"message": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)",
@ -394,18 +394,18 @@
"fuzzy": true "fuzzy": true
}, },
{ {
"id": "Visit the URL for authorization: {Url}", "id": "Visit the URL for authorization: {URL}",
"message": "Visit the URL for authorization: {Url}", "message": "Visit the URL for authorization: {URL}",
"translation": "Visit the URL for authorization: {Url}", "translation": "Visit the URL for authorization: {URL}",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"placeholders": [ "placeholders": [
{ {
"id": "Url", "id": "URL",
"string": "%[1]v", "string": "%[1]v",
"type": "string", "type": "string",
"underlyingType": "string", "underlyingType": "string",
"argNum": 1, "argNum": 1,
"expr": "authUrl.Url" "expr": "authURL.URL"
} }
], ],
"fuzzy": true "fuzzy": true
@ -491,9 +491,9 @@
"fuzzy": true "fuzzy": true
}, },
{ {
"id": "Transferring {Entity} from {SourceName} to {TargetName}...", "id": "Transferring {Entity} from {SourceName} to {TargetName}",
"message": "Transferring {Entity} from {SourceName} to {TargetName}...", "message": "Transferring {Entity} from {SourceName} to {TargetName}",
"translation": "Transferring {Entity} from {SourceName} to {TargetName}...", "translation": "Transferring {Entity} from {SourceName} to {TargetName}",
"translatorComment": "Copied from source.", "translatorComment": "Copied from source.",
"placeholders": [ "placeholders": [
{ {

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2024 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the 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 terms of the GNU General Public License as published by the Free Software
@ -17,7 +17,7 @@ package version
const ( const (
AppName = "scotty" AppName = "scotty"
AppVersion = "0.4.1" AppVersion = "0.5.0"
AppURL = "https://git.sr.ht/~phw/scotty/" AppURL = "https://git.sr.ht/~phw/scotty/"
) )

View file

@ -26,9 +26,9 @@ import "time"
const ( const (
// The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension // The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension
MusicBrainzPlaylistExtensionId = "https://musicbrainz.org/doc/jspf#playlist" MusicBrainzPlaylistExtensionID = "https://musicbrainz.org/doc/jspf#playlist"
// The identifier for the MusicBrainz / ListenBrainz JSPF track extension // The identifier for the MusicBrainz / ListenBrainz JSPF track extension
MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track" MusicBrainzTrackExtensionID = "https://musicbrainz.org/doc/jspf#track"
) )
// MusicBrainz / ListenBrainz JSPF track extension // MusicBrainz / ListenBrainz JSPF track extension

View file

@ -39,7 +39,7 @@ func ExampleMusicBrainzTrackExtension() {
{ {
Title: "Oweynagat", Title: "Oweynagat",
Extension: map[string]any{ Extension: map[string]any{
jspf.MusicBrainzTrackExtensionId: jspf.MusicBrainzTrackExtension{ jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{
AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC),
AddedBy: "scotty", AddedBy: "scotty",
}, },

View file

@ -45,16 +45,16 @@ import (
type TZInfo string type TZInfo string
const ( const (
TZ_UNKNOWN TZInfo = "UNKNOWN" TimezoneUnknown TZInfo = "UNKNOWN"
TZ_UTC TZInfo = "UTC" TimezoneUTC TZInfo = "UTC"
) )
// L if listened at least 50% or S if skipped // L if listened at least 50% or S if skipped
type Rating string type Rating string
const ( const (
RATING_LISTENED Rating = "L" RatingListened Rating = "L"
RATING_SKIPPED Rating = "S" RatingSkipped Rating = "S"
) )
// A single entry of a track in the scrobbler log file. // A single entry of a track in the scrobbler log file.
@ -75,7 +75,7 @@ type ScrobblerLog struct {
Client string Client string
Records []Record Records []Record
// Timezone to be used for timestamps in the log file, // Timezone to be used for timestamps in the log file,
// if TZ is set to [TZ_UNKNOWN]. // if TZ is set to [TimezoneUnknown].
FallbackTimezone *time.Location FallbackTimezone *time.Location
} }
@ -116,7 +116,7 @@ func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error {
return err return err
} }
if ignoreSkipped && record.Rating == RATING_SKIPPED { if ignoreSkipped && record.Rating == RatingSkipped {
continue continue
} }
@ -224,7 +224,7 @@ func (l ScrobblerLog) rowToRecord(row []string) (Record, error) {
} }
var timezone *time.Location = nil var timezone *time.Location = nil
if l.TZ == TZ_UNKNOWN { if l.TZ == TimezoneUnknown {
timezone = l.FallbackTimezone timezone = l.FallbackTimezone
} }

View file

@ -50,7 +50,7 @@ func TestParser(t *testing.T) {
result := scrobblerlog.ScrobblerLog{} result := scrobblerlog.ScrobblerLog{}
err := result.Parse(data, false) err := result.Parse(data, false)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(scrobblerlog.TZ_UNKNOWN, result.TZ) assert.Equal(scrobblerlog.TimezoneUnknown, result.TZ)
assert.Equal("Rockbox sansaclipplus $Revision$", result.Client) assert.Equal("Rockbox sansaclipplus $Revision$", result.Client)
assert.Len(result.Records, 5) assert.Len(result.Records, 5)
record1 := result.Records[0] record1 := result.Records[0]
@ -59,11 +59,11 @@ func TestParser(t *testing.T) {
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", record1.TrackName) assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", record1.TrackName)
assert.Equal(5, record1.TrackNumber) assert.Equal(5, record1.TrackNumber)
assert.Equal(time.Duration(306*time.Second), record1.Duration) assert.Equal(time.Duration(306*time.Second), record1.Duration)
assert.Equal(scrobblerlog.RATING_LISTENED, record1.Rating) assert.Equal(scrobblerlog.RatingListened, record1.Rating)
assert.Equal(time.Unix(1260342084, 0), record1.Timestamp) assert.Equal(time.Unix(1260342084, 0), record1.Timestamp)
assert.Equal(mbtypes.MBID(""), record1.MusicBrainzRecordingID) assert.Equal(mbtypes.MBID(""), record1.MusicBrainzRecordingID)
record4 := result.Records[3] record4 := result.Records[3]
assert.Equal(scrobblerlog.RATING_SKIPPED, record4.Rating) assert.Equal(scrobblerlog.RatingSkipped, record4.Rating)
assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"),
record4.MusicBrainzRecordingID) record4.MusicBrainzRecordingID)
} }
@ -76,7 +76,7 @@ func TestParserIgnoreSkipped(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Len(result.Records, 4) assert.Len(result.Records, 4)
record4 := result.Records[3] record4 := result.Records[3]
assert.Equal(scrobblerlog.RATING_LISTENED, record4.Rating) assert.Equal(scrobblerlog.RatingListened, record4.Rating)
assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"),
record4.MusicBrainzRecordingID) record4.MusicBrainzRecordingID)
} }
@ -101,7 +101,7 @@ func TestAppend(t *testing.T) {
data := make([]byte, 0, 10) data := make([]byte, 0, 10)
buffer := bytes.NewBuffer(data) buffer := bytes.NewBuffer(data)
log := scrobblerlog.ScrobblerLog{ log := scrobblerlog.ScrobblerLog{
TZ: scrobblerlog.TZ_UNKNOWN, TZ: scrobblerlog.TimezoneUnknown,
Client: "Rockbox foo $Revision$", Client: "Rockbox foo $Revision$",
} }
records := []scrobblerlog.Record{ records := []scrobblerlog.Record{
@ -111,7 +111,7 @@ func TestAppend(t *testing.T) {
TrackName: "Reign", TrackName: "Reign",
TrackNumber: 1, TrackNumber: 1,
Duration: 271 * time.Second, Duration: 271 * time.Second,
Rating: scrobblerlog.RATING_LISTENED, Rating: scrobblerlog.RatingListened,
Timestamp: time.Unix(1699572072, 0), Timestamp: time.Unix(1699572072, 0),
MusicBrainzRecordingID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"), MusicBrainzRecordingID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
}, },
@ -139,7 +139,7 @@ func TestReadHeader(t *testing.T) {
log := scrobblerlog.ScrobblerLog{} log := scrobblerlog.ScrobblerLog{}
err := log.ReadHeader(reader) err := log.ReadHeader(reader)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, log.TZ, scrobblerlog.TZ_UNKNOWN) assert.Equal(t, log.TZ, scrobblerlog.TimezoneUnknown)
assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$") assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$")
assert.Empty(t, log.Records) assert.Empty(t, log.Records)
} }