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
## 0.5.0 - (not yet released)
## 0.5.0 - 2025-04-29
- ListenBrainz: handle missing loves metadata in case of merged recordings
- ListenBrainz: fix loves import loading all existing loves
- ListenBrainz: fixed progress for loves import

View file

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

View file

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

View file

@ -33,10 +33,10 @@ func (s deezerStrategy) Config() oauth2.Config {
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))
return auth.AuthUrl{
Url: url,
return auth.AuthURL{
URL: url,
State: state,
Param: "code",
}

View file

@ -36,7 +36,7 @@ const MaxItemsPerGet = 1000
const DefaultRateLimitWaitSeconds = 5
type Client struct {
HttpClient *resty.Client
HTTPClient *resty.Client
token oauth2.TokenSource
}
@ -47,7 +47,7 @@ func NewClient(token oauth2.TokenSource) Client {
client.SetHeader("User-Agent", version.UserAgent())
client.SetRetryCount(5)
return Client{
HttpClient: client,
HTTPClient: client,
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) {
request := c.HttpClient.R().
request := c.HTTPClient.R().
SetQueryParams(map[string]string{
"index": strconv.Itoa(offset),
"limit": strconv.Itoa(limit),

View file

@ -44,7 +44,7 @@ func TestGetUserHistory(t *testing.T) {
token := oauth2.StaticTokenSource(&oauth2.Token{})
client := deezer.NewClient(token)
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.deezer.com/user/me/history",
"testdata/user-history.json")
@ -65,7 +65,7 @@ func TestGetUserTracks(t *testing.T) {
token := oauth2.StaticTokenSource(&oauth2.Token{})
client := deezer.NewClient(token)
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.deezer.com/user/me/tracks",
"testdata/user-tracks.json")
@ -81,7 +81,7 @@ func TestGetUserTracks(t *testing.T) {
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)
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))

View file

@ -31,7 +31,7 @@ import (
type DeezerApiBackend struct {
client Client
clientId string
clientID string
clientSecret string
}
@ -50,19 +50,19 @@ func (b *DeezerApiBackend) Options() []models.BackendOption {
}
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")
return nil
}
func (b *DeezerApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
func (b *DeezerApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
conf := oauth2.Config{
ClientID: b.clientId,
ClientID: b.clientID,
ClientSecret: b.clientSecret,
Scopes: []string{
"offline_access,basic_access,listening_history",
},
RedirectURL: redirectUrl.String(),
RedirectURL: redirectURL.String(),
Endpoint: oauth2.Endpoint{
AuthURL: "https://connect.deezer.com/oauth/auth.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["origin_url"] = t.Link
info["deezer_id"] = t.Link
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_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)
return track
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -62,9 +62,9 @@ func (b *LastfmApiBackend) Options() []models.BackendOption {
}
func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error {
clientId := config.GetString("client-id")
clientID := config.GetString("client-id")
clientSecret := config.GetString("client-secret")
b.client = lastfm.New(clientId, clientSecret)
b.client = lastfm.New(clientID, clientSecret)
b.username = config.GetString("username")
return nil
}
@ -72,10 +72,10 @@ func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error {
func (b *LastfmApiBackend) StartImport() 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{
client: b.client,
redirectUrl: redirectUrl,
redirectURL: redirectURL,
}
}

View file

@ -39,7 +39,7 @@ const (
)
type Client struct {
HttpClient *resty.Client
HTTPClient *resty.Client
MaxResults int
}
@ -55,7 +55,7 @@ func NewClient(token string) Client {
ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In")
return Client{
HttpClient: client,
HTTPClient: client,
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) {
const path = "/user/{username}/listens"
errorResult := ErrorResult{}
response, err := c.HttpClient.R().
response, err := c.HTTPClient.R().
SetPathParam("username", user).
SetQueryParams(map[string]string{
"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) {
const path = "/submit-listens"
errorResult := ErrorResult{}
response, err := c.HttpClient.R().
response, err := c.HTTPClient.R().
SetBody(listens).
SetResult(&result).
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) {
const path = "/feedback/user/{username}/get-feedback"
errorResult := ErrorResult{}
response, err := c.HttpClient.R().
response, err := c.HTTPClient.R().
SetPathParam("username", user).
SetQueryParams(map[string]string{
"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) {
const path = "/feedback/recording-feedback"
errorResult := ErrorResult{}
response, err := c.HttpClient.R().
response, err := c.HTTPClient.R().
SetBody(feedback).
SetResult(&result).
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) {
const path = "/metadata/lookup"
errorResult := ErrorResult{}
response, err := c.HttpClient.R().
response, err := c.HTTPClient.R().
SetQueryParams(map[string]string{
"recording_name": recordingName,
"artist_name": artistName,

View file

@ -36,7 +36,7 @@ import (
func TestNewClient(t *testing.T) {
token := "foobar123"
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)
}
@ -45,7 +45,7 @@ func TestGetListens(t *testing.T) {
client := listenbrainz.NewClient("thetoken")
client.MaxResults = 2
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.listenbrainz.org/1/user/outsidecontext/listens",
"testdata/listens.json")
@ -62,7 +62,7 @@ func TestGetListens(t *testing.T) {
func TestSubmitListens(t *testing.T) {
client := listenbrainz.NewClient("thetoken")
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
Status: "ok",
@ -103,7 +103,7 @@ func TestGetFeedback(t *testing.T) {
client := listenbrainz.NewClient("thetoken")
client.MaxResults = 2
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback",
"testdata/feedback.json")
@ -120,7 +120,7 @@ func TestGetFeedback(t *testing.T) {
func TestSendFeedback(t *testing.T) {
client := listenbrainz.NewClient("thetoken")
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
Status: "ok",
@ -145,7 +145,7 @@ func TestLookup(t *testing.T) {
defer httpmock.DeactivateAndReset()
client := listenbrainz.NewClient("thetoken")
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.listenbrainz.org/1/metadata/lookup",
"testdata/lookup.json")
@ -158,7 +158,7 @@ func TestLookup(t *testing.T) {
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)
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())
}
func TestTrackIsrc(t *testing.T) {
func TestTrackISRC(t *testing.T) {
expected := mbtypes.ISRC("TCAEJ1934417")
track := listenbrainz.Track{
AdditionalInfo: map[string]any{

View file

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

View file

@ -32,19 +32,19 @@ import (
)
func TestNewClient(t *testing.T) {
serverUrl := "https://maloja.example.com"
serverURL := "https://maloja.example.com"
token := "foobar123"
client := maloja.NewClient(serverUrl, token)
assert.Equal(t, serverUrl, client.HttpClient.BaseURL)
client := maloja.NewClient(serverURL, token)
assert.Equal(t, serverURL, client.HTTPClient.BaseURL)
}
func TestGetScrobbles(t *testing.T) {
defer httpmock.DeactivateAndReset()
serverUrl := "https://maloja.example.com"
serverURL := "https://maloja.example.com"
token := "thetoken"
client := maloja.NewClient(serverUrl, token)
setupHttpMock(t, client.HttpClient.GetClient(),
client := maloja.NewClient(serverURL, token)
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://maloja.example.com/apis/mlj_1/scrobbles",
"testdata/scrobbles.json")
@ -60,7 +60,7 @@ func TestGetScrobbles(t *testing.T) {
func TestNewScrobble(t *testing.T) {
server := "https://maloja.example.com"
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"))
if err != nil {
@ -80,7 +80,7 @@ func TestNewScrobble(t *testing.T) {
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)
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 = scrobblerlog.ScrobblerLog{
TZ: scrobblerlog.TZ_UTC,
TZ: scrobblerlog.TimezoneUTC,
Client: "Rockbox unknown $Revision$",
}
return nil
@ -197,7 +197,7 @@ func listenToRecord(listen models.Listen) scrobblerlog.Record {
var rating scrobblerlog.Rating
rockboxRating, ok := listen.AdditionalInfo["rockbox_rating"].(string)
if !ok || rockboxRating == "" {
rating = scrobblerlog.RATING_LISTENED
rating = scrobblerlog.RatingListened
} else {
rating = scrobblerlog.Rating(rating)
}

View file

@ -40,7 +40,7 @@ const (
)
type Client struct {
HttpClient *resty.Client
HTTPClient *resty.Client
}
func NewClient(token oauth2.TokenSource) Client {
@ -55,7 +55,7 @@ func NewClient(token oauth2.TokenSource) Client {
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
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) {
const path = "/me/player/recently-played"
request := c.HttpClient.R().
request := c.HTTPClient.R().
SetQueryParam("limit", strconv.Itoa(limit)).
SetResult(&result)
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) {
const path = "/me/tracks"
response, err := c.HttpClient.R().
response, err := c.HTTPClient.R().
SetQueryParams(map[string]string{
"offset": strconv.Itoa(offset),
"limit": strconv.Itoa(limit),

View file

@ -43,7 +43,7 @@ func TestRecentlyPlayedAfter(t *testing.T) {
defer httpmock.DeactivateAndReset()
client := spotify.NewClient(nil)
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.spotify.com/v1/me/player/recently-played",
"testdata/recently-played.json")
@ -63,7 +63,7 @@ func TestGetUserTracks(t *testing.T) {
defer httpmock.DeactivateAndReset()
client := spotify.NewClient(nil)
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.spotify.com/v1/me/tracks",
"testdata/user-tracks.json")
@ -79,7 +79,7 @@ func TestGetUserTracks(t *testing.T) {
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)
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))

View file

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

View file

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

View file

@ -43,20 +43,20 @@ func AuthenticationFlow(service config.ServiceConfig, backend auth.OAuth2Authent
state := auth.RandomState()
// 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
responseChan := make(chan auth.CodeResponse)
auth.RunOauth2CallbackServer(*redirectURL, authUrl.Param, responseChan)
auth.RunOauth2CallbackServer(*redirectURL, authURL.Param, responseChan)
// Open the URL
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authUrl.Url))
err = browser.OpenURL(authUrl.Url)
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authURL.URL))
err = browser.OpenURL(authURL.URL)
cobra.CheckErr(err)
// Retrieve the code from the authentication callback
code := <-responseChan
if code.State != authUrl.State {
if code.State != authURL.State {
cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch"))
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 {
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
config := viper.GetViper()

View file

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

View file

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

View file

@ -175,7 +175,7 @@
{
"id": "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": [
{
"id": "Backend",
@ -263,7 +263,7 @@
{
"id": "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": [
{
"id": "ListenedAt",
@ -305,9 +305,14 @@
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
},
{
"id": "Include skipped listens",
"message": "Include skipped listens",
"translation": "Übersprungene Titel einbeziehen"
"id": "Ignore skipped listens",
"message": "Ignore skipped listens",
"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",
@ -319,28 +324,23 @@
"message": "Ignore listens in incognito mode",
"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)",
"message": "Minimum playback duration for skipped tracks (seconds)",
"translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)"
},
{
"id": "Visit the URL for authorization: {Url}",
"message": "Visit the URL for authorization: {Url}",
"translation": "URL für Autorisierung öffnen: {Url}",
"id": "Visit the URL for authorization: {URL}",
"message": "Visit the URL for authorization: {URL}",
"translation": "Zur Anmeldung folgende URL aufrufen: {URL}",
"placeholders": [
{
"id": "Url",
"id": "URL",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "authUrl.Url"
"expr": "authURL.URL"
}
]
},
@ -411,9 +411,9 @@
"translation": "Backend"
},
{
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...",
"id": "Transferring {Entity} from {SourceName} to {TargetName}",
"message": "Transferring {Entity} from {SourceName} to {TargetName}",
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}",
"placeholders": [
{
"id": "Entity",

View file

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

View file

@ -359,9 +359,16 @@
"fuzzy": true
},
{
"id": "Include skipped listens",
"message": "Include skipped listens",
"translation": "Include skipped listens",
"id": "Ignore skipped listens",
"message": "Ignore 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.",
"fuzzy": true
},
@ -379,13 +386,6 @@
"translatorComment": "Copied from source.",
"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)",
"message": "Minimum playback duration for skipped tracks (seconds)",
@ -394,18 +394,18 @@
"fuzzy": true
},
{
"id": "Visit the URL for authorization: {Url}",
"message": "Visit the URL for authorization: {Url}",
"translation": "Visit the URL for authorization: {Url}",
"id": "Visit the URL for authorization: {URL}",
"message": "Visit the URL for authorization: {URL}",
"translation": "Visit the URL for authorization: {URL}",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Url",
"id": "URL",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "authUrl.Url"
"expr": "authURL.URL"
}
],
"fuzzy": true
@ -491,9 +491,9 @@
"fuzzy": true
},
{
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
"translation": "Transferring {Entity} from {SourceName} to {TargetName}...",
"id": "Transferring {Entity} from {SourceName} to {TargetName}",
"message": "Transferring {Entity} from {SourceName} to {TargetName}",
"translation": "Transferring {Entity} from {SourceName} to {TargetName}",
"translatorComment": "Copied from source.",
"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
terms of the GNU General Public License as published by the Free Software
@ -17,7 +17,7 @@ package version
const (
AppName = "scotty"
AppVersion = "0.4.1"
AppVersion = "0.5.0"
AppURL = "https://git.sr.ht/~phw/scotty/"
)

View file

@ -26,9 +26,9 @@ import "time"
const (
// 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
MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track"
MusicBrainzTrackExtensionID = "https://musicbrainz.org/doc/jspf#track"
)
// MusicBrainz / ListenBrainz JSPF track extension

View file

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

View file

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

View file

@ -50,7 +50,7 @@ func TestParser(t *testing.T) {
result := scrobblerlog.ScrobblerLog{}
err := result.Parse(data, false)
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.Len(result.Records, 5)
record1 := result.Records[0]
@ -59,11 +59,11 @@ func TestParser(t *testing.T) {
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", record1.TrackName)
assert.Equal(5, record1.TrackNumber)
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(mbtypes.MBID(""), record1.MusicBrainzRecordingID)
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"),
record4.MusicBrainzRecordingID)
}
@ -76,7 +76,7 @@ func TestParserIgnoreSkipped(t *testing.T) {
require.NoError(t, err)
assert.Len(result.Records, 4)
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"),
record4.MusicBrainzRecordingID)
}
@ -101,7 +101,7 @@ func TestAppend(t *testing.T) {
data := make([]byte, 0, 10)
buffer := bytes.NewBuffer(data)
log := scrobblerlog.ScrobblerLog{
TZ: scrobblerlog.TZ_UNKNOWN,
TZ: scrobblerlog.TimezoneUnknown,
Client: "Rockbox foo $Revision$",
}
records := []scrobblerlog.Record{
@ -111,7 +111,7 @@ func TestAppend(t *testing.T) {
TrackName: "Reign",
TrackNumber: 1,
Duration: 271 * time.Second,
Rating: scrobblerlog.RATING_LISTENED,
Rating: scrobblerlog.RatingListened,
Timestamp: time.Unix(1699572072, 0),
MusicBrainzRecordingID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
},
@ -139,7 +139,7 @@ func TestReadHeader(t *testing.T) {
log := scrobblerlog.ScrobblerLog{}
err := log.ReadHeader(reader)
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.Empty(t, log.Records)
}