diff --git a/.build.yml b/.build.yml index 8be4e81..a5d2238 100644 --- a/.build.yml +++ b/.build.yml @@ -5,10 +5,11 @@ packages: - hut - weblate-wlc secrets: - - 2a17e258-3e99-4093-9527-832c350d9c53 + - 0e2ad815-6c46-4cea-878e-70fc33f71e77 oauth: pages.sr.ht/PAGES:RW tasks: - weblate-update: | + cd scotty wlc --format text pull scotty - test: | cd scotty @@ -28,5 +29,15 @@ tasks: - publish-redirect: | # Update redirect on https://go.uploadedlobster.com/scotty ./scotty/pages/publish.sh + # Skip releasing if this is not a tagged release + - only-tags: | + cd scotty + GIT_REF=$(git describe --always) + [[ "$GIT_REF" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || complete-build + - announce-release: | + # Announce new release to Go Module Index + cd scotty + VERSION=$(git describe --exact-match) + curl "https://proxy.golang.org/go.uploadedlobster.com/scotty/@v/${VERSION}.info" artifacts: - scotty/dist/artifacts.tar diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 06b612a..1a1e0ba 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,7 +6,7 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json # vim: set ts=2 sw=2 tw=0 fo=cnqoj -version: 1 +version: 2 before: hooks: @@ -21,6 +21,8 @@ builds: - windows - darwin ignore: + - goos: linux + goarch: "386" - goos: windows goarch: "386" @@ -28,7 +30,7 @@ universal_binaries: - replace: true archives: - - format: tar.gz + - formats: ['tar.gz'] # this name template makes the OS and Arch compatible with the results of `uname`. name_template: >- {{ .ProjectName }}-{{ .Version }}_ @@ -42,7 +44,7 @@ archives: # use zip for windows archives format_overrides: - goos: windows - format: zip + formats: ['zip'] files: - COPYING - README.md diff --git a/.weblate b/.weblate new file mode 100644 index 0000000..9c9511e --- /dev/null +++ b/.weblate @@ -0,0 +1,3 @@ +[weblate] +url = https://translate.uploadedlobster.com/api/ +translation = scotty/app diff --git a/CHANGES.md b/CHANGES.md index d3ee1d7..7324f77 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,20 @@ # Scotty Changelog +## 0.5.0 - 2025-04-29 +- ListenBrainz: handle missing loves metadata in case of merged recordings +- ListenBrainz: fix loves import loading all existing loves +- ListenBrainz: fixed progress for loves import +- ListenBrainz: log missing recording MBID on love import +- Subsonic: support OpenSubsonic fields for recording MBID and genres (#5) +- Subsonic: fixed progress for loves export +- scrobblerlog: add "time-zone" config option (#6). +- scrobblerlog: fixed progress for listen export +- scrobblerlog: renamed setting `include-skipped` to `ignore-skipped`. + +Note: 386 builds for Linux are not available with this release due to an +incompatibility with latest version of gorm. + + ## 0.4.1 - 2024-09-16 - Subsonic: include `subsonic_id` as additional metadata - Deezer: fix artist and album ID URIs (#7) diff --git a/config.example.toml b/config.example.toml index 6a5eb88..6b81bac 100644 --- a/config.example.toml +++ b/config.example.toml @@ -56,11 +56,18 @@ backend = "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), ignore listens marked as skipped. +ignore-skipped = true # If true (default), new listens will be appended to the existing file. Set to # false to overwrite the file and create a new scrobbler log on every run. append = true +# Specify the time zone of the listens in the scrobbler log. While the log files +# are supposed to contain Unix timestamps, which are always in UTC, the player +# writing the log might not be time zone aware. This can cause the timestamps +# to be in a different time zone. Use the time-zone setting to specify a +# different time zone, e.g. "Europe/Berlin" or "America/New_York". +# The default is UTC. +time-zone = "UTC" [service.jspf] # Write listens and loves to JSPF playlist files (https://xspf.org/jspf) @@ -98,9 +105,9 @@ dir-path = "./my_spotify_data_extended/Spotify Extended Streaming Histor 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. +# Only consider skipped listens with a playback duration longer than or equal to +# this number of seconds. Default is 30 seconds. If ignore-skipped is enabled +# this setting has no effect. ignore-min-duration-seconds = 30 [service.deezer] diff --git a/go.mod b/go.mod index 5a67610..ef1286c 100644 --- a/go.mod +++ b/go.mod @@ -1,31 +1,33 @@ module go.uploadedlobster.com/scotty -go 1.22.0 +go 1.23.0 -toolchain go1.22.2 +toolchain go1.24.2 require ( - github.com/Xuanwo/go-locale v1.1.2 - github.com/agnivade/levenshtein v1.1.1 + 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-20240806025900-2a743ec36238 - github.com/fatih/color v1.17.0 + github.com/fatih/color v1.18.0 github.com/glebarez/sqlite v1.11.0 - github.com/go-resty/resty/v2 v2.15.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.2.3 + github.com/pelletier/go-toml/v2 v2.2.4 github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 - github.com/spf13/cast v1.7.0 - github.com/spf13/cobra v1.8.1 - github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.9.0 - github.com/vbauerster/mpb/v8 v8.8.3 - golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 - golang.org/x/oauth2 v0.23.0 - golang.org/x/text v0.18.0 - gorm.io/datatypes v1.2.2 - gorm.io/gorm v1.25.12 + 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/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d + github.com/vbauerster/mpb/v8 v8.9.3 + go.uploadedlobster.com/mbtypes v0.4.0 + go.uploadedlobster.com/musicbrainzws2 v0.14.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.26.0 ) require ( @@ -35,37 +37,39 @@ require ( 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.7.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.8.1 // 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/hashicorp/hcl v1.0.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.16 // indirect - github.com/mitchellh/mapstructure v1.5.0 // 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.7 // indirect - github.com/sagikazarmark/locafero v0.6.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.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/multierr v1.11.0 // indirect - golang.org/x/net v0.29.0 // indirect - golang.org/x/sys v0.25.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + golang.org/x/image v0.26.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.5.7 // indirect - modernc.org/libc v1.60.1 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.8.0 // indirect - modernc.org/sqlite v1.33.1 // indirect + modernc.org/libc v1.64.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.10.0 // indirect + modernc.org/sqlite v1.37.0 // indirect ) + +tool golang.org/x/text/cmd/gotext diff --git a/go.sum b/go.sum index 2fb0b5a..8ade87a 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,12 @@ 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.2 h1:6H+olvrQcyVOZ+GAC2rXu4armacTT4ZrFCA0mB24XVo= -github.com/Xuanwo/go-locale v1.1.2/go.mod h1:1JBER4QV7Ji39GJ4AvVlfvqmTUqopzxQxdg2mXYOw94= +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/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= -github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +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= @@ -23,105 +21,78 @@ 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/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.4/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-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= -github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +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.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +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.12.0 h1:rsVL8P90LFvkUYq/V5BTVe203WfRIU4gvcf+yfzJzGA= -github.com/go-resty/resty/v2 v2.12.0/go.mod h1:o0yGPrkS3lOe1+eFajk6kBW8ScXzwU3hD69/gt2yB/0= -github.com/go-resty/resty/v2 v2.15.0 h1:clPQLZ2x9h4yGY81IzpMPnty+xoGyFaDg0XMkCsHf90= -github.com/go-resty/resty/v2 v2.15.0/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= +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.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +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/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -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/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +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/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/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 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/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= -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/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.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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-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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/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.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= -github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -132,193 +103,132 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc 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.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= -github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= -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.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= -github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -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.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= -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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -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/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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.7.3 h1:n/mKPBav4FFWp5fH4U0lPpXfiOmCEgl5Yx/NM3tKJA0= -github.com/vbauerster/mpb/v8 v8.7.3/go.mod h1:9nFlNpDGVoTmQ4QvNjSLtwLmAFjwmq0XaAF26toHGNM= -github.com/vbauerster/mpb/v8 v8.8.3 h1:dTOByGoqwaTJYPubhVz3lO5O6MK553XVgUo33LdnNsQ= -github.com/vbauerster/mpb/v8 v8.8.3/go.mod h1:JfCCrtcMsJwP6ZwMn9e5LMnNyp3TVNpUWWkN+nd4EWk= +github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4= +github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4= +github.com/vbauerster/mpb/v8 v8.9.3 h1:PnMeF+sMvYv9u23l6DO6Q3+Mdj408mjLRXIzmUmU2Z8= +github.com/vbauerster/mpb/v8 v8.9.3/go.mod h1:hxS8Hz4C6ijnppDSIX6LjG8FYJSoPo9iIOcE53Zik0c= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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= +go.uploadedlobster.com/musicbrainzws2 v0.14.0 h1:YaEtxNwLSNT1gzFipQ4XlaThNfXjBpzzb4I6WhIeUwg= +go.uploadedlobster.com/musicbrainzws2 v0.14.0/go.mod h1:T6sYE7ZHRH3mJWT3g9jdSUPKJLZubnBjKyjMPNdkgao= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc= -golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +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/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk= +golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= +golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= 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/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +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.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= -golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= -golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +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.0.0-20190423024810-112230192c58/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/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +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-20201119102817-f84b799fce68/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-20220310020820-b874c991c1a5/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.5.0/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.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 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/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= -golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= -golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -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/datatypes v1.2.2 h1:sdn7ZmG4l7JWtMDUb3L98f2Ym7CO5F8mZLlrQJMfF9g= -gorm.io/datatypes v1.2.2/go.mod h1:f4BsLcFAX67szSv8svwLRjklArSHAvHLeE3pXAS5DZI= -gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= -gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +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/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.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8= -gorm.io/gorm v1.25.9/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.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= -modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= -modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA= -modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI= -modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4= -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.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= -modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= -modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= -modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= -modernc.org/libc v1.60.1 h1:at373l8IFRTkJIkAU85BIuUoBM4T1b51ds0E1ovPG2s= -modernc.org/libc v1.60.1/go.mod h1:xJuobKuNxKH3RUatS7GjR+suWj+5c2K7bi4m/S5arOY= -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.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4= -modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= -modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= -modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +gorm.io/gorm v1.26.0 h1:9lqQVPG5aNNS6AyHdRiwScAVnXHg/L/Srzx55G5fOgs= +gorm.io/gorm v1.26.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= +modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= +modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +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.64.0 h1:U0k8BD2d3cD3e9I8RLcZgJBHAcsJzbXx5mKGSb5pyJA= +modernc.org/libc v1.64.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= +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.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= +modernc.org/memory v1.10.0/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= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 5ba05af..84c85b6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -27,7 +27,7 @@ type OAuth2Authenticator interface { models.Backend // Returns OAuth2 config suitable for this backend - OAuth2Strategy(redirectUrl *url.URL) OAuth2Strategy + OAuth2Strategy(redirectURL *url.URL) OAuth2Strategy // Setup the OAuth2 client OAuth2Setup(token oauth2.TokenSource) error diff --git a/internal/auth/strategy.go b/internal/auth/strategy.go index 3d03fa4..7e3c265 100644 --- a/internal/auth/strategy.go +++ b/internal/auth/strategy.go @@ -24,14 +24,14 @@ import ( type OAuth2Strategy interface { Config() oauth2.Config - AuthCodeURL(verifier string, state string) AuthUrl + AuthCodeURL(verifier string, state string) AuthURL ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error) } -type AuthUrl struct { +type AuthURL struct { // The URL the user must visit to approve access - Url string + URL string // Random state string passed on to the callback. // Leave empty if the service does not support state. State string @@ -56,10 +56,10 @@ func (s StandardStrategy) Config() oauth2.Config { return s.conf } -func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthUrl { +func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthURL { url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) - return AuthUrl{ - Url: url, + return AuthURL{ + URL: url, State: state, Param: "code", } diff --git a/internal/backends/backends.go b/internal/backends/backends.go index e4cbbc9..a9c3292 100644 --- a/internal/backends/backends.go +++ b/internal/backends/backends.go @@ -123,7 +123,11 @@ func backendWithConfig(config config.ServiceConfig) (models.Backend, error) { if err != nil { return nil, err } - return backend.FromConfig(&config), nil + err = backend.InitConfig(&config) + if err != nil { + return nil, err + } + return backend, nil } func ImplementsInterface[T interface{}](backend *models.Backend) (bool, string) { diff --git a/internal/backends/deezer/auth.go b/internal/backends/deezer/auth.go index aa30b04..0304dec 100644 --- a/internal/backends/deezer/auth.go +++ b/internal/backends/deezer/auth.go @@ -33,10 +33,10 @@ func (s deezerStrategy) Config() oauth2.Config { return s.conf } -func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl { +func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL { url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) - return auth.AuthUrl{ - Url: url, + return auth.AuthURL{ + URL: url, State: state, Param: "code", } diff --git a/internal/backends/deezer/client.go b/internal/backends/deezer/client.go index 3c3b740..05264ae 100644 --- a/internal/backends/deezer/client.go +++ b/internal/backends/deezer/client.go @@ -36,7 +36,7 @@ const MaxItemsPerGet = 1000 const DefaultRateLimitWaitSeconds = 5 type Client struct { - HttpClient *resty.Client + HTTPClient *resty.Client token oauth2.TokenSource } @@ -47,7 +47,7 @@ func NewClient(token oauth2.TokenSource) Client { client.SetHeader("User-Agent", version.UserAgent()) client.SetRetryCount(5) return Client{ - HttpClient: client, + HTTPClient: client, token: token, } } @@ -73,7 +73,7 @@ func (c Client) setToken(req *resty.Request) error { } func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) { - request := c.HttpClient.R(). + request := c.HTTPClient.R(). SetQueryParams(map[string]string{ "index": strconv.Itoa(offset), "limit": strconv.Itoa(limit), diff --git a/internal/backends/deezer/client_test.go b/internal/backends/deezer/client_test.go index f8240f8..c90b01a 100644 --- a/internal/backends/deezer/client_test.go +++ b/internal/backends/deezer/client_test.go @@ -44,7 +44,7 @@ func TestGetUserHistory(t *testing.T) { token := oauth2.StaticTokenSource(&oauth2.Token{}) client := deezer.NewClient(token) - setupHttpMock(t, client.HttpClient.GetClient(), + setupHTTPMock(t, client.HTTPClient.GetClient(), "https://api.deezer.com/user/me/history", "testdata/user-history.json") @@ -65,7 +65,7 @@ func TestGetUserTracks(t *testing.T) { token := oauth2.StaticTokenSource(&oauth2.Token{}) client := deezer.NewClient(token) - setupHttpMock(t, client.HttpClient.GetClient(), + setupHTTPMock(t, client.HTTPClient.GetClient(), "https://api.deezer.com/user/me/tracks", "testdata/user-tracks.json") @@ -81,7 +81,7 @@ func TestGetUserTracks(t *testing.T) { assert.Equal("Outland", track1.Track.Album.Title) } -func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { +func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) { httpmock.ActivateNonDefault(client) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) diff --git a/internal/backends/deezer/deezer.go b/internal/backends/deezer/deezer.go index 3131c3e..7b6e1ad 100644 --- a/internal/backends/deezer/deezer.go +++ b/internal/backends/deezer/deezer.go @@ -31,7 +31,7 @@ import ( type DeezerApiBackend struct { client Client - clientId string + clientID string clientSecret string } @@ -49,20 +49,20 @@ func (b *DeezerApiBackend) Options() []models.BackendOption { }} } -func (b *DeezerApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { - b.clientId = config.GetString("client-id") +func (b *DeezerApiBackend) InitConfig(config *config.ServiceConfig) error { + b.clientID = config.GetString("client-id") b.clientSecret = config.GetString("client-secret") - return b + return nil } -func (b *DeezerApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { +func (b *DeezerApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy { conf := oauth2.Config{ - ClientID: b.clientId, + ClientID: b.clientID, ClientSecret: b.clientSecret, Scopes: []string{ "offline_access,basic_access,listening_history", }, - RedirectURL: redirectUrl.String(), + RedirectURL: redirectURL.String(), Endpoint: oauth2.Endpoint{ AuthURL: "https://connect.deezer.com/oauth/auth.php", TokenURL: "https://connect.deezer.com/oauth/access_token.php", @@ -244,8 +244,8 @@ func (t Track) AsTrack() models.Track { info["music_service"] = "deezer.com" info["origin_url"] = t.Link info["deezer_id"] = t.Link - info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/album/%v", t.Album.Id) - info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/artist/%v", t.Artist.Id) + info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/album/%v", t.Album.ID) + info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/artist/%v", t.Artist.ID) return track } diff --git a/internal/backends/deezer/deezer_test.go b/internal/backends/deezer/deezer_test.go index 9550c0e..19776f4 100644 --- a/internal/backends/deezer/deezer_test.go +++ b/internal/backends/deezer/deezer_test.go @@ -35,13 +35,14 @@ var ( testTrack []byte ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("client-id", "someclientid") c.Set("client-secret", "someclientsecret") service := config.NewServiceConfig("test", c) - backend := (&deezer.DeezerApiBackend{}).FromConfig(&service) - assert.IsType(t, &deezer.DeezerApiBackend{}, backend) + backend := deezer.DeezerApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestListenAsListen(t *testing.T) { diff --git a/internal/backends/deezer/models.go b/internal/backends/deezer/models.go index 712cd73..85b569c 100644 --- a/internal/backends/deezer/models.go +++ b/internal/backends/deezer/models.go @@ -51,7 +51,7 @@ type HistoryResult struct { } type Track struct { - Id int `json:"id"` + ID int `json:"id"` Type string `json:"type"` Link string `json:"link"` Title string `json:"title"` @@ -75,7 +75,7 @@ type LovedTrack struct { } type Album struct { - Id int `json:"id"` + ID int `json:"id"` Type string `json:"type"` Link string `json:"link"` Title string `json:"title"` @@ -83,7 +83,7 @@ type Album struct { } type Artist struct { - Id int `json:"id"` + ID int `json:"id"` Type string `json:"type"` Link string `json:"link"` Name string `json:"name"` diff --git a/internal/backends/dump/dump.go b/internal/backends/dump/dump.go index eb342f2..70be12d 100644 --- a/internal/backends/dump/dump.go +++ b/internal/backends/dump/dump.go @@ -29,8 +29,8 @@ func (b *DumpBackend) Name() string { return "dump" } func (b *DumpBackend) Options() []models.BackendOption { return nil } -func (b *DumpBackend) FromConfig(config *config.ServiceConfig) models.Backend { - return b +func (b *DumpBackend) InitConfig(config *config.ServiceConfig) error { + return nil } func (b *DumpBackend) StartImport() error { return nil } @@ -41,7 +41,7 @@ func (b *DumpBackend) ImportListens(export models.ListensResult, importResult mo importResult.UpdateTimestamp(listen.ListenedAt) importResult.ImportCount += 1 msg := fmt.Sprintf("🎶 %v: \"%v\" by %v (%v)", - listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMbid) + listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMBID) importResult.Log(models.Info, msg) progress <- models.Progress{}.FromImportResult(importResult) } @@ -54,7 +54,7 @@ func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models importResult.UpdateTimestamp(love.Created) importResult.ImportCount += 1 msg := fmt.Sprintf("❤️ %v: \"%v\" by %v (%v)", - love.Created, love.TrackName, love.ArtistName(), love.RecordingMbid) + love.Created, love.TrackName, love.ArtistName(), love.RecordingMBID) importResult.Log(models.Info, msg) progress <- models.Progress{}.FromImportResult(importResult) } diff --git a/internal/backends/funkwhale/client.go b/internal/backends/funkwhale/client.go index 8f2848b..c231c94 100644 --- a/internal/backends/funkwhale/client.go +++ b/internal/backends/funkwhale/client.go @@ -33,13 +33,13 @@ import ( const MaxItemsPerGet = 50 type Client struct { - HttpClient *resty.Client + HTTPClient *resty.Client token string } -func NewClient(serverUrl string, token string) Client { +func NewClient(serverURL string, token string) Client { client := resty.New() - client.SetBaseURL(serverUrl) + client.SetBaseURL(serverURL) client.SetAuthScheme("Bearer") client.SetAuthToken(token) client.SetHeader("Accept", "application/json") @@ -49,14 +49,14 @@ func NewClient(serverUrl string, token string) Client { ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After") return Client{ - HttpClient: client, + HTTPClient: client, token: token, } } func (c Client) GetHistoryListenings(user string, page int, perPage int) (result ListeningsResult, err error) { const path = "/api/v1/history/listenings" - response, err := c.HttpClient.R(). + response, err := c.HTTPClient.R(). SetQueryParams(map[string]string{ "username": user, "page": strconv.Itoa(page), @@ -75,7 +75,7 @@ func (c Client) GetHistoryListenings(user string, page int, perPage int) (result func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksResult, err error) { const path = "/api/v1/favorites/tracks" - response, err := c.HttpClient.R(). + response, err := c.HTTPClient.R(). SetQueryParams(map[string]string{ "page": strconv.Itoa(page), "page_size": strconv.Itoa(perPage), diff --git a/internal/backends/funkwhale/client_test.go b/internal/backends/funkwhale/client_test.go index 89325cd..e850a4d 100644 --- a/internal/backends/funkwhale/client_test.go +++ b/internal/backends/funkwhale/client_test.go @@ -32,20 +32,20 @@ import ( ) func TestNewClient(t *testing.T) { - serverUrl := "https://funkwhale.example.com" + serverURL := "https://funkwhale.example.com" token := "foobar123" - client := funkwhale.NewClient(serverUrl, token) - assert.Equal(t, serverUrl, client.HttpClient.BaseURL) - assert.Equal(t, token, client.HttpClient.Token) + client := funkwhale.NewClient(serverURL, token) + assert.Equal(t, serverURL, client.HTTPClient.BaseURL) + assert.Equal(t, token, client.HTTPClient.Token) } func TestGetHistoryListenings(t *testing.T) { defer httpmock.DeactivateAndReset() - serverUrl := "https://funkwhale.example.com" + serverURL := "https://funkwhale.example.com" token := "thetoken" - client := funkwhale.NewClient(serverUrl, token) - setupHttpMock(t, client.HttpClient.GetClient(), + client := funkwhale.NewClient(serverURL, token) + setupHTTPMock(t, client.HTTPClient.GetClient(), "https://funkwhale.example.com/api/v1/history/listenings", "testdata/listenings.json") @@ -67,9 +67,9 @@ func TestGetFavoriteTracks(t *testing.T) { defer httpmock.DeactivateAndReset() token := "thetoken" - serverUrl := "https://funkwhale.example.com" - client := funkwhale.NewClient(serverUrl, token) - setupHttpMock(t, client.HttpClient.GetClient(), + serverURL := "https://funkwhale.example.com" + client := funkwhale.NewClient(serverURL, token) + setupHTTPMock(t, client.HTTPClient.GetClient(), "https://funkwhale.example.com/api/v1/favorites/tracks", "testdata/favorite-tracks.json") @@ -87,7 +87,7 @@ func TestGetFavoriteTracks(t *testing.T) { assert.Equal("phw", fav1.User.UserName) } -func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { +func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) { httpmock.ActivateNonDefault(client) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) diff --git a/internal/backends/funkwhale/funkwhale.go b/internal/backends/funkwhale/funkwhale.go index 05017d3..99bf43d 100644 --- a/internal/backends/funkwhale/funkwhale.go +++ b/internal/backends/funkwhale/funkwhale.go @@ -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" @@ -50,13 +51,13 @@ func (b *FunkwhaleApiBackend) Options() []models.BackendOption { }} } -func (b *FunkwhaleApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *FunkwhaleApiBackend) InitConfig(config *config.ServiceConfig) error { b.client = NewClient( config.GetString("server-url"), config.GetString("token"), ) b.username = config.GetString("username") - return b + return nil } func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { @@ -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, diff --git a/internal/backends/funkwhale/funkwhale_test.go b/internal/backends/funkwhale/funkwhale_test.go index 12b2b48..93ab97b 100644 --- a/internal/backends/funkwhale/funkwhale_test.go +++ b/internal/backends/funkwhale/funkwhale_test.go @@ -25,15 +25,15 @@ 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) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("token", "thetoken") service := config.NewServiceConfig("test", c) - backend := (&funkwhale.FunkwhaleApiBackend{}).FromConfig(&service) - assert.IsType(t, &funkwhale.FunkwhaleApiBackend{}, backend) + backend := funkwhale.FunkwhaleApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestFunkwhaleListeningAsListen(t *testing.T) { @@ -44,17 +44,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 +75,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 +89,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 +119,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"]) } diff --git a/internal/backends/funkwhale/models.go b/internal/backends/funkwhale/models.go index 6e0349e..faaae12 100644 --- a/internal/backends/funkwhale/models.go +++ b/internal/backends/funkwhale/models.go @@ -21,6 +21,8 @@ THE SOFTWARE. */ package funkwhale +import "go.uploadedlobster.com/mbtypes" + type ListeningsResult struct { Count int `json:"count"` Previous string `json:"previous"` @@ -29,7 +31,7 @@ type ListeningsResult struct { } type Listening struct { - Id int `json:"int"` + ID int `json:"int"` User User `json:"user"` Track Track `json:"track"` CreationDate string `json:"creation_date"` @@ -43,41 +45,41 @@ type FavoriteTracksResult struct { } type FavoriteTrack struct { - Id int `json:"int"` + ID int `json:"int"` User User `json:"user"` Track Track `json:"track"` CreationDate string `json:"creation_date"` } type Track struct { - Id int `json:"int"` - Artist Artist `json:"artist"` - Album Album `json:"album"` - Title string `json:"title"` - Position int `json:"position"` - DiscNumber int `json:"disc_number"` - RecordingMbid string `json:"mbid"` - Tags []string `json:"tags"` - Uploads []Upload `json:"uploads"` + ID int `json:"int"` + Artist Artist `json:"artist"` + Album Album `json:"album"` + Title string `json:"title"` + Position int `json:"position"` + DiscNumber int `json:"disc_number"` + RecordingMBID mbtypes.MBID `json:"mbid"` + Tags []string `json:"tags"` + Uploads []Upload `json:"uploads"` } type Artist struct { - Id int `json:"int"` - Name string `json:"name"` - ArtistMbid string `json:"mbid"` + ID int `json:"int"` + Name string `json:"name"` + ArtistMBID mbtypes.MBID `json:"mbid"` } type Album struct { - Id int `json:"int"` - Title string `json:"title"` - AlbumArtist Artist `json:"artist"` - ReleaseDate string `json:"release_date"` - TrackCount int `json:"track_count"` - ReleaseMbid string `json:"mbid"` + ID int `json:"int"` + Title string `json:"title"` + AlbumArtist Artist `json:"artist"` + ReleaseDate string `json:"release_date"` + TrackCount int `json:"track_count"` + ReleaseMBID mbtypes.MBID `json:"mbid"` } type User struct { - Id int `json:"int"` + ID int `json:"int"` UserName string `json:"username"` } diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index 17046e7..3e6866d 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -60,7 +60,7 @@ func (b *JSPFBackend) Options() []models.BackendOption { }} } -func (b *JSPFBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error { b.filePath = config.GetString("file-path") b.append = config.GetBool("append", true) b.playlist = jspf.Playlist{ @@ -69,13 +69,13 @@ func (b *JSPFBackend) FromConfig(config *config.ServiceConfig) models.Backend { Identifier: config.GetString("identifier"), Tracks: make([]jspf.Track, 0), Extension: map[string]any{ - jspf.MusicBrainzPlaylistExtensionId: jspf.MusicBrainzPlaylistExtension{ + jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{ LastModifiedAt: time.Now(), Public: true, }, }, } - return b + return nil } func (b *JSPFBackend) StartImport() error { @@ -116,10 +116,10 @@ func listenAsTrack(l models.Listen) jspf.Track { extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.ListenedAt extension.AddedBy = l.UserName - track.Extension[jspf.MusicBrainzTrackExtensionId] = extension + track.Extension[jspf.MusicBrainzTrackExtensionID] = extension - if l.RecordingMbid != "" { - track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid)) + if l.RecordingMBID != "" { + track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMBID)) } return track @@ -131,14 +131,14 @@ func loveAsTrack(l models.Love) jspf.Track { extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.Created extension.AddedBy = l.UserName - track.Extension[jspf.MusicBrainzTrackExtensionId] = extension + track.Extension[jspf.MusicBrainzTrackExtensionID] = extension - recordingMbid := l.Track.RecordingMbid - if l.RecordingMbid != "" { - 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 @@ -159,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 diff --git a/internal/backends/jspf/jspf_test.go b/internal/backends/jspf/jspf_test.go index 31b5370..bf4f99d 100644 --- a/internal/backends/jspf/jspf_test.go +++ b/internal/backends/jspf/jspf_test.go @@ -26,13 +26,14 @@ import ( "go.uploadedlobster.com/scotty/internal/config" ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("file-path", "/foo/bar.jspf") c.Set("title", "My Playlist") c.Set("username", "outsidecontext") c.Set("identifier", "http://example.com/playlist1") service := config.NewServiceConfig("test", c) - backend := (&jspf.JSPFBackend{}).FromConfig(&service) - assert.IsType(t, &jspf.JSPFBackend{}, backend) + backend := jspf.JSPFBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } diff --git a/internal/backends/lastfm/auth.go b/internal/backends/lastfm/auth.go index bbc65bc..9d43e0c 100644 --- a/internal/backends/lastfm/auth.go +++ b/internal/backends/lastfm/auth.go @@ -25,21 +25,21 @@ import ( type lastfmStrategy struct { client *lastfm.Api - redirectUrl *url.URL + redirectURL *url.URL } func (s lastfmStrategy) Config() oauth2.Config { return oauth2.Config{} } -func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl { +func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL { // Last.fm does not use OAuth2, but the provided authorization flow with // callback URL is close enough we can shoehorn it into the existing // authentication strategy. // TODO: Investigate and use callback-less flow with api.GetAuthTokenUrl(token) - url := s.client.GetAuthRequestUrl(s.redirectUrl.String()) - return auth.AuthUrl{ - Url: url, + url := s.client.GetAuthRequestUrl(s.redirectURL.String()) + return auth.AuthURL{ + URL: url, State: "", // last.fm does not use state Param: "token", } diff --git a/internal/backends/lastfm/lastfm.go b/internal/backends/lastfm/lastfm.go index d2df067..76fe9c7 100644 --- a/internal/backends/lastfm/lastfm.go +++ b/internal/backends/lastfm/lastfm.go @@ -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" @@ -60,21 +61,21 @@ func (b *LastfmApiBackend) Options() []models.BackendOption { }} } -func (b *LastfmApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { - clientId := config.GetString("client-id") +func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error { + clientID := config.GetString("client-id") clientSecret := config.GetString("client-secret") - b.client = lastfm.New(clientId, clientSecret) + b.client = lastfm.New(clientID, clientSecret) b.username = config.GetString("username") - return b + return nil } func (b *LastfmApiBackend) StartImport() error { return nil } func (b *LastfmApiBackend) FinishImport() error { return nil } -func (b *LastfmApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { +func (b *LastfmApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy { return lastfmStrategy{ client: b.client, - redirectUrl: redirectUrl, + redirectURL: redirectURL, } } @@ -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) @@ -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, }, diff --git a/internal/backends/listenbrainz/client.go b/internal/backends/listenbrainz/client.go index 1917bd4..fff476c 100644 --- a/internal/backends/listenbrainz/client.go +++ b/internal/backends/listenbrainz/client.go @@ -39,7 +39,7 @@ const ( ) type Client struct { - HttpClient *resty.Client + HTTPClient *resty.Client MaxResults int } @@ -55,7 +55,7 @@ func NewClient(token string) Client { ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In") return Client{ - HttpClient: client, + HTTPClient: client, MaxResults: DefaultItemsPerGet, } } @@ -63,7 +63,7 @@ func NewClient(token string) Client { func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) { const path = "/user/{username}/listens" errorResult := ErrorResult{} - response, err := c.HttpClient.R(). + response, err := c.HTTPClient.R(). SetPathParam("username", user). SetQueryParams(map[string]string{ "max_ts": strconv.FormatInt(maxTime.Unix(), 10), @@ -84,7 +84,7 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) { const path = "/submit-listens" errorResult := ErrorResult{} - response, err := c.HttpClient.R(). + response, err := c.HTTPClient.R(). SetBody(listens). SetResult(&result). SetError(&errorResult). @@ -100,7 +100,7 @@ func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, er func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) { const path = "/feedback/user/{username}/get-feedback" errorResult := ErrorResult{} - response, err := c.HttpClient.R(). + response, err := c.HTTPClient.R(). SetPathParam("username", user). SetQueryParams(map[string]string{ "status": strconv.Itoa(status), @@ -122,7 +122,7 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) { const path = "/feedback/recording-feedback" errorResult := ErrorResult{} - response, err := c.HttpClient.R(). + response, err := c.HTTPClient.R(). SetBody(feedback). SetResult(&result). SetError(&errorResult). @@ -138,7 +138,7 @@ func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) { const path = "/metadata/lookup" errorResult := ErrorResult{} - response, err := c.HttpClient.R(). + response, err := c.HTTPClient.R(). SetQueryParams(map[string]string{ "recording_name": recordingName, "artist_name": artistName, diff --git a/internal/backends/listenbrainz/client_test.go b/internal/backends/listenbrainz/client_test.go index cc36f1d..2e841ae 100644 --- a/internal/backends/listenbrainz/client_test.go +++ b/internal/backends/listenbrainz/client_test.go @@ -29,13 +29,14 @@ 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" ) func TestNewClient(t *testing.T) { token := "foobar123" client := listenbrainz.NewClient(token) - assert.Equal(t, token, client.HttpClient.Token) + assert.Equal(t, token, client.HTTPClient.Token) assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults) } @@ -44,7 +45,7 @@ func TestGetListens(t *testing.T) { client := listenbrainz.NewClient("thetoken") client.MaxResults = 2 - setupHttpMock(t, client.HttpClient.GetClient(), + setupHTTPMock(t, client.HTTPClient.GetClient(), "https://api.listenbrainz.org/1/user/outsidecontext/listens", "testdata/listens.json") @@ -61,7 +62,7 @@ func TestGetListens(t *testing.T) { func TestSubmitListens(t *testing.T) { client := listenbrainz.NewClient("thetoken") - httpmock.ActivateNonDefault(client.HttpClient.GetClient()) + httpmock.ActivateNonDefault(client.HTTPClient.GetClient()) responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{ Status: "ok", @@ -102,7 +103,7 @@ func TestGetFeedback(t *testing.T) { client := listenbrainz.NewClient("thetoken") client.MaxResults = 2 - setupHttpMock(t, client.HttpClient.GetClient(), + setupHTTPMock(t, client.HTTPClient.GetClient(), "https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback", "testdata/feedback.json") @@ -114,12 +115,12 @@ 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) { client := listenbrainz.NewClient("thetoken") - httpmock.ActivateNonDefault(client.HttpClient.GetClient()) + httpmock.ActivateNonDefault(client.HTTPClient.GetClient()) responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{ Status: "ok", @@ -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) @@ -144,7 +145,7 @@ func TestLookup(t *testing.T) { defer httpmock.DeactivateAndReset() client := listenbrainz.NewClient("thetoken") - setupHttpMock(t, client.HttpClient.GetClient(), + setupHTTPMock(t, client.HTTPClient.GetClient(), "https://api.listenbrainz.org/1/metadata/lookup", "testdata/lookup.json") @@ -154,10 +155,10 @@ 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) { +func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) { httpmock.ActivateNonDefault(client) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) diff --git a/internal/backends/listenbrainz/listenbrainz.go b/internal/backends/listenbrainz/listenbrainz.go index ebeb64c..d0074b1 100644 --- a/internal/backends/listenbrainz/listenbrainz.go +++ b/internal/backends/listenbrainz/listenbrainz.go @@ -21,6 +21,8 @@ import ( "sort" "time" + "go.uploadedlobster.com/mbtypes" + "go.uploadedlobster.com/musicbrainzws2" "go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" @@ -30,9 +32,10 @@ import ( type ListenBrainzApiBackend struct { client Client + mbClient musicbrainzws2.Client username string checkDuplicates bool - existingMbids map[string]bool + existingMBIDs map[mbtypes.MBID]bool } func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" } @@ -53,12 +56,17 @@ func (b *ListenBrainzApiBackend) Options() []models.BackendOption { }} } -func (b *ListenBrainzApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *ListenBrainzApiBackend) InitConfig(config *config.ServiceConfig) error { b.client = NewClient(config.GetString("token")) + b.mbClient = *musicbrainzws2.NewClient(musicbrainzws2.AppInfo{ + Name: version.AppName, + Version: version.AppVersion, + URL: version.AppURL, + }) b.client.MaxResults = MaxItemsPerGet b.username = config.GetString("username") b.checkDuplicates = config.GetBool("check-duplicate-listens", false) - return b + return nil } func (b *ListenBrainzApiBackend) StartImport() error { return nil } @@ -147,7 +155,7 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo } else if isDupe { count -= 1 msg := i18n.Tr("Ignored duplicate listen %v: \"%v\" by %v (%v)", - l.ListenedAt, l.TrackName, l.ArtistName(), l.RecordingMbid) + l.ListenedAt, l.TrackName, l.ArtistName(), l.RecordingMBID) importResult.Log(models.Info, msg) continue } @@ -187,6 +195,27 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo } func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { + defer close(results) + exportChan := make(chan models.LovesResult) + p := models.Progress{} + + go b.exportLoves(time.Unix(0, 0), exportChan) + for existingLoves := range exportChan { + if existingLoves.Error != nil { + progress <- p.Complete() + results <- models.LovesResult{Error: existingLoves.Error} + } + + p.Total = int64(existingLoves.Total) + p.Elapsed += int64(existingLoves.Items.Len()) + progress <- p + results <- existingLoves + } + + progress <- p.Complete() +} + +func (b *ListenBrainzApiBackend) exportLoves(oldestTimestamp time.Time, results chan models.LovesResult) { offset := 0 defer close(results) loves := make(models.LovesList, 0, 2*MaxItemsPerGet) @@ -196,7 +225,6 @@ out: for { result, err := b.client.GetFeedback(b.username, 1, offset) if err != nil { - progress <- p.Complete() results <- models.LovesResult{Error: err} return } @@ -207,11 +235,20 @@ out: } for _, feedback := range result.Feedback { + // Missing track metadata indicates that the recording MBID is no + // longer available and might have been merged. Try fetching details + // from MusicBrainz. + if feedback.TrackMetadata == nil { + track, err := b.lookupRecording(feedback.RecordingMBID) + if err == nil { + feedback.TrackMetadata = track + } + } + love := feedback.AsLove() if love.Created.Unix() > oldestTimestamp.Unix() { loves = append(loves, love) p.Elapsed += 1 - progress <- p } else { break out } @@ -224,49 +261,65 @@ out: } sort.Sort(loves) - progress <- p.Complete() - results <- models.LovesResult{Items: loves} + results <- models.LovesResult{ + Total: len(loves), + Items: loves, + } } 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 - if existingLoves.Error != nil { - return importResult, existingLoves.Error - } + go b.exportLoves(time.Unix(0, 0), existingLovesChan) // TODO: Store MBIDs directly - b.existingMbids = make(map[string]bool, len(existingLoves.Items)) - for _, love := range existingLoves.Items { - b.existingMbids[string(love.RecordingMbid)] = true + b.existingMBIDs = make(map[mbtypes.MBID]bool, MaxItemsPerGet) + + for existingLoves := range existingLovesChan { + if existingLoves.Error != nil { + return importResult, existingLoves.Error + } + + for _, love := range existingLoves.Items { + b.existingMBIDs[love.RecordingMBID] = true + // In case the loved MBID got merged the track MBID represents the + // actual recording MBID. + if love.Track.RecordingMBID != "" && + love.Track.RecordingMBID != love.RecordingMBID { + b.existingMBIDs[love.Track.RecordingMBID] = true + } + } } } for _, love := range export.Items { - recordingMbid := string(love.RecordingMbid) + recordingMBID := love.RecordingMBID + if recordingMBID == "" { + recordingMBID = love.Track.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" if err != nil { errMsg = err.Error() + } else { + b.existingMBIDs[recordingMBID] = true } } @@ -278,6 +331,10 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe love.TrackName, love.ArtistName(), errMsg) importResult.Log(models.Error, msg) } + } else { + msg := fmt.Sprintf("Failed import of \"%s\" by %s: no recording MBID", + love.TrackName, love.ArtistName()) + importResult.Log(models.Error, msg) } progress <- models.Progress{}.FromImportResult(importResult) @@ -313,6 +370,31 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (boo return false, nil } +func (b *ListenBrainzApiBackend) lookupRecording(mbid mbtypes.MBID) (*Track, error) { + filter := musicbrainzws2.IncludesFilter{ + Includes: []string{"artist-credits"}, + } + recording, err := b.mbClient.LookupRecording(mbid, filter) + if err != nil { + return nil, err + } + + artistMBIDs := make([]mbtypes.MBID, 0, len(recording.ArtistCredit)) + for _, artist := range recording.ArtistCredit { + artistMBIDs = append(artistMBIDs, artist.Artist.ID) + } + track := Track{ + TrackName: recording.Title, + ArtistName: recording.ArtistCredit.String(), + MBIDMapping: &MBIDMapping{ + // In case of redirects this MBID differs from the looked up MBID + RecordingMBID: recording.ID, + ArtistMBIDs: artistMBIDs, + }, + } + return &track, nil +} + func (lbListen Listen) AsListen() models.Listen { listen := models.Listen{ ListenedAt: time.Unix(lbListen.ListenedAt, 0), @@ -323,20 +405,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 @@ -350,16 +432,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) } } diff --git a/internal/backends/listenbrainz/listenbrainz_test.go b/internal/backends/listenbrainz/listenbrainz_test.go index f67280e..bf2e4d3 100644 --- a/internal/backends/listenbrainz/listenbrainz_test.go +++ b/internal/backends/listenbrainz/listenbrainz_test.go @@ -23,17 +23,18 @@ 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) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("token", "thetoken") service := config.NewServiceConfig("test", c) - backend := (&listenbrainz.ListenBrainzApiBackend{}).FromConfig(&service) - assert.IsType(t, &listenbrainz.ListenBrainzApiBackend{}, backend) + backend := listenbrainz.ListenBrainzApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestListenBrainzListenAsListen(t *testing.T) { @@ -65,30 +66,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 +100,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) } diff --git a/internal/backends/listenbrainz/models.go b/internal/backends/listenbrainz/models.go index c1552c7..ada75d3 100644 --- a/internal/backends/listenbrainz/models.go +++ b/internal/backends/listenbrainz/models.go @@ -25,6 +25,7 @@ import ( "strconv" "time" + "go.uploadedlobster.com/mbtypes" "golang.org/x/exp/constraints" ) @@ -56,7 +57,7 @@ type ListenSubmission struct { type Listen struct { InsertedAt int64 `json:"inserted_at,omitempty"` ListenedAt int64 `json:"listened_at"` - RecordingMsid string `json:"recording_msid,omitempty"` + RecordingMSID string `json:"recording_msid,omitempty"` UserName string `json:"user_name,omitempty"` TrackMetadata Track `json:"track_metadata"` } @@ -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 { - RecordingName string `json:"recording_name,omitempty"` - RecordingMbid string `json:"recording_mbid,omitempty"` - ReleaseMbid string `json:"release_mbid,omitempty"` - ArtistMbids []string `json:"artist_mbids,omitempty"` - Artists []Artist `json:"artists,omitempty"` +type MBIDMapping struct { + RecordingName string `json:"recording_name,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"` } @@ -91,21 +92,21 @@ type GetFeedbackResult struct { } type Feedback struct { - Created int64 `json:"created,omitempty"` - RecordingMbid string `json:"recording_mbid,omitempty"` - RecordingMsid string `json:"recording_msid,omitempty"` - Score int `json:"score,omitempty"` - TrackMetadata *Track `json:"track_metadata,omitempty"` - UserName string `json:"user_id,omitempty"` + Created int64 `json:"created,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"` } 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"` + ArtistCreditName string `json:"artist_credit_name"` + ReleaseName string `json:"release_name"` + RecordingName string `json:"recording_name"` + 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 { diff --git a/internal/backends/listenbrainz/models_test.go b/internal/backends/listenbrainz/models_test.go index 845690d..02cbe98 100644 --- a/internal/backends/listenbrainz/models_test.go +++ b/internal/backends/listenbrainz/models_test.go @@ -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" ) @@ -130,50 +131,50 @@ func TestTrackTrackNumberString(t *testing.T) { assert.Equal(t, 12, track.TrackNumber()) } -func TestTrackIsrc(t *testing.T) { - expected := "TCAEJ1934417" +func TestTrackISRC(t *testing.T) { + 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) diff --git a/internal/backends/maloja/client.go b/internal/backends/maloja/client.go index f373f93..249819a 100644 --- a/internal/backends/maloja/client.go +++ b/internal/backends/maloja/client.go @@ -32,25 +32,25 @@ import ( const MaxItemsPerGet = 1000 type Client struct { - HttpClient *resty.Client + HTTPClient *resty.Client token string } -func NewClient(serverUrl string, token string) Client { +func NewClient(serverURL string, token string) Client { client := resty.New() - client.SetBaseURL(serverUrl) + client.SetBaseURL(serverURL) client.SetHeader("Accept", "application/json") client.SetHeader("User-Agent", version.UserAgent()) client.SetRetryCount(5) return Client{ - HttpClient: client, + HTTPClient: client, token: token, } } func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) { const path = "/apis/mlj_1/scrobbles" - response, err := c.HttpClient.R(). + response, err := c.HTTPClient.R(). SetQueryParams(map[string]string{ "page": strconv.Itoa(page), "perpage": strconv.Itoa(perPage), @@ -68,7 +68,7 @@ func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err error) { const path = "/apis/mlj_1/newscrobble" scrobble.Key = c.token - response, err := c.HttpClient.R(). + response, err := c.HTTPClient.R(). SetBody(scrobble). SetResult(&result). Post(path) diff --git a/internal/backends/maloja/client_test.go b/internal/backends/maloja/client_test.go index 6a07adb..54316a8 100644 --- a/internal/backends/maloja/client_test.go +++ b/internal/backends/maloja/client_test.go @@ -32,19 +32,19 @@ import ( ) func TestNewClient(t *testing.T) { - serverUrl := "https://maloja.example.com" + serverURL := "https://maloja.example.com" token := "foobar123" - client := maloja.NewClient(serverUrl, token) - assert.Equal(t, serverUrl, client.HttpClient.BaseURL) + client := maloja.NewClient(serverURL, token) + assert.Equal(t, serverURL, client.HTTPClient.BaseURL) } func TestGetScrobbles(t *testing.T) { defer httpmock.DeactivateAndReset() - serverUrl := "https://maloja.example.com" + serverURL := "https://maloja.example.com" token := "thetoken" - client := maloja.NewClient(serverUrl, token) - setupHttpMock(t, client.HttpClient.GetClient(), + client := maloja.NewClient(serverURL, token) + setupHTTPMock(t, client.HTTPClient.GetClient(), "https://maloja.example.com/apis/mlj_1/scrobbles", "testdata/scrobbles.json") @@ -60,7 +60,7 @@ func TestGetScrobbles(t *testing.T) { func TestNewScrobble(t *testing.T) { server := "https://maloja.example.com" client := maloja.NewClient(server, "thetoken") - httpmock.ActivateNonDefault(client.HttpClient.GetClient()) + httpmock.ActivateNonDefault(client.HTTPClient.GetClient()) responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json")) if err != nil { @@ -80,7 +80,7 @@ func TestNewScrobble(t *testing.T) { assert.Equal(t, "success", result.Status) } -func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { +func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) { httpmock.ActivateNonDefault(client) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) diff --git a/internal/backends/maloja/maloja.go b/internal/backends/maloja/maloja.go index 135bef3..e9e3348 100644 --- a/internal/backends/maloja/maloja.go +++ b/internal/backends/maloja/maloja.go @@ -51,13 +51,13 @@ func (b *MalojaApiBackend) Options() []models.BackendOption { }} } -func (b *MalojaApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *MalojaApiBackend) InitConfig(config *config.ServiceConfig) error { b.client = NewClient( config.GetString("server-url"), config.GetString("token"), ) b.nofix = config.GetBool("nofix", false) - return b + return nil } func (b *MalojaApiBackend) StartImport() error { return nil } diff --git a/internal/backends/maloja/maloja_test.go b/internal/backends/maloja/maloja_test.go index 52be58c..4a1f318 100644 --- a/internal/backends/maloja/maloja_test.go +++ b/internal/backends/maloja/maloja_test.go @@ -26,12 +26,13 @@ import ( "go.uploadedlobster.com/scotty/internal/config" ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("token", "thetoken") service := config.NewServiceConfig("test", c) - backend := (&maloja.MalojaApiBackend{}).FromConfig(&service) - assert.IsType(t, &maloja.MalojaApiBackend{}, backend) + backend := maloja.MalojaApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestScrobbleAsListen(t *testing.T) { diff --git a/internal/backends/scrobblerlog/parser.go b/internal/backends/scrobblerlog/parser.go deleted file mode 100644 index af891ac..0000000 --- a/internal/backends/scrobblerlog/parser.go +++ /dev/null @@ -1,210 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ -package scrobblerlog - -import ( - "bufio" - "encoding/csv" - "fmt" - "io" - "strconv" - "strings" - "time" - - "go.uploadedlobster.com/scotty/internal/models" -) - -type ScrobblerLog struct { - Timezone string - Client string - Listens models.ListensList -} - -func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) { - result := ScrobblerLog{ - Listens: make(models.ListensList, 0), - } - - reader := bufio.NewReader(data) - err := ReadHeader(reader, &result) - if err != nil { - return result, err - } - - tsvReader := csv.NewReader(reader) - tsvReader.Comma = '\t' - // Row length is often flexible - tsvReader.FieldsPerRecord = -1 - - for { - // A row is: - // artistName releaseName trackName trackNumber duration rating timestamp recordingMbid - row, err := tsvReader.Read() - if err == io.EOF { - break - } else if err != nil { - return result, err - } - - // fmt.Printf("row: %v\n", row) - - // We consider only the last field (recording MBID) optional - if len(row) < 7 { - line, _ := tsvReader.FieldPos(0) - return result, fmt.Errorf("invalid record in scrobblerlog line %v", line) - } - - rating := row[5] - if !includeSkipped && rating == "S" { - continue - } - - client := strings.Split(result.Client, " ")[0] - listen, err := rowToListen(row, client) - if err != nil { - return result, err - } - - result.Listens = append(result.Listens, listen) - } - - return result, nil -} - -func Write(data io.Writer, listens models.ListensList) (lastTimestamp time.Time, err error) { - tsvWriter := csv.NewWriter(data) - tsvWriter.Comma = '\t' - - for _, listen := range listens { - if listen.ListenedAt.Unix() > lastTimestamp.Unix() { - lastTimestamp = listen.ListenedAt - } - - // A row is: - // artistName releaseName trackName trackNumber duration rating timestamp recordingMbid - rating, ok := listen.AdditionalInfo["rockbox_rating"].(string) - if !ok || rating == "" { - rating = "L" - } - err = tsvWriter.Write([]string{ - listen.ArtistName(), - listen.ReleaseName, - listen.TrackName, - strconv.Itoa(listen.TrackNumber), - strconv.Itoa(int(listen.Duration.Seconds())), - rating, - strconv.Itoa(int(listen.ListenedAt.Unix())), - string(listen.RecordingMbid), - }) - } - - tsvWriter.Flush() - return -} - -func ReadHeader(reader *bufio.Reader, log *ScrobblerLog) error { - // Skip header - for i := 0; i < 3; i++ { - line, _, err := reader.ReadLine() - if err != nil { - return err - } - - if len(line) == 0 || line[0] != '#' { - err = fmt.Errorf("unexpected header (line %v)", i) - } else { - text := string(line) - if i == 0 && !strings.HasPrefix(text, "#AUDIOSCROBBLER/1") { - err = fmt.Errorf("not a scrobbler log file") - } - - timezone, found := strings.CutPrefix(text, "#TZ/") - if found { - log.Timezone = timezone - } - - client, found := strings.CutPrefix(text, "#CLIENT/") - if found { - log.Client = client - } - } - - if err != nil { - return err - } - } - return nil -} - -func WriteHeader(writer io.Writer, log *ScrobblerLog) error { - headers := []string{ - "#AUDIOSCROBBLER/1.1\n", - "#TZ/" + log.Timezone + "\n", - "#CLIENT/" + log.Client + "\n", - } - for _, line := range headers { - _, err := writer.Write([]byte(line)) - if err != nil { - return err - } - } - return nil -} - -func rowToListen(row []string, client string) (models.Listen, error) { - var listen models.Listen - trackNumber, err := strconv.Atoi(row[3]) - if err != nil { - return listen, err - } - - duration, err := strconv.Atoi(row[4]) - if err != nil { - return listen, err - } - - timestamp, err := strconv.Atoi(row[6]) - if err != nil { - return listen, err - } - - listen = models.Listen{ - Track: models.Track{ - ArtistNames: []string{row[0]}, - ReleaseName: row[1], - TrackName: row[2], - TrackNumber: trackNumber, - Duration: time.Duration(duration * int(time.Second)), - AdditionalInfo: models.AdditionalInfo{ - "rockbox_rating": row[5], - "media_player": client, - }, - }, - ListenedAt: time.Unix(int64(timestamp), 0), - } - - if len(row) > 7 { - listen.Track.RecordingMbid = models.MBID(row[7]) - } - - return listen, nil -} diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 84cae88..19ed30b 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -18,21 +18,25 @@ package scrobblerlog import ( "bufio" + "fmt" "os" "sort" + "strings" "time" "go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" + "go.uploadedlobster.com/scotty/pkg/scrobblerlog" ) type ScrobblerLogBackend struct { - filePath string - includeSkipped bool - append bool - file *os.File - log ScrobblerLog + filePath string + ignoreSkipped bool + append bool + file *os.File + timezone *time.Location + log scrobblerlog.ScrobblerLog } func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" } @@ -43,26 +47,39 @@ func (b *ScrobblerLogBackend) Options() []models.BackendOption { Label: i18n.Tr("File path"), Type: models.String, }, { - Name: "include-skipped", - Label: i18n.Tr("Include skipped listens"), - Type: models.Bool, + Name: "ignore-skipped", + Label: i18n.Tr("Ignore skipped listens"), + Type: models.Bool, + Default: "true", }, { Name: "append", Label: i18n.Tr("Append to file"), Type: models.Bool, Default: "true", + }, { + Name: "time-zone", + Label: i18n.Tr("Specify a time zone for the listen timestamps"), + Type: models.String, }} } -func (b *ScrobblerLogBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *ScrobblerLogBackend) InitConfig(config *config.ServiceConfig) error { b.filePath = config.GetString("file-path") - b.includeSkipped = config.GetBool("include-skipped", false) + b.ignoreSkipped = config.GetBool("ignore-skipped", true) b.append = config.GetBool("append", true) - b.log = ScrobblerLog{ - Timezone: "UNKNOWN", - Client: "Rockbox unknown $Revision$", + timezone := config.GetString("time-zone") + if timezone != "" { + location, err := time.LoadLocation(timezone) + if err != nil { + return fmt.Errorf("Invalid time-zone %q: %w", timezone, err) + } + b.log.FallbackTimezone = location } - return b + b.log = scrobblerlog.ScrobblerLog{ + TZ: scrobblerlog.TimezoneUTC, + Client: "Rockbox unknown $Revision$", + } + return nil } func (b *ScrobblerLogBackend) StartImport() error { @@ -88,7 +105,7 @@ func (b *ScrobblerLogBackend) StartImport() error { } else { // Verify existing file is a scrobbler log reader := bufio.NewReader(file) - if err = ReadHeader(reader, &b.log); err != nil { + if err = b.log.ReadHeader(reader); err != nil { file.Close() return err } @@ -99,7 +116,7 @@ func (b *ScrobblerLogBackend) StartImport() error { } if !b.append { - if err = WriteHeader(file, &b.log); err != nil { + if err = b.log.WriteHeader(file); err != nil { file.Close() return err } @@ -124,21 +141,29 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c defer file.Close() - log, err := Parse(file, b.includeSkipped) + err = b.log.Parse(file, b.ignoreSkipped) if err != nil { progress <- models.Progress{}.Complete() results <- models.ListensResult{Error: err} return } - listens := log.Listens.NewerThan(oldestTimestamp) - sort.Sort(listens) - progress <- models.Progress{Elapsed: int64(len(listens))}.Complete() + listens := make(models.ListensList, 0, len(b.log.Records)) + client := strings.Split(b.log.Client, " ")[0] + for _, record := range b.log.Records { + listens = append(listens, recordToListen(record, client)) + } + sort.Sort(listens.NewerThan(oldestTimestamp)) + progress <- models.Progress{Total: int64(len(listens))}.Complete() results <- models.ListensResult{Items: listens} } func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { - lastTimestamp, err := Write(b.file, export.Items) + records := make([]scrobblerlog.Record, len(export.Items)) + for i, listen := range export.Items { + records[i] = listenToRecord(listen) + } + lastTimestamp, err := b.log.Append(b.file, records) if err != nil { return importResult, err } @@ -149,3 +174,42 @@ func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importR return importResult, nil } + +func recordToListen(record scrobblerlog.Record, client string) models.Listen { + return models.Listen{ + ListenedAt: record.Timestamp, + Track: models.Track{ + ArtistNames: []string{record.ArtistName}, + ReleaseName: record.AlbumName, + TrackName: record.TrackName, + TrackNumber: record.TrackNumber, + Duration: record.Duration, + RecordingMBID: record.MusicBrainzRecordingID, + AdditionalInfo: models.AdditionalInfo{ + "rockbox_rating": record.Rating, + "media_player": client, + }, + }, + } +} + +func listenToRecord(listen models.Listen) scrobblerlog.Record { + var rating scrobblerlog.Rating + rockboxRating, ok := listen.AdditionalInfo["rockbox_rating"].(string) + if !ok || rockboxRating == "" { + rating = scrobblerlog.RatingListened + } else { + rating = scrobblerlog.Rating(rating) + } + + return scrobblerlog.Record{ + ArtistName: listen.ArtistName(), + AlbumName: listen.ReleaseName, + TrackName: listen.TrackName, + TrackNumber: listen.TrackNumber, + Duration: listen.Duration, + Rating: rating, + Timestamp: listen.ListenedAt, + MusicBrainzRecordingID: listen.RecordingMBID, + } +} diff --git a/internal/backends/scrobblerlog/scrobblerlog_test.go b/internal/backends/scrobblerlog/scrobblerlog_test.go index 04e76c1..962aebf 100644 --- a/internal/backends/scrobblerlog/scrobblerlog_test.go +++ b/internal/backends/scrobblerlog/scrobblerlog_test.go @@ -25,10 +25,21 @@ import ( "go.uploadedlobster.com/scotty/internal/config" ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("token", "thetoken") service := config.NewServiceConfig("test", c) - backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(&service) - assert.IsType(t, &scrobblerlog.ScrobblerLogBackend{}, backend) + backend := scrobblerlog.ScrobblerLogBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) +} + +func TestInitConfigInvalidTimezone(t *testing.T) { + c := viper.New() + configuredTimezone := "Invalid/Timezone" + c.Set("time-zone", configuredTimezone) + service := config.NewServiceConfig("test", c) + backend := scrobblerlog.ScrobblerLogBackend{} + err := backend.InitConfig(&service) + assert.ErrorContains(t, err, `Invalid time-zone "Invalid/Timezone"`) } diff --git a/internal/backends/spotify/client.go b/internal/backends/spotify/client.go index 1c002c0..ff2b0a3 100644 --- a/internal/backends/spotify/client.go +++ b/internal/backends/spotify/client.go @@ -40,7 +40,7 @@ const ( ) type Client struct { - HttpClient *resty.Client + HTTPClient *resty.Client } func NewClient(token oauth2.TokenSource) Client { @@ -55,7 +55,7 @@ func NewClient(token oauth2.TokenSource) Client { ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After") return Client{ - HttpClient: client, + HTTPClient: client, } } @@ -69,7 +69,7 @@ func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlaye func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) { const path = "/me/player/recently-played" - request := c.HttpClient.R(). + request := c.HTTPClient.R(). SetQueryParam("limit", strconv.Itoa(limit)). SetResult(&result) if after != nil { @@ -87,7 +87,7 @@ func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) ( func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) { const path = "/me/tracks" - response, err := c.HttpClient.R(). + response, err := c.HTTPClient.R(). SetQueryParams(map[string]string{ "offset": strconv.Itoa(offset), "limit": strconv.Itoa(limit), diff --git a/internal/backends/spotify/client_test.go b/internal/backends/spotify/client_test.go index 7d738bf..78ff063 100644 --- a/internal/backends/spotify/client_test.go +++ b/internal/backends/spotify/client_test.go @@ -43,7 +43,7 @@ func TestRecentlyPlayedAfter(t *testing.T) { defer httpmock.DeactivateAndReset() client := spotify.NewClient(nil) - setupHttpMock(t, client.HttpClient.GetClient(), + setupHTTPMock(t, client.HTTPClient.GetClient(), "https://api.spotify.com/v1/me/player/recently-played", "testdata/recently-played.json") @@ -63,7 +63,7 @@ func TestGetUserTracks(t *testing.T) { defer httpmock.DeactivateAndReset() client := spotify.NewClient(nil) - setupHttpMock(t, client.HttpClient.GetClient(), + setupHTTPMock(t, client.HTTPClient.GetClient(), "https://api.spotify.com/v1/me/tracks", "testdata/user-tracks.json") @@ -79,7 +79,7 @@ func TestGetUserTracks(t *testing.T) { assert.Equal("Zeal & Ardor", track1.Track.Album.Name) } -func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { +func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) { httpmock.ActivateNonDefault(client) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) diff --git a/internal/backends/spotify/models.go b/internal/backends/spotify/models.go index e80eccc..e279e15 100644 --- a/internal/backends/spotify/models.go +++ b/internal/backends/spotify/models.go @@ -22,6 +22,8 @@ THE SOFTWARE. package spotify +import "go.uploadedlobster.com/mbtypes" + type TracksResult struct { Href string `json:"href"` Limit int `json:"limit"` @@ -56,7 +58,7 @@ type Listen struct { } type Track struct { - Id string `json:"id"` + ID string `json:"id"` Name string `json:"name"` Href string `json:"href"` Uri string `json:"uri"` @@ -67,14 +69,14 @@ type Track struct { Explicit bool `json:"explicit"` IsLocal bool `json:"is_local"` Popularity int `json:"popularity"` - ExternalIds ExternalIds `json:"external_ids"` - ExternalUrls ExternalUrls `json:"external_urls"` + ExternalIDs ExternalIDs `json:"external_ids"` + ExternalURLs ExternalURLs `json:"external_urls"` Album Album `json:"album"` Artists []Artist `json:"artists"` } type Album struct { - Id string `json:"id"` + ID string `json:"id"` Name string `json:"name"` Href string `json:"href"` Uri string `json:"uri"` @@ -83,32 +85,32 @@ type Album struct { ReleaseDate string `json:"release_date"` ReleaseDatePrecision string `json:"release_date_precision"` AlbumType string `json:"album_type"` - ExternalUrls ExternalUrls `json:"external_urls"` + ExternalURLs ExternalURLs `json:"external_urls"` Artists []Artist `json:"artists"` Images []Image `json:"images"` } type Artist struct { - Id string `json:"id"` + ID string `json:"id"` Name string `json:"name"` Href string `json:"href"` Uri string `json:"uri"` Type string `json:"type"` - ExternalUrls ExternalUrls `json:"external_urls"` + ExternalURLs ExternalURLs `json:"external_urls"` } -type ExternalIds struct { - ISRC string `json:"isrc"` - EAN string `json:"ean"` - UPC string `json:"upc"` +type ExternalIDs struct { + ISRC mbtypes.ISRC `json:"isrc"` + EAN string `json:"ean"` + UPC string `json:"upc"` } -type ExternalUrls struct { +type ExternalURLs struct { Spotify string `json:"spotify"` } type Image struct { - Url string `json:"url"` + URL string `json:"url"` Height int `json:"height"` Width int `json:"width"` } diff --git a/internal/backends/spotify/spotify.go b/internal/backends/spotify/spotify.go index a4e3c87..1827769 100644 --- a/internal/backends/spotify/spotify.go +++ b/internal/backends/spotify/spotify.go @@ -34,7 +34,7 @@ import ( type SpotifyApiBackend struct { client Client - clientId string + clientID string clientSecret string } @@ -52,15 +52,15 @@ func (b *SpotifyApiBackend) Options() []models.BackendOption { }} } -func (b *SpotifyApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { - b.clientId = config.GetString("client-id") +func (b *SpotifyApiBackend) InitConfig(config *config.ServiceConfig) error { + b.clientID = config.GetString("client-id") b.clientSecret = config.GetString("client-secret") - return b + return nil } -func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { +func (b *SpotifyApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy { conf := oauth2.Config{ - ClientID: b.clientId, + ClientID: b.clientID, ClientSecret: b.clientSecret, Scopes: []string{ "user-read-currently-playing", @@ -68,16 +68,16 @@ func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Stra "user-library-read", "user-library-modify", }, - RedirectURL: redirectUrl.String(), + RedirectURL: redirectURL.String(), Endpoint: spotify.Endpoint, } return auth.NewStandardStrategy(conf) } -func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config { +func (b *SpotifyApiBackend) OAuth2Config(redirectURL *url.URL) oauth2.Config { return oauth2.Config{ - ClientID: b.clientId, + ClientID: b.clientID, ClientSecret: b.clientSecret, Scopes: []string{ "user-read-currently-playing", @@ -85,7 +85,7 @@ func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config { "user-library-read", "user-library-modify", }, - RedirectURL: redirectUrl.String(), + RedirectURL: redirectURL.String(), Endpoint: spotify.Endpoint, } } @@ -251,7 +251,7 @@ func (t Track) AsTrack() models.Track { Duration: time.Duration(t.DurationMs * int(time.Millisecond)), TrackNumber: t.TrackNumber, DiscNumber: t.DiscNumber, - ISRC: t.ExternalIds.ISRC, + ISRC: t.ExternalIDs.ISRC, AdditionalInfo: map[string]any{}, } @@ -264,30 +264,30 @@ func (t Track) AsTrack() models.Track { info["music_service"] = "spotify.com" } - if t.ExternalUrls.Spotify != "" { - info["origin_url"] = t.ExternalUrls.Spotify - info["spotify_id"] = t.ExternalUrls.Spotify + if t.ExternalURLs.Spotify != "" { + info["origin_url"] = t.ExternalURLs.Spotify + info["spotify_id"] = t.ExternalURLs.Spotify } - if t.Album.ExternalUrls.Spotify != "" { - info["spotify_album_id"] = t.Album.ExternalUrls.Spotify + if t.Album.ExternalURLs.Spotify != "" { + info["spotify_album_id"] = t.Album.ExternalURLs.Spotify } if len(t.Artists) > 0 { - info["spotify_artist_ids"] = extractArtistIds(t.Artists) + info["spotify_artist_ids"] = extractArtistIDs(t.Artists) } if len(t.Album.Artists) > 0 { - info["spotify_album_artist_ids"] = extractArtistIds(t.Album.Artists) + info["spotify_album_artist_ids"] = extractArtistIDs(t.Album.Artists) } return track } -func extractArtistIds(artists []Artist) []string { - artistIds := make([]string, len(artists)) +func extractArtistIDs(artists []Artist) []string { + artistIDs := make([]string, len(artists)) for i, artist := range artists { - artistIds[i] = artist.ExternalUrls.Spotify + artistIDs[i] = artist.ExternalURLs.Spotify } - return artistIds + return artistIDs } diff --git a/internal/backends/spotify/spotify_test.go b/internal/backends/spotify/spotify_test.go index bd7ff58..8949128 100644 --- a/internal/backends/spotify/spotify_test.go +++ b/internal/backends/spotify/spotify_test.go @@ -26,6 +26,7 @@ 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/spotify" "go.uploadedlobster.com/scotty/internal/config" ) @@ -37,13 +38,14 @@ var ( testTrack []byte ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("client-id", "someclientid") c.Set("client-secret", "someclientsecret") service := config.NewServiceConfig("test", c) - backend := (&spotify.SpotifyApiBackend{}).FromConfig(&service) - assert.IsType(t, &spotify.SpotifyApiBackend{}, backend) + backend := spotify.SpotifyApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestSpotifyListenAsListen(t *testing.T) { @@ -59,7 +61,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"]) diff --git a/internal/backends/spotifyhistory/models.go b/internal/backends/spotifyhistory/models.go index fad4fb1..a2eba23 100644 --- a/internal/backends/spotifyhistory/models.go +++ b/internal/backends/spotifyhistory/models.go @@ -92,8 +92,8 @@ func (i HistoryItem) AsListen() models.Listen { PlaybackDuration: time.Duration(i.MillisecondsPlayed * int(time.Millisecond)), UserName: i.UserName, } - if trackUrl, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil { - listen.AdditionalInfo["spotify_id"] = trackUrl + if trackURL, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil { + listen.AdditionalInfo["spotify_id"] = trackURL } return listen } diff --git a/internal/backends/spotifyhistory/spotifyhistory.go b/internal/backends/spotifyhistory/spotifyhistory.go index 40323a4..1c986be 100644 --- a/internal/backends/spotifyhistory/spotifyhistory.go +++ b/internal/backends/spotifyhistory/spotifyhistory.go @@ -64,12 +64,12 @@ func (b *SpotifyHistoryBackend) Options() []models.BackendOption { }} } -func (b *SpotifyHistoryBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *SpotifyHistoryBackend) InitConfig(config *config.ServiceConfig) error { 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 + return nil } func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { diff --git a/internal/backends/subsonic/subsonic.go b/internal/backends/subsonic/subsonic.go index 6a59630..1c26bfd 100644 --- a/internal/backends/subsonic/subsonic.go +++ b/internal/backends/subsonic/subsonic.go @@ -21,7 +21,8 @@ import ( "sort" "time" - "github.com/delucks/go-subsonic" + "github.com/supersonic-app/go-subsonic/subsonic" + "go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" @@ -51,7 +52,7 @@ func (b *SubsonicApiBackend) Options() []models.BackendOption { }} } -func (b *SubsonicApiBackend) FromConfig(config *config.ServiceConfig) models.Backend { +func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error { b.client = subsonic.Client{ Client: &http.Client{}, BaseUrl: config.GetString("server-url"), @@ -59,7 +60,7 @@ func (b *SubsonicApiBackend) FromConfig(config *config.ServiceConfig) models.Bac ClientName: version.AppName, } b.password = config.GetString("token") - return b + return nil } func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { @@ -78,8 +79,11 @@ func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan return } - progress <- models.Progress{Elapsed: int64(len(starred.Song))}.Complete() - results <- models.LovesResult{Items: b.filterSongs(starred.Song, oldestTimestamp)} + loves := b.filterSongs(starred.Song, oldestTimestamp) + progress <- models.Progress{ + Total: int64(loves.Len()), + }.Complete() + results <- models.LovesResult{Items: loves} } func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestamp time.Time) models.LovesList { @@ -96,15 +100,19 @@ func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestam } func SongAsLove(song subsonic.Child, username string) models.Love { + recordingMBID := mbtypes.MBID(song.MusicBrainzID) love := models.Love{ - UserName: username, - Created: song.Starred, + UserName: username, + Created: song.Starred, + RecordingMBID: recordingMBID, Track: models.Track{ - TrackName: song.Title, - ReleaseName: song.Album, - ArtistNames: []string{song.Artist}, - TrackNumber: song.Track, - DiscNumber: song.DiscNumber, + TrackName: song.Title, + ReleaseName: song.Album, + ArtistNames: []string{song.Artist}, + TrackNumber: song.Track, + DiscNumber: song.DiscNumber, + RecordingMBID: recordingMBID, + Tags: []string{}, AdditionalInfo: map[string]any{ "subsonic_id": song.ID, }, @@ -112,7 +120,13 @@ func SongAsLove(song subsonic.Child, username string) models.Love { }, } - if song.Genre != "" { + if len(song.Genres) > 0 { + genres := make([]string, 0, len(song.Genres)) + for _, genre := range song.Genres { + genres = append(genres, genre.Name) + } + love.Track.Tags = genres + } else if song.Genre != "" { love.Track.Tags = []string{song.Genre} } diff --git a/internal/backends/subsonic/subsonic_test.go b/internal/backends/subsonic/subsonic_test.go index c5bfe36..638c116 100644 --- a/internal/backends/subsonic/subsonic_test.go +++ b/internal/backends/subsonic/subsonic_test.go @@ -20,20 +20,21 @@ import ( "testing" "time" - go_subsonic "github.com/delucks/go-subsonic" "github.com/spf13/viper" "github.com/stretchr/testify/assert" + go_subsonic "github.com/supersonic-app/go-subsonic/subsonic" "go.uploadedlobster.com/scotty/internal/backends/subsonic" "go.uploadedlobster.com/scotty/internal/config" ) -func TestFromConfig(t *testing.T) { +func TestInitConfig(t *testing.T) { c := viper.New() c.Set("server-url", "https://subsonic.example.com") c.Set("token", "thetoken") service := config.NewServiceConfig("test", c) - backend := (&subsonic.SubsonicApiBackend{}).FromConfig(&service) - assert.IsType(t, &subsonic.SubsonicApiBackend{}, backend) + backend := subsonic.SubsonicApiBackend{} + err := backend.InitConfig(&service) + assert.NoError(t, err) } func TestSongToLove(t *testing.T) { diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 828651a..ff14573 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -43,20 +43,20 @@ func AuthenticationFlow(service config.ServiceConfig, backend auth.OAuth2Authent state := auth.RandomState() // Redirect user to consent page to ask for permission specified scopes. - authUrl := strategy.AuthCodeURL(verifier, state) + authURL := strategy.AuthCodeURL(verifier, state) // Start an HTTP server to listen for the response responseChan := make(chan auth.CodeResponse) - auth.RunOauth2CallbackServer(*redirectURL, authUrl.Param, responseChan) + auth.RunOauth2CallbackServer(*redirectURL, authURL.Param, responseChan) // Open the URL - fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authUrl.Url)) - err = browser.OpenURL(authUrl.Url) + fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authURL.URL)) + err = browser.OpenURL(authURL.URL) cobra.CheckErr(err) // Retrieve the code from the authentication callback code := <-responseChan - if code.State != authUrl.State { + if code.State != authURL.State { cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch")) os.Exit(1) } diff --git a/internal/cli/transfer.go b/internal/cli/transfer.go index 427af06..ade7ece 100644 --- a/internal/cli/transfer.go +++ b/internal/cli/transfer.go @@ -88,7 +88,7 @@ func (c *TransferCmd[E, I, R]) resolveBackends(source string, target string) err } func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp backends.ImportProcessor[R]) error { - fmt.Println(i18n.Tr("Transferring %s from %s to %s...", c.entity, c.sourceName, c.targetName)) + fmt.Println(i18n.Tr("Transferring %s from %s to %s…", c.entity, c.sourceName, c.targetName)) // Authenticate backends, if needed config := viper.GetViper() diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index cbfd516..a910ca0 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -16,11 +16,10 @@ Scotty. If not, see . package i18n import ( - "log" - "github.com/Xuanwo/go-locale" _ "go.uploadedlobster.com/scotty/internal/translations" + "golang.org/x/text/language" "golang.org/x/text/message" ) @@ -29,7 +28,7 @@ var localizer Localizer func init() { tag, err := locale.Detect() if err != nil { - log.Fatal(err) + tag = language.English } localizer = New(tag) } diff --git a/internal/models/interfaces.go b/internal/models/interfaces.go index cc19d8d..1c593d0 100644 --- a/internal/models/interfaces.go +++ b/internal/models/interfaces.go @@ -30,7 +30,7 @@ type Backend interface { Name() string // Initialize the backend from a config. - FromConfig(config *config.ServiceConfig) Backend + InitConfig(config *config.ServiceConfig) error // Return configuration options Options() []BackendOption diff --git a/internal/models/models.go b/internal/models/models.go index a225344..18e3b44 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -24,9 +24,10 @@ package models import ( "strings" "time" + + "go.uploadedlobster.com/mbtypes" ) -type MBID string type Entity string const ( @@ -43,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 } @@ -62,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 @@ -110,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 diff --git a/internal/models/models_test.go b/internal/models/models_test.go index cd1f207..5395610 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -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"]) diff --git a/internal/similarity/similarity.go b/internal/similarity/similarity.go index 358404a..3fb27c4 100644 --- a/internal/similarity/similarity.go +++ b/internal/similarity/similarity.go @@ -63,7 +63,7 @@ func NormalizeTitle(s string) string { // 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 != "" { + if t1.RecordingMBID == t2.RecordingMBID && t1.RecordingMBID != "" { return 1.0 } diff --git a/internal/similarity/similarity_test.go b/internal/similarity/similarity_test.go index f1e92a5..c43e1d7 100644 --- a/internal/similarity/similarity_test.go +++ b/internal/similarity/similarity_test.go @@ -20,6 +20,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/similarity" ) @@ -74,13 +75,13 @@ func TestCompareTracksSameMBID(t *testing.T) { t1 := models.Track{ ArtistNames: []string{"Paradise Lost"}, TrackName: "Forever After", - RecordingMbid: models.MBID("2886d15c-09b0-43c6-af56-932f70dde164"), + RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"), } t2 := models.Track{ ArtistNames: []string{"Paradise Lost"}, TrackName: "Forever Failure (radio edit)", ReleaseName: "Draconian Times", - RecordingMbid: models.MBID("2886d15c-09b0-43c6-af56-932f70dde164"), + RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"), } assert.Equal(t, 1.0, similarity.CompareTracks(t1, t2)) } diff --git a/internal/translations/catalog.go b/internal/translations/catalog.go index 614b5b6..3eb2f7e 100644 --- a/internal/translations/catalog.go +++ b/internal/translations/catalog.go @@ -42,56 +42,56 @@ var messageKeyToIndex = map[string]int{ "\tbackend: %v": 11, "\texport: %s": 0, "\timport: %s\n": 1, - "%v: %v": 52, + "%v: %v": 48, "Aborted": 8, "Access token": 19, - "Access token received, you can use %v now.\n": 28, + "Access token received, you can use %v now.\n": 34, "Append to file": 21, - "Backend": 36, - "Check for duplicate listens on import (slower)": 45, + "Backend": 42, + "Check for duplicate listens on import (slower)": 24, "Client ID": 15, "Client secret": 16, "Delete the service configuration \"%v\"?": 7, - "Directory path": 47, - "Disable auto correction of submitted listens": 24, - "Error: OAuth state mismatch": 27, + "Directory path": 29, + "Disable auto correction of submitted listens": 26, + "Error: OAuth state mismatch": 33, "Failed reading config: %v": 2, "File path": 20, - "From timestamp: %v (%v)": 38, - "Ignore listens in incognito mode": 48, - "Ignore skipped listens": 49, - "Ignored duplicate listen %v: \"%v\" by %v (%v)": 46, - "Import failed, last reported timestamp was %v (%s)": 39, - "Import log:": 51, - "Imported %v of %v %s into %v.": 40, - "Include skipped listens": 25, - "Latest timestamp: %v (%v)": 41, - "Minimum playback duration for skipped tracks (seconds)": 50, - "No": 33, + "From timestamp: %v (%v)": 44, + "Ignore listens in incognito mode": 30, + "Ignore skipped listens": 27, + "Ignored duplicate listen %v: \"%v\" by %v (%v)": 25, + "Import failed, last reported timestamp was %v (%s)": 45, + "Import log:": 47, + "Imported %v of %v %s into %v.": 46, + "Latest timestamp: %v (%v)": 50, + "Minimum playback duration for skipped tracks (seconds)": 31, + "No": 39, "Playlist title": 22, "Saved service %v using backend %v": 5, "Server URL": 17, - "Service": 35, + "Service": 41, "Service \"%v\" deleted\n": 9, "Service name": 3, + "Specify a time zone for the listen timestamps": 28, "The backend %v requires authentication. Authenticate now?": 6, "Token received, you can close this window now.": 12, - "Transferring %s from %s to %s...": 37, + "Transferring %s from %s to %s…": 43, "Unique playlist identifier": 23, "Updated service %v using backend %v\n": 10, "User name": 18, - "Visit the URL for authorization: %v": 26, - "Yes": 32, + "Visit the URL for authorization: %v": 32, + "Yes": 38, "a service with this name already exists": 4, "backend %s does not implement %s": 13, - "done": 31, - "exporting": 29, - "importing": 30, - "invalid timestamp string \"%v\"": 53, - "key must only consist of A-Za-z0-9_-": 43, - "no configuration file defined, cannot write config": 42, - "no existing service configurations": 34, - "no service configuration \"%v\"": 44, + "done": 37, + "exporting": 35, + "importing": 36, + "invalid timestamp string \"%v\"": 49, + "key must only consist of A-Za-z0-9_-": 52, + "no configuration file defined, cannot write config": 51, + "no existing service configurations": 40, + "no service configuration \"%v\"": 53, "unknown backend \"%s\"": 14, } @@ -103,18 +103,18 @@ var deIndex = []uint32{ // 55 elements 0x000001ac, 0x000001e7, 0x00000213, 0x00000233, 0x0000023d, 0x0000024b, 0x00000256, 0x00000263, 0x00000271, 0x0000027b, 0x0000028e, 0x000002a1, - 0x000002b8, 0x000002ec, 0x0000030d, 0x00000333, - 0x0000035d, 0x0000039d, 0x000003a8, 0x000003b3, + 0x000002b8, 0x000002ed, 0x00000328, 0x0000035c, + 0x0000037e, 0x000003a4, 0x000003b4, 0x000003da, // Entry 20 - 3F - 0x000003ba, 0x000003bd, 0x000003c2, 0x000003eb, - 0x000003f3, 0x000003fb, 0x00000424, 0x00000442, - 0x0000047f, 0x000004aa, 0x000004cd, 0x0000051e, - 0x00000555, 0x0000057c, 0x0000057c, 0x0000057c, - 0x0000057c, 0x0000057c, 0x0000057c, 0x0000057c, - 0x0000057c, 0x0000057c, 0x0000057c, + 0x00000418, 0x00000443, 0x0000046d, 0x000004ad, + 0x000004b8, 0x000004c3, 0x000004ca, 0x000004cd, + 0x000004d2, 0x000004fb, 0x00000503, 0x0000050b, + 0x00000534, 0x00000552, 0x0000058f, 0x000005ba, + 0x000005c5, 0x000005d2, 0x000005f6, 0x00000619, + 0x0000066a, 0x000006a1, 0x000006c8, } // Size: 244 bytes -const deData string = "" + // Size: 1404 bytes +const deData string = "" + // Size: 1736 bytes "\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" + " %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" + "in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" + @@ -123,21 +123,26 @@ const deData string = "" + // Size: 1404 bytes "\x02Abgebrochen\x04\x00\x01\x0a\x1e\x02Service „%[1]v“ gelöscht\x04\x00" + "\x01\x0a1\x02Service %[1]v mit dem Backend %[2]v aktualisiert\x04\x01" + "\x09\x00\x0f\x02Backend: %[1]v\x02Token erhalten, das Fenster kann jetzt" + - " geschlossen werden.\x02das backend %[1]s implementiert %[2]s nicht\x02u" + + " geschlossen werden.\x02das Backend %[1]s implementiert %[2]s nicht\x02u" + "nbekanntes Backend „%[1]s“\x02Client-ID\x02Client-Secret\x02Server-URL" + "\x02Benutzername\x02Zugriffstoken\x02Dateipfad\x02An Datei anhängen\x02T" + - "itel der Playlist\x02Eindeutige Playlist-ID\x02Autokorrektur für übermit" + - "telte Titel deaktivieren\x02Übersprungene Titel einbeziehen\x02URL für A" + - "utorisierung öffnen: %[1]v\x02Fehler: OAuth-State stimmt nicht überein" + - "\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwendet " + - "werden.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine bes" + - "tehenden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %[1]s" + - " von %[2]s nach %[3]s...\x02Ab Zeitstempel: %[1]v (%[2]v)\x02Import fehl" + - "geschlagen, letzter Zeitstempel war %[1]v (%[2]s)\x02%[1]v von %[2]v %[3" + - "]s in %[4]v importiert.\x02Letzter Zeitstempel: %[1]v (%[2]v)\x02keine K" + - "onfigurationsdatei definiert, Konfiguration kann nicht geschrieben werde" + - "n\x02Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten\x02keine Serv" + - "icekonfiguration „%[1]v“" + "itel der Playlist\x02Eindeutige Playlist-ID\x02Beim Import auf Listen-Du" + + "plikate prüfen (langsamer)\x02Listen-Duplikat ignoriert %[1]v: \x22%[2]v" + + "\x22 von %[3]v (%[4]v)\x02Autokorrektur für übermittelte Titel deaktivie" + + "ren\x02Übersprungene Listens ignorieren\x02Zeitzone für den Abspiel-Zeit" + + "stempel\x02Verzeichnispfad\x02Listens im Inkognito-Modus ignorieren\x02M" + + "inimale Wiedergabedauer für übersprungene Titel (Sekunden)\x02Zur Anmeld" + + "ung folgende URL aufrufen: %[1]v\x02Fehler: OAuth-State stimmt nicht übe" + + "rein\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwen" + + "det werden.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine" + + " bestehenden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %" + + "[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[2]v)\x02Import fe" + + "hlgeschlagen, letzter Zeitstempel war %[1]v (%[2]s)\x02%[1]v von %[2]v %" + + "[3]s in %[4]v importiert.\x02Importlog:\x02%[1]v: %[2]v\x02ungültiger Ze" + + "itstempel „%[1]v“\x02Letzter Zeitstempel: %[1]v (%[2]v)\x02keine Konfigu" + + "rationsdatei definiert, Konfiguration kann nicht geschrieben werden\x02S" + + "chlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten\x02keine Servicekon" + + "figuration „%[1]v“" var enIndex = []uint32{ // 55 elements // Entry 0 - 1F @@ -147,18 +152,18 @@ var enIndex = []uint32{ // 55 elements 0x00000170, 0x0000019f, 0x000001c6, 0x000001de, 0x000001e8, 0x000001f6, 0x00000201, 0x0000020b, 0x00000218, 0x00000222, 0x00000231, 0x00000240, - 0x0000025b, 0x00000288, 0x000002a0, 0x000002c7, - 0x000002e3, 0x00000316, 0x00000320, 0x0000032a, + 0x0000025b, 0x0000028a, 0x000002c3, 0x000002f0, + 0x00000307, 0x00000335, 0x00000344, 0x00000365, // Entry 20 - 3F - 0x0000032f, 0x00000333, 0x00000336, 0x00000359, - 0x00000361, 0x00000369, 0x00000393, 0x000003b1, - 0x000003ea, 0x00000414, 0x00000434, 0x00000467, - 0x0000048c, 0x000004ad, 0x000004dc, 0x00000515, - 0x00000524, 0x00000545, 0x0000055c, 0x00000593, - 0x0000059f, 0x000005ac, 0x000005cd, + 0x0000039c, 0x000003c3, 0x000003df, 0x00000412, + 0x0000041c, 0x00000426, 0x0000042b, 0x0000042f, + 0x00000432, 0x00000455, 0x0000045d, 0x00000465, + 0x0000048f, 0x000004ad, 0x000004e6, 0x00000510, + 0x0000051c, 0x00000529, 0x0000054a, 0x0000056a, + 0x0000059d, 0x000005c2, 0x000005e3, } // Size: 244 bytes -const enData string = "" + // Size: 1485 bytes +const enData string = "" + // Size: 1507 bytes "\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" + " %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" + " this name already exists\x02Saved service %[1]v using backend %[2]v\x02" + @@ -169,20 +174,20 @@ const enData string = "" + // Size: 1485 bytes "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\x02Disable auto corre" + - "ction of submitted listens\x02Include skipped listens\x02Visit the URL f" + - "or authorization: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a." + - "\x02Access token received, you can use %[1]v now.\x02exporting\x02import" + - "ing\x02done\x02Yes\x02No\x02no existing service configurations\x02Servic" + - "e\x02Backend\x02Transferring %[1]s from %[2]s to %[3]s...\x02From timest" + - "amp: %[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.\x02Latest timestamp:" + - " %[1]v (%[2]v)\x02no configuration file defined, cannot write config\x02" + - "key must only consist of A-Za-z0-9_-\x02no service configuration \x22%[1" + - "]v\x22\x02Check for duplicate listens on import (slower)\x02Ignored dupl" + - "icate listen %[1]v: \x22%[2]v\x22 by %[3]v (%[4]v)\x02Directory path\x02" + - "Ignore listens in incognito mode\x02Ignore skipped listens\x02Minimum pl" + - "ayback duration for skipped tracks (seconds)\x02Import log:\x02%[1]v: %[" + - "2]v\x02invalid timestamp string \x22%[1]v\x22" + "le\x02Playlist title\x02Unique playlist identifier\x02Check for duplicat" + + "e listens on import (slower)\x02Ignored duplicate listen %[1]v: \x22%[2]" + + "v\x22 by %[3]v (%[4]v)\x02Disable auto correction of submitted listens" + + "\x02Ignore skipped listens\x02Specify a time zone for the listen timesta" + + "mps\x02Directory path\x02Ignore listens in incognito mode\x02Minimum pla" + + "yback duration for skipped tracks (seconds)\x02Visit the URL for authori" + + "zation: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Access " + + "token received, you can use %[1]v now.\x02exporting\x02importing\x02done" + + "\x02Yes\x02No\x02no existing service configurations\x02Service\x02Backen" + + "d\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestamp: %[1]v (%" + + "[2]v)\x02Import failed, last reported timestamp was %[1]v (%[2]s)\x02Imp" + + "orted %[1]v of %[2]v %[3]s into %[4]v.\x02Import log:\x02%[1]v: %[2]v" + + "\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: %[1]v (%" + + "[2]v)\x02no configuration file defined, cannot write config\x02key must " + + "only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]v\x22" - // Total table size 3377 bytes (3KiB); checksum: 6715024 + // Total table size 3731 bytes (3KiB); checksum: F7951710 diff --git a/internal/translations/locales/de/messages.gotext.json b/internal/translations/locales/de/messages.gotext.json index afa4129..8cbe44a 100644 --- a/internal/translations/locales/de/messages.gotext.json +++ b/internal/translations/locales/de/messages.gotext.json @@ -175,7 +175,7 @@ { "id": "backend {Backend} does not implement {InterfaceName}", "message": "backend {Backend} does not implement {InterfaceName}", - "translation": "das backend {Backend} implementiert {InterfaceName} nicht", + "translation": "das Backend {Backend} implementiert {InterfaceName} nicht", "placeholders": [ { "id": "Backend", @@ -261,9 +261,9 @@ "translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)" }, { - "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", - "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", - "translation": "Listen-Duplikat ignoriert {ListenedAt}: „{TrackName}“ von {ArtistName} ({RecordingMbid})", + "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", + "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", + "translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})", "placeholders": [ { "id": "ListenedAt", @@ -290,12 +290,12 @@ "expr": "l.ArtistName()" }, { - "id": "RecordingMbid", + "id": "RecordingMBID", "string": "%[4]v", - "type": "go.uploadedlobster.com/scotty/internal/models.MBID", + "type": "go.uploadedlobster.com/mbtypes.MBID", "underlyingType": "string", "argNum": 4, - "expr": "l.RecordingMbid" + "expr": "l.RecordingMBID" } ] }, @@ -305,9 +305,14 @@ "translation": "Autokorrektur für übermittelte Titel deaktivieren" }, { - "id": "Include skipped listens", - "message": "Include skipped listens", - "translation": "Übersprungene Titel einbeziehen" + "id": "Ignore skipped listens", + "message": "Ignore skipped listens", + "translation": "Übersprungene Listens ignorieren" + }, + { + "id": "Specify a time zone for the listen timestamps", + "message": "Specify a time zone for the listen timestamps", + "translation": "Zeitzone für den Abspiel-Zeitstempel" }, { "id": "Directory path", @@ -319,28 +324,23 @@ "message": "Ignore listens in incognito mode", "translation": "Listens im Inkognito-Modus ignorieren" }, - { - "id": "Ignore skipped listens", - "message": "Ignore skipped listens", - "translation": "Übersprungene Listens ignorieren" - }, { "id": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)", "translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)" }, { - "id": "Visit the URL for authorization: {Url}", - "message": "Visit the URL for authorization: {Url}", - "translation": "URL für Autorisierung öffnen: {Url}", + "id": "Visit the URL for authorization: {URL}", + "message": "Visit the URL for authorization: {URL}", + "translation": "Zur Anmeldung folgende URL aufrufen: {URL}", "placeholders": [ { - "id": "Url", + "id": "URL", "string": "%[1]v", "type": "string", "underlyingType": "string", "argNum": 1, - "expr": "authUrl.Url" + "expr": "authURL.URL" } ] }, @@ -411,9 +411,9 @@ "translation": "Backend" }, { - "id": "Transferring {Entity} from {SourceName} to {TargetName}...", - "message": "Transferring {Entity} from {SourceName} to {TargetName}...", - "translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...", + "id": "Transferring {Entity} from {SourceName} to {TargetName}…", + "message": "Transferring {Entity} from {SourceName} to {TargetName}…", + "translation": "Übertrage {Entity} von {SourceName} nach {TargetName}…", "placeholders": [ { "id": "Entity", diff --git a/internal/translations/locales/de/out.gotext.json b/internal/translations/locales/de/out.gotext.json index 3d84fe8..680505e 100644 --- a/internal/translations/locales/de/out.gotext.json +++ b/internal/translations/locales/de/out.gotext.json @@ -175,7 +175,7 @@ { "id": "backend {Backend} does not implement {InterfaceName}", "message": "backend {Backend} does not implement {InterfaceName}", - "translation": "das backend {Backend} implementiert {InterfaceName} nicht", + "translation": "das Backend {Backend} implementiert {InterfaceName} nicht", "placeholders": [ { "id": "Backend", @@ -258,12 +258,12 @@ { "id": "Check for duplicate listens on import (slower)", "message": "Check for duplicate listens on import (slower)", - "translation": "" + "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": "", + "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", + "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", + "translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})", "placeholders": [ { "id": "ListenedAt", @@ -290,12 +290,12 @@ "expr": "l.ArtistName()" }, { - "id": "RecordingMbid", + "id": "RecordingMBID", "string": "%[4]v", - "type": "go.uploadedlobster.com/scotty/internal/models.MBID", + "type": "go.uploadedlobster.com/mbtypes.MBID", "underlyingType": "string", "argNum": 4, - "expr": "l.RecordingMbid" + "expr": "l.RecordingMBID" } ] }, @@ -305,42 +305,42 @@ "translation": "Autokorrektur für übermittelte Titel deaktivieren" }, { - "id": "Include skipped listens", - "message": "Include skipped listens", - "translation": "Übersprungene Titel einbeziehen" + "id": "Ignore skipped listens", + "message": "Ignore skipped listens", + "translation": "Übersprungene Listens ignorieren" + }, + { + "id": "Specify a time zone for the listen timestamps", + "message": "Specify a time zone for the listen timestamps", + "translation": "Zeitzone für den Abspiel-Zeitstempel" }, { "id": "Directory path", "message": "Directory path", - "translation": "" + "translation": "Verzeichnispfad" }, { "id": "Ignore listens in incognito mode", "message": "Ignore listens in incognito mode", - "translation": "" - }, - { - "id": "Ignore skipped listens", - "message": "Ignore skipped listens", - "translation": "" + "translation": "Listens im Inkognito-Modus ignorieren" }, { "id": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)", - "translation": "" + "translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)" }, { - "id": "Visit the URL for authorization: {Url}", - "message": "Visit the URL for authorization: {Url}", - "translation": "URL für Autorisierung öffnen: {Url}", + "id": "Visit the URL for authorization: {URL}", + "message": "Visit the URL for authorization: {URL}", + "translation": "Zur Anmeldung folgende URL aufrufen: {URL}", "placeholders": [ { - "id": "Url", + "id": "URL", "string": "%[1]v", "type": "string", "underlyingType": "string", "argNum": 1, - "expr": "authUrl.Url" + "expr": "authURL.URL" } ] }, @@ -411,9 +411,9 @@ "translation": "Backend" }, { - "id": "Transferring {Entity} from {SourceName} to {TargetName}...", - "message": "Transferring {Entity} from {SourceName} to {TargetName}...", - "translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...", + "id": "Transferring {Entity} from {SourceName} to {TargetName}…", + "message": "Transferring {Entity} from {SourceName} to {TargetName}…", + "translation": "Übertrage {Entity} von {SourceName} nach {TargetName}…", "placeholders": [ { "id": "Entity", @@ -525,12 +525,12 @@ { "id": "Import log:", "message": "Import log:", - "translation": "" + "translation": "Importlog:" }, { "id": "{Type}: {Message}", "message": "{Type}: {Message}", - "translation": "", + "translation": "{Type}: {Message}", "placeholders": [ { "id": "Type", @@ -553,7 +553,7 @@ { "id": "invalid timestamp string \"{FlagValue}\"", "message": "invalid timestamp string \"{FlagValue}\"", - "translation": "", + "translation": "ungültiger Zeitstempel „{FlagValue}“", "placeholders": [ { "id": "FlagValue", diff --git a/internal/translations/locales/en/messages.gotext.json b/internal/translations/locales/en/messages.gotext.json index 7687276..ed62636 100644 --- a/internal/translations/locales/en/messages.gotext.json +++ b/internal/translations/locales/en/messages.gotext.json @@ -311,9 +311,9 @@ "fuzzy": true }, { - "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", - "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", - "translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", + "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", + "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", + "translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", "translatorComment": "Copied from source.", "placeholders": [ { @@ -341,12 +341,12 @@ "expr": "l.ArtistName()" }, { - "id": "RecordingMbid", + "id": "RecordingMBID", "string": "%[4]v", - "type": "go.uploadedlobster.com/scotty/internal/models.MBID", + "type": "go.uploadedlobster.com/mbtypes.MBID", "underlyingType": "string", "argNum": 4, - "expr": "l.RecordingMbid" + "expr": "l.RecordingMBID" } ], "fuzzy": true @@ -359,9 +359,16 @@ "fuzzy": true }, { - "id": "Include skipped listens", - "message": "Include skipped listens", - "translation": "Include skipped listens", + "id": "Ignore skipped listens", + "message": "Ignore skipped listens", + "translation": "Ignore skipped listens", + "translatorComment": "Copied from source.", + "fuzzy": true + }, + { + "id": "Specify a time zone for the listen timestamps", + "message": "Specify a time zone for the listen timestamps", + "translation": "Specify a time zone for the listen timestamps", "translatorComment": "Copied from source.", "fuzzy": true }, @@ -379,13 +386,6 @@ "translatorComment": "Copied from source.", "fuzzy": true }, - { - "id": "Ignore skipped listens", - "message": "Ignore skipped listens", - "translation": "Ignore skipped listens", - "translatorComment": "Copied from source.", - "fuzzy": true - }, { "id": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)", @@ -394,18 +394,18 @@ "fuzzy": true }, { - "id": "Visit the URL for authorization: {Url}", - "message": "Visit the URL for authorization: {Url}", - "translation": "Visit the URL for authorization: {Url}", + "id": "Visit the URL for authorization: {URL}", + "message": "Visit the URL for authorization: {URL}", + "translation": "Visit the URL for authorization: {URL}", "translatorComment": "Copied from source.", "placeholders": [ { - "id": "Url", + "id": "URL", "string": "%[1]v", "type": "string", "underlyingType": "string", "argNum": 1, - "expr": "authUrl.Url" + "expr": "authURL.URL" } ], "fuzzy": true @@ -491,9 +491,9 @@ "fuzzy": true }, { - "id": "Transferring {Entity} from {SourceName} to {TargetName}...", - "message": "Transferring {Entity} from {SourceName} to {TargetName}...", - "translation": "Transferring {Entity} from {SourceName} to {TargetName}...", + "id": "Transferring {Entity} from {SourceName} to {TargetName}…", + "message": "Transferring {Entity} from {SourceName} to {TargetName}…", + "translation": "Transferring {Entity} from {SourceName} to {TargetName}…", "translatorComment": "Copied from source.", "placeholders": [ { @@ -714,4 +714,4 @@ "fuzzy": true } ] -} \ No newline at end of file +} diff --git a/internal/translations/locales/en/out.gotext.json b/internal/translations/locales/en/out.gotext.json index 7687276..eecf359 100644 --- a/internal/translations/locales/en/out.gotext.json +++ b/internal/translations/locales/en/out.gotext.json @@ -311,9 +311,9 @@ "fuzzy": true }, { - "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", - "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", - "translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})", + "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", + "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", + "translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", "translatorComment": "Copied from source.", "placeholders": [ { @@ -341,12 +341,12 @@ "expr": "l.ArtistName()" }, { - "id": "RecordingMbid", + "id": "RecordingMBID", "string": "%[4]v", - "type": "go.uploadedlobster.com/scotty/internal/models.MBID", + "type": "go.uploadedlobster.com/mbtypes.MBID", "underlyingType": "string", "argNum": 4, - "expr": "l.RecordingMbid" + "expr": "l.RecordingMBID" } ], "fuzzy": true @@ -359,9 +359,16 @@ "fuzzy": true }, { - "id": "Include skipped listens", - "message": "Include skipped listens", - "translation": "Include skipped listens", + "id": "Ignore skipped listens", + "message": "Ignore skipped listens", + "translation": "Ignore skipped listens", + "translatorComment": "Copied from source.", + "fuzzy": true + }, + { + "id": "Specify a time zone for the listen timestamps", + "message": "Specify a time zone for the listen timestamps", + "translation": "Specify a time zone for the listen timestamps", "translatorComment": "Copied from source.", "fuzzy": true }, @@ -379,13 +386,6 @@ "translatorComment": "Copied from source.", "fuzzy": true }, - { - "id": "Ignore skipped listens", - "message": "Ignore skipped listens", - "translation": "Ignore skipped listens", - "translatorComment": "Copied from source.", - "fuzzy": true - }, { "id": "Minimum playback duration for skipped tracks (seconds)", "message": "Minimum playback duration for skipped tracks (seconds)", @@ -394,18 +394,18 @@ "fuzzy": true }, { - "id": "Visit the URL for authorization: {Url}", - "message": "Visit the URL for authorization: {Url}", - "translation": "Visit the URL for authorization: {Url}", + "id": "Visit the URL for authorization: {URL}", + "message": "Visit the URL for authorization: {URL}", + "translation": "Visit the URL for authorization: {URL}", "translatorComment": "Copied from source.", "placeholders": [ { - "id": "Url", + "id": "URL", "string": "%[1]v", "type": "string", "underlyingType": "string", "argNum": 1, - "expr": "authUrl.Url" + "expr": "authURL.URL" } ], "fuzzy": true @@ -491,9 +491,9 @@ "fuzzy": true }, { - "id": "Transferring {Entity} from {SourceName} to {TargetName}...", - "message": "Transferring {Entity} from {SourceName} to {TargetName}...", - "translation": "Transferring {Entity} from {SourceName} to {TargetName}...", + "id": "Transferring {Entity} from {SourceName} to {TargetName}…", + "message": "Transferring {Entity} from {SourceName} to {TargetName}…", + "translation": "Transferring {Entity} from {SourceName} to {TargetName}…", "translatorComment": "Copied from source.", "placeholders": [ { diff --git a/internal/translations/translations.go b/internal/translations/translations.go index c555d32..9961c41 100644 --- a/internal/translations/translations.go +++ b/internal/translations/translations.go @@ -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 diff --git a/internal/version/version.go b/internal/version/version.go index 3f02fe2..07d1569 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2024 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer 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,8 @@ package version const ( AppName = "scotty" - AppVersion = "0.4.1" + AppVersion = "0.5.0" + AppURL = "https://git.sr.ht/~phw/scotty/" ) func UserAgent() string { diff --git a/pkg/jspf/extensions.go b/pkg/jspf/extensions.go index 07cd3c1..0f521c4 100644 --- a/pkg/jspf/extensions.go +++ b/pkg/jspf/extensions.go @@ -26,9 +26,9 @@ import "time" const ( // The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension - MusicBrainzPlaylistExtensionId = "https://musicbrainz.org/doc/jspf#playlist" + MusicBrainzPlaylistExtensionID = "https://musicbrainz.org/doc/jspf#playlist" // The identifier for the MusicBrainz / ListenBrainz JSPF track extension - MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track" + MusicBrainzTrackExtensionID = "https://musicbrainz.org/doc/jspf#track" ) // MusicBrainz / ListenBrainz JSPF track extension diff --git a/pkg/jspf/extensions_test.go b/pkg/jspf/extensions_test.go index 8d8653d..883301d 100644 --- a/pkg/jspf/extensions_test.go +++ b/pkg/jspf/extensions_test.go @@ -39,7 +39,7 @@ func ExampleMusicBrainzTrackExtension() { { Title: "Oweynagat", Extension: map[string]any{ - jspf.MusicBrainzTrackExtensionId: jspf.MusicBrainzTrackExtension{ + jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{ AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), AddedBy: "scotty", }, diff --git a/pkg/scrobblerlog/parser.go b/pkg/scrobblerlog/parser.go new file mode 100644 index 0000000..9c6471c --- /dev/null +++ b/pkg/scrobblerlog/parser.go @@ -0,0 +1,264 @@ +/* +Copyright © 2023-2025 Philipp Wolfer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +// Package to parse and writer .scrobbler.log files as written by Rockbox. +// +// See +// - https://www.rockbox.org/wiki/LastFMLog +// - https://git.rockbox.org/cgit/rockbox.git/tree/apps/plugins/lastfm_scrobbler.c +package scrobblerlog + +import ( + "bufio" + "encoding/csv" + "fmt" + "io" + "strconv" + "strings" + "time" + + "go.uploadedlobster.com/mbtypes" +) + +// TZInfo is the timezone information in the header of the scrobbler log file. +// It can be "UTC" or "UNKNOWN", if the device writing the scrobbler log file +// knows the time, but not the timezone. +type TZInfo string + +const ( + TimezoneUnknown TZInfo = "UNKNOWN" + TimezoneUTC TZInfo = "UTC" +) + +// L if listened at least 50% or S if skipped +type Rating string + +const ( + RatingListened Rating = "L" + RatingSkipped Rating = "S" +) + +// A single entry of a track in the scrobbler log file. +type Record struct { + ArtistName string + AlbumName string + TrackName string + TrackNumber int + Duration time.Duration + Rating Rating + Timestamp time.Time + MusicBrainzRecordingID mbtypes.MBID +} + +// Represents a scrobbler log file. +type ScrobblerLog struct { + TZ TZInfo + Client string + Records []Record + // Timezone to be used for timestamps in the log file, + // if TZ is set to [TimezoneUnknown]. + FallbackTimezone *time.Location +} + +func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error { + l.Records = make([]Record, 0) + + reader := bufio.NewReader(data) + err := l.ReadHeader(reader) + if err != nil { + return err + } + + tsvReader := csv.NewReader(reader) + tsvReader.Comma = '\t' + // Row length is often flexible + tsvReader.FieldsPerRecord = -1 + + for { + // A row is: + // artistName releaseName trackName trackNumber duration rating timestamp recordingMBID + row, err := tsvReader.Read() + if err == io.EOF { + break + } else if err != nil { + return err + } + + // fmt.Printf("row: %v\n", row) + + // We consider only the last field (recording MBID) optional + if len(row) < 7 { + line, _ := tsvReader.FieldPos(0) + return fmt.Errorf("invalid record in scrobblerlog line %v", line) + } + + record, err := l.rowToRecord(row) + if err != nil { + return err + } + + if ignoreSkipped && record.Rating == RatingSkipped { + continue + } + + l.Records = append(l.Records, record) + } + + return nil +} + +func (l *ScrobblerLog) Append(data io.Writer, records []Record) (lastTimestamp time.Time, err error) { + tsvWriter := csv.NewWriter(data) + tsvWriter.Comma = '\t' + + for _, record := range records { + if record.Timestamp.After(lastTimestamp) { + lastTimestamp = record.Timestamp + } + + // A row is: + // artistName releaseName trackName trackNumber duration rating timestamp recordingMBID + err = tsvWriter.Write([]string{ + record.ArtistName, + record.AlbumName, + record.TrackName, + strconv.Itoa(record.TrackNumber), + strconv.Itoa(int(record.Duration.Seconds())), + string(record.Rating), + strconv.FormatInt(record.Timestamp.Unix(), 10), + string(record.MusicBrainzRecordingID), + }) + } + + tsvWriter.Flush() + return +} + +func (l *ScrobblerLog) ReadHeader(reader *bufio.Reader) error { + // Skip header + for i := 0; i < 3; i++ { + line, _, err := reader.ReadLine() + if err != nil { + return err + } + + if len(line) == 0 || line[0] != '#' { + err = fmt.Errorf("unexpected header (line %v)", i) + } else { + text := string(line) + if i == 0 && !strings.HasPrefix(text, "#AUDIOSCROBBLER/1") { + err = fmt.Errorf("not a scrobbler log file") + } + + // The timezone can be set to "UTC" or "UNKNOWN", if the device writing + // the log knows the time, but not the timezone. + timezone, found := strings.CutPrefix(text, "#TZ/") + if found { + l.TZ = TZInfo(timezone) + continue + } + + client, found := strings.CutPrefix(text, "#CLIENT/") + if found { + l.Client = client + continue + } + } + + if err != nil { + return err + } + } + return nil +} + +func (l *ScrobblerLog) WriteHeader(writer io.Writer) error { + headers := []string{ + "#AUDIOSCROBBLER/1.1\n", + "#TZ/" + string(l.TZ) + "\n", + "#CLIENT/" + l.Client + "\n", + } + for _, line := range headers { + _, err := writer.Write([]byte(line)) + if err != nil { + return err + } + } + return nil +} + +func (l ScrobblerLog) rowToRecord(row []string) (Record, error) { + var record Record + trackNumber, err := strconv.Atoi(row[3]) + if err != nil { + return record, err + } + + duration, err := strconv.Atoi(row[4]) + if err != nil { + return record, err + } + + timestamp, err := strconv.ParseInt(row[6], 10, 64) + if err != nil { + return record, err + } + + var timezone *time.Location = nil + if l.TZ == TimezoneUnknown { + timezone = l.FallbackTimezone + } + + record = Record{ + ArtistName: row[0], + AlbumName: row[1], + TrackName: row[2], + TrackNumber: trackNumber, + Duration: time.Duration(duration) * time.Second, + Rating: Rating(row[5]), + Timestamp: timeFromLocalTimestamp(timestamp, timezone), + } + + if len(row) > 7 { + record.MusicBrainzRecordingID = mbtypes.MBID(row[7]) + } + + return record, nil +} + +// Convert a Unix timestamp to a [time.Time] object, but treat the timestamp +// as being in the given location's timezone instead of UTC. +// If location is nil, the timestamp is returned as UTC. +func timeFromLocalTimestamp(timestamp int64, location *time.Location) time.Time { + t := time.Unix(timestamp, 0) + + // The time is now in UTC. Get the offset to the requested timezone + // and shift the time accordingly. + if location != nil { + _, offset := t.In(location).Zone() + if offset != 0 { + t = t.Add(time.Duration(offset) * time.Second) + } + } + + return t +} diff --git a/internal/backends/scrobblerlog/parser_test.go b/pkg/scrobblerlog/parser_test.go similarity index 54% rename from internal/backends/scrobblerlog/parser_test.go rename to pkg/scrobblerlog/parser_test.go index 51d15c7..f4527cc 100644 --- a/internal/backends/scrobblerlog/parser_test.go +++ b/pkg/scrobblerlog/parser_test.go @@ -30,8 +30,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uploadedlobster.com/scotty/internal/backends/scrobblerlog" - "go.uploadedlobster.com/scotty/internal/models" + "go.uploadedlobster.com/mbtypes" + "go.uploadedlobster.com/scotty/pkg/scrobblerlog" ) var testScrobblerLog = `#AUDIOSCROBBLER/1.1 @@ -47,67 +47,84 @@ Teeth Agency You Don't Have To Live In Pain Wolfs Jam 2 107 L 1260359404 1262bea func TestParser(t *testing.T) { assert := assert.New(t) data := bytes.NewBufferString(testScrobblerLog) - result, err := scrobblerlog.Parse(data, true) + result := scrobblerlog.ScrobblerLog{} + err := result.Parse(data, false) require.NoError(t, err) - assert.Equal("UNKNOWN", result.Timezone) + assert.Equal(scrobblerlog.TimezoneUnknown, result.TZ) assert.Equal("Rockbox sansaclipplus $Revision$", result.Client) - assert.Len(result.Listens, 5) - listen1 := result.Listens[0] - assert.Equal("Özcan Deniz", listen1.ArtistName()) - assert.Equal("Ses ve Ayrilik", listen1.ReleaseName) - assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", listen1.TrackName) - assert.Equal(5, listen1.TrackNumber) - 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) - listen4 := result.Listens[3] - assert.Equal("S", listen4.AdditionalInfo["rockbox_rating"]) - assert.Equal(models.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), listen4.RecordingMbid) + assert.Len(result.Records, 5) + record1 := result.Records[0] + assert.Equal("Özcan Deniz", record1.ArtistName) + assert.Equal("Ses ve Ayrilik", record1.AlbumName) + assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", record1.TrackName) + assert.Equal(5, record1.TrackNumber) + assert.Equal(time.Duration(306*time.Second), record1.Duration) + assert.Equal(scrobblerlog.RatingListened, record1.Rating) + assert.Equal(time.Unix(1260342084, 0), record1.Timestamp) + assert.Equal(mbtypes.MBID(""), record1.MusicBrainzRecordingID) + record4 := result.Records[3] + assert.Equal(scrobblerlog.RatingSkipped, record4.Rating) + assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), + record4.MusicBrainzRecordingID) } -func TestParserExcludeSkipped(t *testing.T) { +func TestParserIgnoreSkipped(t *testing.T) { assert := assert.New(t) data := bytes.NewBufferString(testScrobblerLog) - result, err := scrobblerlog.Parse(data, false) + result := scrobblerlog.ScrobblerLog{} + err := result.Parse(data, true) require.NoError(t, err) - 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.Len(result.Records, 4) + record4 := result.Records[3] + assert.Equal(scrobblerlog.RatingListened, record4.Rating) + assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), + record4.MusicBrainzRecordingID) } -func TestWrite(t *testing.T) { +func TestParserFallbackTimezone(t *testing.T) { + assert := assert.New(t) + data := bytes.NewBufferString(testScrobblerLog) + result := scrobblerlog.ScrobblerLog{ + FallbackTimezone: time.FixedZone("UTC+2", 7200), + } + err := result.Parse(data, false) + require.NoError(t, err) + record1 := result.Records[0] + assert.Equal( + time.Unix(1260342084, 0).Add(2*time.Hour), + record1.Timestamp, + ) +} + +func TestAppend(t *testing.T) { assert := assert.New(t) data := make([]byte, 0, 10) buffer := bytes.NewBuffer(data) log := scrobblerlog.ScrobblerLog{ - Timezone: "Unknown", - Client: "Rockbox foo $Revision$", - Listens: []models.Listen{ - { - ListenedAt: time.Unix(1699572072, 0), - Track: models.Track{ - ArtistNames: []string{"Prinzhorn Dance School"}, - ReleaseName: "Home Economics", - TrackName: "Reign", - TrackNumber: 1, - Duration: 271 * time.Second, - RecordingMbid: models.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"), - AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"}, - }, - }, + TZ: scrobblerlog.TimezoneUnknown, + Client: "Rockbox foo $Revision$", + } + records := []scrobblerlog.Record{ + { + ArtistName: "Prinzhorn Dance School", + AlbumName: "Home Economics", + TrackName: "Reign", + TrackNumber: 1, + Duration: 271 * time.Second, + Rating: scrobblerlog.RatingListened, + Timestamp: time.Unix(1699572072, 0), + MusicBrainzRecordingID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"), }, } - err := scrobblerlog.WriteHeader(buffer, &log) + err := log.WriteHeader(buffer) require.NoError(t, err) - lastTimestamp, err := scrobblerlog.Write(buffer, log.Listens) + lastTimestamp, err := log.Append(buffer, records) require.NoError(t, err) result := buffer.String() lines := strings.Split(result, "\n") assert.Equal(5, len(lines)) assert.Equal("#AUDIOSCROBBLER/1.1", lines[0]) - assert.Equal("#TZ/Unknown", lines[1]) + assert.Equal("#TZ/UNKNOWN", lines[1]) assert.Equal("#CLIENT/Rockbox foo $Revision$", lines[2]) assert.Equal( "Prinzhorn Dance School\tHome Economics\tReign\t1\t271\tL\t1699572072\tb59cf4e7-caee-4019-a844-79d2c58d4dff", @@ -120,9 +137,9 @@ func TestReadHeader(t *testing.T) { data := bytes.NewBufferString(testScrobblerLog) reader := bufio.NewReader(data) log := scrobblerlog.ScrobblerLog{} - err := scrobblerlog.ReadHeader(reader, &log) + err := log.ReadHeader(reader) assert.NoError(t, err) - assert.Equal(t, log.Timezone, "UNKNOWN") + assert.Equal(t, log.TZ, scrobblerlog.TimezoneUnknown) assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$") - assert.Empty(t, log.Listens) + assert.Empty(t, log.Records) }