Compare commits

..

55 commits
v0.3.1 ... main

Author SHA1 Message Date
Philipp Wolfer
1ea90d2d2b Update translation files 2025-04-09 22:31:34 +02:00
Philipp Wolfer
329f696b55 Manage gotext as a tool with go.mod 2025-04-09 22:30:06 +02:00
Philipp Wolfer
5f9c0f24ab Updated dependencies 2025-04-09 22:11:59 +02:00
Philipp Wolfer
dc834e9b6f
update dependencies 2025-04-07 08:46:46 +02:00
Philipp Wolfer
0d9bc74bc0
More conversion to mbtypes.MBID 2025-04-03 15:19:26 +02:00
Philipp Wolfer
13eb8342ab
Use mbtypes.ISRC type 2025-04-03 15:08:02 +02:00
Philipp Wolfer
ad1644672c
Write acronym MBID all uppercase 2025-04-03 15:00:45 +02:00
Philipp Wolfer
8fff19ceac
Use MBID type from go.uploadedlobster.com/mbtypes 2025-04-03 14:56:39 +02:00
Philipp Wolfer
04eddfda33
Release 0.4.1 2024-09-16 19:07:07 +02:00
Philipp Wolfer
1c1ce224f7
Update dependencies 2024-09-16 19:02:14 +02:00
Philipp Wolfer
7175d3453d
Fix go version definition in go.mod 2024-09-16 19:00:24 +02:00
Philipp Wolfer
cdf20728ae
Update and tidy dependencies 2024-04-15 15:47:55 +02:00
Philipp Wolfer
bcc7bf3167
Replaced util Min/Max functions with builtin 2024-04-15 15:47:16 +02:00
Philipp Wolfer
357932f9b0
Use resty response.IsSuccess() instead of checking for status code 200 2024-03-24 16:36:53 +01:00
Philipp Wolfer
3f1bebd8ed
deezer: fix artist and album ID URIs
Fixes #7
2024-01-29 08:27:51 +01:00
Philipp Wolfer
1aa7b61649
subsonic: include subsonic_id as additional metadata 2024-01-26 12:21:11 +01:00
Philipp Wolfer
fee1eba080
Release 0.4.0 2024-01-21 16:28:55 +01:00
Philipp Wolfer
757aeed7b5 Translated using Weblate (German)
Currently translated at 100.0% (54 of 54 strings)

Co-authored-by: Philipp Wolfer <ph.wolfer@gmail.com>
Translate-URL: https://translate.uploadedlobster.com/projects/scotty/app/de/
Translation: Scotty/app
2024-01-21 15:26:29 +00:00
Philipp Wolfer
df423acdeb
Update translation files 2024-01-21 16:19:50 +01:00
Philipp Wolfer
c69097434c
Update dependencies 2024-01-15 08:41:50 +01:00
Philipp Wolfer
84443d0e69
Close export progress in export goroutine
Fixes crash in case of importer exiting prematurely
2024-01-15 08:21:38 +01:00
Philipp Wolfer
1cea9bd301
Use ImportResult log for dump backend 2024-01-15 08:05:05 +01:00
Philipp Wolfer
8a2ddb7772
Replaced ImportResult.ImportErrors with ImportResult.ImportLog 2024-01-15 08:00:17 +01:00
Philipp Wolfer
91f9b62db3
Update translations 2024-01-15 07:35:44 +01:00
Philipp Wolfer
210fe928fd
Update config.example.toml
Add spotify-history and listenbrainz check-duplicate-listens. Clarify
documentation.
2024-01-15 07:34:42 +01:00
Philipp Wolfer
6281554248
jspf: fixed creating new file in append mode 2024-01-14 23:41:15 +01:00
Philipp Wolfer
66242d0057
Updated changelog 2024-01-14 22:32:56 +01:00
Philipp Wolfer
d704e4d3cb
Updated README 2024-01-14 22:22:34 +01:00
Philipp Wolfer
60bbbb9f15
spotify-history: min. playback time for skipped tracks is now in seconds 2024-01-14 22:22:00 +01:00
Philipp Wolfer
01380bd730
listenbrainz: localize duplicate listen message 2024-01-14 22:09:12 +01:00
Philipp Wolfer
fa316b3025
Fixed completed progress bar showing empty 2024-01-14 22:04:28 +01:00
Philipp Wolfer
0d04b73338
listenbrainz: implement duplicate listen check on import 2024-01-14 22:04:15 +01:00
Philipp Wolfer
b2b5c69278
New similarity.CompareTracks function 2024-01-14 18:52:58 +01:00
Philipp Wolfer
bace31471e
New similarity module to help with comparing track titles 2024-01-14 13:14:59 +01:00
Philipp Wolfer
d9d83a4282
Fixed examples not being run during test 2024-01-14 11:54:54 +01:00
Philipp Wolfer
925c21893b
spotifyhistory: configurable min duration for skipped tracks 2024-01-13 14:12:19 +01:00
Philipp Wolfer
97e93553a1
Support integer config values 2024-01-13 14:11:58 +01:00
Philipp Wolfer
8c459f4d2f
Spotify extended streaming history exporter 2024-01-13 13:55:05 +01:00
Philipp Wolfer
7666ca53a7
Allow default values for boolean config settings 2024-01-13 13:18:52 +01:00
Philipp Wolfer
6ac2b4f142
Moved ratelimit to pkg 2024-01-12 17:24:05 +01:00
Philipp Wolfer
c4da3a40cc
Added util.Min and util.Max helpers 2024-01-12 17:24:05 +01:00
Philipp Wolfer
be1cfdac9e
allow datetime string as --timestamp parameter 2023-12-10 16:15:09 +01:00
Philipp Wolfer
c6be6c558f
update translations 2023-12-10 15:24:15 +01:00
Philipp Wolfer
788fa3828d
fixed redeclared Entity 2023-12-10 15:19:11 +01:00
Philipp Wolfer
ba4825aae9 Translated using Weblate (German)
Currently translated at 100.0% (47 of 47 strings)

Co-authored-by: Philipp Wolfer <ph.wolfer@gmail.com>
Translate-URL: https://translate.uploadedlobster.com/projects/scotty/app/de/
Translation: Scotty/app
2023-12-10 14:17:29 +00:00
Philipp Wolfer
53f7dbb568
update translations for weblate 2023-12-10 14:49:07 +01:00
Philipp Wolfer
78baba8154 Introduce models.Entity type 2023-12-10 13:25:17 +00:00
Philipp Wolfer
086bf25616
update translations 2023-12-10 14:25:05 +01:00
Philipp Wolfer
c4587b80af
Introduce models.Entity type 2023-12-10 14:24:06 +01:00
Philipp Wolfer
a59a542967
moved OAuth2Authenticator to auth package 2023-12-10 14:19:23 +01:00
Philipp Wolfer
dd501df5c5
use go:embed to simplify testdata loading 2023-12-10 14:11:54 +01:00
Philipp Wolfer
c4193f42a1
Code cleanup and missing error checks 2023-12-10 13:51:38 +01:00
Philipp Wolfer
6eaef18188
subsonic: only set tags if genre is non-empty 2023-12-10 01:33:14 +01:00
Philipp Wolfer
acb0e9cb11
scrobblerlog: configuring should show append mode as enabled by default 2023-12-10 00:25:29 +01:00
Philipp Wolfer
4d07a39b64
jspf: implement append mode 2023-12-10 00:24:39 +01:00
72 changed files with 2836 additions and 1359 deletions

View file

@ -1,7 +1,47 @@
# Scotty Changelog
## 0.3.0 - unreleased
- listenbrainz: fetch listens in reverse listen time order
## 0.4.1 - 2024-09-16
- Subsonic: include `subsonic_id` as additional metadata
- Deezer: fix artist and album ID URIs (#7)
- Fix installation issues due to wrong go version format in `go.mod`
## 0.4.0 - 2024-01-21
- JSPF: implement append mode
- scrobberlog: append mode is enabled by default
- Subsonic: Only set tags if genre is not empty
- ListenBrainz: Listen import can be configured to check for duplicate listens
- spotify-history: New backend for importing from Spotify extended streaming
history JSON files
- Allow date time string for `--timestamp` parameter
- Enabled output for "dump" backend again
- Fixed completed progress bar showing empty
- Fixed crash in case of importer returning an error on import start
## 0.3.1 - 2023-12-10
- Prompt user to authenticate after service requiring authentication added
- Commands service auth, edit and delete now all support `--service` flag
- Do not apply locale formatting in UI output to Unix timestamps
- Default for service delete confirmation is now "no"
- Default for Maloja "nofix" option is now "no"
- Fixed last stored timestamp for beam loves not getting loaded
- Fixed crash with invalid target config name in beam commands
## 0.3.0 - 2023-12-09
- Initialize config if it does not exist
- Set database path relative to config file location
- Implemented service configuration commands
- Use positional arguments for source and target in beam commands
- Allow specifying `--timestamp 0`
- Subsonic: fixed filtering songs based on timestamp
- JSPF: add MB playlist extension
- Spotify: fixed loves export count
- Deezer: fixed listen export count
- ListenBrainz: fetch listens in reverse listen time order
- Initial localization of user interface
- Documented general configuration and usage
## 0.2.0 - 2023-11-28

View file

@ -10,6 +10,7 @@ Scotty transfers your listens/scrobbles and favorite tracks between various musi
- Submit listens from ListenBrainz to Maloja or Last.fm
- Transfer loved tracks from Funkwhale to ListenBrainz
- Submit listens stored in a Rockbox `.scrobbler.log` file to ListenBrainz, Last.fm or Maloja
- Submit listens from Spotify extended history files to ListenBrainz, Last.fm or Maloja
- Store your favorite tracks from Deezer as a JSPF playlist
- Backup your listening history from ListenBrainz or Last.fm
@ -104,12 +105,12 @@ Imported 4 of 4 loves into listenbrainz.
Latest timestamp: 2023-11-23 14:44:46 +0100 CET (1700747086)
```
Scotty will remember the latest timestamp for which it transferred data between the two services. The next time you run `scotty beam loves deezer listenbrainz` it will only consider tracks loved after the previous import. If you for some reason want to override this and start importing at an earlier time again, you can specify an earlier start time with the `--timestamp` parameter, which expects a Unix timestamp.
Scotty will remember the latest timestamp for which it transferred data between the two services. The next time you run `scotty beam loves deezer listenbrainz` it will only consider tracks loved after the previous import. If you for some reason want to override this and start importing at an earlier time again, you can specify an earlier start time with the `--timestamp` parameter, which can be either a Unix timestamp (seconds since 1970-01-01 00:00:00) or a date time string like "2023-12-10 16:12:00".
For example to import listens starting at a specific timestamp use:
```
scotty beam listens deezer listenbrainz --timestamp 1701872784
scotty beam listens deezer listenbrainz --timestamp "2023-12-06 14:26:24"
```
@ -117,7 +118,7 @@ scotty beam listens deezer listenbrainz --timestamp 1701872784
The following table lists the available backends and the currently supported features.
Backend | Listens Export | Listens Import | Loves Export | Loves Import
---------------|----------------|----------------|--------------|-------------
----------------|----------------|----------------|--------------|-------------
deezer | ✓ | | ✓ | -
funkwhale | ✓ | | ✓ | -
jspf | - | ✓ | - | ✓
@ -126,6 +127,7 @@ listenbrainz | ✓ | ✓ | ✓ | ✓
maloja | ✓ | ✓ | |
scrobbler-log | ✓ | ✓ | |
spotify | ✓ | | ✓ | -
spotify-history | ✓ | | |
subsonic | | | ✓ | -
**✓** implemented**-** not yet implemented**** unavailable / not planned
@ -143,7 +145,7 @@ You can help translate this project into your language with [Weblate](https://tr
## License
Scotty © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Scotty © 2023-2024 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View file

@ -17,8 +17,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package cmd
import (
"math"
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/cli"
@ -60,5 +58,5 @@ func init() {
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// beamListensCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
beamListensCmd.Flags().Int64P("timestamp", "t", math.MinInt64, "Only import listens newer then given Unix timestamp")
beamListensCmd.Flags().StringP("timestamp", "t", "", "only import listens newer then given timestamp")
}

View file

@ -17,8 +17,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package cmd
import (
"math"
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/cli"
@ -60,5 +58,5 @@ func init() {
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// beamLovesCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
beamLovesCmd.Flags().Int64P("timestamp", "t", math.MinInt64, "Only import loves newer then given Unix timestamp")
beamLovesCmd.Flags().StringP("timestamp", "t", "", "only import loves newer then given timestamp")
}

View file

@ -27,11 +27,11 @@ import (
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/cli"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
)
var serviceAddCmd = &cobra.Command{
@ -95,7 +95,7 @@ func init() {
}
func promptForAuth(service config.ServiceConfig) error {
backend, err := backends.ResolveBackend[models.OAuth2Authenticator](service)
backend, err := backends.ResolveBackend[auth.OAuth2Authenticator](service)
if err != nil {
// No authentication required, return
return nil

View file

@ -18,9 +18,9 @@ package cmd
import (
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/cli"
"go.uploadedlobster.com/scotty/internal/models"
)
var serviceAuthCmd = &cobra.Command{
@ -33,7 +33,7 @@ multiple services using the same backend but different authentication.`,
Run: func(cmd *cobra.Command, args []string) {
serviceConfig, err := cli.SelectService(cmd)
cobra.CheckErr(err)
backend, err := backends.ResolveBackend[models.OAuth2Authenticator](serviceConfig)
backend, err := backends.ResolveBackend[auth.OAuth2Authenticator](serviceConfig)
cobra.CheckErr(err)
cli.AuthenticationFlow(serviceConfig, backend)
},

View file

@ -11,6 +11,13 @@ backend = "listenbrainz"
username = ""
# Your ListenBrainz access token from https://listenbrainz.org/profile/
token = ""
# If true, for each listen to submit it is checked whether this listen already
# exists on ListenBrainz. For this similar listens are searched at the listen
# time +/- the duration of the track. The default is false and this check is not
# perfomed. Note that this verification significanly slows down the import and
# is only recommended if you are importing historic listens which might or might
# not already exists in your ListenBrainz profile.
check-duplicate-listens = false
[service.maloja]
# Maloja is a self hosted listening service (https://github.com/krateng/maloja)
@ -46,8 +53,9 @@ token = ""
[service.scrobbler-log]
# Read or write listens from a Rockbox .scobbler.log file
backend = "scrobbler-log"
# The file path to the .scrobbler.log file
file-path = "data/.scrobbler.log"
# The file path to the .scrobbler.log file. Relative paths are resolved against
# the current working directory when running scotty.
file-path = "./.scrobbler.log"
# If true, reading listens from the file also returns listens marked as "skipped"
include-skipped = true
# If true (default), new listens will be appended to the existing file. Set to
@ -57,13 +65,17 @@ append = true
[service.jspf]
# Write listens and loves to JSPF playlist files (https://xspf.org/jspf)
backend = "jspf"
# The file path to the XSPF file
file-path = "data/playlist.jspf"
# Title of the playlist
# The file path to the JSPF file. Relative paths are resolved against
# the current working directory when running scotty.
file-path = "./playlist.jspf"
# If true (default), new listens will be appended to the existing file. Set to
# false to overwrite the file and create a new JSPF playlist on every run.
append = true
# Title of the playlist. Not used in append mode.
title = "My Playlist"
# Creator of the playlist (only informational)
# Creator of the playlist (only informational). Not used in append mode.
username = ""
# A unique identifier for your playlist
# A unique identifier for your playlist. Not used in append mode.
identifier = ""
[service.spotify]
@ -76,6 +88,21 @@ backend = "spotify"
client-id = ""
client-secret = ""
[service.spotify-history]
# Read listens from a Spotify extended history export
backend = "spotify-history"
# Directory where the extended history JSON files are located. The files must
# follow the naming scheme "Streaming_History_Audio_*.json".
dir-path = "./my_spotify_data_extended/Spotify Extended Streaming History"
# If true (default), ignore listens from a Spotify "private session".
ignore-incognito = true
# If true, ignore listens marked as skipped. Default is false.
ignore-skipped = false
# Only consider skipped listens with a playback duration longer than this number
# of seconds. Default is 30 seconds. If ignore-skipped is set to false this
# setting has no effect.
ignore-min-duration-seconds = 30
[service.deezer]
# Read listens and loves from a Deezer account
backend = "deezer"

93
go.mod
View file

@ -1,70 +1,73 @@
module go.uploadedlobster.com/scotty
go 1.21.1
go 1.23.0
toolchain go1.24.2
require (
github.com/Xuanwo/go-locale v1.1.0
github.com/Xuanwo/go-locale v1.1.3
github.com/agnivade/levenshtein v1.2.1
github.com/cli/browser v1.3.0
github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5
github.com/fatih/color v1.16.0
github.com/glebarez/sqlite v1.10.0
github.com/go-resty/resty/v2 v2.10.0
github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238
github.com/fatih/color v1.18.0
github.com/glebarez/sqlite v1.11.0
github.com/go-resty/resty/v2 v2.16.5
github.com/jarcoal/httpmock v1.3.1
github.com/manifoldco/promptui v0.9.0
github.com/pelletier/go-toml/v2 v2.1.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0
github.com/spf13/cast v1.5.1
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.17.0
github.com/stretchr/testify v1.8.4
github.com/vbauerster/mpb/v8 v8.6.2
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
golang.org/x/oauth2 v0.14.0
golang.org/x/text v0.14.0
gorm.io/datatypes v1.2.0
gorm.io/gorm v1.25.5
github.com/spf13/cast v1.7.1
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
github.com/vbauerster/mpb/v8 v8.9.3
go.uploadedlobster.com/mbtypes v0.4.0
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/oauth2 v0.29.0
golang.org/x/text v0.24.0
gorm.io/datatypes v1.2.5
gorm.io/gorm v1.25.12
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/net v0.18.0 // indirect
golang.org/x/sys v0.14.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/tools v0.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.4.7 // indirect
modernc.org/libc v1.34.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.27.0 // indirect
gorm.io/driver/mysql v1.5.7 // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
modernc.org/sqlite v1.37.0 // indirect
)
tool golang.org/x/text/cmd/gotext

723
go.sum
View file

@ -1,651 +1,198 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/Xuanwo/go-locale v1.1.0 h1:51gUxhxl66oXAjI9uPGb2O0qwPECpriKQb2hl35mQkg=
github.com/Xuanwo/go-locale v1.1.0/go.mod h1:UKrHoZB3FPIk9wIG2/tVSobnHgNnceGSH3Y8DY5cASs=
github.com/Xuanwo/go-locale v1.1.3 h1:EWZZJJt5rqPHHbqPRH1zFCn5D7xHjjebODctA4aUO3A=
github.com/Xuanwo/go-locale v1.1.3/go.mod h1:REn+F/c+AtGSWYACBSYZgl23AP+0lfQC+SEFPN+hj30=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5 h1:RuuxidatioSKGOiBzL1mTY4X22DQD8weEbS3iRLHnAg=
github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5/go.mod h1:vnbEuj6Z20PLcHB4rrLQAOXGMjtULfMGhRVSFPcSdUo=
github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238 h1:uejyepOdHISrJTw7P84Y7yEC0FMyv1q3KNDRxWsviKw=
github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238/go.mod h1:vnbEuj6Z20PLcHB4rrLQAOXGMjtULfMGhRVSFPcSdUo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo=
github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA=
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 h1:cgqwZtnR+IQfUYDLJ3Kiy4aE+O/wExTzEIg8xwC4Qfs=
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0/go.mod h1:n3nudMl178cEvD44PaopxH9jhJaQzthSxUzLO5iKMy4=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.7 h1:I6tZjLXD2Q1kjvNbIzB1wvQBsXmKXiVrhpRE8ZjP5jY=
github.com/smartystreets/goconvey v1.6.7/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/vbauerster/mpb/v8 v8.6.2 h1:9EhnJGQRtvgDVCychJgR96EDCOqgg2NsMuk5JUcX4DA=
github.com/vbauerster/mpb/v8 v8.6.2/go.mod h1:oVJ7T+dib99kZ/VBjoBaC8aPXiSAihnzuKmotuihyFo=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0=
golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
github.com/vbauerster/mpb/v8 v8.9.3 h1:PnMeF+sMvYv9u23l6DO6Q3+Mdj408mjLRXIzmUmU2Z8=
github.com/vbauerster/mpb/v8 v8.9.3/go.mod h1:hxS8Hz4C6ijnppDSIX6LjG8FYJSoPo9iIOcE53Zik0c=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s=
go.uploadedlobster.com/mbtypes v0.4.0/go.mod h1:Bu1K1Hl77QTAE2Z7QKiW/JAp9KqYWQebkRRfG02dlZM=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco=
gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04=
gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y=
gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I=
gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
modernc.org/libc v1.34.3 h1:ag+3JIGF0o009YKhKjkqAG3N36X6ctUv2V85hGM45WA=
modernc.org/libc v1.34.3/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8=
modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

34
internal/auth/auth.go Normal file
View file

@ -0,0 +1,34 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
Scotty. If not, see <https://www.gnu.org/licenses/>.
*/
package auth
import (
"net/url"
"go.uploadedlobster.com/scotty/internal/models"
"golang.org/x/oauth2"
)
// Must be implemented by backends requiring OAuth2 authentication
type OAuth2Authenticator interface {
models.Backend
// Returns OAuth2 config suitable for this backend
OAuth2Strategy(redirectUrl *url.URL) OAuth2Strategy
// Setup the OAuth2 client
OAuth2Setup(token oauth2.TokenSource) error
}

View file

@ -17,6 +17,7 @@ package auth
import (
"fmt"
"log"
"net/http"
"net/url"
@ -34,5 +35,12 @@ func RunOauth2CallbackServer(redirectURL url.URL, param string, responseChan cha
}
})
go http.ListenAndServe(redirectURL.Host, nil)
go runServer(redirectURL.Host)
}
func runServer(addr string) {
err := http.ListenAndServe(addr, nil)
if err != nil {
log.Fatal(err)
}
}

