mirror of
https://git.sr.ht/~phw/scotty
synced 2025-04-29 21:27:05 +02:00
Compare commits
44 commits
Author | SHA1 | Date | |
---|---|---|---|
|
0a411fe2fa | ||
|
1e91b684cb | ||
|
19852be68b | ||
|
a6cc8d49ac | ||
|
a5442b477e | ||
|
90e101080f | ||
|
dff34b249c | ||
|
bcb1834994 | ||
|
d51c97c648 | ||
|
39b31fc664 | ||
|
1516a3a9d6 | ||
|
82858315fa | ||
|
e135ea5fa9 | ||
|
597914e6db | ||
|
c817480809 | ||
|
47486ff659 | ||
|
159f486cdc | ||
|
b104c2bc42 | ||
|
ed191d2f15 | ||
|
0f4b04c641 | ||
|
aad542850a | ||
|
aeb3a56982 | ||
|
69665bc286 | ||
|
9184d2c3cf | ||
|
4a30bdf9d9 | ||
|
91f78d04dd | ||
|
9e1c2d8435 | ||
|
db78bfe457 | ||
|
20c9ada6ec | ||
|
7c0774fb8d | ||
|
90bf51a00b | ||
|
910056b0a6 | ||
|
bed60c7cdf | ||
|
2d66d41873 | ||
|
da6c920789 | ||
|
01e7569051 | ||
|
1ea90d2d2b | ||
|
329f696b55 | ||
|
5f9c0f24ab | ||
|
dc834e9b6f | ||
|
0d9bc74bc0 | ||
|
13eb8342ab | ||
|
ad1644672c | ||
|
8fff19ceac |
67 changed files with 1315 additions and 1094 deletions
.build.yml.goreleaser.yaml.weblateCHANGES.mdconfig.example.tomlgo.modgo.sum
internal
auth
backends
cli
i18n
models
similarity
translations
version
pkg
13
.build.yml
13
.build.yml
|
@ -5,10 +5,11 @@ packages:
|
||||||
- hut
|
- hut
|
||||||
- weblate-wlc
|
- weblate-wlc
|
||||||
secrets:
|
secrets:
|
||||||
- 2a17e258-3e99-4093-9527-832c350d9c53
|
- 0e2ad815-6c46-4cea-878e-70fc33f71e77
|
||||||
oauth: pages.sr.ht/PAGES:RW
|
oauth: pages.sr.ht/PAGES:RW
|
||||||
tasks:
|
tasks:
|
||||||
- weblate-update: |
|
- weblate-update: |
|
||||||
|
cd scotty
|
||||||
wlc --format text pull scotty
|
wlc --format text pull scotty
|
||||||
- test: |
|
- test: |
|
||||||
cd scotty
|
cd scotty
|
||||||
|
@ -28,5 +29,15 @@ tasks:
|
||||||
- publish-redirect: |
|
- publish-redirect: |
|
||||||
# Update redirect on https://go.uploadedlobster.com/scotty
|
# Update redirect on https://go.uploadedlobster.com/scotty
|
||||||
./scotty/pages/publish.sh
|
./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:
|
artifacts:
|
||||||
- scotty/dist/artifacts.tar
|
- scotty/dist/artifacts.tar
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||||
|
|
||||||
version: 1
|
version: 2
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
|
@ -21,6 +21,8 @@ builds:
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
ignore:
|
ignore:
|
||||||
|
- goos: linux
|
||||||
|
goarch: "386"
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: "386"
|
goarch: "386"
|
||||||
|
|
||||||
|
@ -28,7 +30,7 @@ universal_binaries:
|
||||||
- replace: true
|
- replace: true
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: tar.gz
|
- formats: ['tar.gz']
|
||||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .ProjectName }}-{{ .Version }}_
|
{{ .ProjectName }}-{{ .Version }}_
|
||||||
|
@ -42,7 +44,7 @@ archives:
|
||||||
# use zip for windows archives
|
# use zip for windows archives
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats: ['zip']
|
||||||
files:
|
files:
|
||||||
- COPYING
|
- COPYING
|
||||||
- README.md
|
- README.md
|
||||||
|
|
3
.weblate
Normal file
3
.weblate
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[weblate]
|
||||||
|
url = https://translate.uploadedlobster.com/api/
|
||||||
|
translation = scotty/app
|
15
CHANGES.md
15
CHANGES.md
|
@ -1,5 +1,20 @@
|
||||||
# Scotty Changelog
|
# 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
|
## 0.4.1 - 2024-09-16
|
||||||
- Subsonic: include `subsonic_id` as additional metadata
|
- Subsonic: include `subsonic_id` as additional metadata
|
||||||
- Deezer: fix artist and album ID URIs (#7)
|
- Deezer: fix artist and album ID URIs (#7)
|
||||||
|
|
|
@ -56,11 +56,18 @@ backend = "scrobbler-log"
|
||||||
# The file path to the .scrobbler.log file. Relative paths are resolved against
|
# The file path to the .scrobbler.log file. Relative paths are resolved against
|
||||||
# the current working directory when running scotty.
|
# the current working directory when running scotty.
|
||||||
file-path = "./.scrobbler.log"
|
file-path = "./.scrobbler.log"
|
||||||
# If true, reading listens from the file also returns listens marked as "skipped"
|
# If true (default), ignore listens marked as skipped.
|
||||||
include-skipped = true
|
ignore-skipped = true
|
||||||
# If true (default), new listens will be appended to the existing file. Set to
|
# 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.
|
# false to overwrite the file and create a new scrobbler log on every run.
|
||||||
append = true
|
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]
|
[service.jspf]
|
||||||
# Write listens and loves to JSPF playlist files (https://xspf.org/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
|
ignore-incognito = true
|
||||||
# If true, ignore listens marked as skipped. Default is false.
|
# If true, ignore listens marked as skipped. Default is false.
|
||||||
ignore-skipped = false
|
ignore-skipped = false
|
||||||
# Only consider skipped listens with a playback duration longer than this number
|
# Only consider skipped listens with a playback duration longer than or equal to
|
||||||
# of seconds. Default is 30 seconds. If ignore-skipped is set to false this
|
# this number of seconds. Default is 30 seconds. If ignore-skipped is enabled
|
||||||
# setting has no effect.
|
# this setting has no effect.
|
||||||
ignore-min-duration-seconds = 30
|
ignore-min-duration-seconds = 30
|
||||||
|
|
||||||
[service.deezer]
|
[service.deezer]
|
||||||
|
|
74
go.mod
74
go.mod
|
@ -1,31 +1,33 @@
|
||||||
module go.uploadedlobster.com/scotty
|
module go.uploadedlobster.com/scotty
|
||||||
|
|
||||||
go 1.22.0
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.22.2
|
toolchain go1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Xuanwo/go-locale v1.1.2
|
github.com/Xuanwo/go-locale v1.1.3
|
||||||
github.com/agnivade/levenshtein v1.1.1
|
github.com/agnivade/levenshtein v1.2.1
|
||||||
github.com/cli/browser v1.3.0
|
github.com/cli/browser v1.3.0
|
||||||
github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238
|
github.com/fatih/color v1.18.0
|
||||||
github.com/fatih/color v1.17.0
|
|
||||||
github.com/glebarez/sqlite v1.11.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/jarcoal/httpmock v1.3.1
|
||||||
github.com/manifoldco/promptui v0.9.0
|
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/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0
|
||||||
github.com/spf13/cast v1.7.0
|
github.com/spf13/cast v1.7.1
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/viper v1.19.0
|
github.com/spf13/viper v1.20.1
|
||||||
github.com/stretchr/testify v1.9.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/vbauerster/mpb/v8 v8.8.3
|
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
|
||||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
|
github.com/vbauerster/mpb/v8 v8.9.3
|
||||||
golang.org/x/oauth2 v0.23.0
|
go.uploadedlobster.com/mbtypes v0.4.0
|
||||||
golang.org/x/text v0.18.0
|
go.uploadedlobster.com/musicbrainzws2 v0.14.0
|
||||||
gorm.io/datatypes v1.2.2
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
|
||||||
gorm.io/gorm v1.25.12
|
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 (
|
require (
|
||||||
|
@ -35,37 +37,39 @@ require (
|
||||||
github.com/chzyer/readline v1.5.1 // indirect
|
github.com/chzyer/readline v1.5.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/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/google/uuid v1.6.0 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // 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/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.6.0 // indirect
|
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
github.com/spf13/afero v1.11.0 // indirect
|
github.com/spf13/afero v1.14.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/net v0.29.0 // indirect
|
golang.org/x/image v0.26.0 // indirect
|
||||||
golang.org/x/sys v0.25.0 // indirect
|
golang.org/x/mod v0.24.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/driver/mysql v1.5.7 // indirect
|
gorm.io/driver/mysql v1.5.7 // indirect
|
||||||
modernc.org/libc v1.60.1 // indirect
|
modernc.org/libc v1.64.0 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.8.0 // indirect
|
modernc.org/memory v1.10.0 // indirect
|
||||||
modernc.org/sqlite v1.33.1 // indirect
|
modernc.org/sqlite v1.37.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tool golang.org/x/text/cmd/gotext
|
||||||
|
|
300
go.sum
300
go.sum
|
@ -2,14 +2,12 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
|
||||||
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
|
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.3 h1:EWZZJJt5rqPHHbqPRH1zFCn5D7xHjjebODctA4aUO3A=
|
||||||
github.com/Xuanwo/go-locale v1.1.0/go.mod h1:UKrHoZB3FPIk9wIG2/tVSobnHgNnceGSH3Y8DY5cASs=
|
github.com/Xuanwo/go-locale v1.1.3/go.mod h1:REn+F/c+AtGSWYACBSYZgl23AP+0lfQC+SEFPN+hj30=
|
||||||
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/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
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/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.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
|
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 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/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/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 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
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.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
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/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/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||||
github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5/go.mod h1:vnbEuj6Z20PLcHB4rrLQAOXGMjtULfMGhRVSFPcSdUo=
|
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||||
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
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/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.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
|
|
||||||
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
|
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
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/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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
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 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
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 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
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.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||||
github.com/go-resty/resty/v2 v2.12.0/go.mod h1:o0yGPrkS3lOe1+eFajk6kBW8ScXzwU3hD69/gt2yB/0=
|
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
||||||
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-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
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.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
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 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
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 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||||
github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA=
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
|
|
||||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
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 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
|
||||||
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
||||||
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
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-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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
|
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/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 v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||||
github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
|
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||||
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
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/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.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
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/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=
|
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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
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/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.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||||
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/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 h1:cgqwZtnR+IQfUYDLJ3Kiy4aE+O/wExTzEIg8xwC4Qfs=
|
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/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 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
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.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4=
|
||||||
github.com/vbauerster/mpb/v8 v8.7.3/go.mod h1:9nFlNpDGVoTmQ4QvNjSLtwLmAFjwmq0XaAF26toHGNM=
|
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4=
|
||||||
github.com/vbauerster/mpb/v8 v8.8.3 h1:dTOByGoqwaTJYPubhVz3lO5O6MK553XVgUo33LdnNsQ=
|
github.com/vbauerster/mpb/v8 v8.9.3 h1:PnMeF+sMvYv9u23l6DO6Q3+Mdj408mjLRXIzmUmU2Z8=
|
||||||
github.com/vbauerster/mpb/v8 v8.8.3/go.mod h1:JfCCrtcMsJwP6ZwMn9e5LMnNyp3TVNpUWWkN+nd4EWk=
|
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=
|
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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
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-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.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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
|
||||||
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc=
|
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||||
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
|
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||||
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
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/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
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-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.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.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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
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/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
|
||||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
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-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-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-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-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-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-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
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.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.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.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.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.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
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/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
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-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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY=
|
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||||
golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg=
|
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||||
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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 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 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I=
|
||||||
gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04=
|
gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4=
|
||||||
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/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
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/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 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
||||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
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 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
|
||||||
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
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.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
|
||||||
gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
|
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.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
|
gorm.io/gorm v1.26.0 h1:9lqQVPG5aNNS6AyHdRiwScAVnXHg/L/Srzx55G5fOgs=
|
||||||
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/gorm v1.26.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
|
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
||||||
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||||
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
|
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
modernc.org/libc v1.64.0 h1:U0k8BD2d3cD3e9I8RLcZgJBHAcsJzbXx5mKGSb5pyJA=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
modernc.org/libc v1.64.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||||
modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg=
|
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/libc v1.60.1 h1:at373l8IFRTkJIkAU85BIuUoBM4T1b51ds0E1ovPG2s=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/libc v1.60.1/go.mod h1:xJuobKuNxKH3RUatS7GjR+suWj+5c2K7bi4m/S5arOY=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
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=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|
|
@ -27,7 +27,7 @@ type OAuth2Authenticator interface {
|
||||||
models.Backend
|
models.Backend
|
||||||
|
|
||||||
// Returns OAuth2 config suitable for this backend
|
// Returns OAuth2 config suitable for this backend
|
||||||
OAuth2Strategy(redirectUrl *url.URL) OAuth2Strategy
|
OAuth2Strategy(redirectURL *url.URL) OAuth2Strategy
|
||||||
|
|
||||||
// Setup the OAuth2 client
|
// Setup the OAuth2 client
|
||||||
OAuth2Setup(token oauth2.TokenSource) error
|
OAuth2Setup(token oauth2.TokenSource) error
|
||||||
|
|
|
@ -24,14 +24,14 @@ import (
|
||||||
type OAuth2Strategy interface {
|
type OAuth2Strategy interface {
|
||||||
Config() oauth2.Config
|
Config() oauth2.Config
|
||||||
|
|
||||||
AuthCodeURL(verifier string, state string) AuthUrl
|
AuthCodeURL(verifier string, state string) AuthURL
|
||||||
|
|
||||||
ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error)
|
ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthUrl struct {
|
type AuthURL struct {
|
||||||
// The URL the user must visit to approve access
|
// The URL the user must visit to approve access
|
||||||
Url string
|
URL string
|
||||||
// Random state string passed on to the callback.
|
// Random state string passed on to the callback.
|
||||||
// Leave empty if the service does not support state.
|
// Leave empty if the service does not support state.
|
||||||
State string
|
State string
|
||||||
|
@ -56,10 +56,10 @@ func (s StandardStrategy) Config() oauth2.Config {
|
||||||
return s.conf
|
return s.conf
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthUrl {
|
func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthURL {
|
||||||
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
||||||
return AuthUrl{
|
return AuthURL{
|
||||||
Url: url,
|
URL: url,
|
||||||
State: state,
|
State: state,
|
||||||
Param: "code",
|
Param: "code",
|
||||||
}
|
}
|
||||||
|
|
|
@ -123,7 +123,11 @@ func backendWithConfig(config config.ServiceConfig) (models.Backend, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func ImplementsInterface[T interface{}](backend *models.Backend) (bool, string) {
|
||||||
|
|
|
@ -33,10 +33,10 @@ func (s deezerStrategy) Config() oauth2.Config {
|
||||||
return s.conf
|
return s.conf
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl {
|
func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL {
|
||||||
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
||||||
return auth.AuthUrl{
|
return auth.AuthURL{
|
||||||
Url: url,
|
URL: url,
|
||||||
State: state,
|
State: state,
|
||||||
Param: "code",
|
Param: "code",
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ const MaxItemsPerGet = 1000
|
||||||
const DefaultRateLimitWaitSeconds = 5
|
const DefaultRateLimitWaitSeconds = 5
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HttpClient *resty.Client
|
HTTPClient *resty.Client
|
||||||
token oauth2.TokenSource
|
token oauth2.TokenSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ func NewClient(token oauth2.TokenSource) Client {
|
||||||
client.SetHeader("User-Agent", version.UserAgent())
|
client.SetHeader("User-Agent", version.UserAgent())
|
||||||
client.SetRetryCount(5)
|
client.SetRetryCount(5)
|
||||||
return Client{
|
return Client{
|
||||||
HttpClient: client,
|
HTTPClient: client,
|
||||||
token: token,
|
token: token,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -73,7 +73,7 @@ func (c Client) setToken(req *resty.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) {
|
func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) {
|
||||||
request := c.HttpClient.R().
|
request := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"index": strconv.Itoa(offset),
|
"index": strconv.Itoa(offset),
|
||||||
"limit": strconv.Itoa(limit),
|
"limit": strconv.Itoa(limit),
|
||||||
|
|
|
@ -44,7 +44,7 @@ func TestGetUserHistory(t *testing.T) {
|
||||||
|
|
||||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||||
client := deezer.NewClient(token)
|
client := deezer.NewClient(token)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.deezer.com/user/me/history",
|
"https://api.deezer.com/user/me/history",
|
||||||
"testdata/user-history.json")
|
"testdata/user-history.json")
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ func TestGetUserTracks(t *testing.T) {
|
||||||
|
|
||||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||||
client := deezer.NewClient(token)
|
client := deezer.NewClient(token)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.deezer.com/user/me/tracks",
|
"https://api.deezer.com/user/me/tracks",
|
||||||
"testdata/user-tracks.json")
|
"testdata/user-tracks.json")
|
||||||
|
|
||||||
|
@ -81,7 +81,7 @@ func TestGetUserTracks(t *testing.T) {
|
||||||
assert.Equal("Outland", track1.Track.Album.Title)
|
assert.Equal("Outland", track1.Track.Album.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||||
httpmock.ActivateNonDefault(client)
|
httpmock.ActivateNonDefault(client)
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||||
|
|
|
@ -31,7 +31,7 @@ import (
|
||||||
|
|
||||||
type DeezerApiBackend struct {
|
type DeezerApiBackend struct {
|
||||||
client Client
|
client Client
|
||||||
clientId string
|
clientID string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,20 +49,20 @@ func (b *DeezerApiBackend) Options() []models.BackendOption {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *DeezerApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *DeezerApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
b.clientId = config.GetString("client-id")
|
b.clientID = config.GetString("client-id")
|
||||||
b.clientSecret = config.GetString("client-secret")
|
b.clientSecret = config.GetString("client-secret")
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *DeezerApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
func (b *DeezerApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
|
||||||
conf := oauth2.Config{
|
conf := oauth2.Config{
|
||||||
ClientID: b.clientId,
|
ClientID: b.clientID,
|
||||||
ClientSecret: b.clientSecret,
|
ClientSecret: b.clientSecret,
|
||||||
Scopes: []string{
|
Scopes: []string{
|
||||||
"offline_access,basic_access,listening_history",
|
"offline_access,basic_access,listening_history",
|
||||||
},
|
},
|
||||||
RedirectURL: redirectUrl.String(),
|
RedirectURL: redirectURL.String(),
|
||||||
Endpoint: oauth2.Endpoint{
|
Endpoint: oauth2.Endpoint{
|
||||||
AuthURL: "https://connect.deezer.com/oauth/auth.php",
|
AuthURL: "https://connect.deezer.com/oauth/auth.php",
|
||||||
TokenURL: "https://connect.deezer.com/oauth/access_token.php",
|
TokenURL: "https://connect.deezer.com/oauth/access_token.php",
|
||||||
|
@ -244,8 +244,8 @@ func (t Track) AsTrack() models.Track {
|
||||||
info["music_service"] = "deezer.com"
|
info["music_service"] = "deezer.com"
|
||||||
info["origin_url"] = t.Link
|
info["origin_url"] = t.Link
|
||||||
info["deezer_id"] = t.Link
|
info["deezer_id"] = t.Link
|
||||||
info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/album/%v", t.Album.Id)
|
info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/album/%v", t.Album.ID)
|
||||||
info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/artist/%v", t.Artist.Id)
|
info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/artist/%v", t.Artist.ID)
|
||||||
|
|
||||||
return track
|
return track
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,13 +35,14 @@ var (
|
||||||
testTrack []byte
|
testTrack []byte
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("client-id", "someclientid")
|
c.Set("client-id", "someclientid")
|
||||||
c.Set("client-secret", "someclientsecret")
|
c.Set("client-secret", "someclientsecret")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&deezer.DeezerApiBackend{}).FromConfig(&service)
|
backend := deezer.DeezerApiBackend{}
|
||||||
assert.IsType(t, &deezer.DeezerApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListenAsListen(t *testing.T) {
|
func TestListenAsListen(t *testing.T) {
|
||||||
|
|
|
@ -51,7 +51,7 @@ type HistoryResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
Id int `json:"id"`
|
ID int `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
@ -75,7 +75,7 @@ type LovedTrack struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Album struct {
|
type Album struct {
|
||||||
Id int `json:"id"`
|
ID int `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
@ -83,7 +83,7 @@ type Album struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
Id int `json:"id"`
|
ID int `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
|
@ -29,8 +29,8 @@ func (b *DumpBackend) Name() string { return "dump" }
|
||||||
|
|
||||||
func (b *DumpBackend) Options() []models.BackendOption { return nil }
|
func (b *DumpBackend) Options() []models.BackendOption { return nil }
|
||||||
|
|
||||||
func (b *DumpBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *DumpBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *DumpBackend) StartImport() 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.UpdateTimestamp(listen.ListenedAt)
|
||||||
importResult.ImportCount += 1
|
importResult.ImportCount += 1
|
||||||
msg := fmt.Sprintf("🎶 %v: \"%v\" by %v (%v)",
|
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)
|
importResult.Log(models.Info, msg)
|
||||||
progress <- models.Progress{}.FromImportResult(importResult)
|
progress <- models.Progress{}.FromImportResult(importResult)
|
||||||
}
|
}
|
||||||
|
@ -54,7 +54,7 @@ func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models
|
||||||
importResult.UpdateTimestamp(love.Created)
|
importResult.UpdateTimestamp(love.Created)
|
||||||
importResult.ImportCount += 1
|
importResult.ImportCount += 1
|
||||||
msg := fmt.Sprintf("❤️ %v: \"%v\" by %v (%v)",
|
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)
|
importResult.Log(models.Info, msg)
|
||||||
progress <- models.Progress{}.FromImportResult(importResult)
|
progress <- models.Progress{}.FromImportResult(importResult)
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,13 +33,13 @@ import (
|
||||||
const MaxItemsPerGet = 50
|
const MaxItemsPerGet = 50
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HttpClient *resty.Client
|
HTTPClient *resty.Client
|
||||||
token string
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(serverUrl string, token string) Client {
|
func NewClient(serverURL string, token string) Client {
|
||||||
client := resty.New()
|
client := resty.New()
|
||||||
client.SetBaseURL(serverUrl)
|
client.SetBaseURL(serverURL)
|
||||||
client.SetAuthScheme("Bearer")
|
client.SetAuthScheme("Bearer")
|
||||||
client.SetAuthToken(token)
|
client.SetAuthToken(token)
|
||||||
client.SetHeader("Accept", "application/json")
|
client.SetHeader("Accept", "application/json")
|
||||||
|
@ -49,14 +49,14 @@ func NewClient(serverUrl string, token string) Client {
|
||||||
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
||||||
|
|
||||||
return Client{
|
return Client{
|
||||||
HttpClient: client,
|
HTTPClient: client,
|
||||||
token: token,
|
token: token,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) GetHistoryListenings(user string, page int, perPage int) (result ListeningsResult, err error) {
|
func (c Client) GetHistoryListenings(user string, page int, perPage int) (result ListeningsResult, err error) {
|
||||||
const path = "/api/v1/history/listenings"
|
const path = "/api/v1/history/listenings"
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"username": user,
|
"username": user,
|
||||||
"page": strconv.Itoa(page),
|
"page": strconv.Itoa(page),
|
||||||
|
@ -75,7 +75,7 @@ func (c Client) GetHistoryListenings(user string, page int, perPage int) (result
|
||||||
|
|
||||||
func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksResult, err error) {
|
func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksResult, err error) {
|
||||||
const path = "/api/v1/favorites/tracks"
|
const path = "/api/v1/favorites/tracks"
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"page": strconv.Itoa(page),
|
"page": strconv.Itoa(page),
|
||||||
"page_size": strconv.Itoa(perPage),
|
"page_size": strconv.Itoa(perPage),
|
||||||
|
|
|
@ -32,20 +32,20 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
func TestNewClient(t *testing.T) {
|
||||||
serverUrl := "https://funkwhale.example.com"
|
serverURL := "https://funkwhale.example.com"
|
||||||
token := "foobar123"
|
token := "foobar123"
|
||||||
client := funkwhale.NewClient(serverUrl, token)
|
client := funkwhale.NewClient(serverURL, token)
|
||||||
assert.Equal(t, serverUrl, client.HttpClient.BaseURL)
|
assert.Equal(t, serverURL, client.HTTPClient.BaseURL)
|
||||||
assert.Equal(t, token, client.HttpClient.Token)
|
assert.Equal(t, token, client.HTTPClient.Token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHistoryListenings(t *testing.T) {
|
func TestGetHistoryListenings(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
serverUrl := "https://funkwhale.example.com"
|
serverURL := "https://funkwhale.example.com"
|
||||||
token := "thetoken"
|
token := "thetoken"
|
||||||
client := funkwhale.NewClient(serverUrl, token)
|
client := funkwhale.NewClient(serverURL, token)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://funkwhale.example.com/api/v1/history/listenings",
|
"https://funkwhale.example.com/api/v1/history/listenings",
|
||||||
"testdata/listenings.json")
|
"testdata/listenings.json")
|
||||||
|
|
||||||
|
@ -67,9 +67,9 @@ func TestGetFavoriteTracks(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
token := "thetoken"
|
token := "thetoken"
|
||||||
serverUrl := "https://funkwhale.example.com"
|
serverURL := "https://funkwhale.example.com"
|
||||||
client := funkwhale.NewClient(serverUrl, token)
|
client := funkwhale.NewClient(serverURL, token)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://funkwhale.example.com/api/v1/favorites/tracks",
|
"https://funkwhale.example.com/api/v1/favorites/tracks",
|
||||||
"testdata/favorite-tracks.json")
|
"testdata/favorite-tracks.json")
|
||||||
|
|
||||||
|
@ -87,7 +87,7 @@ func TestGetFavoriteTracks(t *testing.T) {
|
||||||
assert.Equal("phw", fav1.User.UserName)
|
assert.Equal("phw", fav1.User.UserName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||||
httpmock.ActivateNonDefault(client)
|
httpmock.ActivateNonDefault(client)
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||||
|
|
|
@ -20,6 +20,7 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"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(
|
b.client = NewClient(
|
||||||
config.GetString("server-url"),
|
config.GetString("server-url"),
|
||||||
config.GetString("token"),
|
config.GetString("token"),
|
||||||
)
|
)
|
||||||
b.username = config.GetString("username")
|
b.username = config.GetString("username")
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
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()
|
track := f.Track.AsTrack()
|
||||||
love := models.Love{
|
love := models.Love{
|
||||||
UserName: f.User.UserName,
|
UserName: f.User.UserName,
|
||||||
RecordingMbid: track.RecordingMbid,
|
RecordingMBID: track.RecordingMBID,
|
||||||
Track: track,
|
Track: track,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,16 +189,15 @@ func (f FavoriteTrack) AsLove() models.Love {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Track) AsTrack() models.Track {
|
func (t Track) AsTrack() models.Track {
|
||||||
recordingMbid := models.MBID(t.RecordingMbid)
|
|
||||||
track := models.Track{
|
track := models.Track{
|
||||||
TrackName: t.Title,
|
TrackName: t.Title,
|
||||||
ReleaseName: t.Album.Title,
|
ReleaseName: t.Album.Title,
|
||||||
ArtistNames: []string{t.Artist.Name},
|
ArtistNames: []string{t.Artist.Name},
|
||||||
TrackNumber: t.Position,
|
TrackNumber: t.Position,
|
||||||
DiscNumber: t.DiscNumber,
|
DiscNumber: t.DiscNumber,
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: t.RecordingMBID,
|
||||||
ReleaseMbid: models.MBID(t.Album.ReleaseMbid),
|
ReleaseMBID: t.Album.ReleaseMBID,
|
||||||
ArtistMbids: []models.MBID{models.MBID(t.Artist.ArtistMbid)},
|
ArtistMBIDs: []mbtypes.MBID{t.Artist.ArtistMBID},
|
||||||
Tags: t.Tags,
|
Tags: t.Tags,
|
||||||
AdditionalInfo: map[string]any{
|
AdditionalInfo: map[string]any{
|
||||||
"media_player": FunkwhaleClientName,
|
"media_player": FunkwhaleClientName,
|
||||||
|
|
|
@ -25,15 +25,15 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/funkwhale"
|
"go.uploadedlobster.com/scotty/internal/backends/funkwhale"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"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 := viper.New()
|
||||||
c.Set("token", "thetoken")
|
c.Set("token", "thetoken")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&funkwhale.FunkwhaleApiBackend{}).FromConfig(&service)
|
backend := funkwhale.FunkwhaleApiBackend{}
|
||||||
assert.IsType(t, &funkwhale.FunkwhaleApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFunkwhaleListeningAsListen(t *testing.T) {
|
func TestFunkwhaleListeningAsListen(t *testing.T) {
|
||||||
|
@ -44,17 +44,17 @@ func TestFunkwhaleListeningAsListen(t *testing.T) {
|
||||||
},
|
},
|
||||||
Track: funkwhale.Track{
|
Track: funkwhale.Track{
|
||||||
Title: "Oweynagat",
|
Title: "Oweynagat",
|
||||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||||
Position: 5,
|
Position: 5,
|
||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
Tags: []string{"foo", "bar"},
|
Tags: []string{"foo", "bar"},
|
||||||
Artist: funkwhale.Artist{
|
Artist: funkwhale.Artist{
|
||||||
Name: "Dool",
|
Name: "Dool",
|
||||||
ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131",
|
ArtistMBID: "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||||
},
|
},
|
||||||
Album: funkwhale.Album{
|
Album: funkwhale.Album{
|
||||||
Title: "Here Now, There Then",
|
Title: "Here Now, There Then",
|
||||||
ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
ReleaseMBID: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||||
},
|
},
|
||||||
Uploads: []funkwhale.Upload{
|
Uploads: []funkwhale.Upload{
|
||||||
{
|
{
|
||||||
|
@ -75,9 +75,9 @@ func TestFunkwhaleListeningAsListen(t *testing.T) {
|
||||||
assert.Equal(fwListen.Track.DiscNumber, listen.Track.DiscNumber)
|
assert.Equal(fwListen.Track.DiscNumber, listen.Track.DiscNumber)
|
||||||
assert.Equal(fwListen.Track.Tags, listen.Track.Tags)
|
assert.Equal(fwListen.Track.Tags, listen.Track.Tags)
|
||||||
// assert.Equal(backends.FunkwhaleClientName, listen.AdditionalInfo["disc_number"])
|
// assert.Equal(backends.FunkwhaleClientName, listen.AdditionalInfo["disc_number"])
|
||||||
assert.Equal(models.MBID(fwListen.Track.RecordingMbid), listen.RecordingMbid)
|
assert.Equal(fwListen.Track.RecordingMBID, listen.RecordingMBID)
|
||||||
assert.Equal(models.MBID(fwListen.Track.Album.ReleaseMbid), listen.ReleaseMbid)
|
assert.Equal(fwListen.Track.Album.ReleaseMBID, listen.ReleaseMBID)
|
||||||
assert.Equal(models.MBID(fwListen.Track.Artist.ArtistMbid), listen.ArtistMbids[0])
|
assert.Equal(fwListen.Track.Artist.ArtistMBID, listen.ArtistMBIDs[0])
|
||||||
assert.Equal(funkwhale.FunkwhaleClientName, listen.AdditionalInfo["media_player"])
|
assert.Equal(funkwhale.FunkwhaleClientName, listen.AdditionalInfo["media_player"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,17 +89,17 @@ func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) {
|
||||||
},
|
},
|
||||||
Track: funkwhale.Track{
|
Track: funkwhale.Track{
|
||||||
Title: "Oweynagat",
|
Title: "Oweynagat",
|
||||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||||
Position: 5,
|
Position: 5,
|
||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
Tags: []string{"foo", "bar"},
|
Tags: []string{"foo", "bar"},
|
||||||
Artist: funkwhale.Artist{
|
Artist: funkwhale.Artist{
|
||||||
Name: "Dool",
|
Name: "Dool",
|
||||||
ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131",
|
ArtistMBID: "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||||
},
|
},
|
||||||
Album: funkwhale.Album{
|
Album: funkwhale.Album{
|
||||||
Title: "Here Now, There Then",
|
Title: "Here Now, There Then",
|
||||||
ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
ReleaseMBID: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||||
},
|
},
|
||||||
Uploads: []funkwhale.Upload{
|
Uploads: []funkwhale.Upload{
|
||||||
{
|
{
|
||||||
|
@ -119,10 +119,10 @@ func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) {
|
||||||
assert.Equal(favorite.Track.Position, love.Track.TrackNumber)
|
assert.Equal(favorite.Track.Position, love.Track.TrackNumber)
|
||||||
assert.Equal(favorite.Track.DiscNumber, love.Track.DiscNumber)
|
assert.Equal(favorite.Track.DiscNumber, love.Track.DiscNumber)
|
||||||
assert.Equal(favorite.Track.Tags, love.Track.Tags)
|
assert.Equal(favorite.Track.Tags, love.Track.Tags)
|
||||||
assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.RecordingMbid)
|
assert.Equal(favorite.Track.RecordingMBID, love.RecordingMBID)
|
||||||
assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.Track.RecordingMbid)
|
assert.Equal(favorite.Track.RecordingMBID, love.Track.RecordingMBID)
|
||||||
assert.Equal(models.MBID(favorite.Track.Album.ReleaseMbid), love.ReleaseMbid)
|
assert.Equal(favorite.Track.Album.ReleaseMBID, love.ReleaseMBID)
|
||||||
require.Len(t, love.Track.ArtistMbids, 1)
|
require.Len(t, love.Track.ArtistMBIDs, 1)
|
||||||
assert.Equal(models.MBID(favorite.Track.Artist.ArtistMbid), love.ArtistMbids[0])
|
assert.Equal(favorite.Track.Artist.ArtistMBID, love.ArtistMBIDs[0])
|
||||||
assert.Equal(funkwhale.FunkwhaleClientName, love.AdditionalInfo["media_player"])
|
assert.Equal(funkwhale.FunkwhaleClientName, love.AdditionalInfo["media_player"])
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,8 @@ THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
package funkwhale
|
package funkwhale
|
||||||
|
|
||||||
|
import "go.uploadedlobster.com/mbtypes"
|
||||||
|
|
||||||
type ListeningsResult struct {
|
type ListeningsResult struct {
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Previous string `json:"previous"`
|
Previous string `json:"previous"`
|
||||||
|
@ -29,7 +31,7 @@ type ListeningsResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Listening struct {
|
type Listening struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
Track Track `json:"track"`
|
Track Track `json:"track"`
|
||||||
CreationDate string `json:"creation_date"`
|
CreationDate string `json:"creation_date"`
|
||||||
|
@ -43,41 +45,41 @@ type FavoriteTracksResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type FavoriteTrack struct {
|
type FavoriteTrack struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
Track Track `json:"track"`
|
Track Track `json:"track"`
|
||||||
CreationDate string `json:"creation_date"`
|
CreationDate string `json:"creation_date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
Artist Artist `json:"artist"`
|
Artist Artist `json:"artist"`
|
||||||
Album Album `json:"album"`
|
Album Album `json:"album"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Position int `json:"position"`
|
Position int `json:"position"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
RecordingMbid string `json:"mbid"`
|
RecordingMBID mbtypes.MBID `json:"mbid"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Uploads []Upload `json:"uploads"`
|
Uploads []Upload `json:"uploads"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ArtistMbid string `json:"mbid"`
|
ArtistMBID mbtypes.MBID `json:"mbid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Album struct {
|
type Album struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
AlbumArtist Artist `json:"artist"`
|
AlbumArtist Artist `json:"artist"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
TrackCount int `json:"track_count"`
|
TrackCount int `json:"track_count"`
|
||||||
ReleaseMbid string `json:"mbid"`
|
ReleaseMBID mbtypes.MBID `json:"mbid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
UserName string `json:"username"`
|
UserName string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.filePath = config.GetString("file-path")
|
||||||
b.append = config.GetBool("append", true)
|
b.append = config.GetBool("append", true)
|
||||||
b.playlist = jspf.Playlist{
|
b.playlist = jspf.Playlist{
|
||||||
|
@ -69,13 +69,13 @@ func (b *JSPFBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||||
Identifier: config.GetString("identifier"),
|
Identifier: config.GetString("identifier"),
|
||||||
Tracks: make([]jspf.Track, 0),
|
Tracks: make([]jspf.Track, 0),
|
||||||
Extension: map[string]any{
|
Extension: map[string]any{
|
||||||
jspf.MusicBrainzPlaylistExtensionId: jspf.MusicBrainzPlaylistExtension{
|
jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{
|
||||||
LastModifiedAt: time.Now(),
|
LastModifiedAt: time.Now(),
|
||||||
Public: true,
|
Public: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *JSPFBackend) StartImport() error {
|
func (b *JSPFBackend) StartImport() error {
|
||||||
|
@ -116,10 +116,10 @@ func listenAsTrack(l models.Listen) jspf.Track {
|
||||||
extension := makeMusicBrainzExtension(l.Track)
|
extension := makeMusicBrainzExtension(l.Track)
|
||||||
extension.AddedAt = l.ListenedAt
|
extension.AddedAt = l.ListenedAt
|
||||||
extension.AddedBy = l.UserName
|
extension.AddedBy = l.UserName
|
||||||
track.Extension[jspf.MusicBrainzTrackExtensionId] = extension
|
track.Extension[jspf.MusicBrainzTrackExtensionID] = extension
|
||||||
|
|
||||||
if l.RecordingMbid != "" {
|
if l.RecordingMBID != "" {
|
||||||
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid))
|
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMBID))
|
||||||
}
|
}
|
||||||
|
|
||||||
return track
|
return track
|
||||||
|
@ -131,14 +131,14 @@ func loveAsTrack(l models.Love) jspf.Track {
|
||||||
extension := makeMusicBrainzExtension(l.Track)
|
extension := makeMusicBrainzExtension(l.Track)
|
||||||
extension.AddedAt = l.Created
|
extension.AddedAt = l.Created
|
||||||
extension.AddedBy = l.UserName
|
extension.AddedBy = l.UserName
|
||||||
track.Extension[jspf.MusicBrainzTrackExtensionId] = extension
|
track.Extension[jspf.MusicBrainzTrackExtensionID] = extension
|
||||||
|
|
||||||
recordingMbid := l.Track.RecordingMbid
|
recordingMBID := l.Track.RecordingMBID
|
||||||
if l.RecordingMbid != "" {
|
if l.RecordingMBID != "" {
|
||||||
recordingMbid = l.RecordingMbid
|
recordingMBID = l.RecordingMBID
|
||||||
}
|
}
|
||||||
if recordingMbid != "" {
|
if recordingMBID != "" {
|
||||||
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMbid))
|
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMBID))
|
||||||
}
|
}
|
||||||
|
|
||||||
return track
|
return track
|
||||||
|
@ -159,15 +159,15 @@ func trackAsTrack(t models.Track) jspf.Track {
|
||||||
func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension {
|
func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension {
|
||||||
extension := jspf.MusicBrainzTrackExtension{
|
extension := jspf.MusicBrainzTrackExtension{
|
||||||
AdditionalMetadata: t.AdditionalInfo,
|
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)
|
extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid)
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.ReleaseMbid != "" {
|
if t.ReleaseMBID != "" {
|
||||||
extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMbid)
|
extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMBID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tracknumber tag would be redundant
|
// The tracknumber tag would be redundant
|
||||||
|
|
|
@ -26,13 +26,14 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("file-path", "/foo/bar.jspf")
|
c.Set("file-path", "/foo/bar.jspf")
|
||||||
c.Set("title", "My Playlist")
|
c.Set("title", "My Playlist")
|
||||||
c.Set("username", "outsidecontext")
|
c.Set("username", "outsidecontext")
|
||||||
c.Set("identifier", "http://example.com/playlist1")
|
c.Set("identifier", "http://example.com/playlist1")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&jspf.JSPFBackend{}).FromConfig(&service)
|
backend := jspf.JSPFBackend{}
|
||||||
assert.IsType(t, &jspf.JSPFBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,21 +25,21 @@ import (
|
||||||
|
|
||||||
type lastfmStrategy struct {
|
type lastfmStrategy struct {
|
||||||
client *lastfm.Api
|
client *lastfm.Api
|
||||||
redirectUrl *url.URL
|
redirectURL *url.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s lastfmStrategy) Config() oauth2.Config {
|
func (s lastfmStrategy) Config() oauth2.Config {
|
||||||
return oauth2.Config{}
|
return oauth2.Config{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl {
|
func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL {
|
||||||
// Last.fm does not use OAuth2, but the provided authorization flow with
|
// Last.fm does not use OAuth2, but the provided authorization flow with
|
||||||
// callback URL is close enough we can shoehorn it into the existing
|
// callback URL is close enough we can shoehorn it into the existing
|
||||||
// authentication strategy.
|
// authentication strategy.
|
||||||
// TODO: Investigate and use callback-less flow with api.GetAuthTokenUrl(token)
|
// TODO: Investigate and use callback-less flow with api.GetAuthTokenUrl(token)
|
||||||
url := s.client.GetAuthRequestUrl(s.redirectUrl.String())
|
url := s.client.GetAuthRequestUrl(s.redirectURL.String())
|
||||||
return auth.AuthUrl{
|
return auth.AuthURL{
|
||||||
Url: url,
|
URL: url,
|
||||||
State: "", // last.fm does not use state
|
State: "", // last.fm does not use state
|
||||||
Param: "token",
|
Param: "token",
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shkh/lastfm-go/lastfm"
|
"github.com/shkh/lastfm-go/lastfm"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/auth"
|
"go.uploadedlobster.com/scotty/internal/auth"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"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 {
|
func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
clientId := config.GetString("client-id")
|
clientID := config.GetString("client-id")
|
||||||
clientSecret := config.GetString("client-secret")
|
clientSecret := config.GetString("client-secret")
|
||||||
b.client = lastfm.New(clientId, clientSecret)
|
b.client = lastfm.New(clientID, clientSecret)
|
||||||
b.username = config.GetString("username")
|
b.username = config.GetString("username")
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *LastfmApiBackend) StartImport() error { return nil }
|
func (b *LastfmApiBackend) StartImport() error { return nil }
|
||||||
func (b *LastfmApiBackend) FinishImport() error { return nil }
|
func (b *LastfmApiBackend) FinishImport() error { return nil }
|
||||||
|
|
||||||
func (b *LastfmApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
func (b *LastfmApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
|
||||||
return lastfmStrategy{
|
return lastfmStrategy{
|
||||||
client: b.client,
|
client: b.client,
|
||||||
redirectUrl: redirectUrl,
|
redirectURL: redirectURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,16 +141,16 @@ out:
|
||||||
TrackName: scrobble.Name,
|
TrackName: scrobble.Name,
|
||||||
ArtistNames: []string{},
|
ArtistNames: []string{},
|
||||||
ReleaseName: scrobble.Album.Name,
|
ReleaseName: scrobble.Album.Name,
|
||||||
RecordingMbid: models.MBID(scrobble.Mbid),
|
RecordingMBID: mbtypes.MBID(scrobble.Mbid),
|
||||||
ArtistMbids: []models.MBID{},
|
ArtistMBIDs: []mbtypes.MBID{},
|
||||||
ReleaseMbid: models.MBID(scrobble.Album.Mbid),
|
ReleaseMBID: mbtypes.MBID(scrobble.Album.Mbid),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if scrobble.Artist.Name != "" {
|
if scrobble.Artist.Name != "" {
|
||||||
listen.Track.ArtistNames = []string{scrobble.Artist.Name}
|
listen.Track.ArtistNames = []string{scrobble.Artist.Name}
|
||||||
}
|
}
|
||||||
if scrobble.Artist.Mbid != "" {
|
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)
|
listens = append(listens, listen)
|
||||||
} else {
|
} else {
|
||||||
|
@ -203,8 +204,8 @@ func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResu
|
||||||
if l.TrackNumber > 0 {
|
if l.TrackNumber > 0 {
|
||||||
trackNumbers = append(trackNumbers, strconv.Itoa(l.TrackNumber))
|
trackNumbers = append(trackNumbers, strconv.Itoa(l.TrackNumber))
|
||||||
}
|
}
|
||||||
if l.RecordingMbid != "" {
|
if l.RecordingMBID != "" {
|
||||||
mbids = append(mbids, string(l.RecordingMbid))
|
mbids = append(mbids, string(l.RecordingMBID))
|
||||||
}
|
}
|
||||||
// if l.ReleaseArtist != "" {
|
// if l.ReleaseArtist != "" {
|
||||||
// albumArtists = append(albums, l.ReleaseArtist)
|
// albumArtists = append(albums, l.ReleaseArtist)
|
||||||
|
@ -294,12 +295,12 @@ out:
|
||||||
love := models.Love{
|
love := models.Love{
|
||||||
Created: time.Unix(timestamp, 0),
|
Created: time.Unix(timestamp, 0),
|
||||||
UserName: result.User,
|
UserName: result.User,
|
||||||
RecordingMbid: models.MBID(track.Mbid),
|
RecordingMBID: mbtypes.MBID(track.Mbid),
|
||||||
Track: models.Track{
|
Track: models.Track{
|
||||||
TrackName: track.Name,
|
TrackName: track.Name,
|
||||||
ArtistNames: []string{track.Artist.Name},
|
ArtistNames: []string{track.Artist.Name},
|
||||||
RecordingMbid: models.MBID(track.Mbid),
|
RecordingMBID: mbtypes.MBID(track.Mbid),
|
||||||
ArtistMbids: []models.MBID{models.MBID(track.Artist.Mbid)},
|
ArtistMBIDs: []mbtypes.MBID{mbtypes.MBID(track.Artist.Mbid)},
|
||||||
AdditionalInfo: models.AdditionalInfo{
|
AdditionalInfo: models.AdditionalInfo{
|
||||||
"lastfm_url": track.Url,
|
"lastfm_url": track.Url,
|
||||||
},
|
},
|
||||||
|
|
|
@ -39,7 +39,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HttpClient *resty.Client
|
HTTPClient *resty.Client
|
||||||
MaxResults int
|
MaxResults int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +55,7 @@ func NewClient(token string) Client {
|
||||||
ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In")
|
ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In")
|
||||||
|
|
||||||
return Client{
|
return Client{
|
||||||
HttpClient: client,
|
HTTPClient: client,
|
||||||
MaxResults: DefaultItemsPerGet,
|
MaxResults: DefaultItemsPerGet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -63,7 +63,7 @@ func NewClient(token string) Client {
|
||||||
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
|
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
|
||||||
const path = "/user/{username}/listens"
|
const path = "/user/{username}/listens"
|
||||||
errorResult := ErrorResult{}
|
errorResult := ErrorResult{}
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetPathParam("username", user).
|
SetPathParam("username", user).
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"max_ts": strconv.FormatInt(maxTime.Unix(), 10),
|
"max_ts": strconv.FormatInt(maxTime.Unix(), 10),
|
||||||
|
@ -84,7 +84,7 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
|
||||||
func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) {
|
func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) {
|
||||||
const path = "/submit-listens"
|
const path = "/submit-listens"
|
||||||
errorResult := ErrorResult{}
|
errorResult := ErrorResult{}
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetBody(listens).
|
SetBody(listens).
|
||||||
SetResult(&result).
|
SetResult(&result).
|
||||||
SetError(&errorResult).
|
SetError(&errorResult).
|
||||||
|
@ -100,7 +100,7 @@ func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, er
|
||||||
func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) {
|
func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) {
|
||||||
const path = "/feedback/user/{username}/get-feedback"
|
const path = "/feedback/user/{username}/get-feedback"
|
||||||
errorResult := ErrorResult{}
|
errorResult := ErrorResult{}
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetPathParam("username", user).
|
SetPathParam("username", user).
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"status": strconv.Itoa(status),
|
"status": strconv.Itoa(status),
|
||||||
|
@ -122,7 +122,7 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed
|
||||||
func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) {
|
func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) {
|
||||||
const path = "/feedback/recording-feedback"
|
const path = "/feedback/recording-feedback"
|
||||||
errorResult := ErrorResult{}
|
errorResult := ErrorResult{}
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetBody(feedback).
|
SetBody(feedback).
|
||||||
SetResult(&result).
|
SetResult(&result).
|
||||||
SetError(&errorResult).
|
SetError(&errorResult).
|
||||||
|
@ -138,7 +138,7 @@ func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error)
|
||||||
func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) {
|
func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) {
|
||||||
const path = "/metadata/lookup"
|
const path = "/metadata/lookup"
|
||||||
errorResult := ErrorResult{}
|
errorResult := ErrorResult{}
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"recording_name": recordingName,
|
"recording_name": recordingName,
|
||||||
"artist_name": artistName,
|
"artist_name": artistName,
|
||||||
|
|
|
@ -29,13 +29,14 @@ import (
|
||||||
"github.com/jarcoal/httpmock"
|
"github.com/jarcoal/httpmock"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
func TestNewClient(t *testing.T) {
|
||||||
token := "foobar123"
|
token := "foobar123"
|
||||||
client := listenbrainz.NewClient(token)
|
client := listenbrainz.NewClient(token)
|
||||||
assert.Equal(t, token, client.HttpClient.Token)
|
assert.Equal(t, token, client.HTTPClient.Token)
|
||||||
assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults)
|
assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +45,7 @@ func TestGetListens(t *testing.T) {
|
||||||
|
|
||||||
client := listenbrainz.NewClient("thetoken")
|
client := listenbrainz.NewClient("thetoken")
|
||||||
client.MaxResults = 2
|
client.MaxResults = 2
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.listenbrainz.org/1/user/outsidecontext/listens",
|
"https://api.listenbrainz.org/1/user/outsidecontext/listens",
|
||||||
"testdata/listens.json")
|
"testdata/listens.json")
|
||||||
|
|
||||||
|
@ -61,7 +62,7 @@ func TestGetListens(t *testing.T) {
|
||||||
|
|
||||||
func TestSubmitListens(t *testing.T) {
|
func TestSubmitListens(t *testing.T) {
|
||||||
client := listenbrainz.NewClient("thetoken")
|
client := listenbrainz.NewClient("thetoken")
|
||||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
|
@ -102,7 +103,7 @@ func TestGetFeedback(t *testing.T) {
|
||||||
|
|
||||||
client := listenbrainz.NewClient("thetoken")
|
client := listenbrainz.NewClient("thetoken")
|
||||||
client.MaxResults = 2
|
client.MaxResults = 2
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback",
|
"https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback",
|
||||||
"testdata/feedback.json")
|
"testdata/feedback.json")
|
||||||
|
|
||||||
|
@ -114,12 +115,12 @@ func TestGetFeedback(t *testing.T) {
|
||||||
assert.Equal(302, result.TotalCount)
|
assert.Equal(302, result.TotalCount)
|
||||||
assert.Equal(3, result.Offset)
|
assert.Equal(3, result.Offset)
|
||||||
require.Len(t, result.Feedback, 2)
|
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) {
|
func TestSendFeedback(t *testing.T) {
|
||||||
client := listenbrainz.NewClient("thetoken")
|
client := listenbrainz.NewClient("thetoken")
|
||||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
|
@ -131,7 +132,7 @@ func TestSendFeedback(t *testing.T) {
|
||||||
httpmock.RegisterResponder("POST", url, responder)
|
httpmock.RegisterResponder("POST", url, responder)
|
||||||
|
|
||||||
feedback := listenbrainz.Feedback{
|
feedback := listenbrainz.Feedback{
|
||||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||||
Score: 1,
|
Score: 1,
|
||||||
}
|
}
|
||||||
result, err := client.SendFeedback(feedback)
|
result, err := client.SendFeedback(feedback)
|
||||||
|
@ -144,7 +145,7 @@ func TestLookup(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
client := listenbrainz.NewClient("thetoken")
|
client := listenbrainz.NewClient("thetoken")
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.listenbrainz.org/1/metadata/lookup",
|
"https://api.listenbrainz.org/1/metadata/lookup",
|
||||||
"testdata/lookup.json")
|
"testdata/lookup.json")
|
||||||
|
|
||||||
|
@ -154,10 +155,10 @@ func TestLookup(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
assert.Equal("Say Just Words", result.RecordingName)
|
assert.Equal("Say Just Words", result.RecordingName)
|
||||||
assert.Equal("Paradise Lost", result.ArtistCreditName)
|
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)
|
httpmock.ActivateNonDefault(client)
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||||
|
|
|
@ -21,6 +21,8 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
|
"go.uploadedlobster.com/musicbrainzws2"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
@ -30,9 +32,10 @@ import (
|
||||||
|
|
||||||
type ListenBrainzApiBackend struct {
|
type ListenBrainzApiBackend struct {
|
||||||
client Client
|
client Client
|
||||||
|
mbClient musicbrainzws2.Client
|
||||||
username string
|
username string
|
||||||
checkDuplicates bool
|
checkDuplicates bool
|
||||||
existingMbids map[string]bool
|
existingMBIDs map[mbtypes.MBID]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" }
|
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.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.client.MaxResults = MaxItemsPerGet
|
||||||
b.username = config.GetString("username")
|
b.username = config.GetString("username")
|
||||||
b.checkDuplicates = config.GetBool("check-duplicate-listens", false)
|
b.checkDuplicates = config.GetBool("check-duplicate-listens", false)
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ListenBrainzApiBackend) StartImport() error { return nil }
|
func (b *ListenBrainzApiBackend) StartImport() error { return nil }
|
||||||
|
@ -147,7 +155,7 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
|
||||||
} else if isDupe {
|
} else if isDupe {
|
||||||
count -= 1
|
count -= 1
|
||||||
msg := i18n.Tr("Ignored duplicate listen %v: \"%v\" by %v (%v)",
|
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)
|
importResult.Log(models.Info, msg)
|
||||||
continue
|
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) {
|
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
|
offset := 0
|
||||||
defer close(results)
|
defer close(results)
|
||||||
loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
|
loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
|
||||||
|
@ -196,7 +225,6 @@ out:
|
||||||
for {
|
for {
|
||||||
result, err := b.client.GetFeedback(b.username, 1, offset)
|
result, err := b.client.GetFeedback(b.username, 1, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
progress <- p.Complete()
|
|
||||||
results <- models.LovesResult{Error: err}
|
results <- models.LovesResult{Error: err}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -207,11 +235,20 @@ out:
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, feedback := range result.Feedback {
|
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()
|
love := feedback.AsLove()
|
||||||
if love.Created.Unix() > oldestTimestamp.Unix() {
|
if love.Created.Unix() > oldestTimestamp.Unix() {
|
||||||
loves = append(loves, love)
|
loves = append(loves, love)
|
||||||
p.Elapsed += 1
|
p.Elapsed += 1
|
||||||
progress <- p
|
|
||||||
} else {
|
} else {
|
||||||
break out
|
break out
|
||||||
}
|
}
|
||||||
|
@ -224,49 +261,65 @@ out:
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Sort(loves)
|
sort.Sort(loves)
|
||||||
progress <- p.Complete()
|
results <- models.LovesResult{
|
||||||
results <- models.LovesResult{Items: loves}
|
Total: len(loves),
|
||||||
|
Items: loves,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
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)
|
existingLovesChan := make(chan models.LovesResult)
|
||||||
go b.ExportLoves(time.Unix(0, 0), existingLovesChan, progress)
|
go b.exportLoves(time.Unix(0, 0), existingLovesChan)
|
||||||
existingLoves := <-existingLovesChan
|
|
||||||
|
// TODO: Store MBIDs directly
|
||||||
|
b.existingMBIDs = make(map[mbtypes.MBID]bool, MaxItemsPerGet)
|
||||||
|
|
||||||
|
for existingLoves := range existingLovesChan {
|
||||||
if existingLoves.Error != nil {
|
if existingLoves.Error != nil {
|
||||||
return importResult, existingLoves.Error
|
return importResult, existingLoves.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Store MBIDs directly
|
|
||||||
b.existingMbids = make(map[string]bool, len(existingLoves.Items))
|
|
||||||
for _, love := range existingLoves.Items {
|
for _, love := range existingLoves.Items {
|
||||||
b.existingMbids[string(love.RecordingMbid)] = true
|
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 {
|
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())
|
lookup, err := b.client.Lookup(love.TrackName, love.ArtistName())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
recordingMbid = lookup.RecordingMbid
|
recordingMBID = lookup.RecordingMBID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if recordingMbid != "" {
|
if recordingMBID != "" {
|
||||||
ok := false
|
ok := false
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if b.existingMbids[recordingMbid] {
|
if b.existingMBIDs[recordingMBID] {
|
||||||
ok = true
|
ok = true
|
||||||
} else {
|
} else {
|
||||||
resp, err := b.client.SendFeedback(Feedback{
|
resp, err := b.client.SendFeedback(Feedback{
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: recordingMBID,
|
||||||
Score: 1,
|
Score: 1,
|
||||||
})
|
})
|
||||||
ok = err == nil && resp.Status == "ok"
|
ok = err == nil && resp.Status == "ok"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
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)
|
love.TrackName, love.ArtistName(), errMsg)
|
||||||
importResult.Log(models.Error, msg)
|
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)
|
progress <- models.Progress{}.FromImportResult(importResult)
|
||||||
|
@ -313,6 +370,31 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (boo
|
||||||
return false, nil
|
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 {
|
func (lbListen Listen) AsListen() models.Listen {
|
||||||
listen := models.Listen{
|
listen := models.Listen{
|
||||||
ListenedAt: time.Unix(lbListen.ListenedAt, 0),
|
ListenedAt: time.Unix(lbListen.ListenedAt, 0),
|
||||||
|
@ -323,20 +405,20 @@ func (lbListen Listen) AsListen() models.Listen {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f Feedback) AsLove() models.Love {
|
func (f Feedback) AsLove() models.Love {
|
||||||
recordingMbid := models.MBID(f.RecordingMbid)
|
recordingMBID := f.RecordingMBID
|
||||||
track := f.TrackMetadata
|
track := f.TrackMetadata
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track = &Track{}
|
track = &Track{}
|
||||||
}
|
}
|
||||||
love := models.Love{
|
love := models.Love{
|
||||||
UserName: f.UserName,
|
UserName: f.UserName,
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: recordingMBID,
|
||||||
Created: time.Unix(f.Created, 0),
|
Created: time.Unix(f.Created, 0),
|
||||||
Track: track.AsTrack(),
|
Track: track.AsTrack(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if love.Track.RecordingMbid == "" {
|
if love.Track.RecordingMBID == "" {
|
||||||
love.Track.RecordingMbid = love.RecordingMbid
|
love.Track.RecordingMBID = love.RecordingMBID
|
||||||
}
|
}
|
||||||
|
|
||||||
return love
|
return love
|
||||||
|
@ -350,16 +432,16 @@ func (t Track) AsTrack() models.Track {
|
||||||
Duration: t.Duration(),
|
Duration: t.Duration(),
|
||||||
TrackNumber: t.TrackNumber(),
|
TrackNumber: t.TrackNumber(),
|
||||||
DiscNumber: t.DiscNumber(),
|
DiscNumber: t.DiscNumber(),
|
||||||
RecordingMbid: models.MBID(t.RecordingMbid()),
|
RecordingMBID: t.RecordingMBID(),
|
||||||
ReleaseMbid: models.MBID(t.ReleaseMbid()),
|
ReleaseMBID: t.ReleaseMBID(),
|
||||||
ReleaseGroupMbid: models.MBID(t.ReleaseGroupMbid()),
|
ReleaseGroupMBID: t.ReleaseGroupMBID(),
|
||||||
ISRC: t.ISRC(),
|
ISRC: t.ISRC(),
|
||||||
AdditionalInfo: t.AdditionalInfo,
|
AdditionalInfo: t.AdditionalInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.MbidMapping != nil && len(track.ArtistMbids) == 0 {
|
if t.MBIDMapping != nil && len(track.ArtistMBIDs) == 0 {
|
||||||
for _, artistMbid := range t.MbidMapping.ArtistMbids {
|
for _, artistMBID := range t.MBIDMapping.ArtistMBIDs {
|
||||||
track.ArtistMbids = append(track.ArtistMbids, models.MBID(artistMbid))
|
track.ArtistMBIDs = append(track.ArtistMBIDs, artistMBID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,17 +23,18 @@ import (
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"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 := viper.New()
|
||||||
c.Set("token", "thetoken")
|
c.Set("token", "thetoken")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&listenbrainz.ListenBrainzApiBackend{}).FromConfig(&service)
|
backend := listenbrainz.ListenBrainzApiBackend{}
|
||||||
assert.IsType(t, &listenbrainz.ListenBrainzApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListenBrainzListenAsListen(t *testing.T) {
|
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, []string{lbListen.TrackMetadata.ArtistName}, listen.ArtistNames)
|
||||||
assert.Equal(t, 5, listen.TrackNumber)
|
assert.Equal(t, 5, listen.TrackNumber)
|
||||||
assert.Equal(t, 1, listen.DiscNumber)
|
assert.Equal(t, 1, listen.DiscNumber)
|
||||||
assert.Equal(t, models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMbid)
|
assert.Equal(t, mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMBID)
|
||||||
assert.Equal(t, models.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMbid)
|
assert.Equal(t, mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMBID)
|
||||||
assert.Equal(t, models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMbid)
|
assert.Equal(t, mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMBID)
|
||||||
assert.Equal(t, "DES561620801", listen.ISRC)
|
assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC)
|
||||||
assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"])
|
assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListenBrainzFeedbackAsLove(t *testing.T) {
|
func TestListenBrainzFeedbackAsLove(t *testing.T) {
|
||||||
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
|
recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12")
|
||||||
releaseMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
|
releaseMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68")
|
||||||
artistMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
|
artistMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68")
|
||||||
feedback := listenbrainz.Feedback{
|
feedback := listenbrainz.Feedback{
|
||||||
Created: 1699859066,
|
Created: 1699859066,
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: recordingMBID,
|
||||||
Score: 1,
|
Score: 1,
|
||||||
UserName: "ousidecontext",
|
UserName: "ousidecontext",
|
||||||
TrackMetadata: &listenbrainz.Track{
|
TrackMetadata: &listenbrainz.Track{
|
||||||
TrackName: "Oweynagat",
|
TrackName: "Oweynagat",
|
||||||
ArtistName: "Dool",
|
ArtistName: "Dool",
|
||||||
ReleaseName: "Here Now, There Then",
|
ReleaseName: "Here Now, There Then",
|
||||||
MbidMapping: &listenbrainz.MbidMapping{
|
MBIDMapping: &listenbrainz.MBIDMapping{
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: recordingMBID,
|
||||||
ReleaseMbid: releaseMbid,
|
ReleaseMBID: releaseMBID,
|
||||||
ArtistMbids: []string{artistMbid},
|
ArtistMBIDs: []mbtypes.MBID{artistMBID},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -99,24 +100,24 @@ func TestListenBrainzFeedbackAsLove(t *testing.T) {
|
||||||
assert.Equal(feedback.TrackMetadata.TrackName, love.TrackName)
|
assert.Equal(feedback.TrackMetadata.TrackName, love.TrackName)
|
||||||
assert.Equal(feedback.TrackMetadata.ReleaseName, love.ReleaseName)
|
assert.Equal(feedback.TrackMetadata.ReleaseName, love.ReleaseName)
|
||||||
assert.Equal([]string{feedback.TrackMetadata.ArtistName}, love.ArtistNames)
|
assert.Equal([]string{feedback.TrackMetadata.ArtistName}, love.ArtistNames)
|
||||||
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
|
assert.Equal(recordingMBID, love.RecordingMBID)
|
||||||
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
|
assert.Equal(recordingMBID, love.Track.RecordingMBID)
|
||||||
assert.Equal(models.MBID(releaseMbid), love.Track.ReleaseMbid)
|
assert.Equal(releaseMBID, love.Track.ReleaseMBID)
|
||||||
require.Len(t, love.Track.ArtistMbids, 1)
|
require.Len(t, love.Track.ArtistMBIDs, 1)
|
||||||
assert.Equal(models.MBID(artistMbid), love.Track.ArtistMbids[0])
|
assert.Equal(artistMBID, love.Track.ArtistMBIDs[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
|
func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
|
||||||
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
|
recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12")
|
||||||
feedback := listenbrainz.Feedback{
|
feedback := listenbrainz.Feedback{
|
||||||
Created: 1699859066,
|
Created: 1699859066,
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: recordingMBID,
|
||||||
Score: 1,
|
Score: 1,
|
||||||
}
|
}
|
||||||
love := feedback.AsLove()
|
love := feedback.AsLove()
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
|
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
|
||||||
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
|
assert.Equal(recordingMBID, love.RecordingMBID)
|
||||||
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
|
assert.Equal(recordingMBID, love.Track.RecordingMBID)
|
||||||
assert.Empty(love.Track.TrackName)
|
assert.Empty(love.Track.TrackName)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"golang.org/x/exp/constraints"
|
"golang.org/x/exp/constraints"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -56,7 +57,7 @@ type ListenSubmission struct {
|
||||||
type Listen struct {
|
type Listen struct {
|
||||||
InsertedAt int64 `json:"inserted_at,omitempty"`
|
InsertedAt int64 `json:"inserted_at,omitempty"`
|
||||||
ListenedAt int64 `json:"listened_at"`
|
ListenedAt int64 `json:"listened_at"`
|
||||||
RecordingMsid string `json:"recording_msid,omitempty"`
|
RecordingMSID string `json:"recording_msid,omitempty"`
|
||||||
UserName string `json:"user_name,omitempty"`
|
UserName string `json:"user_name,omitempty"`
|
||||||
TrackMetadata Track `json:"track_metadata"`
|
TrackMetadata Track `json:"track_metadata"`
|
||||||
}
|
}
|
||||||
|
@ -66,20 +67,20 @@ type Track struct {
|
||||||
ArtistName string `json:"artist_name,omitempty"`
|
ArtistName string `json:"artist_name,omitempty"`
|
||||||
ReleaseName string `json:"release_name,omitempty"`
|
ReleaseName string `json:"release_name,omitempty"`
|
||||||
AdditionalInfo map[string]any `json:"additional_info,omitempty"`
|
AdditionalInfo map[string]any `json:"additional_info,omitempty"`
|
||||||
MbidMapping *MbidMapping `json:"mbid_mapping,omitempty"`
|
MBIDMapping *MBIDMapping `json:"mbid_mapping,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MbidMapping struct {
|
type MBIDMapping struct {
|
||||||
RecordingName string `json:"recording_name,omitempty"`
|
RecordingName string `json:"recording_name,omitempty"`
|
||||||
RecordingMbid string `json:"recording_mbid,omitempty"`
|
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
|
||||||
ReleaseMbid string `json:"release_mbid,omitempty"`
|
ReleaseMBID mbtypes.MBID `json:"release_mbid,omitempty"`
|
||||||
ArtistMbids []string `json:"artist_mbids,omitempty"`
|
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids,omitempty"`
|
||||||
Artists []Artist `json:"artists,omitempty"`
|
Artists []Artist `json:"artists,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
ArtistCreditName string `json:"artist_credit_name,omitempty"`
|
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"`
|
JoinPhrase string `json:"join_phrase,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,8 +93,8 @@ type GetFeedbackResult struct {
|
||||||
|
|
||||||
type Feedback struct {
|
type Feedback struct {
|
||||||
Created int64 `json:"created,omitempty"`
|
Created int64 `json:"created,omitempty"`
|
||||||
RecordingMbid string `json:"recording_mbid,omitempty"`
|
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
|
||||||
RecordingMsid string `json:"recording_msid,omitempty"`
|
RecordingMSID mbtypes.MBID `json:"recording_msid,omitempty"`
|
||||||
Score int `json:"score,omitempty"`
|
Score int `json:"score,omitempty"`
|
||||||
TrackMetadata *Track `json:"track_metadata,omitempty"`
|
TrackMetadata *Track `json:"track_metadata,omitempty"`
|
||||||
UserName string `json:"user_id,omitempty"`
|
UserName string `json:"user_id,omitempty"`
|
||||||
|
@ -103,9 +104,9 @@ type LookupResult struct {
|
||||||
ArtistCreditName string `json:"artist_credit_name"`
|
ArtistCreditName string `json:"artist_credit_name"`
|
||||||
ReleaseName string `json:"release_name"`
|
ReleaseName string `json:"release_name"`
|
||||||
RecordingName string `json:"recording_name"`
|
RecordingName string `json:"recording_name"`
|
||||||
RecordingMbid string `json:"recording_mbid"`
|
RecordingMBID mbtypes.MBID `json:"recording_mbid"`
|
||||||
ReleaseMbid string `json:"release_mbid"`
|
ReleaseMBID mbtypes.MBID `json:"release_mbid"`
|
||||||
ArtistMbids []string `json:"artist_mbids"`
|
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusResult struct {
|
type StatusResult struct {
|
||||||
|
@ -158,30 +159,30 @@ func (t Track) DiscNumber() int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Track) ISRC() string {
|
func (t Track) ISRC() mbtypes.ISRC {
|
||||||
return tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc")
|
return mbtypes.ISRC(tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Track) RecordingMbid() string {
|
func (t Track) RecordingMBID() mbtypes.MBID {
|
||||||
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid")
|
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid"))
|
||||||
if mbid == "" && t.MbidMapping != nil {
|
if mbid == "" && t.MBIDMapping != nil {
|
||||||
return t.MbidMapping.RecordingMbid
|
return t.MBIDMapping.RecordingMBID
|
||||||
} else {
|
} else {
|
||||||
return mbid
|
return mbid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Track) ReleaseMbid() string {
|
func (t Track) ReleaseMBID() mbtypes.MBID {
|
||||||
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid")
|
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid"))
|
||||||
if mbid == "" && t.MbidMapping != nil {
|
if mbid == "" && t.MBIDMapping != nil {
|
||||||
return t.MbidMapping.ReleaseMbid
|
return t.MBIDMapping.ReleaseMBID
|
||||||
} else {
|
} else {
|
||||||
return mbid
|
return mbid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Track) ReleaseGroupMbid() string {
|
func (t Track) ReleaseGroupMBID() mbtypes.MBID {
|
||||||
return tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid")
|
return mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryGetValueOrEmpty[T any](dict map[string]any, key string) T {
|
func tryGetValueOrEmpty[T any](dict map[string]any, key string) T {
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -130,50 +131,50 @@ func TestTrackTrackNumberString(t *testing.T) {
|
||||||
assert.Equal(t, 12, track.TrackNumber())
|
assert.Equal(t, 12, track.TrackNumber())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrackIsrc(t *testing.T) {
|
func TestTrackISRC(t *testing.T) {
|
||||||
expected := "TCAEJ1934417"
|
expected := mbtypes.ISRC("TCAEJ1934417")
|
||||||
track := listenbrainz.Track{
|
track := listenbrainz.Track{
|
||||||
AdditionalInfo: map[string]any{
|
AdditionalInfo: map[string]any{
|
||||||
"isrc": expected,
|
"isrc": string(expected),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
assert.Equal(t, expected, track.ISRC())
|
assert.Equal(t, expected, track.ISRC())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrackRecordingMbid(t *testing.T) {
|
func TestTrackRecordingMBID(t *testing.T) {
|
||||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||||
track := listenbrainz.Track{
|
track := listenbrainz.Track{
|
||||||
AdditionalInfo: map[string]any{
|
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) {
|
func TestTrackReleaseMBID(t *testing.T) {
|
||||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||||
track := listenbrainz.Track{
|
track := listenbrainz.Track{
|
||||||
AdditionalInfo: map[string]any{
|
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) {
|
func TestReleaseGroupMBID(t *testing.T) {
|
||||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||||
track := listenbrainz.Track{
|
track := listenbrainz.Track{
|
||||||
AdditionalInfo: map[string]any{
|
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) {
|
func TestMarshalPartialFeedback(t *testing.T) {
|
||||||
feedback := listenbrainz.Feedback{
|
feedback := listenbrainz.Feedback{
|
||||||
Created: 1699859066,
|
Created: 1699859066,
|
||||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||||
}
|
}
|
||||||
b, err := json.Marshal(feedback)
|
b, err := json.Marshal(feedback)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
@ -32,25 +32,25 @@ import (
|
||||||
const MaxItemsPerGet = 1000
|
const MaxItemsPerGet = 1000
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HttpClient *resty.Client
|
HTTPClient *resty.Client
|
||||||
token string
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(serverUrl string, token string) Client {
|
func NewClient(serverURL string, token string) Client {
|
||||||
client := resty.New()
|
client := resty.New()
|
||||||
client.SetBaseURL(serverUrl)
|
client.SetBaseURL(serverURL)
|
||||||
client.SetHeader("Accept", "application/json")
|
client.SetHeader("Accept", "application/json")
|
||||||
client.SetHeader("User-Agent", version.UserAgent())
|
client.SetHeader("User-Agent", version.UserAgent())
|
||||||
client.SetRetryCount(5)
|
client.SetRetryCount(5)
|
||||||
return Client{
|
return Client{
|
||||||
HttpClient: client,
|
HTTPClient: client,
|
||||||
token: token,
|
token: token,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) {
|
func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) {
|
||||||
const path = "/apis/mlj_1/scrobbles"
|
const path = "/apis/mlj_1/scrobbles"
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"page": strconv.Itoa(page),
|
"page": strconv.Itoa(page),
|
||||||
"perpage": strconv.Itoa(perPage),
|
"perpage": strconv.Itoa(perPage),
|
||||||
|
@ -68,7 +68,7 @@ func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult,
|
||||||
func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err error) {
|
func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err error) {
|
||||||
const path = "/apis/mlj_1/newscrobble"
|
const path = "/apis/mlj_1/newscrobble"
|
||||||
scrobble.Key = c.token
|
scrobble.Key = c.token
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetBody(scrobble).
|
SetBody(scrobble).
|
||||||
SetResult(&result).
|
SetResult(&result).
|
||||||
Post(path)
|
Post(path)
|
||||||
|
|
|
@ -32,19 +32,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
func TestNewClient(t *testing.T) {
|
||||||
serverUrl := "https://maloja.example.com"
|
serverURL := "https://maloja.example.com"
|
||||||
token := "foobar123"
|
token := "foobar123"
|
||||||
client := maloja.NewClient(serverUrl, token)
|
client := maloja.NewClient(serverURL, token)
|
||||||
assert.Equal(t, serverUrl, client.HttpClient.BaseURL)
|
assert.Equal(t, serverURL, client.HTTPClient.BaseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetScrobbles(t *testing.T) {
|
func TestGetScrobbles(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
serverUrl := "https://maloja.example.com"
|
serverURL := "https://maloja.example.com"
|
||||||
token := "thetoken"
|
token := "thetoken"
|
||||||
client := maloja.NewClient(serverUrl, token)
|
client := maloja.NewClient(serverURL, token)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://maloja.example.com/apis/mlj_1/scrobbles",
|
"https://maloja.example.com/apis/mlj_1/scrobbles",
|
||||||
"testdata/scrobbles.json")
|
"testdata/scrobbles.json")
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ func TestGetScrobbles(t *testing.T) {
|
||||||
func TestNewScrobble(t *testing.T) {
|
func TestNewScrobble(t *testing.T) {
|
||||||
server := "https://maloja.example.com"
|
server := "https://maloja.example.com"
|
||||||
client := maloja.NewClient(server, "thetoken")
|
client := maloja.NewClient(server, "thetoken")
|
||||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json"))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -80,7 +80,7 @@ func TestNewScrobble(t *testing.T) {
|
||||||
assert.Equal(t, "success", result.Status)
|
assert.Equal(t, "success", result.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||||
httpmock.ActivateNonDefault(client)
|
httpmock.ActivateNonDefault(client)
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||||
|
|
|
@ -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(
|
b.client = NewClient(
|
||||||
config.GetString("server-url"),
|
config.GetString("server-url"),
|
||||||
config.GetString("token"),
|
config.GetString("token"),
|
||||||
)
|
)
|
||||||
b.nofix = config.GetBool("nofix", false)
|
b.nofix = config.GetBool("nofix", false)
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *MalojaApiBackend) StartImport() error { return nil }
|
func (b *MalojaApiBackend) StartImport() error { return nil }
|
||||||
|
|
|
@ -26,12 +26,13 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("token", "thetoken")
|
c.Set("token", "thetoken")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&maloja.MalojaApiBackend{}).FromConfig(&service)
|
backend := maloja.MalojaApiBackend{}
|
||||||
assert.IsType(t, &maloja.MalojaApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScrobbleAsListen(t *testing.T) {
|
func TestScrobbleAsListen(t *testing.T) {
|
||||||
|
|
|
@ -1,210 +0,0 @@
|
||||||
/*
|
|
||||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
*/
|
|
||||||
package 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
|
|
||||||
}
|
|
|
@ -18,21 +18,25 @@ package scrobblerlog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
"go.uploadedlobster.com/scotty/pkg/scrobblerlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ScrobblerLogBackend struct {
|
type ScrobblerLogBackend struct {
|
||||||
filePath string
|
filePath string
|
||||||
includeSkipped bool
|
ignoreSkipped bool
|
||||||
append bool
|
append bool
|
||||||
file *os.File
|
file *os.File
|
||||||
log ScrobblerLog
|
timezone *time.Location
|
||||||
|
log scrobblerlog.ScrobblerLog
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" }
|
func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" }
|
||||||
|
@ -43,26 +47,39 @@ func (b *ScrobblerLogBackend) Options() []models.BackendOption {
|
||||||
Label: i18n.Tr("File path"),
|
Label: i18n.Tr("File path"),
|
||||||
Type: models.String,
|
Type: models.String,
|
||||||
}, {
|
}, {
|
||||||
Name: "include-skipped",
|
Name: "ignore-skipped",
|
||||||
Label: i18n.Tr("Include skipped listens"),
|
Label: i18n.Tr("Ignore skipped listens"),
|
||||||
Type: models.Bool,
|
Type: models.Bool,
|
||||||
|
Default: "true",
|
||||||
}, {
|
}, {
|
||||||
Name: "append",
|
Name: "append",
|
||||||
Label: i18n.Tr("Append to file"),
|
Label: i18n.Tr("Append to file"),
|
||||||
Type: models.Bool,
|
Type: models.Bool,
|
||||||
Default: "true",
|
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.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.append = config.GetBool("append", true)
|
||||||
b.log = ScrobblerLog{
|
timezone := config.GetString("time-zone")
|
||||||
Timezone: "UNKNOWN",
|
if timezone != "" {
|
||||||
|
location, err := time.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Invalid time-zone %q: %w", timezone, err)
|
||||||
|
}
|
||||||
|
b.log.FallbackTimezone = location
|
||||||
|
}
|
||||||
|
b.log = scrobblerlog.ScrobblerLog{
|
||||||
|
TZ: scrobblerlog.TimezoneUTC,
|
||||||
Client: "Rockbox unknown $Revision$",
|
Client: "Rockbox unknown $Revision$",
|
||||||
}
|
}
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ScrobblerLogBackend) StartImport() error {
|
func (b *ScrobblerLogBackend) StartImport() error {
|
||||||
|
@ -88,7 +105,7 @@ func (b *ScrobblerLogBackend) StartImport() error {
|
||||||
} else {
|
} else {
|
||||||
// Verify existing file is a scrobbler log
|
// Verify existing file is a scrobbler log
|
||||||
reader := bufio.NewReader(file)
|
reader := bufio.NewReader(file)
|
||||||
if err = ReadHeader(reader, &b.log); err != nil {
|
if err = b.log.ReadHeader(reader); err != nil {
|
||||||
file.Close()
|
file.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -99,7 +116,7 @@ func (b *ScrobblerLogBackend) StartImport() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !b.append {
|
if !b.append {
|
||||||
if err = WriteHeader(file, &b.log); err != nil {
|
if err = b.log.WriteHeader(file); err != nil {
|
||||||
file.Close()
|
file.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -124,21 +141,29 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c
|
||||||
|
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
log, err := Parse(file, b.includeSkipped)
|
err = b.log.Parse(file, b.ignoreSkipped)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
progress <- models.Progress{}.Complete()
|
progress <- models.Progress{}.Complete()
|
||||||
results <- models.ListensResult{Error: err}
|
results <- models.ListensResult{Error: err}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
listens := log.Listens.NewerThan(oldestTimestamp)
|
listens := make(models.ListensList, 0, len(b.log.Records))
|
||||||
sort.Sort(listens)
|
client := strings.Split(b.log.Client, " ")[0]
|
||||||
progress <- models.Progress{Elapsed: int64(len(listens))}.Complete()
|
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}
|
results <- models.ListensResult{Items: listens}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
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 {
|
if err != nil {
|
||||||
return importResult, err
|
return importResult, err
|
||||||
}
|
}
|
||||||
|
@ -149,3 +174,42 @@ func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importR
|
||||||
|
|
||||||
return importResult, nil
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -25,10 +25,21 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("token", "thetoken")
|
c.Set("token", "thetoken")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(&service)
|
backend := scrobblerlog.ScrobblerLogBackend{}
|
||||||
assert.IsType(t, &scrobblerlog.ScrobblerLogBackend{}, backend)
|
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"`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,7 +40,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HttpClient *resty.Client
|
HTTPClient *resty.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(token oauth2.TokenSource) Client {
|
func NewClient(token oauth2.TokenSource) Client {
|
||||||
|
@ -55,7 +55,7 @@ func NewClient(token oauth2.TokenSource) Client {
|
||||||
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
||||||
|
|
||||||
return Client{
|
return Client{
|
||||||
HttpClient: client,
|
HTTPClient: client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlaye
|
||||||
|
|
||||||
func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) {
|
func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) {
|
||||||
const path = "/me/player/recently-played"
|
const path = "/me/player/recently-played"
|
||||||
request := c.HttpClient.R().
|
request := c.HTTPClient.R().
|
||||||
SetQueryParam("limit", strconv.Itoa(limit)).
|
SetQueryParam("limit", strconv.Itoa(limit)).
|
||||||
SetResult(&result)
|
SetResult(&result)
|
||||||
if after != nil {
|
if after != nil {
|
||||||
|
@ -87,7 +87,7 @@ func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (
|
||||||
|
|
||||||
func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) {
|
func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) {
|
||||||
const path = "/me/tracks"
|
const path = "/me/tracks"
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"offset": strconv.Itoa(offset),
|
"offset": strconv.Itoa(offset),
|
||||||
"limit": strconv.Itoa(limit),
|
"limit": strconv.Itoa(limit),
|
||||||
|
|
|
@ -43,7 +43,7 @@ func TestRecentlyPlayedAfter(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
client := spotify.NewClient(nil)
|
client := spotify.NewClient(nil)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.spotify.com/v1/me/player/recently-played",
|
"https://api.spotify.com/v1/me/player/recently-played",
|
||||||
"testdata/recently-played.json")
|
"testdata/recently-played.json")
|
||||||
|
|
||||||
|
@ -63,7 +63,7 @@ func TestGetUserTracks(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
client := spotify.NewClient(nil)
|
client := spotify.NewClient(nil)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.spotify.com/v1/me/tracks",
|
"https://api.spotify.com/v1/me/tracks",
|
||||||
"testdata/user-tracks.json")
|
"testdata/user-tracks.json")
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ func TestGetUserTracks(t *testing.T) {
|
||||||
assert.Equal("Zeal & Ardor", track1.Track.Album.Name)
|
assert.Equal("Zeal & Ardor", track1.Track.Album.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||||
httpmock.ActivateNonDefault(client)
|
httpmock.ActivateNonDefault(client)
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||||
|
|
|
@ -22,6 +22,8 @@ THE SOFTWARE.
|
||||||
|
|
||||||
package spotify
|
package spotify
|
||||||
|
|
||||||
|
import "go.uploadedlobster.com/mbtypes"
|
||||||
|
|
||||||
type TracksResult struct {
|
type TracksResult struct {
|
||||||
Href string `json:"href"`
|
Href string `json:"href"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
|
@ -56,7 +58,7 @@ type Listen struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
Id string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Href string `json:"href"`
|
Href string `json:"href"`
|
||||||
Uri string `json:"uri"`
|
Uri string `json:"uri"`
|
||||||
|
@ -67,14 +69,14 @@ type Track struct {
|
||||||
Explicit bool `json:"explicit"`
|
Explicit bool `json:"explicit"`
|
||||||
IsLocal bool `json:"is_local"`
|
IsLocal bool `json:"is_local"`
|
||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
ExternalIds ExternalIds `json:"external_ids"`
|
ExternalIDs ExternalIDs `json:"external_ids"`
|
||||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
ExternalURLs ExternalURLs `json:"external_urls"`
|
||||||
Album Album `json:"album"`
|
Album Album `json:"album"`
|
||||||
Artists []Artist `json:"artists"`
|
Artists []Artist `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Album struct {
|
type Album struct {
|
||||||
Id string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Href string `json:"href"`
|
Href string `json:"href"`
|
||||||
Uri string `json:"uri"`
|
Uri string `json:"uri"`
|
||||||
|
@ -83,32 +85,32 @@ type Album struct {
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ReleaseDatePrecision string `json:"release_date_precision"`
|
ReleaseDatePrecision string `json:"release_date_precision"`
|
||||||
AlbumType string `json:"album_type"`
|
AlbumType string `json:"album_type"`
|
||||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
ExternalURLs ExternalURLs `json:"external_urls"`
|
||||||
Artists []Artist `json:"artists"`
|
Artists []Artist `json:"artists"`
|
||||||
Images []Image `json:"images"`
|
Images []Image `json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
Id string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Href string `json:"href"`
|
Href string `json:"href"`
|
||||||
Uri string `json:"uri"`
|
Uri string `json:"uri"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
ExternalURLs ExternalURLs `json:"external_urls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExternalIds struct {
|
type ExternalIDs struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC mbtypes.ISRC `json:"isrc"`
|
||||||
EAN string `json:"ean"`
|
EAN string `json:"ean"`
|
||||||
UPC string `json:"upc"`
|
UPC string `json:"upc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExternalUrls struct {
|
type ExternalURLs struct {
|
||||||
Spotify string `json:"spotify"`
|
Spotify string `json:"spotify"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Image struct {
|
type Image struct {
|
||||||
Url string `json:"url"`
|
URL string `json:"url"`
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ import (
|
||||||
|
|
||||||
type SpotifyApiBackend struct {
|
type SpotifyApiBackend struct {
|
||||||
client Client
|
client Client
|
||||||
clientId string
|
clientID string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,15 +52,15 @@ func (b *SpotifyApiBackend) Options() []models.BackendOption {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SpotifyApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *SpotifyApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
b.clientId = config.GetString("client-id")
|
b.clientID = config.GetString("client-id")
|
||||||
b.clientSecret = config.GetString("client-secret")
|
b.clientSecret = config.GetString("client-secret")
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
func (b *SpotifyApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
|
||||||
conf := oauth2.Config{
|
conf := oauth2.Config{
|
||||||
ClientID: b.clientId,
|
ClientID: b.clientID,
|
||||||
ClientSecret: b.clientSecret,
|
ClientSecret: b.clientSecret,
|
||||||
Scopes: []string{
|
Scopes: []string{
|
||||||
"user-read-currently-playing",
|
"user-read-currently-playing",
|
||||||
|
@ -68,16 +68,16 @@ func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Stra
|
||||||
"user-library-read",
|
"user-library-read",
|
||||||
"user-library-modify",
|
"user-library-modify",
|
||||||
},
|
},
|
||||||
RedirectURL: redirectUrl.String(),
|
RedirectURL: redirectURL.String(),
|
||||||
Endpoint: spotify.Endpoint,
|
Endpoint: spotify.Endpoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
return auth.NewStandardStrategy(conf)
|
return auth.NewStandardStrategy(conf)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config {
|
func (b *SpotifyApiBackend) OAuth2Config(redirectURL *url.URL) oauth2.Config {
|
||||||
return oauth2.Config{
|
return oauth2.Config{
|
||||||
ClientID: b.clientId,
|
ClientID: b.clientID,
|
||||||
ClientSecret: b.clientSecret,
|
ClientSecret: b.clientSecret,
|
||||||
Scopes: []string{
|
Scopes: []string{
|
||||||
"user-read-currently-playing",
|
"user-read-currently-playing",
|
||||||
|
@ -85,7 +85,7 @@ func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config {
|
||||||
"user-library-read",
|
"user-library-read",
|
||||||
"user-library-modify",
|
"user-library-modify",
|
||||||
},
|
},
|
||||||
RedirectURL: redirectUrl.String(),
|
RedirectURL: redirectURL.String(),
|
||||||
Endpoint: spotify.Endpoint,
|
Endpoint: spotify.Endpoint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -251,7 +251,7 @@ func (t Track) AsTrack() models.Track {
|
||||||
Duration: time.Duration(t.DurationMs * int(time.Millisecond)),
|
Duration: time.Duration(t.DurationMs * int(time.Millisecond)),
|
||||||
TrackNumber: t.TrackNumber,
|
TrackNumber: t.TrackNumber,
|
||||||
DiscNumber: t.DiscNumber,
|
DiscNumber: t.DiscNumber,
|
||||||
ISRC: t.ExternalIds.ISRC,
|
ISRC: t.ExternalIDs.ISRC,
|
||||||
AdditionalInfo: map[string]any{},
|
AdditionalInfo: map[string]any{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -264,30 +264,30 @@ func (t Track) AsTrack() models.Track {
|
||||||
info["music_service"] = "spotify.com"
|
info["music_service"] = "spotify.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.ExternalUrls.Spotify != "" {
|
if t.ExternalURLs.Spotify != "" {
|
||||||
info["origin_url"] = t.ExternalUrls.Spotify
|
info["origin_url"] = t.ExternalURLs.Spotify
|
||||||
info["spotify_id"] = t.ExternalUrls.Spotify
|
info["spotify_id"] = t.ExternalURLs.Spotify
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.Album.ExternalUrls.Spotify != "" {
|
if t.Album.ExternalURLs.Spotify != "" {
|
||||||
info["spotify_album_id"] = t.Album.ExternalUrls.Spotify
|
info["spotify_album_id"] = t.Album.ExternalURLs.Spotify
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.Artists) > 0 {
|
if len(t.Artists) > 0 {
|
||||||
info["spotify_artist_ids"] = extractArtistIds(t.Artists)
|
info["spotify_artist_ids"] = extractArtistIDs(t.Artists)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.Album.Artists) > 0 {
|
if len(t.Album.Artists) > 0 {
|
||||||
info["spotify_album_artist_ids"] = extractArtistIds(t.Album.Artists)
|
info["spotify_album_artist_ids"] = extractArtistIDs(t.Album.Artists)
|
||||||
}
|
}
|
||||||
|
|
||||||
return track
|
return track
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractArtistIds(artists []Artist) []string {
|
func extractArtistIDs(artists []Artist) []string {
|
||||||
artistIds := make([]string, len(artists))
|
artistIDs := make([]string, len(artists))
|
||||||
for i, artist := range artists {
|
for i, artist := range artists {
|
||||||
artistIds[i] = artist.ExternalUrls.Spotify
|
artistIDs[i] = artist.ExternalURLs.Spotify
|
||||||
}
|
}
|
||||||
return artistIds
|
return artistIDs
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import (
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
@ -37,13 +38,14 @@ var (
|
||||||
testTrack []byte
|
testTrack []byte
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("client-id", "someclientid")
|
c.Set("client-id", "someclientid")
|
||||||
c.Set("client-secret", "someclientsecret")
|
c.Set("client-secret", "someclientsecret")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&spotify.SpotifyApiBackend{}).FromConfig(&service)
|
backend := spotify.SpotifyApiBackend{}
|
||||||
assert.IsType(t, &spotify.SpotifyApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSpotifyListenAsListen(t *testing.T) {
|
func TestSpotifyListenAsListen(t *testing.T) {
|
||||||
|
@ -59,7 +61,7 @@ func TestSpotifyListenAsListen(t *testing.T) {
|
||||||
assert.Equal(t, []string{"Dool"}, listen.ArtistNames)
|
assert.Equal(t, []string{"Dool"}, listen.ArtistNames)
|
||||||
assert.Equal(t, 5, listen.TrackNumber)
|
assert.Equal(t, 5, listen.TrackNumber)
|
||||||
assert.Equal(t, 1, listen.DiscNumber)
|
assert.Equal(t, 1, listen.DiscNumber)
|
||||||
assert.Equal(t, "DES561620801", listen.ISRC)
|
assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC)
|
||||||
info := listen.AdditionalInfo
|
info := listen.AdditionalInfo
|
||||||
assert.Equal(t, "spotify.com", info["music_service"])
|
assert.Equal(t, "spotify.com", info["music_service"])
|
||||||
assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["origin_url"])
|
assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["origin_url"])
|
||||||
|
|
|
@ -92,8 +92,8 @@ func (i HistoryItem) AsListen() models.Listen {
|
||||||
PlaybackDuration: time.Duration(i.MillisecondsPlayed * int(time.Millisecond)),
|
PlaybackDuration: time.Duration(i.MillisecondsPlayed * int(time.Millisecond)),
|
||||||
UserName: i.UserName,
|
UserName: i.UserName,
|
||||||
}
|
}
|
||||||
if trackUrl, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil {
|
if trackURL, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil {
|
||||||
listen.AdditionalInfo["spotify_id"] = trackUrl
|
listen.AdditionalInfo["spotify_id"] = trackURL
|
||||||
}
|
}
|
||||||
return listen
|
return listen
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.dirPath = config.GetString("dir-path")
|
||||||
b.ignoreIncognito = config.GetBool("ignore-incognito", true)
|
b.ignoreIncognito = config.GetBool("ignore-incognito", true)
|
||||||
b.ignoreSkipped = config.GetBool("ignore-skipped", false)
|
b.ignoreSkipped = config.GetBool("ignore-skipped", false)
|
||||||
b.skippedMinSeconds = config.GetInt("ignore-min-duration-seconds", 30)
|
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) {
|
func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||||
|
|
|
@ -21,7 +21,8 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"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/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"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{
|
b.client = subsonic.Client{
|
||||||
Client: &http.Client{},
|
Client: &http.Client{},
|
||||||
BaseUrl: config.GetString("server-url"),
|
BaseUrl: config.GetString("server-url"),
|
||||||
|
@ -59,7 +60,7 @@ func (b *SubsonicApiBackend) FromConfig(config *config.ServiceConfig) models.Bac
|
||||||
ClientName: version.AppName,
|
ClientName: version.AppName,
|
||||||
}
|
}
|
||||||
b.password = config.GetString("token")
|
b.password = config.GetString("token")
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
progress <- models.Progress{Elapsed: int64(len(starred.Song))}.Complete()
|
loves := b.filterSongs(starred.Song, oldestTimestamp)
|
||||||
results <- models.LovesResult{Items: 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 {
|
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 {
|
func SongAsLove(song subsonic.Child, username string) models.Love {
|
||||||
|
recordingMBID := mbtypes.MBID(song.MusicBrainzID)
|
||||||
love := models.Love{
|
love := models.Love{
|
||||||
UserName: username,
|
UserName: username,
|
||||||
Created: song.Starred,
|
Created: song.Starred,
|
||||||
|
RecordingMBID: recordingMBID,
|
||||||
Track: models.Track{
|
Track: models.Track{
|
||||||
TrackName: song.Title,
|
TrackName: song.Title,
|
||||||
ReleaseName: song.Album,
|
ReleaseName: song.Album,
|
||||||
ArtistNames: []string{song.Artist},
|
ArtistNames: []string{song.Artist},
|
||||||
TrackNumber: song.Track,
|
TrackNumber: song.Track,
|
||||||
DiscNumber: song.DiscNumber,
|
DiscNumber: song.DiscNumber,
|
||||||
|
RecordingMBID: recordingMBID,
|
||||||
|
Tags: []string{},
|
||||||
AdditionalInfo: map[string]any{
|
AdditionalInfo: map[string]any{
|
||||||
"subsonic_id": song.ID,
|
"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}
|
love.Track.Tags = []string{song.Genre}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,20 +20,21 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
go_subsonic "github.com/delucks/go-subsonic"
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"github.com/stretchr/testify/assert"
|
"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/backends/subsonic"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("server-url", "https://subsonic.example.com")
|
c.Set("server-url", "https://subsonic.example.com")
|
||||||
c.Set("token", "thetoken")
|
c.Set("token", "thetoken")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&subsonic.SubsonicApiBackend{}).FromConfig(&service)
|
backend := subsonic.SubsonicApiBackend{}
|
||||||
assert.IsType(t, &subsonic.SubsonicApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSongToLove(t *testing.T) {
|
func TestSongToLove(t *testing.T) {
|
||||||
|
|
|
@ -43,20 +43,20 @@ func AuthenticationFlow(service config.ServiceConfig, backend auth.OAuth2Authent
|
||||||
|
|
||||||
state := auth.RandomState()
|
state := auth.RandomState()
|
||||||
// Redirect user to consent page to ask for permission specified scopes.
|
// Redirect user to consent page to ask for permission specified scopes.
|
||||||
authUrl := strategy.AuthCodeURL(verifier, state)
|
authURL := strategy.AuthCodeURL(verifier, state)
|
||||||
|
|
||||||
// Start an HTTP server to listen for the response
|
// Start an HTTP server to listen for the response
|
||||||
responseChan := make(chan auth.CodeResponse)
|
responseChan := make(chan auth.CodeResponse)
|
||||||
auth.RunOauth2CallbackServer(*redirectURL, authUrl.Param, responseChan)
|
auth.RunOauth2CallbackServer(*redirectURL, authURL.Param, responseChan)
|
||||||
|
|
||||||
// Open the URL
|
// Open the URL
|
||||||
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authUrl.Url))
|
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authURL.URL))
|
||||||
err = browser.OpenURL(authUrl.Url)
|
err = browser.OpenURL(authURL.URL)
|
||||||
cobra.CheckErr(err)
|
cobra.CheckErr(err)
|
||||||
|
|
||||||
// Retrieve the code from the authentication callback
|
// Retrieve the code from the authentication callback
|
||||||
code := <-responseChan
|
code := <-responseChan
|
||||||
if code.State != authUrl.State {
|
if code.State != authURL.State {
|
||||||
cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch"))
|
cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch"))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,7 +88,7 @@ func (c *TransferCmd[E, I, R]) resolveBackends(source string, target string) err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp backends.ImportProcessor[R]) error {
|
func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp backends.ImportProcessor[R]) error {
|
||||||
fmt.Println(i18n.Tr("Transferring %s from %s to %s...", c.entity, c.sourceName, c.targetName))
|
fmt.Println(i18n.Tr("Transferring %s from %s to %s…", c.entity, c.sourceName, c.targetName))
|
||||||
|
|
||||||
// Authenticate backends, if needed
|
// Authenticate backends, if needed
|
||||||
config := viper.GetViper()
|
config := viper.GetViper()
|
||||||
|
|
|
@ -16,11 +16,10 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
package i18n
|
package i18n
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/Xuanwo/go-locale"
|
"github.com/Xuanwo/go-locale"
|
||||||
_ "go.uploadedlobster.com/scotty/internal/translations"
|
_ "go.uploadedlobster.com/scotty/internal/translations"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
"golang.org/x/text/message"
|
"golang.org/x/text/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -29,7 +28,7 @@ var localizer Localizer
|
||||||
func init() {
|
func init() {
|
||||||
tag, err := locale.Detect()
|
tag, err := locale.Detect()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
tag = language.English
|
||||||
}
|
}
|
||||||
localizer = New(tag)
|
localizer = New(tag)
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ type Backend interface {
|
||||||
Name() string
|
Name() string
|
||||||
|
|
||||||
// Initialize the backend from a config.
|
// Initialize the backend from a config.
|
||||||
FromConfig(config *config.ServiceConfig) Backend
|
InitConfig(config *config.ServiceConfig) error
|
||||||
|
|
||||||
// Return configuration options
|
// Return configuration options
|
||||||
Options() []BackendOption
|
Options() []BackendOption
|
||||||
|
|
|
@ -24,9 +24,10 @@ package models
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MBID string
|
|
||||||
type Entity string
|
type Entity string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -43,12 +44,12 @@ type Track struct {
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
ISRC string
|
ISRC mbtypes.ISRC
|
||||||
RecordingMbid MBID
|
RecordingMBID mbtypes.MBID
|
||||||
ReleaseMbid MBID
|
ReleaseMBID mbtypes.MBID
|
||||||
ReleaseGroupMbid MBID
|
ReleaseGroupMBID mbtypes.MBID
|
||||||
ArtistMbids []MBID
|
ArtistMBIDs []mbtypes.MBID
|
||||||
WorkMbids []MBID
|
WorkMBIDs []mbtypes.MBID
|
||||||
Tags []string
|
Tags []string
|
||||||
AdditionalInfo AdditionalInfo
|
AdditionalInfo AdditionalInfo
|
||||||
}
|
}
|
||||||
|
@ -62,20 +63,20 @@ func (t *Track) FillAdditionalInfo() {
|
||||||
if t.AdditionalInfo == nil {
|
if t.AdditionalInfo == nil {
|
||||||
t.AdditionalInfo = make(AdditionalInfo, 5)
|
t.AdditionalInfo = make(AdditionalInfo, 5)
|
||||||
}
|
}
|
||||||
if t.RecordingMbid != "" {
|
if t.RecordingMBID != "" {
|
||||||
t.AdditionalInfo["recording_mbid"] = t.RecordingMbid
|
t.AdditionalInfo["recording_mbid"] = t.RecordingMBID
|
||||||
}
|
}
|
||||||
if t.ReleaseGroupMbid != "" {
|
if t.ReleaseGroupMBID != "" {
|
||||||
t.AdditionalInfo["release_group_mbid"] = t.ReleaseGroupMbid
|
t.AdditionalInfo["release_group_mbid"] = t.ReleaseGroupMBID
|
||||||
}
|
}
|
||||||
if t.ReleaseMbid != "" {
|
if t.ReleaseMBID != "" {
|
||||||
t.AdditionalInfo["release_mbid"] = t.ReleaseMbid
|
t.AdditionalInfo["release_mbid"] = t.ReleaseMBID
|
||||||
}
|
}
|
||||||
if len(t.ArtistMbids) > 0 {
|
if len(t.ArtistMBIDs) > 0 {
|
||||||
t.AdditionalInfo["artist_mbids"] = t.ArtistMbids
|
t.AdditionalInfo["artist_mbids"] = t.ArtistMBIDs
|
||||||
}
|
}
|
||||||
if len(t.WorkMbids) > 0 {
|
if len(t.WorkMBIDs) > 0 {
|
||||||
t.AdditionalInfo["work_mbids"] = t.WorkMbids
|
t.AdditionalInfo["work_mbids"] = t.WorkMBIDs
|
||||||
}
|
}
|
||||||
if t.ISRC != "" {
|
if t.ISRC != "" {
|
||||||
t.AdditionalInfo["isrc"] = t.ISRC
|
t.AdditionalInfo["isrc"] = t.ISRC
|
||||||
|
@ -110,8 +111,8 @@ type Love struct {
|
||||||
Track
|
Track
|
||||||
Created time.Time
|
Created time.Time
|
||||||
UserName string
|
UserName string
|
||||||
RecordingMbid MBID
|
RecordingMBID mbtypes.MBID
|
||||||
RecordingMsid MBID
|
RecordingMSID mbtypes.MBID
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListensList []Listen
|
type ListensList []Listen
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -44,25 +45,25 @@ func TestTrackArtistName(t *testing.T) {
|
||||||
|
|
||||||
func TestTrackFillAdditionalInfo(t *testing.T) {
|
func TestTrackFillAdditionalInfo(t *testing.T) {
|
||||||
track := models.Track{
|
track := models.Track{
|
||||||
RecordingMbid: models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
|
RecordingMBID: mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
|
||||||
ReleaseGroupMbid: models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
|
ReleaseGroupMBID: mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
|
||||||
ReleaseMbid: models.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
|
ReleaseMBID: mbtypes.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
|
||||||
ArtistMbids: []models.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
|
ArtistMBIDs: []mbtypes.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
|
||||||
WorkMbids: []models.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"},
|
WorkMBIDs: []mbtypes.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"},
|
||||||
TrackNumber: 5,
|
TrackNumber: 5,
|
||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
Duration: time.Duration(413787 * time.Millisecond),
|
Duration: time.Duration(413787 * time.Millisecond),
|
||||||
ISRC: "DES561620801",
|
ISRC: mbtypes.ISRC("DES561620801"),
|
||||||
Tags: []string{"rock", "psychedelic rock"},
|
Tags: []string{"rock", "psychedelic rock"},
|
||||||
}
|
}
|
||||||
track.FillAdditionalInfo()
|
track.FillAdditionalInfo()
|
||||||
i := track.AdditionalInfo
|
i := track.AdditionalInfo
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
assert.Equal(track.RecordingMbid, i["recording_mbid"])
|
assert.Equal(track.RecordingMBID, i["recording_mbid"])
|
||||||
assert.Equal(track.ReleaseGroupMbid, i["release_group_mbid"])
|
assert.Equal(track.ReleaseGroupMBID, i["release_group_mbid"])
|
||||||
assert.Equal(track.ReleaseMbid, i["release_mbid"])
|
assert.Equal(track.ReleaseMBID, i["release_mbid"])
|
||||||
assert.Equal(track.ArtistMbids, i["artist_mbids"])
|
assert.Equal(track.ArtistMBIDs, i["artist_mbids"])
|
||||||
assert.Equal(track.WorkMbids, i["work_mbids"])
|
assert.Equal(track.WorkMBIDs, i["work_mbids"])
|
||||||
assert.Equal(track.TrackNumber, i["tracknumber"])
|
assert.Equal(track.TrackNumber, i["tracknumber"])
|
||||||
assert.Equal(track.DiscNumber, i["discnumber"])
|
assert.Equal(track.DiscNumber, i["discnumber"])
|
||||||
assert.Equal(track.Duration.Milliseconds(), i["duration_ms"])
|
assert.Equal(track.Duration.Milliseconds(), i["duration_ms"])
|
||||||
|
|
|
@ -63,7 +63,7 @@ func NormalizeTitle(s string) string {
|
||||||
// Compare two tracks for similarity.
|
// Compare two tracks for similarity.
|
||||||
func CompareTracks(t1 models.Track, t2 models.Track) float64 {
|
func CompareTracks(t1 models.Track, t2 models.Track) float64 {
|
||||||
// Identical recording MBID always compares 100%
|
// Identical recording MBID always compares 100%
|
||||||
if t1.RecordingMbid == t2.RecordingMbid && t1.RecordingMbid != "" {
|
if t1.RecordingMBID == t2.RecordingMBID && t1.RecordingMBID != "" {
|
||||||
return 1.0
|
return 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
"go.uploadedlobster.com/scotty/internal/similarity"
|
"go.uploadedlobster.com/scotty/internal/similarity"
|
||||||
)
|
)
|
||||||
|
@ -74,13 +75,13 @@ func TestCompareTracksSameMBID(t *testing.T) {
|
||||||
t1 := models.Track{
|
t1 := models.Track{
|
||||||
ArtistNames: []string{"Paradise Lost"},
|
ArtistNames: []string{"Paradise Lost"},
|
||||||
TrackName: "Forever After",
|
TrackName: "Forever After",
|
||||||
RecordingMbid: models.MBID("2886d15c-09b0-43c6-af56-932f70dde164"),
|
RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"),
|
||||||
}
|
}
|
||||||
t2 := models.Track{
|
t2 := models.Track{
|
||||||
ArtistNames: []string{"Paradise Lost"},
|
ArtistNames: []string{"Paradise Lost"},
|
||||||
TrackName: "Forever Failure (radio edit)",
|
TrackName: "Forever Failure (radio edit)",
|
||||||
ReleaseName: "Draconian Times",
|
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))
|
assert.Equal(t, 1.0, similarity.CompareTracks(t1, t2))
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,56 +42,56 @@ var messageKeyToIndex = map[string]int{
|
||||||
"\tbackend: %v": 11,
|
"\tbackend: %v": 11,
|
||||||
"\texport: %s": 0,
|
"\texport: %s": 0,
|
||||||
"\timport: %s\n": 1,
|
"\timport: %s\n": 1,
|
||||||
"%v: %v": 52,
|
"%v: %v": 48,
|
||||||
"Aborted": 8,
|
"Aborted": 8,
|
||||||
"Access token": 19,
|
"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,
|
"Append to file": 21,
|
||||||
"Backend": 36,
|
"Backend": 42,
|
||||||
"Check for duplicate listens on import (slower)": 45,
|
"Check for duplicate listens on import (slower)": 24,
|
||||||
"Client ID": 15,
|
"Client ID": 15,
|
||||||
"Client secret": 16,
|
"Client secret": 16,
|
||||||
"Delete the service configuration \"%v\"?": 7,
|
"Delete the service configuration \"%v\"?": 7,
|
||||||
"Directory path": 47,
|
"Directory path": 29,
|
||||||
"Disable auto correction of submitted listens": 24,
|
"Disable auto correction of submitted listens": 26,
|
||||||
"Error: OAuth state mismatch": 27,
|
"Error: OAuth state mismatch": 33,
|
||||||
"Failed reading config: %v": 2,
|
"Failed reading config: %v": 2,
|
||||||
"File path": 20,
|
"File path": 20,
|
||||||
"From timestamp: %v (%v)": 38,
|
"From timestamp: %v (%v)": 44,
|
||||||
"Ignore listens in incognito mode": 48,
|
"Ignore listens in incognito mode": 30,
|
||||||
"Ignore skipped listens": 49,
|
"Ignore skipped listens": 27,
|
||||||
"Ignored duplicate listen %v: \"%v\" by %v (%v)": 46,
|
"Ignored duplicate listen %v: \"%v\" by %v (%v)": 25,
|
||||||
"Import failed, last reported timestamp was %v (%s)": 39,
|
"Import failed, last reported timestamp was %v (%s)": 45,
|
||||||
"Import log:": 51,
|
"Import log:": 47,
|
||||||
"Imported %v of %v %s into %v.": 40,
|
"Imported %v of %v %s into %v.": 46,
|
||||||
"Include skipped listens": 25,
|
"Latest timestamp: %v (%v)": 50,
|
||||||
"Latest timestamp: %v (%v)": 41,
|
"Minimum playback duration for skipped tracks (seconds)": 31,
|
||||||
"Minimum playback duration for skipped tracks (seconds)": 50,
|
"No": 39,
|
||||||
"No": 33,
|
|
||||||
"Playlist title": 22,
|
"Playlist title": 22,
|
||||||
"Saved service %v using backend %v": 5,
|
"Saved service %v using backend %v": 5,
|
||||||
"Server URL": 17,
|
"Server URL": 17,
|
||||||
"Service": 35,
|
"Service": 41,
|
||||||
"Service \"%v\" deleted\n": 9,
|
"Service \"%v\" deleted\n": 9,
|
||||||
"Service name": 3,
|
"Service name": 3,
|
||||||
|
"Specify a time zone for the listen timestamps": 28,
|
||||||
"The backend %v requires authentication. Authenticate now?": 6,
|
"The backend %v requires authentication. Authenticate now?": 6,
|
||||||
"Token received, you can close this window now.": 12,
|
"Token received, you can close this window now.": 12,
|
||||||
"Transferring %s from %s to %s...": 37,
|
"Transferring %s from %s to %s…": 43,
|
||||||
"Unique playlist identifier": 23,
|
"Unique playlist identifier": 23,
|
||||||
"Updated service %v using backend %v\n": 10,
|
"Updated service %v using backend %v\n": 10,
|
||||||
"User name": 18,
|
"User name": 18,
|
||||||
"Visit the URL for authorization: %v": 26,
|
"Visit the URL for authorization: %v": 32,
|
||||||
"Yes": 32,
|
"Yes": 38,
|
||||||
"a service with this name already exists": 4,
|
"a service with this name already exists": 4,
|
||||||
"backend %s does not implement %s": 13,
|
"backend %s does not implement %s": 13,
|
||||||
"done": 31,
|
"done": 37,
|
||||||
"exporting": 29,
|
"exporting": 35,
|
||||||
"importing": 30,
|
"importing": 36,
|
||||||
"invalid timestamp string \"%v\"": 53,
|
"invalid timestamp string \"%v\"": 49,
|
||||||
"key must only consist of A-Za-z0-9_-": 43,
|
"key must only consist of A-Za-z0-9_-": 52,
|
||||||
"no configuration file defined, cannot write config": 42,
|
"no configuration file defined, cannot write config": 51,
|
||||||
"no existing service configurations": 34,
|
"no existing service configurations": 40,
|
||||||
"no service configuration \"%v\"": 44,
|
"no service configuration \"%v\"": 53,
|
||||||
"unknown backend \"%s\"": 14,
|
"unknown backend \"%s\"": 14,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,18 +103,18 @@ var deIndex = []uint32{ // 55 elements
|
||||||
0x000001ac, 0x000001e7, 0x00000213, 0x00000233,
|
0x000001ac, 0x000001e7, 0x00000213, 0x00000233,
|
||||||
0x0000023d, 0x0000024b, 0x00000256, 0x00000263,
|
0x0000023d, 0x0000024b, 0x00000256, 0x00000263,
|
||||||
0x00000271, 0x0000027b, 0x0000028e, 0x000002a1,
|
0x00000271, 0x0000027b, 0x0000028e, 0x000002a1,
|
||||||
0x000002b8, 0x000002ec, 0x0000030d, 0x00000333,
|
0x000002b8, 0x000002ed, 0x00000328, 0x0000035c,
|
||||||
0x0000035d, 0x0000039d, 0x000003a8, 0x000003b3,
|
0x0000037e, 0x000003a4, 0x000003b4, 0x000003da,
|
||||||
// Entry 20 - 3F
|
// Entry 20 - 3F
|
||||||
0x000003ba, 0x000003bd, 0x000003c2, 0x000003eb,
|
0x00000418, 0x00000443, 0x0000046d, 0x000004ad,
|
||||||
0x000003f3, 0x000003fb, 0x00000424, 0x00000442,
|
0x000004b8, 0x000004c3, 0x000004ca, 0x000004cd,
|
||||||
0x0000047f, 0x000004aa, 0x000004cd, 0x0000051e,
|
0x000004d2, 0x000004fb, 0x00000503, 0x0000050b,
|
||||||
0x00000555, 0x0000057c, 0x0000057c, 0x0000057c,
|
0x00000534, 0x00000552, 0x0000058f, 0x000005ba,
|
||||||
0x0000057c, 0x0000057c, 0x0000057c, 0x0000057c,
|
0x000005c5, 0x000005d2, 0x000005f6, 0x00000619,
|
||||||
0x0000057c, 0x0000057c, 0x0000057c,
|
0x0000066a, 0x000006a1, 0x000006c8,
|
||||||
} // Size: 244 bytes
|
} // 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:" +
|
"\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" +
|
||||||
" %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" +
|
" %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" +
|
||||||
"in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" +
|
"in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" +
|
||||||
|
@ -123,21 +123,26 @@ const deData string = "" + // Size: 1404 bytes
|
||||||
"\x02Abgebrochen\x04\x00\x01\x0a\x1e\x02Service „%[1]v“ gelöscht\x04\x00" +
|
"\x02Abgebrochen\x04\x00\x01\x0a\x1e\x02Service „%[1]v“ gelöscht\x04\x00" +
|
||||||
"\x01\x0a1\x02Service %[1]v mit dem Backend %[2]v aktualisiert\x04\x01" +
|
"\x01\x0a1\x02Service %[1]v mit dem Backend %[2]v aktualisiert\x04\x01" +
|
||||||
"\x09\x00\x0f\x02Backend: %[1]v\x02Token erhalten, das Fenster kann jetzt" +
|
"\x09\x00\x0f\x02Backend: %[1]v\x02Token erhalten, das Fenster kann jetzt" +
|
||||||
" geschlossen werden.\x02das backend %[1]s implementiert %[2]s nicht\x02u" +
|
" geschlossen werden.\x02das Backend %[1]s implementiert %[2]s nicht\x02u" +
|
||||||
"nbekanntes Backend „%[1]s“\x02Client-ID\x02Client-Secret\x02Server-URL" +
|
"nbekanntes Backend „%[1]s“\x02Client-ID\x02Client-Secret\x02Server-URL" +
|
||||||
"\x02Benutzername\x02Zugriffstoken\x02Dateipfad\x02An Datei anhängen\x02T" +
|
"\x02Benutzername\x02Zugriffstoken\x02Dateipfad\x02An Datei anhängen\x02T" +
|
||||||
"itel der Playlist\x02Eindeutige Playlist-ID\x02Autokorrektur für übermit" +
|
"itel der Playlist\x02Eindeutige Playlist-ID\x02Beim Import auf Listen-Du" +
|
||||||
"telte Titel deaktivieren\x02Übersprungene Titel einbeziehen\x02URL für A" +
|
"plikate prüfen (langsamer)\x02Listen-Duplikat ignoriert %[1]v: \x22%[2]v" +
|
||||||
"utorisierung öffnen: %[1]v\x02Fehler: OAuth-State stimmt nicht überein" +
|
"\x22 von %[3]v (%[4]v)\x02Autokorrektur für übermittelte Titel deaktivie" +
|
||||||
"\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwendet " +
|
"ren\x02Übersprungene Listens ignorieren\x02Zeitzone für den Abspiel-Zeit" +
|
||||||
"werden.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine bes" +
|
"stempel\x02Verzeichnispfad\x02Listens im Inkognito-Modus ignorieren\x02M" +
|
||||||
"tehenden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %[1]s" +
|
"inimale Wiedergabedauer für übersprungene Titel (Sekunden)\x02Zur Anmeld" +
|
||||||
" von %[2]s nach %[3]s...\x02Ab Zeitstempel: %[1]v (%[2]v)\x02Import fehl" +
|
"ung folgende URL aufrufen: %[1]v\x02Fehler: OAuth-State stimmt nicht übe" +
|
||||||
"geschlagen, letzter Zeitstempel war %[1]v (%[2]s)\x02%[1]v von %[2]v %[3" +
|
"rein\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwen" +
|
||||||
"]s in %[4]v importiert.\x02Letzter Zeitstempel: %[1]v (%[2]v)\x02keine K" +
|
"det werden.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine" +
|
||||||
"onfigurationsdatei definiert, Konfiguration kann nicht geschrieben werde" +
|
" bestehenden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %" +
|
||||||
"n\x02Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten\x02keine Serv" +
|
"[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[2]v)\x02Import fe" +
|
||||||
"icekonfiguration „%[1]v“"
|
"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
|
var enIndex = []uint32{ // 55 elements
|
||||||
// Entry 0 - 1F
|
// Entry 0 - 1F
|
||||||
|
@ -147,18 +152,18 @@ var enIndex = []uint32{ // 55 elements
|
||||||
0x00000170, 0x0000019f, 0x000001c6, 0x000001de,
|
0x00000170, 0x0000019f, 0x000001c6, 0x000001de,
|
||||||
0x000001e8, 0x000001f6, 0x00000201, 0x0000020b,
|
0x000001e8, 0x000001f6, 0x00000201, 0x0000020b,
|
||||||
0x00000218, 0x00000222, 0x00000231, 0x00000240,
|
0x00000218, 0x00000222, 0x00000231, 0x00000240,
|
||||||
0x0000025b, 0x00000288, 0x000002a0, 0x000002c7,
|
0x0000025b, 0x0000028a, 0x000002c3, 0x000002f0,
|
||||||
0x000002e3, 0x00000316, 0x00000320, 0x0000032a,
|
0x00000307, 0x00000335, 0x00000344, 0x00000365,
|
||||||
// Entry 20 - 3F
|
// Entry 20 - 3F
|
||||||
0x0000032f, 0x00000333, 0x00000336, 0x00000359,
|
0x0000039c, 0x000003c3, 0x000003df, 0x00000412,
|
||||||
0x00000361, 0x00000369, 0x00000393, 0x000003b1,
|
0x0000041c, 0x00000426, 0x0000042b, 0x0000042f,
|
||||||
0x000003ea, 0x00000414, 0x00000434, 0x00000467,
|
0x00000432, 0x00000455, 0x0000045d, 0x00000465,
|
||||||
0x0000048c, 0x000004ad, 0x000004dc, 0x00000515,
|
0x0000048f, 0x000004ad, 0x000004e6, 0x00000510,
|
||||||
0x00000524, 0x00000545, 0x0000055c, 0x00000593,
|
0x0000051c, 0x00000529, 0x0000054a, 0x0000056a,
|
||||||
0x0000059f, 0x000005ac, 0x000005cd,
|
0x0000059d, 0x000005c2, 0x000005e3,
|
||||||
} // Size: 244 bytes
|
} // Size: 244 bytes
|
||||||
|
|
||||||
const enData string = "" + // Size: 1485 bytes
|
const enData string = "" + // Size: 1507 bytes
|
||||||
"\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" +
|
"\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" +
|
||||||
" %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" +
|
" %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" +
|
||||||
" this name already exists\x02Saved service %[1]v using backend %[2]v\x02" +
|
" this name already exists\x02Saved service %[1]v using backend %[2]v\x02" +
|
||||||
|
@ -169,20 +174,20 @@ const enData string = "" + // Size: 1485 bytes
|
||||||
"eceived, you can close this window now.\x02backend %[1]s does not implem" +
|
"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" +
|
"ent %[2]s\x02unknown backend \x22%[1]s\x22\x02Client ID\x02Client secret" +
|
||||||
"\x02Server URL\x02User name\x02Access token\x02File path\x02Append to fi" +
|
"\x02Server URL\x02User name\x02Access token\x02File path\x02Append to fi" +
|
||||||
"le\x02Playlist title\x02Unique playlist identifier\x02Disable auto corre" +
|
"le\x02Playlist title\x02Unique playlist identifier\x02Check for duplicat" +
|
||||||
"ction of submitted listens\x02Include skipped listens\x02Visit the URL f" +
|
"e listens on import (slower)\x02Ignored duplicate listen %[1]v: \x22%[2]" +
|
||||||
"or authorization: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a." +
|
"v\x22 by %[3]v (%[4]v)\x02Disable auto correction of submitted listens" +
|
||||||
"\x02Access token received, you can use %[1]v now.\x02exporting\x02import" +
|
"\x02Ignore skipped listens\x02Specify a time zone for the listen timesta" +
|
||||||
"ing\x02done\x02Yes\x02No\x02no existing service configurations\x02Servic" +
|
"mps\x02Directory path\x02Ignore listens in incognito mode\x02Minimum pla" +
|
||||||
"e\x02Backend\x02Transferring %[1]s from %[2]s to %[3]s...\x02From timest" +
|
"yback duration for skipped tracks (seconds)\x02Visit the URL for authori" +
|
||||||
"amp: %[1]v (%[2]v)\x02Import failed, last reported timestamp was %[1]v (" +
|
"zation: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Access " +
|
||||||
"%[2]s)\x02Imported %[1]v of %[2]v %[3]s into %[4]v.\x02Latest timestamp:" +
|
"token received, you can use %[1]v now.\x02exporting\x02importing\x02done" +
|
||||||
" %[1]v (%[2]v)\x02no configuration file defined, cannot write config\x02" +
|
"\x02Yes\x02No\x02no existing service configurations\x02Service\x02Backen" +
|
||||||
"key must only consist of A-Za-z0-9_-\x02no service configuration \x22%[1" +
|
"d\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestamp: %[1]v (%" +
|
||||||
"]v\x22\x02Check for duplicate listens on import (slower)\x02Ignored dupl" +
|
"[2]v)\x02Import failed, last reported timestamp was %[1]v (%[2]s)\x02Imp" +
|
||||||
"icate listen %[1]v: \x22%[2]v\x22 by %[3]v (%[4]v)\x02Directory path\x02" +
|
"orted %[1]v of %[2]v %[3]s into %[4]v.\x02Import log:\x02%[1]v: %[2]v" +
|
||||||
"Ignore listens in incognito mode\x02Ignore skipped listens\x02Minimum pl" +
|
"\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: %[1]v (%" +
|
||||||
"ayback duration for skipped tracks (seconds)\x02Import log:\x02%[1]v: %[" +
|
"[2]v)\x02no configuration file defined, cannot write config\x02key must " +
|
||||||
"2]v\x02invalid timestamp string \x22%[1]v\x22"
|
"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
|
||||||
|
|
|
@ -175,7 +175,7 @@
|
||||||
{
|
{
|
||||||
"id": "backend {Backend} does not implement {InterfaceName}",
|
"id": "backend {Backend} does not implement {InterfaceName}",
|
||||||
"message": "backend {Backend} does not implement {InterfaceName}",
|
"message": "backend {Backend} does not implement {InterfaceName}",
|
||||||
"translation": "das backend {Backend} implementiert {InterfaceName} nicht",
|
"translation": "das Backend {Backend} implementiert {InterfaceName} nicht",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Backend",
|
"id": "Backend",
|
||||||
|
@ -261,9 +261,9 @@
|
||||||
"translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)"
|
"translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
"translation": "Listen-Duplikat ignoriert {ListenedAt}: „{TrackName}“ von {ArtistName} ({RecordingMbid})",
|
"translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "ListenedAt",
|
"id": "ListenedAt",
|
||||||
|
@ -290,12 +290,12 @@
|
||||||
"expr": "l.ArtistName()"
|
"expr": "l.ArtistName()"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "RecordingMbid",
|
"id": "RecordingMBID",
|
||||||
"string": "%[4]v",
|
"string": "%[4]v",
|
||||||
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
|
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 4,
|
"argNum": 4,
|
||||||
"expr": "l.RecordingMbid"
|
"expr": "l.RecordingMBID"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -305,9 +305,14 @@
|
||||||
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
|
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Include skipped listens",
|
"id": "Ignore skipped listens",
|
||||||
"message": "Include skipped listens",
|
"message": "Ignore skipped listens",
|
||||||
"translation": "Übersprungene Titel einbeziehen"
|
"translation": "Übersprungene Listens ignorieren"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Specify a time zone for the listen timestamps",
|
||||||
|
"message": "Specify a time zone for the listen timestamps",
|
||||||
|
"translation": "Zeitzone für den Abspiel-Zeitstempel"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Directory path",
|
"id": "Directory path",
|
||||||
|
@ -319,28 +324,23 @@
|
||||||
"message": "Ignore listens in incognito mode",
|
"message": "Ignore listens in incognito mode",
|
||||||
"translation": "Listens im Inkognito-Modus ignorieren"
|
"translation": "Listens im Inkognito-Modus ignorieren"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "Ignore skipped listens",
|
|
||||||
"message": "Ignore skipped listens",
|
|
||||||
"translation": "Übersprungene Listens ignorieren"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "Minimum playback duration for skipped tracks (seconds)",
|
"id": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
"message": "Minimum playback duration for skipped tracks (seconds)",
|
"message": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
"translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)"
|
"translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Visit the URL for authorization: {Url}",
|
"id": "Visit the URL for authorization: {URL}",
|
||||||
"message": "Visit the URL for authorization: {Url}",
|
"message": "Visit the URL for authorization: {URL}",
|
||||||
"translation": "URL für Autorisierung öffnen: {Url}",
|
"translation": "Zur Anmeldung folgende URL aufrufen: {URL}",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Url",
|
"id": "URL",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "authUrl.Url"
|
"expr": "authURL.URL"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -411,9 +411,9 @@
|
||||||
"translation": "Backend"
|
"translation": "Backend"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...",
|
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}…",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Entity",
|
"id": "Entity",
|
||||||
|
|
|
@ -175,7 +175,7 @@
|
||||||
{
|
{
|
||||||
"id": "backend {Backend} does not implement {InterfaceName}",
|
"id": "backend {Backend} does not implement {InterfaceName}",
|
||||||
"message": "backend {Backend} does not implement {InterfaceName}",
|
"message": "backend {Backend} does not implement {InterfaceName}",
|
||||||
"translation": "das backend {Backend} implementiert {InterfaceName} nicht",
|
"translation": "das Backend {Backend} implementiert {InterfaceName} nicht",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Backend",
|
"id": "Backend",
|
||||||
|
@ -258,12 +258,12 @@
|
||||||
{
|
{
|
||||||
"id": "Check for duplicate listens on import (slower)",
|
"id": "Check for duplicate listens on import (slower)",
|
||||||
"message": "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})",
|
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
"translation": "",
|
"translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "ListenedAt",
|
"id": "ListenedAt",
|
||||||
|
@ -290,12 +290,12 @@
|
||||||
"expr": "l.ArtistName()"
|
"expr": "l.ArtistName()"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "RecordingMbid",
|
"id": "RecordingMBID",
|
||||||
"string": "%[4]v",
|
"string": "%[4]v",
|
||||||
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
|
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 4,
|
"argNum": 4,
|
||||||
"expr": "l.RecordingMbid"
|
"expr": "l.RecordingMBID"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -305,42 +305,42 @@
|
||||||
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
|
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Include skipped listens",
|
"id": "Ignore skipped listens",
|
||||||
"message": "Include skipped listens",
|
"message": "Ignore skipped listens",
|
||||||
"translation": "Übersprungene Titel einbeziehen"
|
"translation": "Übersprungene Listens ignorieren"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Specify a time zone for the listen timestamps",
|
||||||
|
"message": "Specify a time zone for the listen timestamps",
|
||||||
|
"translation": "Zeitzone für den Abspiel-Zeitstempel"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Directory path",
|
"id": "Directory path",
|
||||||
"message": "Directory path",
|
"message": "Directory path",
|
||||||
"translation": ""
|
"translation": "Verzeichnispfad"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Ignore listens in incognito mode",
|
"id": "Ignore listens in incognito mode",
|
||||||
"message": "Ignore listens in incognito mode",
|
"message": "Ignore listens in incognito mode",
|
||||||
"translation": ""
|
"translation": "Listens im Inkognito-Modus ignorieren"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "Ignore skipped listens",
|
|
||||||
"message": "Ignore skipped listens",
|
|
||||||
"translation": ""
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Minimum playback duration for skipped tracks (seconds)",
|
"id": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
"message": "Minimum playback duration for skipped tracks (seconds)",
|
"message": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
"translation": ""
|
"translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Visit the URL for authorization: {Url}",
|
"id": "Visit the URL for authorization: {URL}",
|
||||||
"message": "Visit the URL for authorization: {Url}",
|
"message": "Visit the URL for authorization: {URL}",
|
||||||
"translation": "URL für Autorisierung öffnen: {Url}",
|
"translation": "Zur Anmeldung folgende URL aufrufen: {URL}",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Url",
|
"id": "URL",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "authUrl.Url"
|
"expr": "authURL.URL"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -411,9 +411,9 @@
|
||||||
"translation": "Backend"
|
"translation": "Backend"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...",
|
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}…",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Entity",
|
"id": "Entity",
|
||||||
|
@ -525,12 +525,12 @@
|
||||||
{
|
{
|
||||||
"id": "Import log:",
|
"id": "Import log:",
|
||||||
"message": "Import log:",
|
"message": "Import log:",
|
||||||
"translation": ""
|
"translation": "Importlog:"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "{Type}: {Message}",
|
"id": "{Type}: {Message}",
|
||||||
"message": "{Type}: {Message}",
|
"message": "{Type}: {Message}",
|
||||||
"translation": "",
|
"translation": "{Type}: {Message}",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Type",
|
"id": "Type",
|
||||||
|
@ -553,7 +553,7 @@
|
||||||
{
|
{
|
||||||
"id": "invalid timestamp string \"{FlagValue}\"",
|
"id": "invalid timestamp string \"{FlagValue}\"",
|
||||||
"message": "invalid timestamp string \"{FlagValue}\"",
|
"message": "invalid timestamp string \"{FlagValue}\"",
|
||||||
"translation": "",
|
"translation": "ungültiger Zeitstempel „{FlagValue}“",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "FlagValue",
|
"id": "FlagValue",
|
||||||
|
|
|
@ -311,9 +311,9 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
|
@ -341,12 +341,12 @@
|
||||||
"expr": "l.ArtistName()"
|
"expr": "l.ArtistName()"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "RecordingMbid",
|
"id": "RecordingMBID",
|
||||||
"string": "%[4]v",
|
"string": "%[4]v",
|
||||||
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
|
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 4,
|
"argNum": 4,
|
||||||
"expr": "l.RecordingMbid"
|
"expr": "l.RecordingMBID"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
|
@ -359,9 +359,16 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Include skipped listens",
|
"id": "Ignore skipped listens",
|
||||||
"message": "Include skipped listens",
|
"message": "Ignore skipped listens",
|
||||||
"translation": "Include skipped listens",
|
"translation": "Ignore skipped listens",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Specify a time zone for the listen timestamps",
|
||||||
|
"message": "Specify a time zone for the listen timestamps",
|
||||||
|
"translation": "Specify a time zone for the listen timestamps",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
|
@ -379,13 +386,6 @@
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "Ignore skipped listens",
|
|
||||||
"message": "Ignore skipped listens",
|
|
||||||
"translation": "Ignore skipped listens",
|
|
||||||
"translatorComment": "Copied from source.",
|
|
||||||
"fuzzy": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "Minimum playback duration for skipped tracks (seconds)",
|
"id": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
"message": "Minimum playback duration for skipped tracks (seconds)",
|
"message": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
|
@ -394,18 +394,18 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Visit the URL for authorization: {Url}",
|
"id": "Visit the URL for authorization: {URL}",
|
||||||
"message": "Visit the URL for authorization: {Url}",
|
"message": "Visit the URL for authorization: {URL}",
|
||||||
"translation": "Visit the URL for authorization: {Url}",
|
"translation": "Visit the URL for authorization: {URL}",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Url",
|
"id": "URL",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "authUrl.Url"
|
"expr": "authURL.URL"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
|
@ -491,9 +491,9 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"translation": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"translation": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
|
|
|
@ -311,9 +311,9 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
|
@ -341,12 +341,12 @@
|
||||||
"expr": "l.ArtistName()"
|
"expr": "l.ArtistName()"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "RecordingMbid",
|
"id": "RecordingMBID",
|
||||||
"string": "%[4]v",
|
"string": "%[4]v",
|
||||||
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
|
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 4,
|
"argNum": 4,
|
||||||
"expr": "l.RecordingMbid"
|
"expr": "l.RecordingMBID"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
|
@ -359,9 +359,16 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Include skipped listens",
|
"id": "Ignore skipped listens",
|
||||||
"message": "Include skipped listens",
|
"message": "Ignore skipped listens",
|
||||||
"translation": "Include skipped listens",
|
"translation": "Ignore skipped listens",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Specify a time zone for the listen timestamps",
|
||||||
|
"message": "Specify a time zone for the listen timestamps",
|
||||||
|
"translation": "Specify a time zone for the listen timestamps",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
|
@ -379,13 +386,6 @@
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "Ignore skipped listens",
|
|
||||||
"message": "Ignore skipped listens",
|
|
||||||
"translation": "Ignore skipped listens",
|
|
||||||
"translatorComment": "Copied from source.",
|
|
||||||
"fuzzy": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "Minimum playback duration for skipped tracks (seconds)",
|
"id": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
"message": "Minimum playback duration for skipped tracks (seconds)",
|
"message": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
|
@ -394,18 +394,18 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Visit the URL for authorization: {Url}",
|
"id": "Visit the URL for authorization: {URL}",
|
||||||
"message": "Visit the URL for authorization: {Url}",
|
"message": "Visit the URL for authorization: {URL}",
|
||||||
"translation": "Visit the URL for authorization: {Url}",
|
"translation": "Visit the URL for authorization: {URL}",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Url",
|
"id": "URL",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "authUrl.Url"
|
"expr": "authURL.URL"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
|
@ -491,9 +491,9 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"translation": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"translation": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
|
|
|
@ -6,4 +6,4 @@ package are published under the conditions of CC0 1.0 Universal (CC0 1.0)
|
||||||
|
|
||||||
package translations
|
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
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright © 2023-2024 Philipp Wolfer <phw@uploadedlobster.com>
|
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
Scotty is free software: you can redistribute it and/or modify it under the
|
Scotty is free software: you can redistribute it and/or modify it under the
|
||||||
terms of the GNU General Public License as published by the Free Software
|
terms of the GNU General Public License as published by the Free Software
|
||||||
|
@ -17,7 +17,8 @@ package version
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AppName = "scotty"
|
AppName = "scotty"
|
||||||
AppVersion = "0.4.1"
|
AppVersion = "0.5.0"
|
||||||
|
AppURL = "https://git.sr.ht/~phw/scotty/"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UserAgent() string {
|
func UserAgent() string {
|
||||||
|
|
|
@ -26,9 +26,9 @@ import "time"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension
|
// The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension
|
||||||
MusicBrainzPlaylistExtensionId = "https://musicbrainz.org/doc/jspf#playlist"
|
MusicBrainzPlaylistExtensionID = "https://musicbrainz.org/doc/jspf#playlist"
|
||||||
// The identifier for the MusicBrainz / ListenBrainz JSPF track extension
|
// The identifier for the MusicBrainz / ListenBrainz JSPF track extension
|
||||||
MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track"
|
MusicBrainzTrackExtensionID = "https://musicbrainz.org/doc/jspf#track"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MusicBrainz / ListenBrainz JSPF track extension
|
// MusicBrainz / ListenBrainz JSPF track extension
|
||||||
|
|
|
@ -39,7 +39,7 @@ func ExampleMusicBrainzTrackExtension() {
|
||||||
{
|
{
|
||||||
Title: "Oweynagat",
|
Title: "Oweynagat",
|
||||||
Extension: map[string]any{
|
Extension: map[string]any{
|
||||||
jspf.MusicBrainzTrackExtensionId: jspf.MusicBrainzTrackExtension{
|
jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{
|
||||||
AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC),
|
AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC),
|
||||||
AddedBy: "scotty",
|
AddedBy: "scotty",
|
||||||
},
|
},
|
||||||
|
|
264
pkg/scrobblerlog/parser.go
Normal file
264
pkg/scrobblerlog/parser.go
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
/*
|
||||||
|
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Package 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
|
||||||
|
}
|
|
@ -30,8 +30,8 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/pkg/scrobblerlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
var testScrobblerLog = `#AUDIOSCROBBLER/1.1
|
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) {
|
func TestParser(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
data := bytes.NewBufferString(testScrobblerLog)
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
result, err := scrobblerlog.Parse(data, true)
|
result := scrobblerlog.ScrobblerLog{}
|
||||||
|
err := result.Parse(data, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal("UNKNOWN", result.Timezone)
|
assert.Equal(scrobblerlog.TimezoneUnknown, result.TZ)
|
||||||
assert.Equal("Rockbox sansaclipplus $Revision$", result.Client)
|
assert.Equal("Rockbox sansaclipplus $Revision$", result.Client)
|
||||||
assert.Len(result.Listens, 5)
|
assert.Len(result.Records, 5)
|
||||||
listen1 := result.Listens[0]
|
record1 := result.Records[0]
|
||||||
assert.Equal("Özcan Deniz", listen1.ArtistName())
|
assert.Equal("Özcan Deniz", record1.ArtistName)
|
||||||
assert.Equal("Ses ve Ayrilik", listen1.ReleaseName)
|
assert.Equal("Ses ve Ayrilik", record1.AlbumName)
|
||||||
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", listen1.TrackName)
|
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", record1.TrackName)
|
||||||
assert.Equal(5, listen1.TrackNumber)
|
assert.Equal(5, record1.TrackNumber)
|
||||||
assert.Equal(time.Duration(306*time.Second), listen1.Duration)
|
assert.Equal(time.Duration(306*time.Second), record1.Duration)
|
||||||
assert.Equal("L", listen1.AdditionalInfo["rockbox_rating"])
|
assert.Equal(scrobblerlog.RatingListened, record1.Rating)
|
||||||
assert.Equal(time.Unix(1260342084, 0), listen1.ListenedAt)
|
assert.Equal(time.Unix(1260342084, 0), record1.Timestamp)
|
||||||
assert.Equal(models.MBID(""), listen1.RecordingMbid)
|
assert.Equal(mbtypes.MBID(""), record1.MusicBrainzRecordingID)
|
||||||
listen4 := result.Listens[3]
|
record4 := result.Records[3]
|
||||||
assert.Equal("S", listen4.AdditionalInfo["rockbox_rating"])
|
assert.Equal(scrobblerlog.RatingSkipped, record4.Rating)
|
||||||
assert.Equal(models.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), listen4.RecordingMbid)
|
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)
|
assert := assert.New(t)
|
||||||
data := bytes.NewBufferString(testScrobblerLog)
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
result, err := scrobblerlog.Parse(data, false)
|
result := scrobblerlog.ScrobblerLog{}
|
||||||
|
err := result.Parse(data, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(result.Listens, 4)
|
assert.Len(result.Records, 4)
|
||||||
listen4 := result.Listens[3]
|
record4 := result.Records[3]
|
||||||
assert.Equal("L", listen4.AdditionalInfo["rockbox_rating"])
|
assert.Equal(scrobblerlog.RatingListened, record4.Rating)
|
||||||
assert.Equal(models.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMbid)
|
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)
|
assert := assert.New(t)
|
||||||
data := make([]byte, 0, 10)
|
data := make([]byte, 0, 10)
|
||||||
buffer := bytes.NewBuffer(data)
|
buffer := bytes.NewBuffer(data)
|
||||||
log := scrobblerlog.ScrobblerLog{
|
log := scrobblerlog.ScrobblerLog{
|
||||||
Timezone: "Unknown",
|
TZ: scrobblerlog.TimezoneUnknown,
|
||||||
Client: "Rockbox foo $Revision$",
|
Client: "Rockbox foo $Revision$",
|
||||||
Listens: []models.Listen{
|
}
|
||||||
|
records := []scrobblerlog.Record{
|
||||||
{
|
{
|
||||||
ListenedAt: time.Unix(1699572072, 0),
|
ArtistName: "Prinzhorn Dance School",
|
||||||
Track: models.Track{
|
AlbumName: "Home Economics",
|
||||||
ArtistNames: []string{"Prinzhorn Dance School"},
|
|
||||||
ReleaseName: "Home Economics",
|
|
||||||
TrackName: "Reign",
|
TrackName: "Reign",
|
||||||
TrackNumber: 1,
|
TrackNumber: 1,
|
||||||
Duration: 271 * time.Second,
|
Duration: 271 * time.Second,
|
||||||
RecordingMbid: models.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
|
Rating: scrobblerlog.RatingListened,
|
||||||
AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"},
|
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)
|
require.NoError(t, err)
|
||||||
lastTimestamp, err := scrobblerlog.Write(buffer, log.Listens)
|
lastTimestamp, err := log.Append(buffer, records)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
result := buffer.String()
|
result := buffer.String()
|
||||||
lines := strings.Split(result, "\n")
|
lines := strings.Split(result, "\n")
|
||||||
assert.Equal(5, len(lines))
|
assert.Equal(5, len(lines))
|
||||||
assert.Equal("#AUDIOSCROBBLER/1.1", lines[0])
|
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("#CLIENT/Rockbox foo $Revision$", lines[2])
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
"Prinzhorn Dance School\tHome Economics\tReign\t1\t271\tL\t1699572072\tb59cf4e7-caee-4019-a844-79d2c58d4dff",
|
"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)
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
reader := bufio.NewReader(data)
|
reader := bufio.NewReader(data)
|
||||||
log := scrobblerlog.ScrobblerLog{}
|
log := scrobblerlog.ScrobblerLog{}
|
||||||
err := scrobblerlog.ReadHeader(reader, &log)
|
err := log.ReadHeader(reader)
|
||||||
assert.NoError(t, err)
|
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.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$")
|
||||||
assert.Empty(t, log.Listens)
|
assert.Empty(t, log.Records)
|
||||||
}
|
}
|
Loading…
Add table
Add a link
Reference in a new issue