View file

@ -37,7 +37,7 @@ func BuildRedirectURL(config *viper.Viper, backend string) (*url.URL, error) {
}
func Authenticate(service string, backend models.Backend, db storage.Database, config *viper.Viper) (bool, error) {
authenticator, needAuth := backend.(models.OAuth2Authenticator)
authenticator, needAuth := backend.(auth.OAuth2Authenticator)
if needAuth {
redirectURL, err := BuildRedirectURL(config, backend.Name())
if err != nil {

View file

@ -31,6 +31,7 @@ import (
"go.uploadedlobster.com/scotty/internal/backends/maloja"
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
"go.uploadedlobster.com/scotty/internal/backends/spotify"
"go.uploadedlobster.com/scotty/internal/backends/spotifyhistory"
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
@ -113,6 +114,7 @@ var knownBackends = map[string]func() models.Backend{
"maloja": func() models.Backend { return &maloja.MalojaApiBackend{} },
"scrobbler-log": func() models.Backend { return &scrobblerlog.ScrobblerLogBackend{} },
"spotify": func() models.Backend { return &spotify.SpotifyApiBackend{} },
"spotify-history": func() models.Backend { return &spotifyhistory.SpotifyHistoryBackend{} },
"subsonic": func() models.Backend { return &subsonic.SubsonicApiBackend{} },
}

View file

@ -35,6 +35,7 @@ import (
"go.uploadedlobster.com/scotty/internal/backends/spotify"
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
)
@ -52,7 +53,7 @@ func TestResolveBackendUnknown(t *testing.T) {
c.Set("backend", "foo")
service := config.NewServiceConfig("test", c)
_, err := backends.ResolveBackend[models.ListensImport](service)
assert.EqualError(t, err, "unknown backend \"foo\"")
assert.EqualError(t, err, i18n.Tr("unknown backend \"%s\"", "foo"))
}
func TestResolveBackendInvalidInterface(t *testing.T) {
@ -60,7 +61,7 @@ func TestResolveBackendInvalidInterface(t *testing.T) {
c.Set("backend", "dump")
service := config.NewServiceConfig("test", c)
_, err := backends.ResolveBackend[models.ListensExport](service)
assert.EqualError(t, err, "backend dump does not implement ListensExport")
assert.EqualError(t, err, i18n.Tr("backend %s does not implement %s", "dump", "ListensExport"))
}
func TestGetBackends(t *testing.T) {
@ -76,7 +77,7 @@ func TestGetBackends(t *testing.T) {
}
// If we got here the "dump" backend was not included
t.Errorf("GetBackends() did not return expected bacend \"dump\"")
t.Errorf("GetBackends() did not return expected backend \"dump\"")
}
func TestImplementsInterfaces(t *testing.T) {

View file

@ -79,10 +79,13 @@ func listRequest[T Result](c Client, path string, offset int, limit int) (result
"limit": strconv.Itoa(limit),
}).
SetResult(&result)
c.setToken(request)
err = c.setToken(request)
if err != nil {
return
}
response, err := request.Get(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(response.String())
} else if result.Error() != nil {
err = errors.New(result.Error().Message)

View file

@ -105,10 +105,7 @@ out:
// and continue.
if offset >= result.Total {
p.Total = int64(result.Total)
offset = result.Total - perPage
if offset < 0 {
offset = 0
}
offset = max(result.Total-perPage, 0)
continue
}
@ -177,10 +174,7 @@ out:
if offset >= result.Total {
p.Total = int64(result.Total)
totalCount = result.Total
offset = result.Total - perPage
if offset < 0 {
offset = 0
}
offset = max(result.Total-perPage, 0)
continue
}
@ -250,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/track/%v", t.Album.Id)
info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/track/%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

@ -16,8 +16,8 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package deezer_test
import (
_ "embed"
"encoding/json"
"os"
"testing"
"time"
@ -28,6 +28,13 @@ import (
"go.uploadedlobster.com/scotty/internal/config"
)
var (
//go:embed testdata/listen.json
testListen []byte
//go:embed testdata/track.json
testTrack []byte
)
func TestFromConfig(t *testing.T) {
c := viper.New()
c.Set("client-id", "someclientid")
@ -38,10 +45,8 @@ func TestFromConfig(t *testing.T) {
}
func TestListenAsListen(t *testing.T) {
data, err := os.ReadFile("testdata/listen.json")
require.NoError(t, err)
track := deezer.Listen{}
err = json.Unmarshal(data, &track)
err := json.Unmarshal(testListen, &track)
require.NoError(t, err)
listen := track.AsListen()
assert.Equal(t, time.Unix(1700753817, 0), listen.ListenedAt)
@ -52,13 +57,13 @@ func TestListenAsListen(t *testing.T) {
assert.Equal(t, "deezer.com", listen.AdditionalInfo["music_service"])
assert.Equal(t, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["origin_url"])
assert.Equal(t, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["deezer_id"])
assert.Equal(t, "https://www.deezer.com/album/1346960", listen.AdditionalInfo["deezer_album_id"])
assert.Equal(t, "https://www.deezer.com/artist/92", listen.AdditionalInfo["deezer_artist_id"])
}
func TestLovedTrackAsLove(t *testing.T) {
data, err := os.ReadFile("testdata/track.json")
require.NoError(t, err)
track := deezer.LovedTrack{}
err = json.Unmarshal(data, &track)
err := json.Unmarshal(testTrack, &track)
require.NoError(t, err)
love := track.AsLove()
assert.Equal(t, time.Unix(1700743848, 0), love.Created)

View file

@ -16,8 +16,8 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package deezer_test
import (
_ "embed"
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/assert"
@ -25,11 +25,16 @@ import (
"go.uploadedlobster.com/scotty/internal/backends/deezer"
)
var (
//go:embed testdata/user-tracks.json
testUserTracks []byte
//go:embed testdata/user-history.json
testUserHistory []byte
)
func TestUserTracksResult(t *testing.T) {
data, err := os.ReadFile("testdata/user-tracks.json")
require.NoError(t, err)
result := deezer.TracksResult{}
err = json.Unmarshal(data, &result)
err := json.Unmarshal(testUserTracks, &result)
require.NoError(t, err)
assert := assert.New(t)
@ -45,10 +50,8 @@ func TestUserTracksResult(t *testing.T) {
}
func TestUserHistoryResult(t *testing.T) {
data, err := os.ReadFile("testdata/user-history.json")
require.NoError(t, err)
result := deezer.HistoryResult{}
err = json.Unmarshal(data, &result)
err := json.Unmarshal(testUserHistory, &result)
require.NoError(t, err)
assert := assert.New(t)

View file

@ -17,6 +17,8 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package dump
import (
"fmt"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/models"
)
@ -38,9 +40,10 @@ func (b *DumpBackend) ImportListens(export models.ListensResult, importResult mo
for _, listen := range export.Items {
importResult.UpdateTimestamp(listen.ListenedAt)
importResult.ImportCount += 1
msg := fmt.Sprintf("🎶 %v: \"%v\" by %v (%v)",
listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMBID)
importResult.Log(models.Info, msg)
progress <- models.Progress{}.FromImportResult(importResult)
// fmt.Printf("🎶 %v: \"%v\" by %v (%v)\n",
// listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMbid)
}
return importResult, nil
@ -50,9 +53,10 @@ func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models
for _, love := range export.Items {
importResult.UpdateTimestamp(love.Created)
importResult.ImportCount += 1
msg := fmt.Sprintf("❤️ %v: \"%v\" by %v (%v)",
love.Created, love.TrackName, love.ArtistName(), love.RecordingMBID)
importResult.Log(models.Info, msg)
progress <- models.Progress{}.FromImportResult(importResult)
// fmt.Printf("❤️ %v: \"%v\" by %v (%v)\n",
// love.Created, love.TrackName, love.ArtistName(), love.RecordingMbid)
}
return importResult, nil

View file

@ -36,6 +36,7 @@ func (p ListensExportProcessor) ExportBackend() models.Backend {
func (p ListensExportProcessor) Process(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
p.Backend.ExportListens(oldestTimestamp, results, progress)
close(progress)
}
type LovesExportProcessor struct {
@ -48,4 +49,5 @@ func (p LovesExportProcessor) ExportBackend() models.Backend {
func (p LovesExportProcessor) Process(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
p.Backend.ExportLoves(oldestTimestamp, results, progress)
close(progress)
}

View file

@ -26,8 +26,8 @@ import (
"strconv"
"github.com/go-resty/resty/v2"
"go.uploadedlobster.com/scotty/internal/ratelimit"
"go.uploadedlobster.com/scotty/internal/version"
"go.uploadedlobster.com/scotty/pkg/ratelimit"
)
const MaxItemsPerGet = 50
@ -66,7 +66,7 @@ func (c Client) GetHistoryListenings(user string, page int, perPage int) (result
SetResult(&result).
Get(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(response.String())
return
}
@ -84,7 +84,7 @@ func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksR
SetResult(&result).
Get(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(response.String())
return
}

View file

@ -20,6 +20,7 @@ import (
"sort"
"time"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
@ -175,7 +176,7 @@ func (f FavoriteTrack) AsLove() models.Love {
track := f.Track.AsTrack()
love := models.Love{
UserName: f.User.UserName,
RecordingMbid: track.RecordingMbid,
RecordingMBID: track.RecordingMBID,
Track: track,
}
@ -188,16 +189,15 @@ func (f FavoriteTrack) AsLove() models.Love {
}
func (t Track) AsTrack() models.Track {
recordingMbid := models.MBID(t.RecordingMbid)
track := models.Track{
TrackName: t.Title,
ReleaseName: t.Album.Title,
ArtistNames: []string{t.Artist.Name},
TrackNumber: t.Position,
DiscNumber: t.DiscNumber,
RecordingMbid: recordingMbid,
ReleaseMbid: models.MBID(t.Album.ReleaseMbid),
ArtistMbids: []models.MBID{models.MBID(t.Artist.ArtistMbid)},
RecordingMBID: t.RecordingMBID,
ReleaseMBID: t.Album.ReleaseMBID,
ArtistMBIDs: []mbtypes.MBID{t.Artist.ArtistMBID},
Tags: t.Tags,
AdditionalInfo: map[string]any{
"media_player": FunkwhaleClientName,

View file

@ -25,7 +25,6 @@ import (
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/scotty/internal/backends/funkwhale"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/models"
)
func TestFromConfig(t *testing.T) {
@ -44,17 +43,17 @@ func TestFunkwhaleListeningAsListen(t *testing.T) {
},
Track: funkwhale.Track{
Title: "Oweynagat",
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
Position: 5,
DiscNumber: 1,
Tags: []string{"foo", "bar"},
Artist: funkwhale.Artist{
Name: "Dool",
ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131",
ArtistMBID: "24412926-c7bd-48e8-afad-8a285b42e131",
},
Album: funkwhale.Album{
Title: "Here Now, There Then",
ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68",
ReleaseMBID: "d7f22677-9803-4d21-ba42-081b633a6f68",
},
Uploads: []funkwhale.Upload{
{
@ -75,9 +74,9 @@ func TestFunkwhaleListeningAsListen(t *testing.T) {
assert.Equal(fwListen.Track.DiscNumber, listen.Track.DiscNumber)
assert.Equal(fwListen.Track.Tags, listen.Track.Tags)
// assert.Equal(backends.FunkwhaleClientName, listen.AdditionalInfo["disc_number"])
assert.Equal(models.MBID(fwListen.Track.RecordingMbid), listen.RecordingMbid)
assert.Equal(models.MBID(fwListen.Track.Album.ReleaseMbid), listen.ReleaseMbid)
assert.Equal(models.MBID(fwListen.Track.Artist.ArtistMbid), listen.ArtistMbids[0])
assert.Equal(fwListen.Track.RecordingMBID, listen.RecordingMBID)
assert.Equal(fwListen.Track.Album.ReleaseMBID, listen.ReleaseMBID)
assert.Equal(fwListen.Track.Artist.ArtistMBID, listen.ArtistMBIDs[0])
assert.Equal(funkwhale.FunkwhaleClientName, listen.AdditionalInfo["media_player"])
}
@ -89,17 +88,17 @@ func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) {
},
Track: funkwhale.Track{
Title: "Oweynagat",
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
Position: 5,
DiscNumber: 1,
Tags: []string{"foo", "bar"},
Artist: funkwhale.Artist{
Name: "Dool",
ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131",
ArtistMBID: "24412926-c7bd-48e8-afad-8a285b42e131",
},
Album: funkwhale.Album{
Title: "Here Now, There Then",
ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68",
ReleaseMBID: "d7f22677-9803-4d21-ba42-081b633a6f68",
},
Uploads: []funkwhale.Upload{
{
@ -119,10 +118,10 @@ func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) {
assert.Equal(favorite.Track.Position, love.Track.TrackNumber)
assert.Equal(favorite.Track.DiscNumber, love.Track.DiscNumber)
assert.Equal(favorite.Track.Tags, love.Track.Tags)
assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.RecordingMbid)
assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.Track.RecordingMbid)
assert.Equal(models.MBID(favorite.Track.Album.ReleaseMbid), love.ReleaseMbid)
require.Len(t, love.Track.ArtistMbids, 1)
assert.Equal(models.MBID(favorite.Track.Artist.ArtistMbid), love.ArtistMbids[0])
assert.Equal(favorite.Track.RecordingMBID, love.RecordingMBID)
assert.Equal(favorite.Track.RecordingMBID, love.Track.RecordingMBID)
assert.Equal(favorite.Track.Album.ReleaseMBID, love.ReleaseMBID)
require.Len(t, love.Track.ArtistMBIDs, 1)
assert.Equal(favorite.Track.Artist.ArtistMBID, love.ArtistMBIDs[0])
assert.Equal(funkwhale.FunkwhaleClientName, love.AdditionalInfo["media_player"])
}

View file

@ -21,6 +21,8 @@ THE SOFTWARE.
*/
package funkwhale
import "go.uploadedlobster.com/mbtypes"
type ListeningsResult struct {
Count int `json:"count"`
Previous string `json:"previous"`
@ -56,7 +58,7 @@ type Track struct {
Title string `json:"title"`
Position int `json:"position"`
DiscNumber int `json:"disc_number"`
RecordingMbid string `json:"mbid"`
RecordingMBID mbtypes.MBID `json:"mbid"`
Tags []string `json:"tags"`
Uploads []Upload `json:"uploads"`
}
@ -64,7 +66,7 @@ type Track struct {
type Artist struct {
Id int `json:"int"`
Name string `json:"name"`
ArtistMbid string `json:"mbid"`
ArtistMBID mbtypes.MBID `json:"mbid"`
}
type Album struct {
@ -73,7 +75,7 @@ type Album struct {
AlbumArtist Artist `json:"artist"`
ReleaseDate string `json:"release_date"`
TrackCount int `json:"track_count"`
ReleaseMbid string `json:"mbid"`
ReleaseMBID mbtypes.MBID `json:"mbid"`
}
type User struct {

View file

@ -1,5 +1,5 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Copyright © 2023-2024 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty.
@ -29,10 +29,8 @@ import (
type JSPFBackend struct {
filePath string
title string
creator string
identifier string
tracks []jspf.Track
playlist jspf.Playlist
append bool
}
func (b *JSPFBackend) Name() string { return "jspf" }
@ -42,6 +40,11 @@ func (b *JSPFBackend) Options() []models.BackendOption {
Name: "file-path",
Label: i18n.Tr("File path"),
Type: models.String,
}, {
Name: "append",
Label: i18n.Tr("Append to file"),
Type: models.Bool,
Default: "true",
}, {
Name: "title",
Label: i18n.Tr("Playlist title"),
@ -59,23 +62,34 @@ func (b *JSPFBackend) Options() []models.BackendOption {
func (b *JSPFBackend) FromConfig(config *config.ServiceConfig) models.Backend {
b.filePath = config.GetString("file-path")
b.title = config.GetString("title")
b.creator = config.GetString("username")
b.identifier = config.GetString("identifier")
b.tracks = make([]jspf.Track, 0)
b.append = config.GetBool("append", true)
b.playlist = jspf.Playlist{
Title: config.GetString("title"),
Creator: config.GetString("username"),
Identifier: config.GetString("identifier"),
Tracks: make([]jspf.Track, 0),
Extension: map[string]any{
jspf.MusicBrainzPlaylistExtensionId: jspf.MusicBrainzPlaylistExtension{
LastModifiedAt: time.Now(),
Public: true,
},
},
}
return b
}
func (b *JSPFBackend) StartImport() error { return nil }
func (b *JSPFBackend) StartImport() error {
return b.readJSPF()
}
func (b *JSPFBackend) FinishImport() error {
err := b.writeJSPF(b.tracks)
return err
return b.writeJSPF()
}
func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
for _, listen := range export.Items {
track := listenAsTrack(listen)
b.tracks = append(b.tracks, track)
b.playlist.Tracks = append(b.playlist.Tracks, track)
importResult.ImportCount += 1
importResult.UpdateTimestamp(listen.ListenedAt)
}
@ -87,7 +101,7 @@ func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult mo
func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
for _, love := range export.Items {
track := loveAsTrack(love)
b.tracks = append(b.tracks, track)
b.playlist.Tracks = append(b.playlist.Tracks, track)
importResult.ImportCount += 1
importResult.UpdateTimestamp(love.Created)
}
@ -104,8 +118,8 @@ func listenAsTrack(l models.Listen) jspf.Track {
extension.AddedBy = l.UserName
track.Extension[jspf.MusicBrainzTrackExtensionId] = extension
if l.RecordingMbid != "" {
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid))
if l.RecordingMBID != "" {
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMBID))
}
return track
@ -119,12 +133,12 @@ func loveAsTrack(l models.Love) jspf.Track {
extension.AddedBy = l.UserName
track.Extension[jspf.MusicBrainzTrackExtensionId] = extension
recordingMbid := l.Track.RecordingMbid
if l.RecordingMbid != "" {
recordingMbid = l.RecordingMbid
recordingMBID := l.Track.RecordingMBID
if l.RecordingMBID != "" {
recordingMBID = l.RecordingMBID
}
if recordingMbid != "" {
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMbid))
if recordingMBID != "" {
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMBID))
}
return track
@ -145,15 +159,15 @@ func trackAsTrack(t models.Track) jspf.Track {
func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension {
extension := jspf.MusicBrainzTrackExtension{
AdditionalMetadata: t.AdditionalInfo,
ArtistIdentifiers: make([]string, len(t.ArtistMbids)),
ArtistIdentifiers: make([]string, len(t.ArtistMBIDs)),
}
for i, mbid := range t.ArtistMbids {
for i, mbid := range t.ArtistMBIDs {
extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid)
}
if t.ReleaseMbid != "" {
extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMbid)
if t.ReleaseMBID != "" {
extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMBID)
}
// The tracknumber tag would be redundant
@ -162,21 +176,38 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension {
return extension
}
func (b JSPFBackend) writeJSPF(tracks []jspf.Track) error {
func (b *JSPFBackend) readJSPF() error {
if b.append {
file, err := os.Open(b.filePath)
if err != nil {
return nil
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return err
}
if stat.Size() == 0 {
// Zero length file, treat as a new file
return nil
} else {
playlist := jspf.JSPF{}
err := playlist.Read(file)
if err != nil {
return err
}
b.playlist = playlist.Playlist
}
}
return nil
}
func (b *JSPFBackend) writeJSPF() error {
playlist := jspf.JSPF{
Playlist: jspf.Playlist{
Title: b.title,
Creator: b.creator,
Identifier: b.identifier,
Date: time.Now(),
Tracks: tracks,
Extension: map[string]any{
jspf.MusicBrainzPlaylistExtensionId: jspf.MusicBrainzPlaylistExtension{
LastModifiedAt: time.Now(),
Public: true,
},
},
},
Playlist: b.playlist,
}
file, err := os.Create(b.filePath)

View file

@ -23,6 +23,7 @@ import (
"time"
"github.com/shkh/lastfm-go/lastfm"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
@ -140,16 +141,16 @@ out:
TrackName: scrobble.Name,
ArtistNames: []string{},
ReleaseName: scrobble.Album.Name,
RecordingMbid: models.MBID(scrobble.Mbid),
ArtistMbids: []models.MBID{},
ReleaseMbid: models.MBID(scrobble.Album.Mbid),
RecordingMBID: mbtypes.MBID(scrobble.Mbid),
ArtistMBIDs: []mbtypes.MBID{},
ReleaseMBID: mbtypes.MBID(scrobble.Album.Mbid),
},
}
if scrobble.Artist.Name != "" {
listen.Track.ArtistNames = []string{scrobble.Artist.Name}
}
if scrobble.Artist.Mbid != "" {
listen.Track.ArtistMbids = []models.MBID{models.MBID(scrobble.Artist.Mbid)}
listen.Track.ArtistMBIDs = []mbtypes.MBID{mbtypes.MBID(scrobble.Artist.Mbid)}
}
listens = append(listens, listen)
} else {
@ -203,8 +204,8 @@ func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResu
if l.TrackNumber > 0 {
trackNumbers = append(trackNumbers, strconv.Itoa(l.TrackNumber))
}
if l.RecordingMbid != "" {
mbids = append(mbids, string(l.RecordingMbid))
if l.RecordingMBID != "" {
mbids = append(mbids, string(l.RecordingMBID))
}
// if l.ReleaseArtist != "" {
// albumArtists = append(albums, l.ReleaseArtist)
@ -236,7 +237,7 @@ func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResu
for _, s := range result.Scrobbles {
ignoreMsg := s.IgnoredMessage.Body
if ignoreMsg != "" {
importResult.ImportErrors = append(importResult.ImportErrors, ignoreMsg)
importResult.Log(models.Warning, ignoreMsg)
}
}
err := fmt.Errorf("last.fm import ignored %v scrobbles", count-accepted)
@ -294,12 +295,12 @@ out:
love := models.Love{
Created: time.Unix(timestamp, 0),
UserName: result.User,
RecordingMbid: models.MBID(track.Mbid),
RecordingMBID: mbtypes.MBID(track.Mbid),
Track: models.Track{
TrackName: track.Name,
ArtistNames: []string{track.Artist.Name},
RecordingMbid: models.MBID(track.Mbid),
ArtistMbids: []models.MBID{models.MBID(track.Artist.Mbid)},
RecordingMBID: mbtypes.MBID(track.Mbid),
ArtistMBIDs: []mbtypes.MBID{mbtypes.MBID(track.Artist.Mbid)},
AdditionalInfo: models.AdditionalInfo{
"lastfm_url": track.Url,
},
@ -335,7 +336,7 @@ func (b *LastfmApiBackend) ImportLoves(export models.LovesResult, importResult m
} else {
msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v",
love.TrackName, love.ArtistName(), err.Error())
importResult.ImportErrors = append(importResult.ImportErrors, msg)
importResult.Log(models.Error, msg)
}
progress <- models.Progress{}.FromImportResult(importResult)

View file

@ -27,8 +27,8 @@ import (
"time"
"github.com/go-resty/resty/v2"
"go.uploadedlobster.com/scotty/internal/ratelimit"
"go.uploadedlobster.com/scotty/internal/version"
"go.uploadedlobster.com/scotty/pkg/ratelimit"
)
const (
@ -74,7 +74,7 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
SetError(&errorResult).
Get(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(errorResult.Error)
return
}
@ -90,7 +90,7 @@ func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, er
SetError(&errorResult).
Post(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(errorResult.Error)
return
}
@ -112,7 +112,7 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed
SetError(&errorResult).
Get(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(errorResult.Error)
return
}
@ -128,7 +128,7 @@ func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error)
SetError(&errorResult).
Post(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(errorResult.Error)
return
}
@ -147,7 +147,7 @@ func (c Client) Lookup(recordingName string, artistName string) (result LookupRe
SetError(&errorResult).
Get(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(errorResult.Error)
return
}

View file

@ -29,6 +29,7 @@ import (
"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
)
@ -114,7 +115,7 @@ func TestGetFeedback(t *testing.T) {
assert.Equal(302, result.TotalCount)
assert.Equal(3, result.Offset)
require.Len(t, result.Feedback, 2)
assert.Equal("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", result.Feedback[0].RecordingMbid)
assert.Equal(mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), result.Feedback[0].RecordingMBID)
}
func TestSendFeedback(t *testing.T) {
@ -131,7 +132,7 @@ func TestSendFeedback(t *testing.T) {
httpmock.RegisterResponder("POST", url, responder)
feedback := listenbrainz.Feedback{
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
Score: 1,
}
result, err := client.SendFeedback(feedback)
@ -154,7 +155,7 @@ func TestLookup(t *testing.T) {
assert := assert.New(t)
assert.Equal("Say Just Words", result.RecordingName)
assert.Equal("Paradise Lost", result.ArtistCreditName)
assert.Equal("569436a1-234a-44bc-a370-8f4d252bef21", result.RecordingMbid)
assert.Equal(mbtypes.MBID("569436a1-234a-44bc-a370-8f4d252bef21"), result.RecordingMBID)
}
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {

View file

@ -21,16 +21,19 @@ import (
"sort"
"time"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/similarity"
"go.uploadedlobster.com/scotty/internal/version"
)
type ListenBrainzApiBackend struct {
client Client
username string
existingMbids map[string]bool
checkDuplicates bool
existingMBIDs map[mbtypes.MBID]bool
}
func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" }
@ -44,6 +47,10 @@ func (b *ListenBrainzApiBackend) Options() []models.BackendOption {
Name: "token",
Label: i18n.Tr("Access token"),
Type: models.Secret,
}, {
Name: "check-duplicate-listens",
Label: i18n.Tr("Check for duplicate listens on import (slower)"),
Type: models.Bool,
}}
}
@ -51,6 +58,7 @@ func (b *ListenBrainzApiBackend) FromConfig(config *config.ServiceConfig) models
b.client = NewClient(config.GetString("token"))
b.client.MaxResults = MaxItemsPerGet
b.username = config.GetString("username")
b.checkDuplicates = config.GetBool("check-duplicate-listens", false)
return b
}
@ -117,6 +125,7 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result
func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
total := len(export.Items)
p := models.Progress{}.FromImportResult(importResult)
for i := 0; i < total; i += MaxListensPerRequest {
listens := export.Items[i:min(i+MaxListensPerRequest, total)]
count := len(listens)
@ -130,6 +139,21 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
}
for _, l := range listens {
if b.checkDuplicates {
isDupe, err := b.checkDuplicateListen(l)
p.Elapsed += 1
progress <- p
if err != nil {
return importResult, err
} else if isDupe {
count -= 1
msg := i18n.Tr("Ignored duplicate listen %v: \"%v\" by %v (%v)",
l.ListenedAt, l.TrackName, l.ArtistName(), l.RecordingMBID)
importResult.Log(models.Info, msg)
continue
}
}
l.FillAdditionalInfo()
listen := Listen{
ListenedAt: l.ListenedAt.Unix(),
@ -142,17 +166,22 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
}
listen.TrackMetadata.AdditionalInfo["submission_client"] = version.AppName
listen.TrackMetadata.AdditionalInfo["submission_client_version"] = version.AppVersion
submission.Payload = append(submission.Payload, listen)
}
if len(submission.Payload) > 0 {
_, err := b.client.SubmitListens(submission)
if err != nil {
return importResult, err
}
}
if count > 0 {
importResult.UpdateTimestamp(listens[count-1].ListenedAt)
}
importResult.ImportCount += count
progress <- models.Progress{}.FromImportResult(importResult)
progress <- p.FromImportResult(importResult)
}
return importResult, nil
@ -201,7 +230,7 @@ out:
}
func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
if len(b.existingMbids) == 0 {
if len(b.existingMBIDs) == 0 {
existingLovesChan := make(chan models.LovesResult)
go b.ExportLoves(time.Unix(0, 0), existingLovesChan, progress)
existingLoves := <-existingLovesChan
@ -210,30 +239,30 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe
}
// TODO: Store MBIDs directly
b.existingMbids = make(map[string]bool, len(existingLoves.Items))
b.existingMBIDs = make(map[mbtypes.MBID]bool, len(existingLoves.Items))
for _, love := range existingLoves.Items {
b.existingMbids[string(love.RecordingMbid)] = true
b.existingMBIDs[love.RecordingMBID] = true
}
}
for _, love := range export.Items {
recordingMbid := string(love.RecordingMbid)
recordingMBID := love.RecordingMBID
if recordingMbid == "" {
if recordingMBID == "" {
lookup, err := b.client.Lookup(love.TrackName, love.ArtistName())
if err == nil {
recordingMbid = lookup.RecordingMbid
recordingMBID = lookup.RecordingMBID
}
}
if recordingMbid != "" {
if recordingMBID != "" {
ok := false
errMsg := ""
if b.existingMbids[recordingMbid] {
if b.existingMBIDs[recordingMBID] {
ok = true
} else {
resp, err := b.client.SendFeedback(Feedback{
RecordingMbid: recordingMbid,
RecordingMBID: recordingMBID,
Score: 1,
})
ok = err == nil && resp.Status == "ok"
@ -248,7 +277,7 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe
} else {
msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v",
love.TrackName, love.ArtistName(), errMsg)
importResult.ImportErrors = append(importResult.ImportErrors, msg)
importResult.Log(models.Error, msg)
}
}
@ -258,6 +287,33 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe
return importResult, nil
}
var defaultDuration = time.Duration(3 * time.Minute)
const trackSimilarityThreshold = 0.9
func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (bool, error) {
// Find listens
duration := listen.Duration
if duration == 0 {
duration = defaultDuration
}
minTime := listen.ListenedAt.Add(-duration)
maxTime := listen.ListenedAt.Add(duration)
candidates, err := b.client.GetListens(b.username, maxTime, minTime)
if err != nil {
return false, err
}
for _, c := range candidates.Payload.Listens {
sim := similarity.CompareTracks(listen.Track, c.TrackMetadata.AsTrack())
if sim >= trackSimilarityThreshold {
return true, nil
}
}
return false, nil
}
func (lbListen Listen) AsListen() models.Listen {
listen := models.Listen{
ListenedAt: time.Unix(lbListen.ListenedAt, 0),
@ -268,20 +324,20 @@ func (lbListen Listen) AsListen() models.Listen {
}
func (f Feedback) AsLove() models.Love {
recordingMbid := models.MBID(f.RecordingMbid)
recordingMBID := f.RecordingMBID
track := f.TrackMetadata
if track == nil {
track = &Track{}
}
love := models.Love{
UserName: f.UserName,
RecordingMbid: recordingMbid,
RecordingMBID: recordingMBID,
Created: time.Unix(f.Created, 0),
Track: track.AsTrack(),
}
if love.Track.RecordingMbid == "" {
love.Track.RecordingMbid = love.RecordingMbid
if love.Track.RecordingMBID == "" {
love.Track.RecordingMBID = love.RecordingMBID
}
return love
@ -295,16 +351,16 @@ func (t Track) AsTrack() models.Track {
Duration: t.Duration(),
TrackNumber: t.TrackNumber(),
DiscNumber: t.DiscNumber(),
RecordingMbid: models.MBID(t.RecordingMbid()),
ReleaseMbid: models.MBID(t.ReleaseMbid()),
ReleaseGroupMbid: models.MBID(t.ReleaseGroupMbid()),
RecordingMBID: t.RecordingMBID(),
ReleaseMBID: t.ReleaseMBID(),
ReleaseGroupMBID: t.ReleaseGroupMBID(),
ISRC: t.ISRC(),
AdditionalInfo: t.AdditionalInfo,
}
if t.MbidMapping != nil && len(track.ArtistMbids) == 0 {
for _, artistMbid := range t.MbidMapping.ArtistMbids {
track.ArtistMbids = append(track.ArtistMbids, models.MBID(artistMbid))
if t.MBIDMapping != nil && len(track.ArtistMBIDs) == 0 {
for _, artistMBID := range t.MBIDMapping.ArtistMBIDs {
track.ArtistMBIDs = append(track.ArtistMBIDs, artistMBID)
}
}

View file

@ -23,9 +23,9 @@ import (
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/models"
)
func TestFromConfig(t *testing.T) {
@ -65,30 +65,30 @@ func TestListenBrainzListenAsListen(t *testing.T) {
assert.Equal(t, []string{lbListen.TrackMetadata.ArtistName}, listen.ArtistNames)
assert.Equal(t, 5, listen.TrackNumber)
assert.Equal(t, 1, listen.DiscNumber)
assert.Equal(t, models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMbid)
assert.Equal(t, models.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMbid)
assert.Equal(t, models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMbid)
assert.Equal(t, "DES561620801", listen.ISRC)
assert.Equal(t, mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMBID)
assert.Equal(t, mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMBID)
assert.Equal(t, mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMBID)
assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC)
assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"])
}
func TestListenBrainzFeedbackAsLove(t *testing.T) {
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
releaseMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
artistMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12")
releaseMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68")
artistMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68")
feedback := listenbrainz.Feedback{
Created: 1699859066,
RecordingMbid: recordingMbid,
RecordingMBID: recordingMBID,
Score: 1,
UserName: "ousidecontext",
TrackMetadata: &listenbrainz.Track{
TrackName: "Oweynagat",
ArtistName: "Dool",
ReleaseName: "Here Now, There Then",
MbidMapping: &listenbrainz.MbidMapping{
RecordingMbid: recordingMbid,
ReleaseMbid: releaseMbid,
ArtistMbids: []string{artistMbid},
MBIDMapping: &listenbrainz.MBIDMapping{
RecordingMBID: recordingMBID,
ReleaseMBID: releaseMBID,
ArtistMBIDs: []mbtypes.MBID{artistMBID},
},
},
}
@ -99,24 +99,24 @@ func TestListenBrainzFeedbackAsLove(t *testing.T) {
assert.Equal(feedback.TrackMetadata.TrackName, love.TrackName)
assert.Equal(feedback.TrackMetadata.ReleaseName, love.ReleaseName)
assert.Equal([]string{feedback.TrackMetadata.ArtistName}, love.ArtistNames)
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
assert.Equal(models.MBID(releaseMbid), love.Track.ReleaseMbid)
require.Len(t, love.Track.ArtistMbids, 1)
assert.Equal(models.MBID(artistMbid), love.Track.ArtistMbids[0])
assert.Equal(recordingMBID, love.RecordingMBID)
assert.Equal(recordingMBID, love.Track.RecordingMBID)
assert.Equal(releaseMBID, love.Track.ReleaseMBID)
require.Len(t, love.Track.ArtistMBIDs, 1)
assert.Equal(artistMBID, love.Track.ArtistMBIDs[0])
}
func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12")
feedback := listenbrainz.Feedback{
Created: 1699859066,
RecordingMbid: recordingMbid,
RecordingMBID: recordingMBID,
Score: 1,
}
love := feedback.AsLove()
assert := assert.New(t)
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
assert.Equal(recordingMBID, love.RecordingMBID)
assert.Equal(recordingMBID, love.Track.RecordingMBID)
assert.Empty(love.Track.TrackName)
}

View file

@ -25,6 +25,7 @@ import (
"strconv"
"time"
"go.uploadedlobster.com/mbtypes"
"golang.org/x/exp/constraints"
)
@ -66,20 +67,20 @@ type Track struct {
ArtistName string `json:"artist_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
AdditionalInfo map[string]any `json:"additional_info,omitempty"`
MbidMapping *MbidMapping `json:"mbid_mapping,omitempty"`
MBIDMapping *MBIDMapping `json:"mbid_mapping,omitempty"`
}
type MbidMapping struct {
type MBIDMapping struct {
RecordingName string `json:"recording_name,omitempty"`
RecordingMbid string `json:"recording_mbid,omitempty"`
ReleaseMbid string `json:"release_mbid,omitempty"`
ArtistMbids []string `json:"artist_mbids,omitempty"`
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
ReleaseMBID mbtypes.MBID `json:"release_mbid,omitempty"`
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids,omitempty"`
Artists []Artist `json:"artists,omitempty"`
}
type Artist struct {
ArtistCreditName string `json:"artist_credit_name,omitempty"`
ArtistMbid string `json:"artist_mbid,omitempty"`
ArtistMBID string `json:"artist_mbid,omitempty"`
JoinPhrase string `json:"join_phrase,omitempty"`
}
@ -92,8 +93,8 @@ type GetFeedbackResult struct {
type Feedback struct {
Created int64 `json:"created,omitempty"`
RecordingMbid string `json:"recording_mbid,omitempty"`
RecordingMsid string `json:"recording_msid,omitempty"`
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
RecordingMsid mbtypes.MBID `json:"recording_msid,omitempty"`
Score int `json:"score,omitempty"`
TrackMetadata *Track `json:"track_metadata,omitempty"`
UserName string `json:"user_id,omitempty"`
@ -103,9 +104,9 @@ type LookupResult struct {
ArtistCreditName string `json:"artist_credit_name"`
ReleaseName string `json:"release_name"`
RecordingName string `json:"recording_name"`
RecordingMbid string `json:"recording_mbid"`
ReleaseMbid string `json:"release_mbid"`
ArtistMbids []string `json:"artist_mbids"`
RecordingMBID mbtypes.MBID `json:"recording_mbid"`
ReleaseMBID mbtypes.MBID `json:"release_mbid"`
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids"`
}
type StatusResult struct {
@ -158,30 +159,30 @@ func (t Track) DiscNumber() int {
return 0
}
func (t Track) ISRC() string {
return tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc")
func (t Track) ISRC() mbtypes.ISRC {
return mbtypes.ISRC(tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc"))
}
func (t Track) RecordingMbid() string {
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid")
if mbid == "" && t.MbidMapping != nil {
return t.MbidMapping.RecordingMbid
func (t Track) RecordingMBID() mbtypes.MBID {
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid"))
if mbid == "" && t.MBIDMapping != nil {
return t.MBIDMapping.RecordingMBID
} else {
return mbid
}
}
func (t Track) ReleaseMbid() string {
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid")
if mbid == "" && t.MbidMapping != nil {
return t.MbidMapping.ReleaseMbid
func (t Track) ReleaseMBID() mbtypes.MBID {
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid"))
if mbid == "" && t.MBIDMapping != nil {
return t.MBIDMapping.ReleaseMBID
} else {
return mbid
}
}
func (t Track) ReleaseGroupMbid() string {
return tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid")
func (t Track) ReleaseGroupMBID() mbtypes.MBID {
return mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid"))
}
func tryGetValueOrEmpty[T any](dict map[string]any, key string) T {

View file

@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
)
@ -131,49 +132,49 @@ func TestTrackTrackNumberString(t *testing.T) {
}
func TestTrackIsrc(t *testing.T) {
expected := "TCAEJ1934417"
expected := mbtypes.ISRC("TCAEJ1934417")
track := listenbrainz.Track{
AdditionalInfo: map[string]any{
"isrc": expected,
"isrc": string(expected),
},
}
assert.Equal(t, expected, track.ISRC())
}
func TestTrackRecordingMbid(t *testing.T) {
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
func TestTrackRecordingMBID(t *testing.T) {
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
track := listenbrainz.Track{
AdditionalInfo: map[string]any{
"recording_mbid": expected,
"recording_mbid": string(expected),
},
}
assert.Equal(t, expected, track.RecordingMbid())
assert.Equal(t, expected, track.RecordingMBID())
}
func TestTrackReleaseMbid(t *testing.T) {
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
func TestTrackReleaseMBID(t *testing.T) {
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
track := listenbrainz.Track{
AdditionalInfo: map[string]any{
"release_mbid": expected,
"release_mbid": string(expected),
},
}
assert.Equal(t, expected, track.ReleaseMbid())
assert.Equal(t, expected, track.ReleaseMBID())
}
func TestReleaseGroupMbid(t *testing.T) {
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
func TestReleaseGroupMBID(t *testing.T) {
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
track := listenbrainz.Track{
AdditionalInfo: map[string]any{
"release_group_mbid": expected,
"release_group_mbid": string(expected),
},
}
assert.Equal(t, expected, track.ReleaseGroupMbid())
assert.Equal(t, expected, track.ReleaseGroupMBID())
}
func TestMarshalPartialFeedback(t *testing.T) {
feedback := listenbrainz.Feedback{
Created: 1699859066,
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
}
b, err := json.Marshal(feedback)
require.NoError(t, err)

View file

@ -58,7 +58,7 @@ func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult,
SetResult(&result).
Get(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(response.String())
return
}
@ -73,7 +73,7 @@ func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err
SetResult(&result).
Post(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(response.String())
return
}

View file

@ -56,7 +56,7 @@ func (b *MalojaApiBackend) FromConfig(config *config.ServiceConfig) models.Backe
config.GetString("server-url"),
config.GetString("token"),
)
b.nofix = config.GetBool("nofix")
b.nofix = config.GetBool("nofix", false)
return b
}

View file

@ -30,6 +30,7 @@ import (
"strings"
"time"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/models"
)
@ -57,7 +58,7 @@ func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) {
for {
// A row is:
// artistName releaseName trackName trackNumber duration rating timestamp recordingMbid
// artistName releaseName trackName trackNumber duration rating timestamp recordingMBID
row, err := tsvReader.Read()
if err == io.EOF {
break
@ -100,12 +101,12 @@ func Write(data io.Writer, listens models.ListensList) (lastTimestamp time.Time,
}
// A row is:
// artistName releaseName trackName trackNumber duration rating timestamp recordingMbid
// artistName releaseName trackName trackNumber duration rating timestamp recordingMBID
rating, ok := listen.AdditionalInfo["rockbox_rating"].(string)
if !ok || rating == "" {
rating = "L"
}
tsvWriter.Write([]string{
err = tsvWriter.Write([]string{
listen.ArtistName(),
listen.ReleaseName,
listen.TrackName,
@ -113,7 +114,7 @@ func Write(data io.Writer, listens models.ListensList) (lastTimestamp time.Time,
strconv.Itoa(int(listen.Duration.Seconds())),
rating,
strconv.Itoa(int(listen.ListenedAt.Unix())),
string(listen.RecordingMbid),
string(listen.RecordingMBID),
})
}
@ -203,7 +204,7 @@ func rowToListen(row []string, client string) (models.Listen, error) {
}
if len(row) > 7 {
listen.Track.RecordingMbid = models.MBID(row[7])
listen.Track.RecordingMBID = mbtypes.MBID(row[7])
}
return listen, nil

View file

@ -30,6 +30,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
"go.uploadedlobster.com/scotty/internal/models"
)
@ -60,10 +61,10 @@ func TestParser(t *testing.T) {
assert.Equal(time.Duration(306*time.Second), listen1.Duration)
assert.Equal("L", listen1.AdditionalInfo["rockbox_rating"])
assert.Equal(time.Unix(1260342084, 0), listen1.ListenedAt)
assert.Equal(models.MBID(""), listen1.RecordingMbid)
assert.Equal(mbtypes.MBID(""), listen1.RecordingMBID)
listen4 := result.Listens[3]
assert.Equal("S", listen4.AdditionalInfo["rockbox_rating"])
assert.Equal(models.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), listen4.RecordingMbid)
assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), listen4.RecordingMBID)
}
func TestParserExcludeSkipped(t *testing.T) {
@ -74,7 +75,7 @@ func TestParserExcludeSkipped(t *testing.T) {
assert.Len(result.Listens, 4)
listen4 := result.Listens[3]
assert.Equal("L", listen4.AdditionalInfo["rockbox_rating"])
assert.Equal(models.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMbid)
assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMBID)
}
func TestWrite(t *testing.T) {
@ -93,7 +94,7 @@ func TestWrite(t *testing.T) {
TrackName: "Reign",
TrackNumber: 1,
Duration: 271 * time.Second,
RecordingMbid: models.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
RecordingMBID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"},
},
},
@ -103,7 +104,7 @@ func TestWrite(t *testing.T) {
require.NoError(t, err)
lastTimestamp, err := scrobblerlog.Write(buffer, log.Listens)
require.NoError(t, err)
result := string(buffer.Bytes())
result := buffer.String()
lines := strings.Split(result, "\n")
assert.Equal(5, len(lines))
assert.Equal("#AUDIOSCROBBLER/1.1", lines[0])

View file

@ -50,16 +50,14 @@ func (b *ScrobblerLogBackend) Options() []models.BackendOption {
Name: "append",
Label: i18n.Tr("Append to file"),
Type: models.Bool,
Default: "true",
}}
}
func (b *ScrobblerLogBackend) FromConfig(config *config.ServiceConfig) models.Backend {
b.filePath = config.GetString("file-path")
b.includeSkipped = config.GetBool("include-skipped")
b.append = true
if config.IsSet("append") {
b.append = config.GetBool("append")
}
b.includeSkipped = config.GetBool("include-skipped", false)
b.append = config.GetBool("append", true)
b.log = ScrobblerLog{
Timezone: "UNKNOWN",
Client: "Rockbox unknown $Revision$",
@ -90,18 +88,18 @@ func (b *ScrobblerLogBackend) StartImport() error {
} else {
// Verify existing file is a scrobbler log
reader := bufio.NewReader(file)
err = ReadHeader(reader, &b.log)
if err != nil {
if err = ReadHeader(reader, &b.log); err != nil {
file.Close()
return err
}
file.Seek(0, 2)
if _, err = file.Seek(0, 2); err != nil {
return err
}
}
}
if !b.append {
err = WriteHeader(file, &b.log)
if err != nil {
if err = WriteHeader(file, &b.log); err != nil {
file.Close()
return err
}

View file

@ -29,8 +29,8 @@ import (
"time"
"github.com/go-resty/resty/v2"
"go.uploadedlobster.com/scotty/internal/ratelimit"
"go.uploadedlobster.com/scotty/internal/version"
"go.uploadedlobster.com/scotty/pkg/ratelimit"
"golang.org/x/oauth2"
)
@ -79,7 +79,7 @@ func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (
}
response, err := request.Get(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(response.String())
}
return
@ -95,7 +95,7 @@ func (c Client) UserTracks(offset int, limit int) (result TracksResult, err erro
SetResult(&result).
Get(path)
if response.StatusCode() != 200 {
if !response.IsSuccess() {
err = errors.New(response.String())
}
return

View file

@ -22,6 +22,8 @@ THE SOFTWARE.
package spotify
import "go.uploadedlobster.com/mbtypes"
type TracksResult struct {
Href string `json:"href"`
Limit int `json:"limit"`
@ -98,7 +100,7 @@ type Artist struct {
}
type ExternalIds struct {
ISRC string `json:"isrc"`
ISRC mbtypes.ISRC `json:"isrc"`
EAN string `json:"ean"`
UPC string `json:"upc"`
}

View file

@ -23,8 +23,8 @@ THE SOFTWARE.
package spotify_test
import (
_ "embed"
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/assert"
@ -32,11 +32,12 @@ import (
"go.uploadedlobster.com/scotty/internal/backends/spotify"
)
//go:embed testdata/recently-played.json
var testRecentlyPlayed []byte
func TestRecentlyPlayedResult(t *testing.T) {
data, err := os.ReadFile("testdata/recently-played.json")
require.NoError(t, err)
result := spotify.RecentlyPlayedResult{}
err = json.Unmarshal(data, &result)
err := json.Unmarshal(testRecentlyPlayed, &result)
require.NoError(t, err)
assert := assert.New(t)

View file

@ -183,10 +183,7 @@ out:
if offset >= result.Total {
p.Total = int64(result.Total)
totalCount = result.Total
offset = result.Total - perPage
if offset < 0 {
offset = 0
}
offset = max(result.Total-perPage, 0)
continue
}

View file

@ -18,18 +18,26 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package spotify_test
import (
_ "embed"
"encoding/json"
"os"
"testing"
"time"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/backends/spotify"
"go.uploadedlobster.com/scotty/internal/config"
)
var (
//go:embed testdata/listen.json
testListen []byte
//go:embed testdata/track.json
testTrack []byte
)
func TestFromConfig(t *testing.T) {
c := viper.New()
c.Set("client-id", "someclientid")
@ -40,10 +48,8 @@ func TestFromConfig(t *testing.T) {
}
func TestSpotifyListenAsListen(t *testing.T) {
data, err := os.ReadFile("testdata/listen.json")
require.NoError(t, err)
spListen := spotify.Listen{}
err = json.Unmarshal(data, &spListen)
err := json.Unmarshal(testListen, &spListen)
require.NoError(t, err)
listen := spListen.AsListen()
listenedAt, _ := time.Parse(time.RFC3339, "2023-11-21T15:24:33.361Z")
@ -54,7 +60,7 @@ func TestSpotifyListenAsListen(t *testing.T) {
assert.Equal(t, []string{"Dool"}, listen.ArtistNames)
assert.Equal(t, 5, listen.TrackNumber)
assert.Equal(t, 1, listen.DiscNumber)
assert.Equal(t, "DES561620801", listen.ISRC)
assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC)
info := listen.AdditionalInfo
assert.Equal(t, "spotify.com", info["music_service"])
assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["origin_url"])
@ -65,10 +71,8 @@ func TestSpotifyListenAsListen(t *testing.T) {
}
func TestSavedTrackAsLove(t *testing.T) {
data, err := os.ReadFile("testdata/track.json")
require.NoError(t, err)
track := spotify.SavedTrack{}
err = json.Unmarshal(data, &track)
err := json.Unmarshal(testTrack, &track)
require.NoError(t, err)
love := track.AsLove()
created, _ := time.Parse(time.RFC3339, "2022-02-13T21:46:08Z")

View file

@ -0,0 +1,110 @@
/*
Copyright © 2024 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
Scotty. If not, see <https://www.gnu.org/licenses/>.
*/
package spotifyhistory
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
"go.uploadedlobster.com/scotty/internal/models"
)
type StreamingHistory []HistoryItem
type ListenListOptions struct {
IgnoreIncognito bool
IgnoreSkipped bool
skippedMinSeconds int
}
type HistoryItem struct {
Timestamp time.Time `json:"ts"`
UserName string `json:"username"`
Platform string `json:"platform"`
MillisecondsPlayed int `json:"ms_played"`
ConnCountry string `json:"conn_country"`
IpAddrDecrypted string `json:"ip_addr_decrypted"`
UserAgentDecrypted string `json:"user_agent_decrypted"`
MasterMetadataTrackName string `json:"master_metadata_track_name"`
MasterMetadataAlbumArtistName string `json:"master_metadata_album_artist_name"`
MasterMetadataAlbumName string `json:"master_metadata_album_album_name"`
SpotifyTrackUri string `json:"spotify_track_uri"`
EpisodeName string `json:"episode_name"`
EpisodeShowName string `json:"episode_show_name"`
SpotifyEpisodeUri string `json:"spotify_episode_uri"`
ReasonStart string `json:"reason_start"`
ReasonEnd string `json:"reason_end"`
Shuffle bool `json:"shuffle"`
Skipped bool `json:"skipped"`
Offline bool `json:"offline"`
OfflineTimestamp int `json:"offline_timestamp"`
IncognitoMode bool `json:"incognito_mode"`
}
func (j *StreamingHistory) Read(in io.Reader) error {
bytes, err := io.ReadAll(in)
if err != nil {
return err
}
err = json.Unmarshal(bytes, j)
return err
}
func (h *StreamingHistory) AsListenList(opt ListenListOptions) models.ListensList {
listens := make(models.ListensList, 0, len(*h))
for _, item := range *h {
if item.MasterMetadataTrackName == "" ||
(opt.IgnoreIncognito && item.IncognitoMode) ||
(opt.IgnoreSkipped && item.Skipped) ||
(item.Skipped && item.MillisecondsPlayed < opt.skippedMinSeconds*1000) {
continue
}
listens = append(listens, item.AsListen())
}
return listens
}
func (i HistoryItem) AsListen() models.Listen {
listen := models.Listen{
Track: models.Track{
TrackName: i.MasterMetadataTrackName,
ReleaseName: i.MasterMetadataAlbumName,
ArtistNames: []string{i.MasterMetadataAlbumArtistName},
AdditionalInfo: models.AdditionalInfo{},
},
ListenedAt: i.Timestamp,
PlaybackDuration: time.Duration(i.MillisecondsPlayed * int(time.Millisecond)),
UserName: i.UserName,
}
if trackUrl, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil {
listen.AdditionalInfo["spotify_id"] = trackUrl
}
return listen
}
// Returns a Spotify ID like "spotify:track:5jzma6gCzYtKB1DbEwFZKH" into an
// URL like "https://open.spotify.com/track/5jzma6gCzYtKB1DbEwFZKH"
func formatSpotifyUri(id string) (string, error) {
parts := strings.Split(id, ":")
if len(parts) == 3 && parts[0] == "spotify" {
return fmt.Sprintf("https://opem.spotify.com/%s/%s", parts[1], parts[2]), nil
} else {
return "", fmt.Errorf("Invalid Spotify ID \"%v\"", id)
}
}

View file

@ -0,0 +1,123 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty.
Scotty is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
Scotty. If not, see <https://www.gnu.org/licenses/>.
*/
package spotifyhistory
import (
"os"
"path"
"path/filepath"
"slices"
"sort"
"time"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
)
const historyFileGlob = "Streaming_History_Audio_*.json"
type SpotifyHistoryBackend struct {
dirPath string
ignoreIncognito bool
ignoreSkipped bool
skippedMinSeconds int
}
func (b *SpotifyHistoryBackend) Name() string { return "spotify-history" }
func (b *SpotifyHistoryBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "dir-path",
Label: i18n.Tr("Directory path"),
Type: models.String,
}, {
Name: "ignore-incognito",
Label: i18n.Tr("Ignore listens in incognito mode"),
Type: models.Bool,
Default: "true",
}, {
Name: "ignore-skipped",
Label: i18n.Tr("Ignore skipped listens"),
Type: models.Bool,
Default: "false",
}, {
Name: "ignore-min-duration-seconds",
Label: i18n.Tr("Minimum playback duration for skipped tracks (seconds)"),
Type: models.Int,
Default: "30",
}}
}
func (b *SpotifyHistoryBackend) FromConfig(config *config.ServiceConfig) models.Backend {
b.dirPath = config.GetString("dir-path")
b.ignoreIncognito = config.GetBool("ignore-incognito", true)
b.ignoreSkipped = config.GetBool("ignore-skipped", false)
b.skippedMinSeconds = config.GetInt("ignore-min-duration-seconds", 30)
return b
}
func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
defer close(results)
files, err := filepath.Glob(path.Join(b.dirPath, historyFileGlob))
if err != nil {
progress <- models.Progress{}.Complete()
results <- models.ListensResult{Error: err}
return
}
slices.Sort(files)
fileCount := int64(len(files))
p := models.Progress{Total: fileCount}
for i, filePath := range files {
history, err := readHistoryFile(filePath)
if err != nil {
progress <- models.Progress{}.Complete()
results <- models.ListensResult{Error: err}
return
}
listens := history.AsListenList(ListenListOptions{
IgnoreIncognito: b.ignoreIncognito,
IgnoreSkipped: b.ignoreSkipped,
skippedMinSeconds: b.skippedMinSeconds,
})
sort.Sort(listens)
results <- models.ListensResult{Items: listens}
p.Elapsed = int64(i)
progress <- p
}
progress <- p.Complete()
}
func readHistoryFile(filePath string) (StreamingHistory, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
history := StreamingHistory{}
err = history.Read(file)
if err != nil {
return nil, err
}
return history, nil
}

View file

@ -105,11 +105,16 @@ func SongAsLove(song subsonic.Child, username string) models.Love {
ArtistNames: []string{song.Artist},
TrackNumber: song.Track,
DiscNumber: song.DiscNumber,
Tags: []string{song.Genre},
AdditionalInfo: map[string]any{},
AdditionalInfo: map[string]any{
"subsonic_id": song.ID,
},
Duration: time.Duration(song.Duration * int(time.Second)),
},
}
if song.Genre != "" {
love.Track.Tags = []string{song.Genre}
}
return love
}

View file

@ -39,6 +39,7 @@ func TestFromConfig(t *testing.T) {
func TestSongToLove(t *testing.T) {
user := "outsidecontext"
song := go_subsonic.Child{
ID: "foo123",
Starred: time.Unix(1699574369, 0),
Title: "Oweynagat",
Album: "Here Now, There Then",
@ -59,4 +60,5 @@ func TestSongToLove(t *testing.T) {
assert.Equal(song.Track, love.Track.TrackNumber)
assert.Equal(song.DiscNumber, love.Track.DiscNumber)
assert.Equal([]string{song.Genre}, love.Track.Tags)
assert.Equal(song.ID, love.AdditionalInfo["subsonic_id"])
}

View file

@ -26,12 +26,11 @@ import (
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/storage"
"golang.org/x/oauth2"
)
func AuthenticationFlow(service config.ServiceConfig, backend models.OAuth2Authenticator) {
func AuthenticationFlow(service config.ServiceConfig, backend auth.OAuth2Authenticator) {
redirectURL, err := backends.BuildRedirectURL(viper.GetViper(), backend.Name())
cobra.CheckErr(err)

View file

@ -24,11 +24,3 @@ func GetServiceConfigFromFlag(cmd *cobra.Command, flagName string) (config.Servi
name := cmd.Flag(flagName).Value.String()
return config.GetService(name)
}
func getInt64FromFlag(cmd *cobra.Command, flagName string) (result int64) {
result, err := cmd.Flags().GetInt64(flagName)
if err != nil {
result = 0
}
return
}

View file

@ -51,7 +51,7 @@ func setupProgressBar(p *mpb.Progress, name string) *mpb.Bar {
mpb.PrependDecorators(
decor.Name(" "),
decor.OnComplete(
decor.Spinner(nil, decor.WC{W: 2, C: decor.DidentRight}),
decor.Spinner(nil, decor.WC{W: 2, C: decor.DindentRight}),
green("✓ "),
),
decor.Name(name, decor.WCSyncWidthR),

View file

@ -17,6 +17,7 @@ package cli
import (
"fmt"
"strconv"
"github.com/manifoldco/promptui"
"go.uploadedlobster.com/scotty/internal/i18n"
@ -31,6 +32,8 @@ func Prompt(opt models.BackendOption) (any, error) {
return PromptSecret(opt)
case models.String:
return PromptString(opt)
case models.Int:
return PromptInt(opt)
default:
return nil, fmt.Errorf("unknown prompt type %v", opt.Type)
}
@ -78,3 +81,28 @@ func PromptYesNo(label string, defaultValue bool) (bool, error) {
_, val, err := sel.Run()
return val == yes, err
}
func PromptInt(opt models.BackendOption) (int, error) {
validate := func(s string) error {
if opt.Validate != nil {
if err := opt.Validate(s); err != nil {
return err
}
}
if _, err := strconv.Atoi(s); err != nil {
return err
}
return nil
}
prompt := promptui.Prompt{
Label: opt.Label,
Validate: validate,
Default: opt.Default,
}
val, err := prompt.Run()
if err != nil {
return 0, err
}
return strconv.Atoi(val)
}

View file

@ -16,8 +16,8 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package cli
import (
"errors"
"fmt"
"math"
"strconv"
"sync"
"time"
@ -38,7 +38,7 @@ func NewTransferCmd[
](
cmd *cobra.Command,
db *storage.Database,
entity string,
entity models.Entity,
source string,
target string,
) (TransferCmd[E, I, R], error) {
@ -57,7 +57,7 @@ func NewTransferCmd[
type TransferCmd[E models.Backend, I models.ImportBackend, R models.ListensResult | models.LovesResult] struct {
cmd *cobra.Command
db *storage.Database
entity string
entity models.Entity
sourceName string
targetName string
ExpBackend E
@ -126,7 +126,6 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac
if result.LastTimestamp.Unix() < timestamp.Unix() {
result.LastTimestamp = timestamp
}
close(exportProgress)
wg.Wait()
progress.Wait()
if result.Error != nil {
@ -143,11 +142,11 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac
}
// Print errors
if len(result.ImportErrors) > 0 {
if len(result.ImportLog) > 0 {
fmt.Println()
fmt.Println(i18n.Tr("During the import the following errors occurred:"))
for _, err := range result.ImportErrors {
fmt.Println(i18n.Tr("Error: %v\n", err))
fmt.Println(i18n.Tr("Import log:"))
for _, entry := range result.ImportLog {
fmt.Println(i18n.Tr("%v: %v", entry.Type, entry.Message))
}
}
@ -155,13 +154,29 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac
}
func (c *TransferCmd[E, I, R]) timestamp() (time.Time, error) {
flagValue, err := c.cmd.Flags().GetInt64("timestamp")
if err == nil && flagValue > math.MinInt64 {
return time.Unix(flagValue, 0), nil
} else {
flagValue, err := c.cmd.Flags().GetString("timestamp")
if err != nil {
return time.Time{}, err
}
// No timestamp given, read from database
if flagValue == "" {
timestamp, err := c.db.GetImportTimestamp(c.sourceName, c.targetName, c.entity)
return timestamp, err
}
// Try using given value as a Unix timestamp
if timestamp, err := strconv.ParseInt(flagValue, 10, 64); err == nil {
return time.Unix(timestamp, 0), nil
}
// Try to parse datetime string
for _, format := range []string{time.DateTime, time.RFC3339} {
if t, err := time.Parse(format, flagValue); err == nil {
return t, nil
}
}
return time.Time{}, errors.New(i18n.Tr("invalid timestamp string \"%v\"", flagValue))
}
func (c *TransferCmd[E, I, R]) updateTimestamp(result models.ImportResult, oldTimestamp time.Time) error {

View file

@ -61,7 +61,8 @@ func InitConfig(cfgFile string) error {
// Create global config if it does not exist
if viper.ConfigFileUsed() == "" && cfgFile == "" {
if err := os.MkdirAll(configDir, 0750); err == nil {
viper.SafeWriteConfig()
// This call is expected to return an error if the file already exists
viper.SafeWriteConfig() //nolint:errcheck
}
}
@ -82,7 +83,7 @@ func WriteConfig(removedKeys ...string) error {
configMap := viper.AllSettings()
for _, key := range removedKeys {
c := configMap
ok := true
var ok bool
subKeys := strings.Split(key, ".")
keyLen := len(subKeys)
// Deep search the key in the config and delete the deepest key, if it exists

View file

@ -54,8 +54,20 @@ func (c *ServiceConfig) GetString(key string) string {
return cast.ToString(c.ConfigValues[key])
}
func (c *ServiceConfig) GetBool(key string) bool {
func (c *ServiceConfig) GetBool(key string, defaultValue bool) bool {
if c.IsSet(key) {
return cast.ToBool(c.ConfigValues[key])
} else {
return defaultValue
}
}
func (c *ServiceConfig) GetInt(key string, defaultValue int) int {
if c.IsSet(key) {
return cast.ToInt(c.ConfigValues[key])
} else {
return defaultValue
}
}
func (c *ServiceConfig) IsSet(key string) bool {

View file

@ -17,12 +17,10 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"net/url"
"time"
"go.uploadedlobster.com/scotty/internal/auth"
// "go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/config"
"golang.org/x/oauth2"
)
// A listen service backend.
@ -85,14 +83,3 @@ type LovesImport interface {
// Imports the given list of loves.
ImportLoves(export LovesResult, importResult ImportResult, progress chan Progress) (ImportResult, error)
}
// Must be implemented by backends requiring OAuth2 authentication
type OAuth2Authenticator interface {
Backend
// Returns OAuth2 config suitable for this backend
OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy
// Setup the OAuth2 client
OAuth2Setup(token oauth2.TokenSource) error
}

View file

@ -24,9 +24,16 @@ package models
import (
"strings"
"time"
"go.uploadedlobster.com/mbtypes"
)
type MBID string
type Entity string
const (
Listens Entity = "listens"
Loves Entity = "loves"
)
type AdditionalInfo map[string]any
@ -37,12 +44,12 @@ type Track struct {
TrackNumber int
DiscNumber int
Duration time.Duration
ISRC string
RecordingMbid MBID
ReleaseMbid MBID
ReleaseGroupMbid MBID
ArtistMbids []MBID
WorkMbids []MBID
ISRC mbtypes.ISRC
RecordingMBID mbtypes.MBID
ReleaseMBID mbtypes.MBID
ReleaseGroupMBID mbtypes.MBID
ArtistMBIDs []mbtypes.MBID
WorkMBIDs []mbtypes.MBID
Tags []string
AdditionalInfo AdditionalInfo
}
@ -56,20 +63,20 @@ func (t *Track) FillAdditionalInfo() {
if t.AdditionalInfo == nil {
t.AdditionalInfo = make(AdditionalInfo, 5)
}
if t.RecordingMbid != "" {
t.AdditionalInfo["recording_mbid"] = t.RecordingMbid
if t.RecordingMBID != "" {
t.AdditionalInfo["recording_mbid"] = t.RecordingMBID
}
if t.ReleaseGroupMbid != "" {
t.AdditionalInfo["release_group_mbid"] = t.ReleaseGroupMbid
if t.ReleaseGroupMBID != "" {
t.AdditionalInfo["release_group_mbid"] = t.ReleaseGroupMBID
}
if t.ReleaseMbid != "" {
t.AdditionalInfo["release_mbid"] = t.ReleaseMbid
if t.ReleaseMBID != "" {
t.AdditionalInfo["release_mbid"] = t.ReleaseMBID
}
if len(t.ArtistMbids) > 0 {
t.AdditionalInfo["artist_mbids"] = t.ArtistMbids
if len(t.ArtistMBIDs) > 0 {
t.AdditionalInfo["artist_mbids"] = t.ArtistMBIDs
}
if len(t.WorkMbids) > 0 {
t.AdditionalInfo["work_mbids"] = t.WorkMbids
if len(t.WorkMBIDs) > 0 {
t.AdditionalInfo["work_mbids"] = t.WorkMBIDs
}
if t.ISRC != "" {
t.AdditionalInfo["isrc"] = t.ISRC
@ -104,8 +111,8 @@ type Love struct {
Track
Created time.Time
UserName string
RecordingMbid MBID
RecordingMsid MBID
RecordingMBID mbtypes.MBID
RecordingMsid mbtypes.MBID
}
type ListensList []Listen
@ -158,11 +165,24 @@ type ListensResult ExportResult[ListensList]
type LovesResult ExportResult[LovesList]
type LogEntryType string
const (
Info LogEntryType = "Info"
Warning LogEntryType = "Warning"
Error LogEntryType = "Error"
)
type LogEntry struct {
Type LogEntryType
Message string
}
type ImportResult struct {
TotalCount int
ImportCount int
LastTimestamp time.Time
ImportErrors []string
ImportLog []LogEntry
// Error is only set if an unrecoverable import error occurred
Error error
@ -179,7 +199,14 @@ func (i *ImportResult) Update(from ImportResult) {
i.TotalCount = from.TotalCount
i.ImportCount = from.ImportCount
i.UpdateTimestamp(from.LastTimestamp)
i.ImportErrors = append(i.ImportErrors, from.ImportErrors...)
i.ImportLog = append(i.ImportLog, from.ImportLog...)
}
func (i *ImportResult) Log(t LogEntryType, msg string) {
i.ImportLog = append(i.ImportLog, LogEntry{
Type: t,
Message: msg,
})
}
type Progress struct {
@ -195,7 +222,7 @@ func (p Progress) FromImportResult(result ImportResult) Progress {
}
func (p Progress) Complete() Progress {
p.Total = p.Elapsed
p.Elapsed = p.Total
p.Completed = true
return p
}

View file

@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/models"
)
@ -44,25 +45,25 @@ func TestTrackArtistName(t *testing.T) {
func TestTrackFillAdditionalInfo(t *testing.T) {
track := models.Track{
RecordingMbid: models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
ReleaseGroupMbid: models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
ReleaseMbid: models.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
ArtistMbids: []models.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
WorkMbids: []models.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"},
RecordingMBID: mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
ReleaseGroupMBID: mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
ReleaseMBID: mbtypes.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
ArtistMBIDs: []mbtypes.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
WorkMBIDs: []mbtypes.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"},
TrackNumber: 5,
DiscNumber: 1,
Duration: time.Duration(413787 * time.Millisecond),
ISRC: "DES561620801",
ISRC: mbtypes.ISRC("DES561620801"),
Tags: []string{"rock", "psychedelic rock"},
}
track.FillAdditionalInfo()
i := track.AdditionalInfo
assert := assert.New(t)
assert.Equal(track.RecordingMbid, i["recording_mbid"])
assert.Equal(track.ReleaseGroupMbid, i["release_group_mbid"])
assert.Equal(track.ReleaseMbid, i["release_mbid"])
assert.Equal(track.ArtistMbids, i["artist_mbids"])
assert.Equal(track.WorkMbids, i["work_mbids"])
assert.Equal(track.RecordingMBID, i["recording_mbid"])
assert.Equal(track.ReleaseGroupMBID, i["release_group_mbid"])
assert.Equal(track.ReleaseMBID, i["release_mbid"])
assert.Equal(track.ArtistMBIDs, i["artist_mbids"])
assert.Equal(track.WorkMBIDs, i["work_mbids"])
assert.Equal(track.TrackNumber, i["tracknumber"])
assert.Equal(track.DiscNumber, i["discnumber"])
assert.Equal(track.Duration.Milliseconds(), i["duration_ms"])
@ -117,23 +118,45 @@ func TestLovesListSort(t *testing.T) {
}
func TestImportResultUpdate(t *testing.T) {
logEntry1 := models.LogEntry{
Type: models.Warning,
Message: "foo",
}
logEntry2 := models.LogEntry{
Type: models.Error,
Message: "bar",
}
result := models.ImportResult{
TotalCount: 100,
ImportCount: 20,
LastTimestamp: time.Now(),
ImportErrors: []string{"foo"},
ImportLog: []models.LogEntry{logEntry1},
}
newResult := models.ImportResult{
TotalCount: 120,
ImportCount: 50,
LastTimestamp: time.Now().Add(1 * time.Hour),
ImportErrors: []string{"bar"},
ImportLog: []models.LogEntry{logEntry2},
}
result.Update(newResult)
assert.Equal(t, 120, result.TotalCount)
assert.Equal(t, 50, result.ImportCount)
assert.Equal(t, newResult.LastTimestamp, result.LastTimestamp)
assert.Equal(t, []string{"foo", "bar"}, result.ImportErrors)
assert.Equal(t, []models.LogEntry{logEntry1, logEntry2}, result.ImportLog)
}
func TestImportResultLog(t *testing.T) {
result := models.ImportResult{}
result.Log(models.Warning, "foo")
result.Log(models.Error, "bar")
expected := []models.LogEntry{{
Type: models.Warning,
Message: "foo",
}, {
Type: models.Error,
Message: "bar",
}}
assert.Equal(t, expected, result.ImportLog)
}
func TestImportResultUpdateTimestamp(t *testing.T) {

View file

@ -21,6 +21,7 @@ const (
Bool OptionType = "bool"
Secret OptionType = "secret"
String OptionType = "string"
Int OptionType = "int"
)
type BackendOption struct {

View file

@ -0,0 +1,82 @@
/*
Copyright © 2024 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
Scotty. If not, see <https://www.gnu.org/licenses/>.
*/
package similarity
import (
"regexp"
"strings"
"github.com/agnivade/levenshtein"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/util"
"golang.org/x/text/unicode/norm"
)
// Returns the Levensthein distance between s1 and s2 relative to the length of
// the longer string.
// Unicode normalization on the strings is performed.
func Similarity(s1 string, s2 string) float64 {
s1 = norm.NFKC.String(s1)
s2 = norm.NFKC.String(s2)
l1 := len([]rune(s1))
l2 := len([]rune(s2))
maxLen := max(l1, l2)
// Empty strings always compare full equal
if maxLen == 0 {
return 1.0
}
dist := levenshtein.ComputeDistance(s1, s2)
// fmt.Printf("%v (%v) ~ %v (%v) = %v\n", s1, l1, s2, l2, dist)
return 1.0 - (float64(dist) / float64(maxLen))
}
var reMultiSpace = regexp.MustCompile(`\s+`)
var reIgnoredPatterns = []*regexp.Regexp{
regexp.MustCompile(`\s+\([^)]+\)$`),
regexp.MustCompile(`\s+- (\d{4} )?remaster(ed)?$`),
}
// Normalizes a track or release title.
func NormalizeTitle(s string) string {
s = strings.TrimSpace(s)
s = strings.ToLower(s)
s = reMultiSpace.ReplaceAllString(s, " ")
for _, re := range reIgnoredPatterns {
s = re.ReplaceAllString(s, "")
}
return s
}
// Compare two tracks for similarity.
func CompareTracks(t1 models.Track, t2 models.Track) float64 {
// Identical recording MBID always compares 100%
if t1.RecordingMBID == t2.RecordingMBID && t1.RecordingMBID != "" {
return 1.0
}
// Compare track name and artist
sims := []float64{
Similarity(NormalizeTitle(t1.TrackName), NormalizeTitle(t2.TrackName)),
Similarity(NormalizeTitle(t1.ArtistName()), NormalizeTitle(t2.ArtistName())),
}
// Compare release names only if they are set for both tracks
if t1.ReleaseName != "" && t2.ReleaseName != "" {
sims = append(sims, Similarity(NormalizeTitle(t1.ReleaseName), NormalizeTitle(t2.ReleaseName)))
}
return util.Average(sims...)
}

View file

@ -0,0 +1,87 @@
/*
Copyright © 2024 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
Scotty. If not, see <https://www.gnu.org/licenses/>.
*/
package similarity_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/similarity"
)
func ExampleSimilarity() {
s := similarity.Similarity("bar1", "bär1")
fmt.Println(s)
// Output: 0.75
}
func TestSimilarity(t *testing.T) {
assert := assert.New(t)
assert.Equal(1.0, similarity.Similarity("", ""))
assert.Equal(0.0, similarity.Similarity("foo", ""))
assert.Equal(0.0, similarity.Similarity("foo", "bar"))
assert.Equal(0.5, similarity.Similarity("foobar", "bar"))
assert.Equal(1.0, similarity.Similarity("foo", "foo"))
assert.Equal(0.6, similarity.Similarity("Forever After", "Forever Failure"))
}
func ExampleNormalizeTitle() {
s := similarity.NormalizeTitle(" Forever \tFailure (video edit) ")
fmt.Println(s)
// Output: forever failure
}
func TestNormalizeTitle(t *testing.T) {
assert := assert.New(t)
assert.Equal("forever failure", similarity.NormalizeTitle("Forever Failure"))
assert.Equal("foo", similarity.NormalizeTitle(" \tfoo\t \t"))
assert.Equal("wasted years", similarity.NormalizeTitle("Wasted Years - 2015 Remaster"))
assert.Equal("london calling", similarity.NormalizeTitle("London Calling - Remastered"))
assert.Equal("london calling", similarity.NormalizeTitle("London Calling (Remastered)"))
}
func ExampleCompareTracks() {
t1 := models.Track{
ArtistNames: []string{"Paradise Lost"},
TrackName: "Forever After",
}
t2 := models.Track{
ArtistNames: []string{"Paradise Lost"},
TrackName: "Forever Failure (radio edit)",
ReleaseName: "Draconian Times",
}
sim := similarity.CompareTracks(t1, t2)
fmt.Println(sim)
// Output: 0.8333333333333334
}
func TestCompareTracksSameMBID(t *testing.T) {
t1 := models.Track{
ArtistNames: []string{"Paradise Lost"},
TrackName: "Forever After",
RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"),
}
t2 := models.Track{
ArtistNames: []string{"Paradise Lost"},
TrackName: "Forever Failure (radio edit)",
ReleaseName: "Draconian Times",
RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"),
}
assert.Equal(t, 1.0, similarity.CompareTracks(t1, t2))
}

View file

@ -24,6 +24,7 @@ import (
"time"
"github.com/glebarez/sqlite"
"go.uploadedlobster.com/scotty/internal/models"
"golang.org/x/oauth2"
"gorm.io/datatypes"
"gorm.io/gorm"
@ -54,7 +55,7 @@ func New(dsn string) (db Database, err error) {
return
}
func (db Database) GetImportTimestamp(source string, target string, entity string) (time.Time, error) {
func (db Database) GetImportTimestamp(source string, target string, entity models.Entity) (time.Time, error) {
result := ImportTimestamp{
SourceService: source,
TargetService: target,
@ -64,7 +65,7 @@ func (db Database) GetImportTimestamp(source string, target string, entity strin
return result.Timestamp, err
}
func (db Database) SetImportTimestamp(source string, target string, entity string, timestamp time.Time) error {
func (db Database) SetImportTimestamp(source string, target string, entity models.Entity, timestamp time.Time) error {
entry := ImportTimestamp{
SourceService: source,
TargetService: target,

View file

@ -23,6 +23,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/storage"
"golang.org/x/oauth2"
)
@ -33,7 +34,7 @@ func TestTimestampUpdate(t *testing.T) {
source := "maloja"
target := "funkwhale"
entity := "loves"
entity := models.Loves
timestamp, err := db.GetImportTimestamp(source, target, entity)
require.NoError(t, err)
assert.Equal(t, time.Time{}, timestamp)

View file

@ -20,13 +20,14 @@ package storage
import (
"time"
"go.uploadedlobster.com/scotty/internal/models"
"gorm.io/datatypes"
)
type ImportTimestamp struct {
SourceService string `gorm:"primaryKey"`
TargetService string `gorm:"primaryKey"`
Entity string `gorm:"primaryKey"`
Entity models.Entity `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
Timestamp time.Time `gorm:"default:'1970-01-01T00:00:00'"`

View file

@ -39,117 +39,154 @@ func init() {
}
var messageKeyToIndex = map[string]int{
"\tbackend: %v": 18,
"\texport: %s": 7,
"\timport: %s\n": 8,
"Aborted": 15,
"Access token": 26,
"Access token received, you can use %v now.\n": 35,
"Append to file": 32,
"Backend": 40,
"Client ID": 22,
"Client secret": 23,
"Delete the service configuration \"%v\"?": 14,
"Disable auto correction of submitted listens": 30,
"During the import the following errors occurred:": 5,
"Error: %v\n": 6,
"Error: OAuth state mismatch": 34,
"Failed reading config: %v": 9,
"File path": 27,
"From timestamp: %v (%v)": 41,
"Import failed, last reported timestamp was %v (%s)": 42,
"Imported %v of %v %s into %v.": 4,
"Include skipped listens": 31,
"Latest timestamp: %v (%v)": 43,
"No": 37,
"Playlist title": 28,
"Saved service %v using backend %v": 12,
"Server URL": 24,
"Service": 39,
"Service \"%v\" deleted\n": 16,
"Service name": 10,
"The backend %v requires authentication. Authenticate now?": 13,
"Token received, you can close this window now.": 19,
"Transferring %s from %s to %s...": 3,
"Unique playlist identifier": 29,
"Updated service %v using backend %v\n": 17,
"User name": 25,
"Visit the URL for authorization: %v": 33,
"Yes": 36,
"a service with this name already exists": 11,
"backend %s does not implement %s": 20,
"done": 2,
"exporting": 0,
"importing": 1,
"key must only consist of A-Za-z0-9_-": 45,
"no configuration file defined, cannot write config": 44,
"no existing service configurations": 38,
"no service configuration \"%v\"": 46,
"unknown backend \"%s\"": 21,
"\tbackend: %v": 11,
"\texport: %s": 0,
"\timport: %s\n": 1,
"%v: %v": 47,
"Aborted": 8,
"Access token": 19,
"Access token received, you can use %v now.\n": 33,
"Append to file": 21,
"Backend": 41,
"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,
"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,
"Playlist title": 22,
"Saved service %v using backend %v": 5,
"Server URL": 17,
"Service": 40,
"Service \"%v\" deleted\n": 9,
"Service name": 3,
"The backend %v requires authentication. Authenticate now?": 6,
"Token received, you can close this window now.": 12,
"Transferring %s from %s to %s...": 42,
"Unique playlist identifier": 23,
"Updated service %v using backend %v\n": 10,
"User name": 18,
"Visit the URL for authorization: %v": 31,
"Yes": 37,
"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,
"unknown backend \"%s\"": 14,
}
var deIndex = []uint32{ // 48 elements
var deIndex = []uint32{ // 55 elements
// Entry 0 - 1F
0x00000000, 0x0000000b, 0x00000016, 0x0000001d,
0x00000046, 0x00000071, 0x000000a8, 0x000000bb,
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
0x00000000, 0x00000013, 0x00000027, 0x00000052,
0x0000005e, 0x0000008d, 0x000000bd, 0x00000104,
0x00000133, 0x0000013f, 0x00000162, 0x00000198,
0x000001ac, 0x000001e7, 0x00000213, 0x00000233,
0x0000023d, 0x0000024b, 0x00000256, 0x00000263,
0x00000271, 0x0000027b, 0x0000028e, 0x000002a1,
0x000002b8, 0x000002ed, 0x00000321, 0x00000342,
0x00000352, 0x00000378, 0x0000039a, 0x000003d8,
// Entry 20 - 3F
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
} // Size: 216 bytes
0x000003fe, 0x00000428, 0x00000468, 0x00000473,
0x0000047e, 0x00000485, 0x00000488, 0x0000048d,
0x000004b6, 0x000004be, 0x000004c6, 0x000004ef,
0x0000050d, 0x0000054a, 0x00000575, 0x00000580,
0x0000058d, 0x000005b1, 0x000005d4, 0x00000625,
0x0000065c, 0x00000683, 0x00000683,
} // Size: 244 bytes
const deData string = "" + // Size: 187 bytes
"\x02exportiere\x02importiere\x02fertig\x02Übertrage %[1]s von %[2]s nach" +
" %[3]s...\x02%[1]v von %[2]v %[3]s in %[4]v importiert.\x02Während des I" +
"mports sind folgende Fehler aufgetreten:\x04\x00\x01\x0a\x0e\x02Fehler: " +
"%[1]v"
const deData string = "" + // Size: 1667 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" +
"ackend %[2]v gespeichert\x02Das Backend %[1]v erfordert Authentifizierun" +
"g. Jetzt authentifizieren?\x02Die Servicekonfiguration „%[1]v“ löschen?" +
"\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" +
"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“"
var enIndex = []uint32{ // 48 elements
var enIndex = []uint32{ // 55 elements
// Entry 0 - 1F
0x00000000, 0x0000000a, 0x00000014, 0x00000019,
0x00000043, 0x0000006d, 0x0000009e, 0x000000b0,
0x000000c3, 0x000000d7, 0x000000f4, 0x00000101,
0x00000129, 0x00000151, 0x0000018e, 0x000001b8,
0x000001c0, 0x000001dd, 0x0000020c, 0x00000220,
0x0000024f, 0x00000276, 0x0000028e, 0x00000298,
0x000002a6, 0x000002b1, 0x000002bb, 0x000002c8,
0x000002d2, 0x000002e1, 0x000002fc, 0x00000329,
0x00000000, 0x00000013, 0x00000027, 0x00000044,
0x00000051, 0x00000079, 0x000000a1, 0x000000de,
0x00000108, 0x00000110, 0x0000012d, 0x0000015c,
0x00000170, 0x0000019f, 0x000001c6, 0x000001de,
0x000001e8, 0x000001f6, 0x00000201, 0x0000020b,
0x00000218, 0x00000222, 0x00000231, 0x00000240,
0x0000025b, 0x0000028a, 0x000002b7, 0x000002cf,
0x000002de, 0x000002ff, 0x00000316, 0x0000034d,
// Entry 20 - 3F
0x00000341, 0x00000350, 0x00000377, 0x00000393,
0x000003c6, 0x000003ca, 0x000003cd, 0x000003f0,
0x000003f8, 0x00000400, 0x0000041e, 0x00000457,
0x00000477, 0x000004aa, 0x000004cf, 0x000004f0,
} // Size: 216 bytes
0x00000374, 0x00000390, 0x000003c3, 0x000003cd,
0x000003d7, 0x000003dc, 0x000003e0, 0x000003e3,
0x00000406, 0x0000040e, 0x00000416, 0x00000440,
0x0000045e, 0x00000497, 0x000004c1, 0x000004cd,
0x000004da, 0x000004fb, 0x0000051b, 0x0000054e,
0x00000573, 0x00000594, 0x000005cd,
} // Size: 244 bytes
const enData string = "" + // Size: 1264 bytes
"\x02exporting\x02importing\x02done\x02Transferring %[1]s from %[2]s to %" +
"[3]s...\x02Imported %[1]v of %[2]v %[3]s into %[4]v.\x02During the impor" +
"t the following errors occurred:\x04\x00\x01\x0a\x0d\x02Error: %[1]v\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 thi" +
"s name already exists\x02Saved service %[1]v using backend %[2]v\x02The " +
"backend %[1]v requires authentication. Authenticate now?\x02Delete the s" +
"ervice configuration \x22%[1]v\x22?\x02Aborted\x04\x00\x01\x0a\x18\x02Se" +
"rvice \x22%[1]v\x22 deleted\x04\x00\x01\x0a*\x02Updated service %[1]v us" +
"ing backend %[2]v\x04\x01\x09\x00\x0f\x02backend: %[1]v\x02Token receive" +
"d, you can close this window now.\x02backend %[1]s does not implement %[" +
"2]s\x02unknown backend \x22%[1]s\x22\x02Client ID\x02Client secret\x02Se" +
"rver URL\x02User name\x02Access token\x02File path\x02Playlist title\x02" +
"Unique playlist identifier\x02Disable auto correction of submitted liste" +
"ns\x02Include skipped listens\x02Append to file\x02Visit the URL for aut" +
"horization: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Acc" +
"ess token received, you can use %[1]v now.\x02Yes\x02No\x02no existing s" +
"ervice configurations\x02Service\x02Backend\x02From timestamp: %[1]v (%[" +
"2]v)\x02Import failed, last reported timestamp was %[1]v (%[2]s)\x02Late" +
"st timestamp: %[1]v (%[2]v)\x02no configuration file defined, cannot wri" +
"te config\x02key must only consist of A-Za-z0-9_-\x02no service configur" +
"ation \x22%[1]v\x22"
const enData string = "" + // Size: 1485 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" +
"The backend %[1]v requires authentication. Authenticate now?\x02Delete t" +
"he service configuration \x22%[1]v\x22?\x02Aborted\x04\x00\x01\x0a\x18" +
"\x02Service \x22%[1]v\x22 deleted\x04\x00\x01\x0a*\x02Updated service %[" +
"1]v using backend %[2]v\x04\x01\x09\x00\x0f\x02backend: %[1]v\x02Token r" +
"eceived, you can close this window now.\x02backend %[1]s does not implem" +
"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)"
// Total table size 1883 bytes (1KiB); checksum: 6875B9DE
// Total table size 3640 bytes (3KiB); checksum: 719A868A

View file

@ -2,24 +2,337 @@
"language": "de",
"messages": [
{
"id": "Authenticate a service",
"message": "Authenticate a service",
"translation": "An einem Service anmelden"
"id": "export: {ExportCapabilities__}",
"message": "export: {ExportCapabilities__}",
"translation": "Export: {ExportCapabilities__}",
"placeholders": [
{
"id": "ExportCapabilities__",
"string": "%[1]s",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "strings.Join(info.ExportCapabilities, \", \")"
}
]
},
{
"id": "For backends requiring authentication this command can be used to authenticate.\n\nAuthentication is always done per configured service. That means you can have\nmultiple services using the same backend but different authentication.",
"message": "For backends requiring authentication this command can be used to authenticate.\n\nAuthentication is always done per configured service. That means you can have\nmultiple services using the same backend but different authentication.",
"translation": ""
"id": "import: {ImportCapabilities__}",
"message": "import: {ImportCapabilities__}",
"translation": "Import: {ImportCapabilities__}",
"placeholders": [
{
"id": "ImportCapabilities__",
"string": "%[1]s",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "strings.Join(info.ImportCapabilities, \", \")"
}
]
},
{
"id": "failed loading service configuration",
"message": "failed loading service configuration",
"translation": ""
"id": "Failed reading config: {Err}",
"message": "Failed reading config: {Err}",
"translation": "Fehler beim Lesen der Konfiguration: {Err}",
"placeholders": [
{
"id": "Err",
"string": "%[1]v",
"type": "error",
"underlyingType": "interface{Error() string}",
"argNum": 1,
"expr": "err"
}
]
},
{
"id": "Service name",
"message": "Service name",
"translation": "Servicename"
},
{
"id": "a service with this name already exists",
"message": "a service with this name already exists",
"translation": "ein Service mit diesem Namen existiert bereits"
},
{
"id": "Saved service {Name} using backend {Backend}",
"message": "Saved service {Name} using backend {Backend}",
"translation": "Service {Name} mit dem Backend {Backend} gespeichert",
"placeholders": [
{
"id": "Name",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "service.Name"
},
{
"id": "Backend",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "service.Backend"
}
]
},
{
"id": "The backend {Backend} requires authentication. Authenticate now?",
"message": "The backend {Backend} requires authentication. Authenticate now?",
"translation": "Das Backend {Backend} erfordert Authentifizierung. Jetzt authentifizieren?",
"placeholders": [
{
"id": "Backend",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "service.Backend"
}
]
},
{
"id": "Delete the service configuration \"{Service}\"?",
"message": "Delete the service configuration \"{Service}\"?",
"translation": "Die Servicekonfiguration „{Service}“ löschen?",
"placeholders": [
{
"id": "Service",
"string": "%[1]v",
"type": "go.uploadedlobster.com/scotty/internal/config.ServiceConfig",
"underlyingType": "struct{Name string; Backend string; ConfigValues map[string]any}",
"argNum": 1,
"expr": "service"
}
]
},
{
"id": "Aborted",
"message": "Aborted",
"translation": "Abgebrochen"
},
{
"id": "Service \"{Name}\" deleted",
"message": "Service \"{Name}\" deleted",
"translation": "Service „{Name}“ gelöscht",
"placeholders": [
{
"id": "Name",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "service.Name"
}
]
},
{
"id": "Updated service {Name} using backend {Backend}",
"message": "Updated service {Name} using backend {Backend}",
"translation": "Service {Name} mit dem Backend {Backend} aktualisiert",
"placeholders": [
{
"id": "Name",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "service.Name"
},
{
"id": "Backend",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "service.Backend"
}
]
},
{
"id": "backend: {Backend}",
"message": "backend: {Backend}",
"translation": "Backend: {Backend}",
"placeholders": [
{
"id": "Backend",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "s.Backend"
}
]
},
{
"id": "Token received, you can close this window now.",
"message": "Token received, you can close this window now.",
"translation": "Token erhalten, das Fenster kann jetzt geschlossen werden."
},
{
"id": "backend {Backend} does not implement {InterfaceName}",
"message": "backend {Backend} does not implement {InterfaceName}",
"translation": "das backend {Backend} implementiert {InterfaceName} nicht",
"placeholders": [
{
"id": "Backend",
"string": "%[1]s",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "config.Backend"
},
{
"id": "InterfaceName",
"string": "%[2]s",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "interfaceName"
}
]
},
{
"id": "unknown backend \"{BackendName}\"",
"message": "unknown backend \"{BackendName}\"",
"translation": "unbekanntes Backend „{BackendName}“",
"placeholders": [
{
"id": "BackendName",
"string": "%[1]s",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "backendName"
}
]
},
{
"id": "Client ID",
"message": "Client ID",
"translation": "Client-ID"
},
{
"id": "Client secret",
"message": "Client secret",
"translation": "Client-Secret"
},
{
"id": "Server URL",
"message": "Server URL",
"translation": "Server-URL"
},
{
"id": "User name",
"message": "User name",
"translation": "Benutzername"
},
{
"id": "Access token",
"message": "Access token",
"translation": "Zugriffstoken"
},
{
"id": "File path",
"message": "File path",
"translation": "Dateipfad"
},
{
"id": "Append to file",
"message": "Append to file",
"translation": "An Datei anhängen"
},
{
"id": "Playlist title",
"message": "Playlist title",
"translation": "Titel der Playlist"
},
{
"id": "Unique playlist identifier",
"message": "Unique playlist identifier",
"translation": "Eindeutige Playlist-ID"
},
{
"id": "Check for duplicate listens on import (slower)",
"message": "Check for duplicate listens on import (slower)",
"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})",
"placeholders": [
{
"id": "ListenedAt",
"string": "%[1]v",
"type": "time.Time",
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
"argNum": 1,
"expr": "l.ListenedAt"
},
{
"id": "TrackName",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "l.TrackName"
},
{
"id": "ArtistName",
"string": "%[3]v",
"type": "string",
"underlyingType": "string",
"argNum": 3,
"expr": "l.ArtistName()"
},
{
"id": "RecordingMbid",
"string": "%[4]v",
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
"underlyingType": "string",
"argNum": 4,
"expr": "l.RecordingMbid"
}
]
},
{
"id": "Disable auto correction of submitted listens",
"message": "Disable auto correction of submitted listens",
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
},
{
"id": "Include skipped listens",
"message": "Include skipped listens",
"translation": "Übersprungene Titel einbeziehen"
},
{
"id": "Directory path",
"message": "Directory path",
"translation": "Verzeichnispfad"
},
{
"id": "Ignore listens in incognito mode",
"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": "",
"translation": "URL für Autorisierung öffnen: {Url}",
"placeholders": [
{
"id": "Url",
@ -34,12 +347,12 @@
{
"id": "Error: OAuth state mismatch",
"message": "Error: OAuth state mismatch",
"translation": ""
"translation": "Fehler: OAuth-State stimmt nicht überein"
},
{
"id": "Access token received, you can use {Name} now.",
"message": "Access token received, you can use {Name} now.",
"translation": "",
"translation": "Zugriffstoken erhalten, {Name} kann jetzt verwendet werden.",
"placeholders": [
{
"id": "Name",
@ -47,35 +360,55 @@
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "serviceConfig.Name"
"expr": "service.Name"
}
]
},
{
"id": "service configuration (required)",
"message": "service configuration (required)",
"translation": "Servicekonfiguration (notwendig)"
},
{
"id": "exporting",
"message": "exporting",
"translation": "exportiere",
"translatorComment": "Copied from source.",
"fuzzy": true
"fuzzy": true,
"translation": "exportiere"
},
{
"id": "importing",
"message": "importing",
"translation": "importiere",
"translatorComment": "Copied from source.",
"fuzzy": true
"fuzzy": true,
"translation": "importiere"
},
{
"id": "done",
"message": "done",
"translation": "fertig",
"translatorComment": "Copied from source.",
"fuzzy": true
"fuzzy": true,
"translation": "fertig"
},
{
"id": "Yes",
"message": "Yes",
"translation": "Ja"
},
{
"id": "No",
"message": "No",
"translation": "Nein"
},
{
"id": "no existing service configurations",
"message": "no existing service configurations",
"translation": "keine bestehenden Servicekonfigurationen"
},
{
"id": "Service",
"message": "Service",
"translation": "Service"
},
{
"id": "Backend",
"message": "Backend",
"translation": "Backend"
},
{
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
@ -85,7 +418,7 @@
{
"id": "Entity",
"string": "%[1]s",
"type": "string",
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
"underlyingType": "string",
"argNum": 1,
"expr": "c.entity"
@ -109,48 +442,44 @@
]
},
{
"id": "From timestamp: {Timestamp} ({Unix})",
"message": "From timestamp: {Timestamp} ({Unix})",
"translation": "Ab Zeitstempel: {Timestamp} ({Unix})",
"id": "From timestamp: {Arg_1} ({Arg_2})",
"message": "From timestamp: {Arg_1} ({Arg_2})",
"translation": "Ab Zeitstempel: {Arg_1} ({Arg_2})",
"placeholders": [
{
"id": "Timestamp",
"id": "Arg_1",
"string": "%[1]v",
"type": "time.Time",
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
"argNum": 1,
"expr": "timestamp"
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Unix",
"id": "Arg_2",
"string": "%[2]v",
"type": "int64",
"underlyingType": "int64",
"argNum": 2,
"expr": "timestamp.Unix()"
"type": "",
"underlyingType": "interface{}",
"argNum": 2
}
]
},
{
"id": "Import failed, last reported timestamp was {LastTimestamp} ({Unix})",
"message": "Import failed, last reported timestamp was {LastTimestamp} ({Unix})",
"translation": "Import fehlgeschlagen, der letzte Zeitstempel war {LastTimestamp} ({Unix})",
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})",
"placeholders": [
{
"id": "LastTimestamp",
"id": "Arg_1",
"string": "%[1]v",
"type": "time.Time",
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
"argNum": 1,
"expr": "result.LastTimestamp"
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Unix",
"string": "%[2]v",
"type": "int64",
"underlyingType": "int64",
"argNum": 2,
"expr": "result.LastTimestamp.Unix()"
"id": "Arg_2",
"string": "%[2]s",
"type": "",
"underlyingType": "string",
"argNum": 2
}
]
},
@ -178,7 +507,7 @@
{
"id": "Entity",
"string": "%[3]s",
"type": "string",
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
"underlyingType": "string",
"argNum": 3,
"expr": "c.entity"
@ -194,45 +523,91 @@
]
},
{
"id": "During the import the following errors occurred:",
"message": "During the import the following errors occurred:",
"translation": "Während des Imports sind folgende Fehler aufgetreten:"
"id": "Import log:",
"message": "Import log:",
"translation": "Importlog:"
},
{
"id": "Error: {Err}",
"message": "Error: {Err}",
"translation": "Fehler: {Err}",
"id": "{Type}: {Message}",
"message": "{Type}: {Message}",
"translation": "{Type}: {Message}",
"placeholders": [
{
"id": "Err",
"id": "Type",
"string": "%[1]v",
"type": "string",
"type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType",
"underlyingType": "string",
"argNum": 1,
"expr": "err"
"expr": "entry.Type"
},
{
"id": "Message",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "entry.Message"
}
]
},
{
"id": "Latest timestamp: {LastTimestamp} ({Unix})",
"message": "Latest timestamp: {LastTimestamp} ({Unix})",
"translation": "Neuester Zeitstempel: {LastTimestamp} ({Unix})",
"id": "invalid timestamp string \"{FlagValue}\"",
"message": "invalid timestamp string \"{FlagValue}\"",
"translation": "ungültiger Zeitstempel „{FlagValue}“",
"placeholders": [
{
"id": "LastTimestamp",
"id": "FlagValue",
"string": "%[1]v",
"type": "time.Time",
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "result.LastTimestamp"
"expr": "flagValue"
}
]
},
{
"id": "Unix",
"id": "Latest timestamp: {Arg_1} ({Arg_2})",
"message": "Latest timestamp: {Arg_1} ({Arg_2})",
"translation": "Letzter Zeitstempel: {Arg_1} ({Arg_2})",
"placeholders": [
{
"id": "Arg_1",
"string": "%[1]v",
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Arg_2",
"string": "%[2]v",
"type": "int64",
"underlyingType": "int64",
"argNum": 2,
"expr": "result.LastTimestamp.Unix()"
"type": "",
"underlyingType": "interface{}",
"argNum": 2
}
]
},
{
"id": "no configuration file defined, cannot write config",
"message": "no configuration file defined, cannot write config",
"translation": "keine Konfigurationsdatei definiert, Konfiguration kann nicht geschrieben werden"
},
{
"id": "key must only consist of A-Za-z0-9_-",
"message": "key must only consist of A-Za-z0-9_-",
"translation": "Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten"
},
{
"id": "no service configuration \"{Name}\"",
"message": "no service configuration \"{Name}\"",
"translation": "keine Servicekonfiguration „{Name}“",
"placeholders": [
{
"id": "Name",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "name"
}
]
}

View file

@ -4,7 +4,7 @@
{
"id": "export: {ExportCapabilities__}",
"message": "export: {ExportCapabilities__}",
"translation": "",
"translation": "Export: {ExportCapabilities__}",
"placeholders": [
{
"id": "ExportCapabilities__",
@ -19,7 +19,7 @@
{
"id": "import: {ImportCapabilities__}",
"message": "import: {ImportCapabilities__}",
"translation": "",
"translation": "Import: {ImportCapabilities__}",
"placeholders": [
{
"id": "ImportCapabilities__",
@ -34,7 +34,7 @@
{
"id": "Failed reading config: {Err}",
"message": "Failed reading config: {Err}",
"translation": "",
"translation": "Fehler beim Lesen der Konfiguration: {Err}",
"placeholders": [
{
"id": "Err",
@ -49,17 +49,17 @@
{
"id": "Service name",
"message": "Service name",
"translation": ""
"translation": "Servicename"
},
{
"id": "a service with this name already exists",
"message": "a service with this name already exists",
"translation": ""
"translation": "ein Service mit diesem Namen existiert bereits"
},
{
"id": "Saved service {Name} using backend {Backend}",
"message": "Saved service {Name} using backend {Backend}",
"translation": "",
"translation": "Service {Name} mit dem Backend {Backend} gespeichert",
"placeholders": [
{
"id": "Name",
@ -82,7 +82,7 @@
{
"id": "The backend {Backend} requires authentication. Authenticate now?",
"message": "The backend {Backend} requires authentication. Authenticate now?",
"translation": "",
"translation": "Das Backend {Backend} erfordert Authentifizierung. Jetzt authentifizieren?",
"placeholders": [
{
"id": "Backend",
@ -97,7 +97,7 @@
{
"id": "Delete the service configuration \"{Service}\"?",
"message": "Delete the service configuration \"{Service}\"?",
"translation": "",
"translation": "Die Servicekonfiguration „{Service}“ löschen?",
"placeholders": [
{
"id": "Service",
@ -112,12 +112,12 @@
{
"id": "Aborted",
"message": "Aborted",
"translation": ""
"translation": "Abgebrochen"
},
{
"id": "Service \"{Name}\" deleted",
"message": "Service \"{Name}\" deleted",
"translation": "",
"translation": "Service „{Name}“ gelöscht",
"placeholders": [
{
"id": "Name",
@ -132,7 +132,7 @@
{
"id": "Updated service {Name} using backend {Backend}",
"message": "Updated service {Name} using backend {Backend}",
"translation": "",
"translation": "Service {Name} mit dem Backend {Backend} aktualisiert",
"placeholders": [
{
"id": "Name",
@ -155,7 +155,7 @@
{
"id": "backend: {Backend}",
"message": "backend: {Backend}",
"translation": "",
"translation": "Backend: {Backend}",
"placeholders": [
{
"id": "Backend",
@ -170,12 +170,12 @@
{
"id": "Token received, you can close this window now.",
"message": "Token received, you can close this window now.",
"translation": ""
"translation": "Token erhalten, das Fenster kann jetzt geschlossen werden."
},
{
"id": "backend {Backend} does not implement {InterfaceName}",
"message": "backend {Backend} does not implement {InterfaceName}",
"translation": "",
"translation": "das backend {Backend} implementiert {InterfaceName} nicht",
"placeholders": [
{
"id": "Backend",
@ -198,7 +198,7 @@
{
"id": "unknown backend \"{BackendName}\"",
"message": "unknown backend \"{BackendName}\"",
"translation": "",
"translation": "unbekanntes Backend „{BackendName}“",
"placeholders": [
{
"id": "BackendName",
@ -213,62 +213,126 @@
{
"id": "Client ID",
"message": "Client ID",
"translation": ""
"translation": "Client-ID"
},
{
"id": "Client secret",
"message": "Client secret",
"translation": ""
"translation": "Client-Secret"
},
{
"id": "Server URL",
"message": "Server URL",
"translation": ""
"translation": "Server-URL"
},
{
"id": "User name",
"message": "User name",
"translation": ""
"translation": "Benutzername"
},
{
"id": "Access token",
"message": "Access token",
"translation": ""
"translation": "Zugriffstoken"
},
{
"id": "File path",
"message": "File path",
"translation": ""
},
{
"id": "Playlist title",
"message": "Playlist title",
"translation": ""
},
{
"id": "Unique playlist identifier",
"message": "Unique playlist identifier",
"translation": ""
},
{
"id": "Disable auto correction of submitted listens",
"message": "Disable auto correction of submitted listens",
"translation": ""
},
{
"id": "Include skipped listens",
"message": "Include skipped listens",
"translation": ""
"translation": "Dateipfad"
},
{
"id": "Append to file",
"message": "Append to file",
"translation": ""
"translation": "An Datei anhängen"
},
{
"id": "Playlist title",
"message": "Playlist title",
"translation": "Titel der Playlist"
},
{
"id": "Unique playlist identifier",
"message": "Unique playlist identifier",
"translation": "Eindeutige Playlist-ID"
},
{
"id": "Check for duplicate listens on import (slower)",
"message": "Check for duplicate listens on import (slower)",
"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": "",
"placeholders": [
{
"id": "ListenedAt",
"string": "%[1]v",
"type": "time.Time",
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
"argNum": 1,
"expr": "l.ListenedAt"
},
{
"id": "TrackName",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "l.TrackName"
},
{
"id": "ArtistName",
"string": "%[3]v",
"type": "string",
"underlyingType": "string",
"argNum": 3,
"expr": "l.ArtistName()"
},
{
"id": "RecordingMBID",
"string": "%[4]v",
"type": "go.uploadedlobster.com/mbtypes.MBID",
"underlyingType": "string",
"argNum": 4,
"expr": "l.RecordingMBID"
}
]
},
{
"id": "Disable auto correction of submitted listens",
"message": "Disable auto correction of submitted listens",
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
},
{
"id": "Include skipped listens",
"message": "Include skipped listens",
"translation": "Übersprungene Titel einbeziehen"
},
{
"id": "Directory path",
"message": "Directory path",
"translation": "Verzeichnispfad"
},
{
"id": "Ignore listens in incognito mode",
"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": "",
"translation": "URL für Autorisierung öffnen: {Url}",
"placeholders": [
{
"id": "Url",
@ -283,12 +347,12 @@
{
"id": "Error: OAuth state mismatch",
"message": "Error: OAuth state mismatch",
"translation": ""
"translation": "Fehler: OAuth-State stimmt nicht überein"
},
{
"id": "Access token received, you can use {Name} now.",
"message": "Access token received, you can use {Name} now.",
"translation": "",
"translation": "Zugriffstoken erhalten, {Name} kann jetzt verwendet werden.",
"placeholders": [
{
"id": "Name",
@ -324,27 +388,27 @@
{
"id": "Yes",
"message": "Yes",
"translation": ""
"translation": "Ja"
},
{
"id": "No",
"message": "No",
"translation": ""
"translation": "Nein"
},
{
"id": "no existing service configurations",
"message": "no existing service configurations",
"translation": ""
"translation": "keine bestehenden Servicekonfigurationen"
},
{
"id": "Service",
"message": "Service",
"translation": ""
"translation": "Service"
},
{
"id": "Backend",
"message": "Backend",
"translation": ""
"translation": "Backend"
},
{
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
@ -354,7 +418,7 @@
{
"id": "Entity",
"string": "%[1]s",
"type": "string",
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
"underlyingType": "string",
"argNum": 1,
"expr": "c.entity"
@ -380,7 +444,7 @@
{
"id": "From timestamp: {Arg_1} ({Arg_2})",
"message": "From timestamp: {Arg_1} ({Arg_2})",
"translation": "",
"translation": "Ab Zeitstempel: {Arg_1} ({Arg_2})",
"placeholders": [
{
"id": "Arg_1",
@ -401,7 +465,7 @@
{
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translation": "",
"translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})",
"placeholders": [
{
"id": "Arg_1",
@ -443,7 +507,7 @@
{
"id": "Entity",
"string": "%[3]s",
"type": "string",
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
"underlyingType": "string",
"argNum": 3,
"expr": "c.entity"
@ -459,29 +523,52 @@
]
},
{
"id": "During the import the following errors occurred:",
"message": "During the import the following errors occurred:",
"translation": "Während des Imports sind folgende Fehler aufgetreten:"
"id": "Import log:",
"message": "Import log:",
"translation": "Importlog:"
},
{
"id": "Error: {Err}",
"message": "Error: {Err}",
"translation": "Fehler: {Err}",
"id": "{Type}: {Message}",
"message": "{Type}: {Message}",
"translation": "{Type}: {Message}",
"placeholders": [
{
"id": "Err",
"id": "Type",
"string": "%[1]v",
"type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType",
"underlyingType": "string",
"argNum": 1,
"expr": "entry.Type"
},
{
"id": "Message",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "entry.Message"
}
]
},
{
"id": "invalid timestamp string \"{FlagValue}\"",
"message": "invalid timestamp string \"{FlagValue}\"",
"translation": "ungültiger Zeitstempel „{FlagValue}“",
"placeholders": [
{
"id": "FlagValue",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "err"
"expr": "flagValue"
}
]
},
{
"id": "Latest timestamp: {Arg_1} ({Arg_2})",
"message": "Latest timestamp: {Arg_1} ({Arg_2})",
"translation": "",
"translation": "Letzter Zeitstempel: {Arg_1} ({Arg_2})",
"placeholders": [
{
"id": "Arg_1",
@ -502,17 +589,17 @@
{
"id": "no configuration file defined, cannot write config",
"message": "no configuration file defined, cannot write config",
"translation": ""
"translation": "keine Konfigurationsdatei definiert, Konfiguration kann nicht geschrieben werden"
},
{
"id": "key must only consist of A-Za-z0-9_-",
"message": "key must only consist of A-Za-z0-9_-",
"translation": ""
"translation": "Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten"
},
{
"id": "no service configuration \"{Name}\"",
"message": "no service configuration \"{Name}\"",
"translation": "",
"translation": "keine Servicekonfiguration „{Name}“",
"placeholders": [
{
"id": "Name",

View file

@ -1,6 +1,439 @@
{
"language": "en",
"messages": [
{
"id": "export: {ExportCapabilities__}",
"message": "export: {ExportCapabilities__}",
"translation": "export: {ExportCapabilities__}",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "ExportCapabilities__",
"string": "%[1]s",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "strings.Join(info.ExportCapabilities, \", \")"
}
],
"fuzzy": true
},
{
"id": "import: {ImportCapabilities__}",
"message": "import: {ImportCapabilities__}",
"translation": "import: {ImportCapabilities__}",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "ImportCapabilities__",
"string": "%[1]s",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "strings.Join(info.ImportCapabilities, \", \")"
}
],
"fuzzy": true
},
{
"id": "Failed reading config: {Err}",
"message": "Failed reading config: {Err}",
"translation": "Failed reading config: {Err}",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Err",
"string": "%[1]v",
"type": "error",
"underlyingType": "interface{Error() string}",
"argNum": 1,
"expr": "err"
}
],
"fuzzy": true
},
{
"id": "Service name",
"message": "Service name",
"translation": "Service name",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "a service with this name already exists",
"message": "a service with this name already exists",
"translation": "a service with this name already exists",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Saved service {Name} using backend {Backend}",
"message": "Saved service {Name} using backend {Backend}",
"translation": "Saved service {Name} using backend {Backend}",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Name",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "service.Name"
},
{
"id": "Backend",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "service.Backend"
}
],
"fuzzy": true
},
{
"id": "The backend {Backend} requires authentication. Authenticate now?",
"message": "The backend {Backend} requires authentication. Authenticate now?",
"translation": "The backend {Backend} requires authentication. Authenticate now?",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Backend",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "service.Backend"
}
],
"fuzzy": true
},
{
"id": "Delete the service configuration \"{Service}\"?",
"message": "Delete the service configuration \"{Service}\"?",
"translation": "Delete the service configuration \"{Service}\"?",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Service",
"string": "%[1]v",
"type": "go.uploadedlobster.com/scotty/internal/config.ServiceConfig",
"underlyingType": "struct{Name string; Backend string; ConfigValues map[string]any}",
"argNum": 1,
"expr": "service"
}
],
"fuzzy": true
},
{
"id": "Aborted",
"message": "Aborted",
"translation": "Aborted",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Service \"{Name}\" deleted",
"message": "Service \"{Name}\" deleted",
"translation": "Service \"{Name}\" deleted",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Name",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "service.Name"
}
],
"fuzzy": true
},
{
"id": "Updated service {Name} using backend {Backend}",
"message": "Updated service {Name} using backend {Backend}",
"translation": "Updated service {Name} using backend {Backend}",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Name",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "service.Name"
},
{
"id": "Backend",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "service.Backend"
}
],
"fuzzy": true
},
{
"id": "backend: {Backend}",
"message": "backend: {Backend}",
"translation": "backend: {Backend}",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Backend",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "s.Backend"
}
],
"fuzzy": true
},
{
"id": "Token received, you can close this window now.",
"message": "Token received, you can close this window now.",
"translation": "Token received, you can close this window now.",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "backend {Backend} does not implement {InterfaceName}",
"message": "backend {Backend} does not implement {InterfaceName}",
"translation": "backend {Backend} does not implement {InterfaceName}",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Backend",
"string": "%[1]s",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "config.Backend"
},
{
"id": "InterfaceName",
"string": "%[2]s",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "interfaceName"
}
],
"fuzzy": true
},
{
"id": "unknown backend \"{BackendName}\"",
"message": "unknown backend \"{BackendName}\"",
"translation": "unknown backend \"{BackendName}\"",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "BackendName",
"string": "%[1]s",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "backendName"
}
],
"fuzzy": true
},
{
"id": "Client ID",
"message": "Client ID",
"translation": "Client ID",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Client secret",
"message": "Client secret",
"translation": "Client secret",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Server URL",
"message": "Server URL",
"translation": "Server URL",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "User name",
"message": "User name",
"translation": "User name",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Access token",
"message": "Access token",
"translation": "Access token",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "File path",
"message": "File path",
"translation": "File path",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Append to file",
"message": "Append to file",
"translation": "Append to file",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Playlist title",
"message": "Playlist title",
"translation": "Playlist title",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Unique playlist identifier",
"message": "Unique playlist identifier",
"translation": "Unique playlist identifier",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Check for duplicate listens on import (slower)",
"message": "Check for duplicate listens on import (slower)",
"translation": "Check for duplicate listens on import (slower)",
"translatorComment": "Copied from source.",
"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})",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "ListenedAt",
"string": "%[1]v",
"type": "time.Time",
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
"argNum": 1,
"expr": "l.ListenedAt"
},
{
"id": "TrackName",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "l.TrackName"
},
{
"id": "ArtistName",
"string": "%[3]v",
"type": "string",
"underlyingType": "string",
"argNum": 3,
"expr": "l.ArtistName()"
},
{
"id": "RecordingMbid",
"string": "%[4]v",
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
"underlyingType": "string",
"argNum": 4,
"expr": "l.RecordingMbid"
}
],
"fuzzy": true
},
{
"id": "Disable auto correction of submitted listens",
"message": "Disable auto correction of submitted listens",
"translation": "Disable auto correction of submitted listens",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Include skipped listens",
"message": "Include skipped listens",
"translation": "Include skipped listens",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Directory path",
"message": "Directory path",
"translation": "Directory path",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Ignore listens in incognito mode",
"message": "Ignore listens in incognito mode",
"translation": "Ignore listens in incognito mode",
"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)",
"translation": "Minimum playback duration for skipped tracks (seconds)",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"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",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "authUrl.Url"
}
],
"fuzzy": true
},
{
"id": "Error: OAuth state mismatch",
"message": "Error: OAuth state mismatch",
"translation": "Error: OAuth state mismatch",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Access token received, you can use {Name} now.",
"message": "Access token received, you can use {Name} now.",
"translation": "Access token received, you can use {Name} now.",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Name",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "service.Name"
}
],
"fuzzy": true
},
{
"id": "exporting",
"message": "exporting",
@ -22,6 +455,41 @@
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Yes",
"message": "Yes",
"translation": "Yes",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "No",
"message": "No",
"translation": "No",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "no existing service configurations",
"message": "no existing service configurations",
"translation": "no existing service configurations",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Service",
"message": "Service",
"translation": "Service",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Backend",
"message": "Backend",
"translation": "Backend",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
@ -31,7 +499,7 @@
{
"id": "Entity",
"string": "%[1]s",
"type": "string",
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
"underlyingType": "string",
"argNum": 1,
"expr": "c.entity"
@ -56,51 +524,47 @@
"fuzzy": true
},
{
"id": "From timestamp: {Timestamp} ({Unix})",
"message": "From timestamp: {Timestamp} ({Unix})",
"translation": "From timestamp: {Timestamp} ({Unix})",
"id": "From timestamp: {Arg_1} ({Arg_2})",
"message": "From timestamp: {Arg_1} ({Arg_2})",
"translation": "From timestamp: {Arg_1} ({Arg_2})",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Timestamp",
"id": "Arg_1",
"string": "%[1]v",
"type": "time.Time",
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
"argNum": 1,
"expr": "timestamp"
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Unix",
"id": "Arg_2",
"string": "%[2]v",
"type": "int64",
"underlyingType": "int64",
"argNum": 2,
"expr": "timestamp.Unix()"
"type": "",
"underlyingType": "interface{}",
"argNum": 2
}
],
"fuzzy": true
},
{
"id": "Import failed, last reported timestamp was {LastTimestamp} ({Unix})",
"message": "Import failed, last reported timestamp was {LastTimestamp} ({Unix})",
"translation": "Import failed, last reported timestamp was {LastTimestamp} ({Unix})",
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translation": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "LastTimestamp",
"id": "Arg_1",
"string": "%[1]v",
"type": "time.Time",
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
"argNum": 1,
"expr": "result.LastTimestamp"
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Unix",
"string": "%[2]v",
"type": "int64",
"underlyingType": "int64",
"argNum": 2,
"expr": "result.LastTimestamp.Unix()"
"id": "Arg_2",
"string": "%[2]s",
"type": "",
"underlyingType": "string",
"argNum": 2
}
],
"fuzzy": true
@ -130,7 +594,7 @@
{
"id": "Entity",
"string": "%[3]s",
"type": "string",
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
"underlyingType": "string",
"argNum": 3,
"expr": "c.entity"
@ -147,50 +611,104 @@
"fuzzy": true
},
{
"id": "During the import the following errors occurred:",
"message": "During the import the following errors occurred:",
"translation": "During the import the following errors occurred:",
"id": "Import log:",
"message": "Import log:",
"translation": "Import log:",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Error: {Err}",
"message": "Error: {Err}",
"translation": "Error: {Err}",
"id": "{Type}: {Message}",
"message": "{Type}: {Message}",
"translation": "{Type}: {Message}",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Err",
"id": "Type",
"string": "%[1]v",
"type": "string",
"type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType",
"underlyingType": "string",
"argNum": 1,
"expr": "err"
"expr": "entry.Type"
},
{
"id": "Message",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "entry.Message"
}
],
"fuzzy": true
},
{
"id": "Latest timestamp: {LastTimestamp} ({Unix})",
"message": "Latest timestamp: {LastTimestamp} ({Unix})",
"translation": "Latest timestamp: {LastTimestamp} ({Unix})",
"id": "invalid timestamp string \"{FlagValue}\"",
"message": "invalid timestamp string \"{FlagValue}\"",
"translation": "invalid timestamp string \"{FlagValue}\"",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "LastTimestamp",
"id": "FlagValue",
"string": "%[1]v",
"type": "time.Time",
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "result.LastTimestamp"
"expr": "flagValue"
}
],
"fuzzy": true
},
{
"id": "Unix",
"id": "Latest timestamp: {Arg_1} ({Arg_2})",
"message": "Latest timestamp: {Arg_1} ({Arg_2})",
"translation": "Latest timestamp: {Arg_1} ({Arg_2})",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Arg_1",
"string": "%[1]v",
"type": "",
"underlyingType": "interface{}",
"argNum": 1
},
{
"id": "Arg_2",
"string": "%[2]v",
"type": "int64",
"underlyingType": "int64",
"argNum": 2,
"expr": "result.LastTimestamp.Unix()"
"type": "",
"underlyingType": "interface{}",
"argNum": 2
}
],
"fuzzy": true
},
{
"id": "no configuration file defined, cannot write config",
"message": "no configuration file defined, cannot write config",
"translation": "no configuration file defined, cannot write config",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "key must only consist of A-Za-z0-9_-",
"message": "key must only consist of A-Za-z0-9_-",
"translation": "key must only consist of A-Za-z0-9_-",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "no service configuration \"{Name}\"",
"message": "no service configuration \"{Name}\"",
"translation": "no service configuration \"{Name}\"",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Name",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "name"
}
],
"fuzzy": true

View file

@ -282,6 +282,13 @@
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Append to file",
"message": "Append to file",
"translation": "Append to file",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Playlist title",
"message": "Playlist title",
@ -296,6 +303,54 @@
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Check for duplicate listens on import (slower)",
"message": "Check for duplicate listens on import (slower)",
"translation": "Check for duplicate listens on import (slower)",
"translatorComment": "Copied from source.",
"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})",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "ListenedAt",
"string": "%[1]v",
"type": "time.Time",
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
"argNum": 1,
"expr": "l.ListenedAt"
},
{
"id": "TrackName",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "l.TrackName"
},
{
"id": "ArtistName",
"string": "%[3]v",
"type": "string",
"underlyingType": "string",
"argNum": 3,
"expr": "l.ArtistName()"
},
{
"id": "RecordingMBID",
"string": "%[4]v",
"type": "go.uploadedlobster.com/mbtypes.MBID",
"underlyingType": "string",
"argNum": 4,
"expr": "l.RecordingMBID"
}
],
"fuzzy": true
},
{
"id": "Disable auto correction of submitted listens",
"message": "Disable auto correction of submitted listens",
@ -311,9 +366,30 @@
"fuzzy": true
},
{
"id": "Append to file",
"message": "Append to file",
"translation": "Append to file",
"id": "Directory path",
"message": "Directory path",
"translation": "Directory path",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Ignore listens in incognito mode",
"message": "Ignore listens in incognito mode",
"translation": "Ignore listens in incognito mode",
"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)",
"translation": "Minimum playback duration for skipped tracks (seconds)",
"translatorComment": "Copied from source.",
"fuzzy": true
},
@ -423,7 +499,7 @@
{
"id": "Entity",
"string": "%[1]s",
"type": "string",
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
"underlyingType": "string",
"argNum": 1,
"expr": "c.entity"
@ -518,7 +594,7 @@
{
"id": "Entity",
"string": "%[3]s",
"type": "string",
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
"underlyingType": "string",
"argNum": 3,
"expr": "c.entity"
@ -535,25 +611,50 @@
"fuzzy": true
},
{
"id": "During the import the following errors occurred:",
"message": "During the import the following errors occurred:",
"translation": "During the import the following errors occurred:",
"id": "Import log:",
"message": "Import log:",
"translation": "Import log:",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "Error: {Err}",
"message": "Error: {Err}",
"translation": "Error: {Err}",
"id": "{Type}: {Message}",
"message": "{Type}: {Message}",
"translation": "{Type}: {Message}",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "Err",
"id": "Type",
"string": "%[1]v",
"type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType",
"underlyingType": "string",
"argNum": 1,
"expr": "entry.Type"
},
{
"id": "Message",
"string": "%[2]v",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "entry.Message"
}
],
"fuzzy": true
},
{
"id": "invalid timestamp string \"{FlagValue}\"",
"message": "invalid timestamp string \"{FlagValue}\"",
"translation": "invalid timestamp string \"{FlagValue}\"",
"translatorComment": "Copied from source.",
"placeholders": [
{
"id": "FlagValue",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "err"
"expr": "flagValue"
}
],
"fuzzy": true

View file

@ -6,4 +6,4 @@ package are published under the conditions of CC0 1.0 Universal (CC0 1.0)
package translations
//go:generate gotext -srclang=en update -out=catalog.go -lang=en,de go.uploadedlobster.com/scotty
//go:generate go tool gotext -srclang=en update -out=catalog.go -lang=en,de go.uploadedlobster.com/scotty

34
internal/util/util.go Normal file
View file

@ -0,0 +1,34 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
Scotty. If not, see <https://www.gnu.org/licenses/>.
*/
package util
import "golang.org/x/exp/constraints"
func Sum[T constraints.Integer | constraints.Float](v ...T) T {
var sum T
for _, i := range v {
sum += i
}
return sum
}
func Average[T constraints.Integer | constraints.Float](v ...T) float64 {
length := len(v)
if length == 0 {
return 0.0
}
return float64(Sum(v...)) / float64(length)
}

View file

@ -0,0 +1,52 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
Scotty. If not, see <https://www.gnu.org/licenses/>.
*/
package util_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"go.uploadedlobster.com/scotty/internal/util"
)
func ExampleSum() {
values := []float64{1.4, 2.2}
sum := util.Sum(values...)
fmt.Print(sum)
// Output: 3.6
}
func TestSumEmpty(t *testing.T) {
assert.Equal(t, 0, util.Sum([]int{}...))
}
func ExampleAverage() {
values := []float64{1.4, 2.2, 0.9}
sum := util.Average(values...)
fmt.Print(sum)
// Output: 1.5
}
func TestAverageEmpty(t *testing.T) {
assert.Equal(t, 0.0, util.Average([]int{}...))
}
func TestAverageInt(t *testing.T) {
assert := assert.New(t)
assert.Equal(3.0, util.Average([]int{2, 4, 3}...))
assert.Equal(1.5, util.Average([]int{2, 1, 1, 2}...))
}

View file

@ -1,5 +1,5 @@
/*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Copyright © 2023-2024 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.3.1"
AppVersion = "0.4.1"
)
func UserAgent() string {

View file

@ -29,6 +29,15 @@ const (
MaxWaitTimeSeconds = 60
)
// Implements rate HTTP header based limiting for resty.
//
// This works with servers that return the status code 429 (Too Many Requests)
// and an HTTP header indicating the time in seconds until rate limit resets.
// Common headers used are "X-RateLimit-Reset-In" or "Retry-After".
//
// Usage:
//
// ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
func EnableHTTPHeaderRateLimit(client *resty.Client, resetInHeader string) {
client.SetRetryCount(RetryCount)
client.AddRetryCondition(