mirror of
https://git.sr.ht/~phw/scotty
synced 2025-04-29 21:27:05 +02:00
Compare commits
52 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 | ||
|
04eddfda33 | ||
|
1c1ce224f7 | ||
|
7175d3453d | ||
|
cdf20728ae | ||
|
bcc7bf3167 | ||
|
357932f9b0 | ||
|
3f1bebd8ed | ||
|
1aa7b61649 |
69 changed files with 1373 additions and 1097 deletions
.build.yml.goreleaser.yaml.weblateCHANGES.mdconfig.example.tomlgo.modgo.sum
internal
auth
backends
cli
i18n
models
similarity
translations
util
version
pkg
13
.build.yml
13
.build.yml
|
@ -5,10 +5,11 @@ packages:
|
|||
- hut
|
||||
- weblate-wlc
|
||||
secrets:
|
||||
- 2a17e258-3e99-4093-9527-832c350d9c53
|
||||
- 0e2ad815-6c46-4cea-878e-70fc33f71e77
|
||||
oauth: pages.sr.ht/PAGES:RW
|
||||
tasks:
|
||||
- weblate-update: |
|
||||
cd scotty
|
||||
wlc --format text pull scotty
|
||||
- test: |
|
||||
cd scotty
|
||||
|
@ -28,5 +29,15 @@ tasks:
|
|||
- publish-redirect: |
|
||||
# Update redirect on https://go.uploadedlobster.com/scotty
|
||||
./scotty/pages/publish.sh
|
||||
# Skip releasing if this is not a tagged release
|
||||
- only-tags: |
|
||||
cd scotty
|
||||
GIT_REF=$(git describe --always)
|
||||
[[ "$GIT_REF" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || complete-build
|
||||
- announce-release: |
|
||||
# Announce new release to Go Module Index
|
||||
cd scotty
|
||||
VERSION=$(git describe --exact-match)
|
||||
curl "https://proxy.golang.org/go.uploadedlobster.com/scotty/@v/${VERSION}.info"
|
||||
artifacts:
|
||||
- scotty/dist/artifacts.tar
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
|
||||
version: 1
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
|
@ -21,6 +21,8 @@ builds:
|
|||
- windows
|
||||
- darwin
|
||||
ignore:
|
||||
- goos: linux
|
||||
goarch: "386"
|
||||
- goos: windows
|
||||
goarch: "386"
|
||||
|
||||
|
@ -28,7 +30,7 @@ universal_binaries:
|
|||
- replace: true
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
- formats: ['tar.gz']
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
name_template: >-
|
||||
{{ .ProjectName }}-{{ .Version }}_
|
||||
|
@ -42,7 +44,7 @@ archives:
|
|||
# use zip for windows archives
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats: ['zip']
|
||||
files:
|
||||
- COPYING
|
||||
- README.md
|
||||
|
|
3
.weblate
Normal file
3
.weblate
Normal file
|
@ -0,0 +1,3 @@
|
|||
[weblate]
|
||||
url = https://translate.uploadedlobster.com/api/
|
||||
translation = scotty/app
|
21
CHANGES.md
21
CHANGES.md
|
@ -1,5 +1,26 @@
|
|||
# Scotty Changelog
|
||||
|
||||
## 0.5.0 - 2025-04-29
|
||||
- ListenBrainz: handle missing loves metadata in case of merged recordings
|
||||
- ListenBrainz: fix loves import loading all existing loves
|
||||
- ListenBrainz: fixed progress for loves import
|
||||
- ListenBrainz: log missing recording MBID on love import
|
||||
- Subsonic: support OpenSubsonic fields for recording MBID and genres (#5)
|
||||
- Subsonic: fixed progress for loves export
|
||||
- scrobblerlog: add "time-zone" config option (#6).
|
||||
- scrobblerlog: fixed progress for listen export
|
||||
- scrobblerlog: renamed setting `include-skipped` to `ignore-skipped`.
|
||||
|
||||
Note: 386 builds for Linux are not available with this release due to an
|
||||
incompatibility with latest version of gorm.
|
||||
|
||||
|
||||
## 0.4.1 - 2024-09-16
|
||||
- Subsonic: include `subsonic_id` as additional metadata
|
||||
- Deezer: fix artist and album ID URIs (#7)
|
||||
- Fix installation issues due to wrong go version format in `go.mod`
|
||||
|
||||
|
||||
## 0.4.0 - 2024-01-21
|
||||
- JSPF: implement append mode
|
||||
- scrobberlog: append mode is enabled by default
|
||||
|
|
|
@ -56,11 +56,18 @@ backend = "scrobbler-log"
|
|||
# The file path to the .scrobbler.log file. Relative paths are resolved against
|
||||
# the current working directory when running scotty.
|
||||
file-path = "./.scrobbler.log"
|
||||
# If true, reading listens from the file also returns listens marked as "skipped"
|
||||
include-skipped = true
|
||||
# If true (default), ignore listens marked as skipped.
|
||||
ignore-skipped = true
|
||||
# If true (default), new listens will be appended to the existing file. Set to
|
||||
# false to overwrite the file and create a new scrobbler log on every run.
|
||||
append = true
|
||||
# Specify the time zone of the listens in the scrobbler log. While the log files
|
||||
# are supposed to contain Unix timestamps, which are always in UTC, the player
|
||||
# writing the log might not be time zone aware. This can cause the timestamps
|
||||
# to be in a different time zone. Use the time-zone setting to specify a
|
||||
# different time zone, e.g. "Europe/Berlin" or "America/New_York".
|
||||
# The default is UTC.
|
||||
time-zone = "UTC"
|
||||
|
||||
[service.jspf]
|
||||
# Write listens and loves to JSPF playlist files (https://xspf.org/jspf)
|
||||
|
@ -98,9 +105,9 @@ dir-path = "./my_spotify_data_extended/Spotify Extended Streaming Histor
|
|||
ignore-incognito = true
|
||||
# If true, ignore listens marked as skipped. Default is false.
|
||||
ignore-skipped = false
|
||||
# Only consider skipped listens with a playback duration longer than this number
|
||||
# of seconds. Default is 30 seconds. If ignore-skipped is set to false this
|
||||
# setting has no effect.
|
||||
# Only consider skipped listens with a playback duration longer than or equal to
|
||||
# this number of seconds. Default is 30 seconds. If ignore-skipped is enabled
|
||||
# this setting has no effect.
|
||||
ignore-min-duration-seconds = 30
|
||||
|
||||
[service.deezer]
|
||||
|
|
89
go.mod
89
go.mod
|
@ -1,70 +1,75 @@
|
|||
module go.uploadedlobster.com/scotty
|
||||
|
||||
go 1.21.1
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.2
|
||||
|
||||
require (
|
||||
github.com/Xuanwo/go-locale v1.1.0
|
||||
github.com/agnivade/levenshtein v1.1.1
|
||||
github.com/Xuanwo/go-locale v1.1.3
|
||||
github.com/agnivade/levenshtein v1.2.1
|
||||
github.com/cli/browser v1.3.0
|
||||
github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5
|
||||
github.com/fatih/color v1.16.0
|
||||
github.com/glebarez/sqlite v1.10.0
|
||||
github.com/go-resty/resty/v2 v2.11.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-resty/resty/v2 v2.16.5
|
||||
github.com/jarcoal/httpmock v1.3.1
|
||||
github.com/manifoldco/promptui v0.9.0
|
||||
github.com/pelletier/go-toml/v2 v2.1.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0
|
||||
github.com/spf13/cast v1.6.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/vbauerster/mpb/v8 v8.7.2
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3
|
||||
golang.org/x/oauth2 v0.16.0
|
||||
golang.org/x/text v0.14.0
|
||||
gorm.io/datatypes v1.2.0
|
||||
gorm.io/gorm v1.25.5
|
||||
github.com/spf13/cast v1.7.1
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
|
||||
github.com/vbauerster/mpb/v8 v8.9.3
|
||||
go.uploadedlobster.com/mbtypes v0.4.0
|
||||
go.uploadedlobster.com/musicbrainzws2 v0.14.0
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
|
||||
golang.org/x/oauth2 v0.29.0
|
||||
golang.org/x/text v0.24.0
|
||||
gorm.io/datatypes v1.2.5
|
||||
gorm.io/gorm v1.26.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
|
||||
github.com/chzyer/readline v1.5.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/uuid v1.5.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/net v0.20.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.32.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
golang.org/x/image v0.26.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/tools v0.32.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.5.2 // indirect
|
||||
modernc.org/libc v1.40.2 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/sqlite v1.28.0 // indirect
|
||||
gorm.io/driver/mysql v1.5.7 // indirect
|
||||
modernc.org/libc v1.64.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.10.0 // indirect
|
||||
modernc.org/sqlite v1.37.0 // indirect
|
||||
)
|
||||
|
||||
tool golang.org/x/text/cmd/gotext
|
||||
|
|
268
go.sum
268
go.sum
|
@ -1,11 +1,13 @@
|
|||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
|
||||
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
|
||||
github.com/Xuanwo/go-locale v1.1.0 h1:51gUxhxl66oXAjI9uPGb2O0qwPECpriKQb2hl35mQkg=
|
||||
github.com/Xuanwo/go-locale v1.1.0/go.mod h1:UKrHoZB3FPIk9wIG2/tVSobnHgNnceGSH3Y8DY5cASs=
|
||||
github.com/Xuanwo/go-locale v1.1.3 h1:EWZZJJt5rqPHHbqPRH1zFCn5D7xHjjebODctA4aUO3A=
|
||||
github.com/Xuanwo/go-locale v1.1.3/go.mod h1:REn+F/c+AtGSWYACBSYZgl23AP+0lfQC+SEFPN+hj30=
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
|
||||
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
|
@ -19,234 +21,214 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
|||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5 h1:RuuxidatioSKGOiBzL1mTY4X22DQD8weEbS3iRLHnAg=
|
||||
github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5/go.mod h1:vnbEuj6Z20PLcHB4rrLQAOXGMjtULfMGhRVSFPcSdUo=
|
||||
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
|
||||
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
|
||||
github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
|
||||
github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8=
|
||||
github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||
github.com/google/uuid v1.5.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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA=
|
||||
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
|
||||
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
||||
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
|
||||
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
|
||||
github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
|
||||
github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 h1:cgqwZtnR+IQfUYDLJ3Kiy4aE+O/wExTzEIg8xwC4Qfs=
|
||||
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0/go.mod h1:n3nudMl178cEvD44PaopxH9jhJaQzthSxUzLO5iKMy4=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.7 h1:I6tZjLXD2Q1kjvNbIzB1wvQBsXmKXiVrhpRE8ZjP5jY=
|
||||
github.com/smartystreets/goconvey v1.6.7/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/vbauerster/mpb/v8 v8.7.2 h1:SMJtxhNho1MV3OuFgS1DAzhANN1Ejc5Ct+0iSaIkB14=
|
||||
github.com/vbauerster/mpb/v8 v8.7.2/go.mod h1:ZFnrjzspgDHoxYLGvxIruiNk73GNTPG4YHgVNpR10VY=
|
||||
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4=
|
||||
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4=
|
||||
github.com/vbauerster/mpb/v8 v8.9.3 h1:PnMeF+sMvYv9u23l6DO6Q3+Mdj408mjLRXIzmUmU2Z8=
|
||||
github.com/vbauerster/mpb/v8 v8.9.3/go.mod h1:hxS8Hz4C6ijnppDSIX6LjG8FYJSoPo9iIOcE53Zik0c=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s=
|
||||
go.uploadedlobster.com/mbtypes v0.4.0/go.mod h1:Bu1K1Hl77QTAE2Z7QKiW/JAp9KqYWQebkRRfG02dlZM=
|
||||
go.uploadedlobster.com/musicbrainzws2 v0.14.0 h1:YaEtxNwLSNT1gzFipQ4XlaThNfXjBpzzb4I6WhIeUwg=
|
||||
go.uploadedlobster.com/musicbrainzws2 v0.14.0/go.mod h1:T6sYE7ZHRH3mJWT3g9jdSUPKJLZubnBjKyjMPNdkgao=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=
|
||||
golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco=
|
||||
gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04=
|
||||
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
|
||||
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
|
||||
gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I=
|
||||
gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
||||
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
|
||||
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
|
||||
gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
|
||||
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
modernc.org/libc v1.40.2 h1:pzVHG9jwYZNWANfltHiU3HYfrzYIsX6ysRLJ93adZXA=
|
||||
modernc.org/libc v1.40.2/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
|
||||
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.26.0 h1:9lqQVPG5aNNS6AyHdRiwScAVnXHg/L/Srzx55G5fOgs=
|
||||
gorm.io/gorm v1.26.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
||||
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
||||
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
|
||||
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.64.0 h1:U0k8BD2d3cD3e9I8RLcZgJBHAcsJzbXx5mKGSb5pyJA=
|
||||
modernc.org/libc v1.64.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
|
@ -27,7 +27,7 @@ type OAuth2Authenticator interface {
|
|||
models.Backend
|
||||
|
||||
// Returns OAuth2 config suitable for this backend
|
||||
OAuth2Strategy(redirectUrl *url.URL) OAuth2Strategy
|
||||
OAuth2Strategy(redirectURL *url.URL) OAuth2Strategy
|
||||
|
||||
// Setup the OAuth2 client
|
||||
OAuth2Setup(token oauth2.TokenSource) error
|
||||
|
|
|
@ -24,14 +24,14 @@ import (
|
|||
type OAuth2Strategy interface {
|
||||
Config() oauth2.Config
|
||||
|
||||
AuthCodeURL(verifier string, state string) AuthUrl
|
||||
AuthCodeURL(verifier string, state string) AuthURL
|
||||
|
||||
ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error)
|
||||
}
|
||||
|
||||
type AuthUrl struct {
|
||||
type AuthURL struct {
|
||||
// The URL the user must visit to approve access
|
||||
Url string
|
||||
URL string
|
||||
// Random state string passed on to the callback.
|
||||
// Leave empty if the service does not support state.
|
||||
State string
|
||||
|
@ -56,10 +56,10 @@ func (s StandardStrategy) Config() oauth2.Config {
|
|||
return s.conf
|
||||
}
|
||||
|
||||
func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthUrl {
|
||||
func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthURL {
|
||||
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
||||
return AuthUrl{
|
||||
Url: url,
|
||||
return AuthURL{
|
||||
URL: url,
|
||||
State: state,
|
||||
Param: "code",
|
||||
}
|
||||
|
|
|
@ -123,7 +123,11 @@ func backendWithConfig(config config.ServiceConfig) (models.Backend, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return backend.FromConfig(&config), nil
|
||||
err = backend.InitConfig(&config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
func ImplementsInterface[T interface{}](backend *models.Backend) (bool, string) {
|
||||
|
|
|
@ -33,10 +33,10 @@ func (s deezerStrategy) Config() oauth2.Config {
|
|||
return s.conf
|
||||
}
|
||||
|
||||
func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl {
|
||||
func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL {
|
||||
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
||||
return auth.AuthUrl{
|
||||
Url: url,
|
||||
return auth.AuthURL{
|
||||
URL: url,
|
||||
State: state,
|
||||
Param: "code",
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ const MaxItemsPerGet = 1000
|
|||
const DefaultRateLimitWaitSeconds = 5
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
HTTPClient *resty.Client
|
||||
token oauth2.TokenSource
|
||||
}
|
||||
|
||||
|
@ -47,7 +47,7 @@ func NewClient(token oauth2.TokenSource) Client {
|
|||
client.SetHeader("User-Agent", version.UserAgent())
|
||||
client.SetRetryCount(5)
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
HTTPClient: client,
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
|
@ -73,7 +73,7 @@ func (c Client) setToken(req *resty.Request) error {
|
|||
}
|
||||
|
||||
func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) {
|
||||
request := c.HttpClient.R().
|
||||
request := c.HTTPClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"index": strconv.Itoa(offset),
|
||||
"limit": strconv.Itoa(limit),
|
||||
|
@ -85,7 +85,7 @@ func listRequest[T Result](c Client, path string, offset int, limit int) (result
|
|||
}
|
||||
response, err := request.Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(response.String())
|
||||
} else if result.Error() != nil {
|
||||
err = errors.New(result.Error().Message)
|
||||
|
|
|
@ -44,7 +44,7 @@ func TestGetUserHistory(t *testing.T) {
|
|||
|
||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||
client := deezer.NewClient(token)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://api.deezer.com/user/me/history",
|
||||
"testdata/user-history.json")
|
||||
|
||||
|
@ -65,7 +65,7 @@ func TestGetUserTracks(t *testing.T) {
|
|||
|
||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||
client := deezer.NewClient(token)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://api.deezer.com/user/me/tracks",
|
||||
"testdata/user-tracks.json")
|
||||
|
||||
|
@ -81,7 +81,7 @@ func TestGetUserTracks(t *testing.T) {
|
|||
assert.Equal("Outland", track1.Track.Album.Title)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
|
|
|
@ -26,13 +26,12 @@ import (
|
|||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"go.uploadedlobster.com/scotty/internal/util"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type DeezerApiBackend struct {
|
||||
client Client
|
||||
clientId string
|
||||
clientID string
|
||||
clientSecret string
|
||||
}
|
||||
|
||||
|
@ -50,20 +49,20 @@ func (b *DeezerApiBackend) Options() []models.BackendOption {
|
|||
}}
|
||||
}
|
||||
|
||||
func (b *DeezerApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
b.clientId = config.GetString("client-id")
|
||||
func (b *DeezerApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
b.clientID = config.GetString("client-id")
|
||||
b.clientSecret = config.GetString("client-secret")
|
||||
return b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DeezerApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
||||
func (b *DeezerApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
|
||||
conf := oauth2.Config{
|
||||
ClientID: b.clientId,
|
||||
ClientID: b.clientID,
|
||||
ClientSecret: b.clientSecret,
|
||||
Scopes: []string{
|
||||
"offline_access,basic_access,listening_history",
|
||||
},
|
||||
RedirectURL: redirectUrl.String(),
|
||||
RedirectURL: redirectURL.String(),
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: "https://connect.deezer.com/oauth/auth.php",
|
||||
TokenURL: "https://connect.deezer.com/oauth/access_token.php",
|
||||
|
@ -106,7 +105,7 @@ out:
|
|||
// and continue.
|
||||
if offset >= result.Total {
|
||||
p.Total = int64(result.Total)
|
||||
offset = util.Max(result.Total-perPage, 0)
|
||||
offset = max(result.Total-perPage, 0)
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -175,7 +174,7 @@ out:
|
|||
if offset >= result.Total {
|
||||
p.Total = int64(result.Total)
|
||||
totalCount = result.Total
|
||||
offset = util.Max(result.Total-perPage, 0)
|
||||
offset = max(result.Total-perPage, 0)
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -245,8 +244,8 @@ func (t Track) AsTrack() models.Track {
|
|||
info["music_service"] = "deezer.com"
|
||||
info["origin_url"] = t.Link
|
||||
info["deezer_id"] = t.Link
|
||||
info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/track/%v", t.Album.Id)
|
||||
info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/track/%v", t.Artist.Id)
|
||||
info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/album/%v", t.Album.ID)
|
||||
info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/artist/%v", t.Artist.ID)
|
||||
|
||||
return track
|
||||
}
|
||||
|
|
|
@ -35,13 +35,14 @@ var (
|
|||
testTrack []byte
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
func TestInitConfig(t *testing.T) {
|
||||
c := viper.New()
|
||||
c.Set("client-id", "someclientid")
|
||||
c.Set("client-secret", "someclientsecret")
|
||||
service := config.NewServiceConfig("test", c)
|
||||
backend := (&deezer.DeezerApiBackend{}).FromConfig(&service)
|
||||
assert.IsType(t, &deezer.DeezerApiBackend{}, backend)
|
||||
backend := deezer.DeezerApiBackend{}
|
||||
err := backend.InitConfig(&service)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestListenAsListen(t *testing.T) {
|
||||
|
@ -57,6 +58,8 @@ func TestListenAsListen(t *testing.T) {
|
|||
assert.Equal(t, "deezer.com", listen.AdditionalInfo["music_service"])
|
||||
assert.Equal(t, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["origin_url"])
|
||||
assert.Equal(t, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["deezer_id"])
|
||||
assert.Equal(t, "https://www.deezer.com/album/1346960", listen.AdditionalInfo["deezer_album_id"])
|
||||
assert.Equal(t, "https://www.deezer.com/artist/92", listen.AdditionalInfo["deezer_artist_id"])
|
||||
}
|
||||
|
||||
func TestLovedTrackAsLove(t *testing.T) {
|
||||
|
|
|
@ -51,7 +51,7 @@ type HistoryResult struct {
|
|||
}
|
||||
|
||||
type Track struct {
|
||||
Id int `json:"id"`
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
|
@ -75,7 +75,7 @@ type LovedTrack struct {
|
|||
}
|
||||
|
||||
type Album struct {
|
||||
Id int `json:"id"`
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Link string `json:"link"`
|
||||
Title string `json:"title"`
|
||||
|
@ -83,7 +83,7 @@ type Album struct {
|
|||
}
|
||||
|
||||
type Artist struct {
|
||||
Id int `json:"id"`
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Link string `json:"link"`
|
||||
Name string `json:"name"`
|
||||
|
|
|
@ -29,8 +29,8 @@ func (b *DumpBackend) Name() string { return "dump" }
|
|||
|
||||
func (b *DumpBackend) Options() []models.BackendOption { return nil }
|
||||
|
||||
func (b *DumpBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
return b
|
||||
func (b *DumpBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *DumpBackend) StartImport() error { return nil }
|
||||
|
@ -41,7 +41,7 @@ func (b *DumpBackend) ImportListens(export models.ListensResult, importResult mo
|
|||
importResult.UpdateTimestamp(listen.ListenedAt)
|
||||
importResult.ImportCount += 1
|
||||
msg := fmt.Sprintf("🎶 %v: \"%v\" by %v (%v)",
|
||||
listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMbid)
|
||||
listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMBID)
|
||||
importResult.Log(models.Info, msg)
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models
|
|||
importResult.UpdateTimestamp(love.Created)
|
||||
importResult.ImportCount += 1
|
||||
msg := fmt.Sprintf("❤️ %v: \"%v\" by %v (%v)",
|
||||
love.Created, love.TrackName, love.ArtistName(), love.RecordingMbid)
|
||||
love.Created, love.TrackName, love.ArtistName(), love.RecordingMBID)
|
||||
importResult.Log(models.Info, msg)
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
}
|
||||
|
|
|
@ -33,13 +33,13 @@ import (
|
|||
const MaxItemsPerGet = 50
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
HTTPClient *resty.Client
|
||||
token string
|
||||
}
|
||||
|
||||
func NewClient(serverUrl string, token string) Client {
|
||||
func NewClient(serverURL string, token string) Client {
|
||||
client := resty.New()
|
||||
client.SetBaseURL(serverUrl)
|
||||
client.SetBaseURL(serverURL)
|
||||
client.SetAuthScheme("Bearer")
|
||||
client.SetAuthToken(token)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
|
@ -49,14 +49,14 @@ func NewClient(serverUrl string, token string) Client {
|
|||
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
||||
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
HTTPClient: client,
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) GetHistoryListenings(user string, page int, perPage int) (result ListeningsResult, err error) {
|
||||
const path = "/api/v1/history/listenings"
|
||||
response, err := c.HttpClient.R().
|
||||
response, err := c.HTTPClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"username": user,
|
||||
"page": strconv.Itoa(page),
|
||||
|
@ -66,7 +66,7 @@ func (c Client) GetHistoryListenings(user string, page int, perPage int) (result
|
|||
SetResult(&result).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(response.String())
|
||||
return
|
||||
}
|
||||
|
@ -75,7 +75,7 @@ func (c Client) GetHistoryListenings(user string, page int, perPage int) (result
|
|||
|
||||
func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksResult, err error) {
|
||||
const path = "/api/v1/favorites/tracks"
|
||||
response, err := c.HttpClient.R().
|
||||
response, err := c.HTTPClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"page": strconv.Itoa(page),
|
||||
"page_size": strconv.Itoa(perPage),
|
||||
|
@ -84,7 +84,7 @@ func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksR
|
|||
SetResult(&result).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(response.String())
|
||||
return
|
||||
}
|
||||
|
|
|
@ -32,20 +32,20 @@ import (
|
|||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
serverUrl := "https://funkwhale.example.com"
|
||||
serverURL := "https://funkwhale.example.com"
|
||||
token := "foobar123"
|
||||
client := funkwhale.NewClient(serverUrl, token)
|
||||
assert.Equal(t, serverUrl, client.HttpClient.BaseURL)
|
||||
assert.Equal(t, token, client.HttpClient.Token)
|
||||
client := funkwhale.NewClient(serverURL, token)
|
||||
assert.Equal(t, serverURL, client.HTTPClient.BaseURL)
|
||||
assert.Equal(t, token, client.HTTPClient.Token)
|
||||
}
|
||||
|
||||
func TestGetHistoryListenings(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
serverUrl := "https://funkwhale.example.com"
|
||||
serverURL := "https://funkwhale.example.com"
|
||||
token := "thetoken"
|
||||
client := funkwhale.NewClient(serverUrl, token)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
client := funkwhale.NewClient(serverURL, token)
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://funkwhale.example.com/api/v1/history/listenings",
|
||||
"testdata/listenings.json")
|
||||
|
||||
|
@ -67,9 +67,9 @@ func TestGetFavoriteTracks(t *testing.T) {
|
|||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
token := "thetoken"
|
||||
serverUrl := "https://funkwhale.example.com"
|
||||
client := funkwhale.NewClient(serverUrl, token)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
serverURL := "https://funkwhale.example.com"
|
||||
client := funkwhale.NewClient(serverURL, token)
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://funkwhale.example.com/api/v1/favorites/tracks",
|
||||
"testdata/favorite-tracks.json")
|
||||
|
||||
|
@ -87,7 +87,7 @@ func TestGetFavoriteTracks(t *testing.T) {
|
|||
assert.Equal("phw", fav1.User.UserName)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
|
|
|
@ -20,6 +20,7 @@ import (
|
|||
"sort"
|
||||
"time"
|
||||
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
|
@ -50,13 +51,13 @@ func (b *FunkwhaleApiBackend) Options() []models.BackendOption {
|
|||
}}
|
||||
}
|
||||
|
||||
func (b *FunkwhaleApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
func (b *FunkwhaleApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
b.client = NewClient(
|
||||
config.GetString("server-url"),
|
||||
config.GetString("token"),
|
||||
)
|
||||
b.username = config.GetString("username")
|
||||
return b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||
|
@ -175,7 +176,7 @@ func (f FavoriteTrack) AsLove() models.Love {
|
|||
track := f.Track.AsTrack()
|
||||
love := models.Love{
|
||||
UserName: f.User.UserName,
|
||||
RecordingMbid: track.RecordingMbid,
|
||||
RecordingMBID: track.RecordingMBID,
|
||||
Track: track,
|
||||
}
|
||||
|
||||
|
@ -188,16 +189,15 @@ func (f FavoriteTrack) AsLove() models.Love {
|
|||
}
|
||||
|
||||
func (t Track) AsTrack() models.Track {
|
||||
recordingMbid := models.MBID(t.RecordingMbid)
|
||||
track := models.Track{
|
||||
TrackName: t.Title,
|
||||
ReleaseName: t.Album.Title,
|
||||
ArtistNames: []string{t.Artist.Name},
|
||||
TrackNumber: t.Position,
|
||||
DiscNumber: t.DiscNumber,
|
||||
RecordingMbid: recordingMbid,
|
||||
ReleaseMbid: models.MBID(t.Album.ReleaseMbid),
|
||||
ArtistMbids: []models.MBID{models.MBID(t.Artist.ArtistMbid)},
|
||||
RecordingMBID: t.RecordingMBID,
|
||||
ReleaseMBID: t.Album.ReleaseMBID,
|
||||
ArtistMBIDs: []mbtypes.MBID{t.Artist.ArtistMBID},
|
||||
Tags: t.Tags,
|
||||
AdditionalInfo: map[string]any{
|
||||
"media_player": FunkwhaleClientName,
|
||||
|
|
|
@ -25,15 +25,15 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/funkwhale"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
func TestInitConfig(t *testing.T) {
|
||||
c := viper.New()
|
||||
c.Set("token", "thetoken")
|
||||
service := config.NewServiceConfig("test", c)
|
||||
backend := (&funkwhale.FunkwhaleApiBackend{}).FromConfig(&service)
|
||||
assert.IsType(t, &funkwhale.FunkwhaleApiBackend{}, backend)
|
||||
backend := funkwhale.FunkwhaleApiBackend{}
|
||||
err := backend.InitConfig(&service)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestFunkwhaleListeningAsListen(t *testing.T) {
|
||||
|
@ -44,17 +44,17 @@ func TestFunkwhaleListeningAsListen(t *testing.T) {
|
|||
},
|
||||
Track: funkwhale.Track{
|
||||
Title: "Oweynagat",
|
||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
Position: 5,
|
||||
DiscNumber: 1,
|
||||
Tags: []string{"foo", "bar"},
|
||||
Artist: funkwhale.Artist{
|
||||
Name: "Dool",
|
||||
ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||
ArtistMBID: "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||
},
|
||||
Album: funkwhale.Album{
|
||||
Title: "Here Now, There Then",
|
||||
ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||
ReleaseMBID: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||
},
|
||||
Uploads: []funkwhale.Upload{
|
||||
{
|
||||
|
@ -75,9 +75,9 @@ func TestFunkwhaleListeningAsListen(t *testing.T) {
|
|||
assert.Equal(fwListen.Track.DiscNumber, listen.Track.DiscNumber)
|
||||
assert.Equal(fwListen.Track.Tags, listen.Track.Tags)
|
||||
// assert.Equal(backends.FunkwhaleClientName, listen.AdditionalInfo["disc_number"])
|
||||
assert.Equal(models.MBID(fwListen.Track.RecordingMbid), listen.RecordingMbid)
|
||||
assert.Equal(models.MBID(fwListen.Track.Album.ReleaseMbid), listen.ReleaseMbid)
|
||||
assert.Equal(models.MBID(fwListen.Track.Artist.ArtistMbid), listen.ArtistMbids[0])
|
||||
assert.Equal(fwListen.Track.RecordingMBID, listen.RecordingMBID)
|
||||
assert.Equal(fwListen.Track.Album.ReleaseMBID, listen.ReleaseMBID)
|
||||
assert.Equal(fwListen.Track.Artist.ArtistMBID, listen.ArtistMBIDs[0])
|
||||
assert.Equal(funkwhale.FunkwhaleClientName, listen.AdditionalInfo["media_player"])
|
||||
}
|
||||
|
||||
|
@ -89,17 +89,17 @@ func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) {
|
|||
},
|
||||
Track: funkwhale.Track{
|
||||
Title: "Oweynagat",
|
||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
Position: 5,
|
||||
DiscNumber: 1,
|
||||
Tags: []string{"foo", "bar"},
|
||||
Artist: funkwhale.Artist{
|
||||
Name: "Dool",
|
||||
ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||
ArtistMBID: "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||
},
|
||||
Album: funkwhale.Album{
|
||||
Title: "Here Now, There Then",
|
||||
ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||
ReleaseMBID: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||
},
|
||||
Uploads: []funkwhale.Upload{
|
||||
{
|
||||
|
@ -119,10 +119,10 @@ func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) {
|
|||
assert.Equal(favorite.Track.Position, love.Track.TrackNumber)
|
||||
assert.Equal(favorite.Track.DiscNumber, love.Track.DiscNumber)
|
||||
assert.Equal(favorite.Track.Tags, love.Track.Tags)
|
||||
assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.RecordingMbid)
|
||||
assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.Track.RecordingMbid)
|
||||
assert.Equal(models.MBID(favorite.Track.Album.ReleaseMbid), love.ReleaseMbid)
|
||||
require.Len(t, love.Track.ArtistMbids, 1)
|
||||
assert.Equal(models.MBID(favorite.Track.Artist.ArtistMbid), love.ArtistMbids[0])
|
||||
assert.Equal(favorite.Track.RecordingMBID, love.RecordingMBID)
|
||||
assert.Equal(favorite.Track.RecordingMBID, love.Track.RecordingMBID)
|
||||
assert.Equal(favorite.Track.Album.ReleaseMBID, love.ReleaseMBID)
|
||||
require.Len(t, love.Track.ArtistMBIDs, 1)
|
||||
assert.Equal(favorite.Track.Artist.ArtistMBID, love.ArtistMBIDs[0])
|
||||
assert.Equal(funkwhale.FunkwhaleClientName, love.AdditionalInfo["media_player"])
|
||||
}
|
||||
|
|
|
@ -21,6 +21,8 @@ THE SOFTWARE.
|
|||
*/
|
||||
package funkwhale
|
||||
|
||||
import "go.uploadedlobster.com/mbtypes"
|
||||
|
||||
type ListeningsResult struct {
|
||||
Count int `json:"count"`
|
||||
Previous string `json:"previous"`
|
||||
|
@ -29,7 +31,7 @@ type ListeningsResult struct {
|
|||
}
|
||||
|
||||
type Listening struct {
|
||||
Id int `json:"int"`
|
||||
ID int `json:"int"`
|
||||
User User `json:"user"`
|
||||
Track Track `json:"track"`
|
||||
CreationDate string `json:"creation_date"`
|
||||
|
@ -43,41 +45,41 @@ type FavoriteTracksResult struct {
|
|||
}
|
||||
|
||||
type FavoriteTrack struct {
|
||||
Id int `json:"int"`
|
||||
ID int `json:"int"`
|
||||
User User `json:"user"`
|
||||
Track Track `json:"track"`
|
||||
CreationDate string `json:"creation_date"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Id int `json:"int"`
|
||||
Artist Artist `json:"artist"`
|
||||
Album Album `json:"album"`
|
||||
Title string `json:"title"`
|
||||
Position int `json:"position"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
RecordingMbid string `json:"mbid"`
|
||||
Tags []string `json:"tags"`
|
||||
Uploads []Upload `json:"uploads"`
|
||||
ID int `json:"int"`
|
||||
Artist Artist `json:"artist"`
|
||||
Album Album `json:"album"`
|
||||
Title string `json:"title"`
|
||||
Position int `json:"position"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
RecordingMBID mbtypes.MBID `json:"mbid"`
|
||||
Tags []string `json:"tags"`
|
||||
Uploads []Upload `json:"uploads"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Id int `json:"int"`
|
||||
Name string `json:"name"`
|
||||
ArtistMbid string `json:"mbid"`
|
||||
ID int `json:"int"`
|
||||
Name string `json:"name"`
|
||||
ArtistMBID mbtypes.MBID `json:"mbid"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
Id int `json:"int"`
|
||||
Title string `json:"title"`
|
||||
AlbumArtist Artist `json:"artist"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TrackCount int `json:"track_count"`
|
||||
ReleaseMbid string `json:"mbid"`
|
||||
ID int `json:"int"`
|
||||
Title string `json:"title"`
|
||||
AlbumArtist Artist `json:"artist"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TrackCount int `json:"track_count"`
|
||||
ReleaseMBID mbtypes.MBID `json:"mbid"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Id int `json:"int"`
|
||||
ID int `json:"int"`
|
||||
UserName string `json:"username"`
|
||||
}
|
||||
|
||||
|
|
|
@ -60,7 +60,7 @@ func (b *JSPFBackend) Options() []models.BackendOption {
|
|||
}}
|
||||
}
|
||||
|
||||
func (b *JSPFBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
b.filePath = config.GetString("file-path")
|
||||
b.append = config.GetBool("append", true)
|
||||
b.playlist = jspf.Playlist{
|
||||
|
@ -69,13 +69,13 @@ func (b *JSPFBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
|||
Identifier: config.GetString("identifier"),
|
||||
Tracks: make([]jspf.Track, 0),
|
||||
Extension: map[string]any{
|
||||
jspf.MusicBrainzPlaylistExtensionId: jspf.MusicBrainzPlaylistExtension{
|
||||
jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{
|
||||
LastModifiedAt: time.Now(),
|
||||
Public: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
return b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *JSPFBackend) StartImport() error {
|
||||
|
@ -116,10 +116,10 @@ func listenAsTrack(l models.Listen) jspf.Track {
|
|||
extension := makeMusicBrainzExtension(l.Track)
|
||||
extension.AddedAt = l.ListenedAt
|
||||
extension.AddedBy = l.UserName
|
||||
track.Extension[jspf.MusicBrainzTrackExtensionId] = extension
|
||||
track.Extension[jspf.MusicBrainzTrackExtensionID] = extension
|
||||
|
||||
if l.RecordingMbid != "" {
|
||||
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid))
|
||||
if l.RecordingMBID != "" {
|
||||
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMBID))
|
||||
}
|
||||
|
||||
return track
|
||||
|
@ -131,14 +131,14 @@ func loveAsTrack(l models.Love) jspf.Track {
|
|||
extension := makeMusicBrainzExtension(l.Track)
|
||||
extension.AddedAt = l.Created
|
||||
extension.AddedBy = l.UserName
|
||||
track.Extension[jspf.MusicBrainzTrackExtensionId] = extension
|
||||
track.Extension[jspf.MusicBrainzTrackExtensionID] = extension
|
||||
|
||||
recordingMbid := l.Track.RecordingMbid
|
||||
if l.RecordingMbid != "" {
|
||||
recordingMbid = l.RecordingMbid
|
||||
recordingMBID := l.Track.RecordingMBID
|
||||
if l.RecordingMBID != "" {
|
||||
recordingMBID = l.RecordingMBID
|
||||
}
|
||||
if recordingMbid != "" {
|
||||
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMbid))
|
||||
if recordingMBID != "" {
|
||||
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMBID))
|
||||
}
|
||||
|
||||
return track
|
||||
|
@ -159,15 +159,15 @@ func trackAsTrack(t models.Track) jspf.Track {
|
|||
func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension {
|
||||
extension := jspf.MusicBrainzTrackExtension{
|
||||
AdditionalMetadata: t.AdditionalInfo,
|
||||
ArtistIdentifiers: make([]string, len(t.ArtistMbids)),
|
||||
ArtistIdentifiers: make([]string, len(t.ArtistMBIDs)),
|
||||
}
|
||||
|
||||
for i, mbid := range t.ArtistMbids {
|
||||
for i, mbid := range t.ArtistMBIDs {
|
||||
extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid)
|
||||
}
|
||||
|
||||
if t.ReleaseMbid != "" {
|
||||
extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMbid)
|
||||
if t.ReleaseMBID != "" {
|
||||
extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMBID)
|
||||
}
|
||||
|
||||
// The tracknumber tag would be redundant
|
||||
|
|
|
@ -26,13 +26,14 @@ import (
|
|||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
func TestInitConfig(t *testing.T) {
|
||||
c := viper.New()
|
||||
c.Set("file-path", "/foo/bar.jspf")
|
||||
c.Set("title", "My Playlist")
|
||||
c.Set("username", "outsidecontext")
|
||||
c.Set("identifier", "http://example.com/playlist1")
|
||||
service := config.NewServiceConfig("test", c)
|
||||
backend := (&jspf.JSPFBackend{}).FromConfig(&service)
|
||||
assert.IsType(t, &jspf.JSPFBackend{}, backend)
|
||||
backend := jspf.JSPFBackend{}
|
||||
err := backend.InitConfig(&service)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
|
|
@ -25,21 +25,21 @@ import (
|
|||
|
||||
type lastfmStrategy struct {
|
||||
client *lastfm.Api
|
||||
redirectUrl *url.URL
|
||||
redirectURL *url.URL
|
||||
}
|
||||
|
||||
func (s lastfmStrategy) Config() oauth2.Config {
|
||||
return oauth2.Config{}
|
||||
}
|
||||
|
||||
func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl {
|
||||
func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL {
|
||||
// Last.fm does not use OAuth2, but the provided authorization flow with
|
||||
// callback URL is close enough we can shoehorn it into the existing
|
||||
// authentication strategy.
|
||||
// TODO: Investigate and use callback-less flow with api.GetAuthTokenUrl(token)
|
||||
url := s.client.GetAuthRequestUrl(s.redirectUrl.String())
|
||||
return auth.AuthUrl{
|
||||
Url: url,
|
||||
url := s.client.GetAuthRequestUrl(s.redirectURL.String())
|
||||
return auth.AuthURL{
|
||||
URL: url,
|
||||
State: "", // last.fm does not use state
|
||||
Param: "token",
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/shkh/lastfm-go/lastfm"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/auth"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||
|
@ -60,21 +61,21 @@ func (b *LastfmApiBackend) Options() []models.BackendOption {
|
|||
}}
|
||||
}
|
||||
|
||||
func (b *LastfmApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
clientId := config.GetString("client-id")
|
||||
func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
clientID := config.GetString("client-id")
|
||||
clientSecret := config.GetString("client-secret")
|
||||
b.client = lastfm.New(clientId, clientSecret)
|
||||
b.client = lastfm.New(clientID, clientSecret)
|
||||
b.username = config.GetString("username")
|
||||
return b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *LastfmApiBackend) StartImport() error { return nil }
|
||||
func (b *LastfmApiBackend) FinishImport() error { return nil }
|
||||
|
||||
func (b *LastfmApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
||||
func (b *LastfmApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
|
||||
return lastfmStrategy{
|
||||
client: b.client,
|
||||
redirectUrl: redirectUrl,
|
||||
redirectURL: redirectURL,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -140,16 +141,16 @@ out:
|
|||
TrackName: scrobble.Name,
|
||||
ArtistNames: []string{},
|
||||
ReleaseName: scrobble.Album.Name,
|
||||
RecordingMbid: models.MBID(scrobble.Mbid),
|
||||
ArtistMbids: []models.MBID{},
|
||||
ReleaseMbid: models.MBID(scrobble.Album.Mbid),
|
||||
RecordingMBID: mbtypes.MBID(scrobble.Mbid),
|
||||
ArtistMBIDs: []mbtypes.MBID{},
|
||||
ReleaseMBID: mbtypes.MBID(scrobble.Album.Mbid),
|
||||
},
|
||||
}
|
||||
if scrobble.Artist.Name != "" {
|
||||
listen.Track.ArtistNames = []string{scrobble.Artist.Name}
|
||||
}
|
||||
if scrobble.Artist.Mbid != "" {
|
||||
listen.Track.ArtistMbids = []models.MBID{models.MBID(scrobble.Artist.Mbid)}
|
||||
listen.Track.ArtistMBIDs = []mbtypes.MBID{mbtypes.MBID(scrobble.Artist.Mbid)}
|
||||
}
|
||||
listens = append(listens, listen)
|
||||
} else {
|
||||
|
@ -203,8 +204,8 @@ func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResu
|
|||
if l.TrackNumber > 0 {
|
||||
trackNumbers = append(trackNumbers, strconv.Itoa(l.TrackNumber))
|
||||
}
|
||||
if l.RecordingMbid != "" {
|
||||
mbids = append(mbids, string(l.RecordingMbid))
|
||||
if l.RecordingMBID != "" {
|
||||
mbids = append(mbids, string(l.RecordingMBID))
|
||||
}
|
||||
// if l.ReleaseArtist != "" {
|
||||
// albumArtists = append(albums, l.ReleaseArtist)
|
||||
|
@ -294,12 +295,12 @@ out:
|
|||
love := models.Love{
|
||||
Created: time.Unix(timestamp, 0),
|
||||
UserName: result.User,
|
||||
RecordingMbid: models.MBID(track.Mbid),
|
||||
RecordingMBID: mbtypes.MBID(track.Mbid),
|
||||
Track: models.Track{
|
||||
TrackName: track.Name,
|
||||
ArtistNames: []string{track.Artist.Name},
|
||||
RecordingMbid: models.MBID(track.Mbid),
|
||||
ArtistMbids: []models.MBID{models.MBID(track.Artist.Mbid)},
|
||||
RecordingMBID: mbtypes.MBID(track.Mbid),
|
||||
ArtistMBIDs: []mbtypes.MBID{mbtypes.MBID(track.Artist.Mbid)},
|
||||
AdditionalInfo: models.AdditionalInfo{
|
||||
"lastfm_url": track.Url,
|
||||
},
|
||||
|
|
|
@ -39,7 +39,7 @@ const (
|
|||
)
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
HTTPClient *resty.Client
|
||||
MaxResults int
|
||||
}
|
||||
|
||||
|
@ -55,7 +55,7 @@ func NewClient(token string) Client {
|
|||
ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In")
|
||||
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
HTTPClient: client,
|
||||
MaxResults: DefaultItemsPerGet,
|
||||
}
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ func NewClient(token string) Client {
|
|||
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
|
||||
const path = "/user/{username}/listens"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
response, err := c.HTTPClient.R().
|
||||
SetPathParam("username", user).
|
||||
SetQueryParams(map[string]string{
|
||||
"max_ts": strconv.FormatInt(maxTime.Unix(), 10),
|
||||
|
@ -74,7 +74,7 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
|
|||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
|
@ -84,13 +84,13 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
|
|||
func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) {
|
||||
const path = "/submit-listens"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
response, err := c.HTTPClient.R().
|
||||
SetBody(listens).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Post(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
|
@ -100,7 +100,7 @@ func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, er
|
|||
func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) {
|
||||
const path = "/feedback/user/{username}/get-feedback"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
response, err := c.HTTPClient.R().
|
||||
SetPathParam("username", user).
|
||||
SetQueryParams(map[string]string{
|
||||
"status": strconv.Itoa(status),
|
||||
|
@ -112,7 +112,7 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed
|
|||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
|
@ -122,13 +122,13 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed
|
|||
func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) {
|
||||
const path = "/feedback/recording-feedback"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
response, err := c.HTTPClient.R().
|
||||
SetBody(feedback).
|
||||
SetResult(&result).
|
||||
SetError(&errorResult).
|
||||
Post(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
|
@ -138,7 +138,7 @@ func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error)
|
|||
func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) {
|
||||
const path = "/metadata/lookup"
|
||||
errorResult := ErrorResult{}
|
||||
response, err := c.HttpClient.R().
|
||||
response, err := c.HTTPClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"recording_name": recordingName,
|
||||
"artist_name": artistName,
|
||||
|
@ -147,7 +147,7 @@ func (c Client) Lookup(recordingName string, artistName string) (result LookupRe
|
|||
SetError(&errorResult).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(errorResult.Error)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -29,13 +29,14 @@ import (
|
|||
"github.com/jarcoal/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
token := "foobar123"
|
||||
client := listenbrainz.NewClient(token)
|
||||
assert.Equal(t, token, client.HttpClient.Token)
|
||||
assert.Equal(t, token, client.HTTPClient.Token)
|
||||
assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults)
|
||||
}
|
||||
|
||||
|
@ -44,7 +45,7 @@ func TestGetListens(t *testing.T) {
|
|||
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
client.MaxResults = 2
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://api.listenbrainz.org/1/user/outsidecontext/listens",
|
||||
"testdata/listens.json")
|
||||
|
||||
|
@ -61,7 +62,7 @@ func TestGetListens(t *testing.T) {
|
|||
|
||||
func TestSubmitListens(t *testing.T) {
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
||||
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
||||
Status: "ok",
|
||||
|
@ -102,7 +103,7 @@ func TestGetFeedback(t *testing.T) {
|
|||
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
client.MaxResults = 2
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback",
|
||||
"testdata/feedback.json")
|
||||
|
||||
|
@ -114,12 +115,12 @@ func TestGetFeedback(t *testing.T) {
|
|||
assert.Equal(302, result.TotalCount)
|
||||
assert.Equal(3, result.Offset)
|
||||
require.Len(t, result.Feedback, 2)
|
||||
assert.Equal("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", result.Feedback[0].RecordingMbid)
|
||||
assert.Equal(mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), result.Feedback[0].RecordingMBID)
|
||||
}
|
||||
|
||||
func TestSendFeedback(t *testing.T) {
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
||||
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
||||
Status: "ok",
|
||||
|
@ -131,7 +132,7 @@ func TestSendFeedback(t *testing.T) {
|
|||
httpmock.RegisterResponder("POST", url, responder)
|
||||
|
||||
feedback := listenbrainz.Feedback{
|
||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
Score: 1,
|
||||
}
|
||||
result, err := client.SendFeedback(feedback)
|
||||
|
@ -144,7 +145,7 @@ func TestLookup(t *testing.T) {
|
|||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := listenbrainz.NewClient("thetoken")
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://api.listenbrainz.org/1/metadata/lookup",
|
||||
"testdata/lookup.json")
|
||||
|
||||
|
@ -154,10 +155,10 @@ func TestLookup(t *testing.T) {
|
|||
assert := assert.New(t)
|
||||
assert.Equal("Say Just Words", result.RecordingName)
|
||||
assert.Equal("Paradise Lost", result.ArtistCreditName)
|
||||
assert.Equal("569436a1-234a-44bc-a370-8f4d252bef21", result.RecordingMbid)
|
||||
assert.Equal(mbtypes.MBID("569436a1-234a-44bc-a370-8f4d252bef21"), result.RecordingMBID)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
|
|
|
@ -21,6 +21,8 @@ import (
|
|||
"sort"
|
||||
"time"
|
||||
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/musicbrainzws2"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
|
@ -30,9 +32,10 @@ import (
|
|||
|
||||
type ListenBrainzApiBackend struct {
|
||||
client Client
|
||||
mbClient musicbrainzws2.Client
|
||||
username string
|
||||
checkDuplicates bool
|
||||
existingMbids map[string]bool
|
||||
existingMBIDs map[mbtypes.MBID]bool
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" }
|
||||
|
@ -53,12 +56,17 @@ func (b *ListenBrainzApiBackend) Options() []models.BackendOption {
|
|||
}}
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
func (b *ListenBrainzApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
b.client = NewClient(config.GetString("token"))
|
||||
b.mbClient = *musicbrainzws2.NewClient(musicbrainzws2.AppInfo{
|
||||
Name: version.AppName,
|
||||
Version: version.AppVersion,
|
||||
URL: version.AppURL,
|
||||
})
|
||||
b.client.MaxResults = MaxItemsPerGet
|
||||
b.username = config.GetString("username")
|
||||
b.checkDuplicates = config.GetBool("check-duplicate-listens", false)
|
||||
return b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) StartImport() error { return nil }
|
||||
|
@ -147,7 +155,7 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
|
|||
} else if isDupe {
|
||||
count -= 1
|
||||
msg := i18n.Tr("Ignored duplicate listen %v: \"%v\" by %v (%v)",
|
||||
l.ListenedAt, l.TrackName, l.ArtistName(), l.RecordingMbid)
|
||||
l.ListenedAt, l.TrackName, l.ArtistName(), l.RecordingMBID)
|
||||
importResult.Log(models.Info, msg)
|
||||
continue
|
||||
}
|
||||
|
@ -187,6 +195,27 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
|
|||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||
defer close(results)
|
||||
exportChan := make(chan models.LovesResult)
|
||||
p := models.Progress{}
|
||||
|
||||
go b.exportLoves(time.Unix(0, 0), exportChan)
|
||||
for existingLoves := range exportChan {
|
||||
if existingLoves.Error != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Error: existingLoves.Error}
|
||||
}
|
||||
|
||||
p.Total = int64(existingLoves.Total)
|
||||
p.Elapsed += int64(existingLoves.Items.Len())
|
||||
progress <- p
|
||||
results <- existingLoves
|
||||
}
|
||||
|
||||
progress <- p.Complete()
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) exportLoves(oldestTimestamp time.Time, results chan models.LovesResult) {
|
||||
offset := 0
|
||||
defer close(results)
|
||||
loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
|
||||
|
@ -196,7 +225,6 @@ out:
|
|||
for {
|
||||
result, err := b.client.GetFeedback(b.username, 1, offset)
|
||||
if err != nil {
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
@ -207,11 +235,20 @@ out:
|
|||
}
|
||||
|
||||
for _, feedback := range result.Feedback {
|
||||
// Missing track metadata indicates that the recording MBID is no
|
||||
// longer available and might have been merged. Try fetching details
|
||||
// from MusicBrainz.
|
||||
if feedback.TrackMetadata == nil {
|
||||
track, err := b.lookupRecording(feedback.RecordingMBID)
|
||||
if err == nil {
|
||||
feedback.TrackMetadata = track
|
||||
}
|
||||
}
|
||||
|
||||
love := feedback.AsLove()
|
||||
if love.Created.Unix() > oldestTimestamp.Unix() {
|
||||
loves = append(loves, love)
|
||||
p.Elapsed += 1
|
||||
progress <- p
|
||||
} else {
|
||||
break out
|
||||
}
|
||||
|
@ -224,49 +261,65 @@ out:
|
|||
}
|
||||
|
||||
sort.Sort(loves)
|
||||
progress <- p.Complete()
|
||||
results <- models.LovesResult{Items: loves}
|
||||
results <- models.LovesResult{
|
||||
Total: len(loves),
|
||||
Items: loves,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
if len(b.existingMbids) == 0 {
|
||||
if len(b.existingMBIDs) == 0 {
|
||||
existingLovesChan := make(chan models.LovesResult)
|
||||
go b.ExportLoves(time.Unix(0, 0), existingLovesChan, progress)
|
||||
existingLoves := <-existingLovesChan
|
||||
if existingLoves.Error != nil {
|
||||
return importResult, existingLoves.Error
|
||||
}
|
||||
go b.exportLoves(time.Unix(0, 0), existingLovesChan)
|
||||
|
||||
// TODO: Store MBIDs directly
|
||||
b.existingMbids = make(map[string]bool, len(existingLoves.Items))
|
||||
for _, love := range existingLoves.Items {
|
||||
b.existingMbids[string(love.RecordingMbid)] = true
|
||||
b.existingMBIDs = make(map[mbtypes.MBID]bool, MaxItemsPerGet)
|
||||
|
||||
for existingLoves := range existingLovesChan {
|
||||
if existingLoves.Error != nil {
|
||||
return importResult, existingLoves.Error
|
||||
}
|
||||
|
||||
for _, love := range existingLoves.Items {
|
||||
b.existingMBIDs[love.RecordingMBID] = true
|
||||
// In case the loved MBID got merged the track MBID represents the
|
||||
// actual recording MBID.
|
||||
if love.Track.RecordingMBID != "" &&
|
||||
love.Track.RecordingMBID != love.RecordingMBID {
|
||||
b.existingMBIDs[love.Track.RecordingMBID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, love := range export.Items {
|
||||
recordingMbid := string(love.RecordingMbid)
|
||||
recordingMBID := love.RecordingMBID
|
||||
if recordingMBID == "" {
|
||||
recordingMBID = love.Track.RecordingMBID
|
||||
}
|
||||
|
||||
if recordingMbid == "" {
|
||||
if recordingMBID == "" {
|
||||
lookup, err := b.client.Lookup(love.TrackName, love.ArtistName())
|
||||
if err == nil {
|
||||
recordingMbid = lookup.RecordingMbid
|
||||
recordingMBID = lookup.RecordingMBID
|
||||
}
|
||||
}
|
||||
|
||||
if recordingMbid != "" {
|
||||
if recordingMBID != "" {
|
||||
ok := false
|
||||
errMsg := ""
|
||||
if b.existingMbids[recordingMbid] {
|
||||
if b.existingMBIDs[recordingMBID] {
|
||||
ok = true
|
||||
} else {
|
||||
resp, err := b.client.SendFeedback(Feedback{
|
||||
RecordingMbid: recordingMbid,
|
||||
RecordingMBID: recordingMBID,
|
||||
Score: 1,
|
||||
})
|
||||
ok = err == nil && resp.Status == "ok"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
} else {
|
||||
b.existingMBIDs[recordingMBID] = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -278,6 +331,10 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe
|
|||
love.TrackName, love.ArtistName(), errMsg)
|
||||
importResult.Log(models.Error, msg)
|
||||
}
|
||||
} else {
|
||||
msg := fmt.Sprintf("Failed import of \"%s\" by %s: no recording MBID",
|
||||
love.TrackName, love.ArtistName())
|
||||
importResult.Log(models.Error, msg)
|
||||
}
|
||||
|
||||
progress <- models.Progress{}.FromImportResult(importResult)
|
||||
|
@ -313,6 +370,31 @@ func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (boo
|
|||
return false, nil
|
||||
}
|
||||
|
||||
func (b *ListenBrainzApiBackend) lookupRecording(mbid mbtypes.MBID) (*Track, error) {
|
||||
filter := musicbrainzws2.IncludesFilter{
|
||||
Includes: []string{"artist-credits"},
|
||||
}
|
||||
recording, err := b.mbClient.LookupRecording(mbid, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
artistMBIDs := make([]mbtypes.MBID, 0, len(recording.ArtistCredit))
|
||||
for _, artist := range recording.ArtistCredit {
|
||||
artistMBIDs = append(artistMBIDs, artist.Artist.ID)
|
||||
}
|
||||
track := Track{
|
||||
TrackName: recording.Title,
|
||||
ArtistName: recording.ArtistCredit.String(),
|
||||
MBIDMapping: &MBIDMapping{
|
||||
// In case of redirects this MBID differs from the looked up MBID
|
||||
RecordingMBID: recording.ID,
|
||||
ArtistMBIDs: artistMBIDs,
|
||||
},
|
||||
}
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
func (lbListen Listen) AsListen() models.Listen {
|
||||
listen := models.Listen{
|
||||
ListenedAt: time.Unix(lbListen.ListenedAt, 0),
|
||||
|
@ -323,20 +405,20 @@ func (lbListen Listen) AsListen() models.Listen {
|
|||
}
|
||||
|
||||
func (f Feedback) AsLove() models.Love {
|
||||
recordingMbid := models.MBID(f.RecordingMbid)
|
||||
recordingMBID := f.RecordingMBID
|
||||
track := f.TrackMetadata
|
||||
if track == nil {
|
||||
track = &Track{}
|
||||
}
|
||||
love := models.Love{
|
||||
UserName: f.UserName,
|
||||
RecordingMbid: recordingMbid,
|
||||
RecordingMBID: recordingMBID,
|
||||
Created: time.Unix(f.Created, 0),
|
||||
Track: track.AsTrack(),
|
||||
}
|
||||
|
||||
if love.Track.RecordingMbid == "" {
|
||||
love.Track.RecordingMbid = love.RecordingMbid
|
||||
if love.Track.RecordingMBID == "" {
|
||||
love.Track.RecordingMBID = love.RecordingMBID
|
||||
}
|
||||
|
||||
return love
|
||||
|
@ -350,16 +432,16 @@ func (t Track) AsTrack() models.Track {
|
|||
Duration: t.Duration(),
|
||||
TrackNumber: t.TrackNumber(),
|
||||
DiscNumber: t.DiscNumber(),
|
||||
RecordingMbid: models.MBID(t.RecordingMbid()),
|
||||
ReleaseMbid: models.MBID(t.ReleaseMbid()),
|
||||
ReleaseGroupMbid: models.MBID(t.ReleaseGroupMbid()),
|
||||
RecordingMBID: t.RecordingMBID(),
|
||||
ReleaseMBID: t.ReleaseMBID(),
|
||||
ReleaseGroupMBID: t.ReleaseGroupMBID(),
|
||||
ISRC: t.ISRC(),
|
||||
AdditionalInfo: t.AdditionalInfo,
|
||||
}
|
||||
|
||||
if t.MbidMapping != nil && len(track.ArtistMbids) == 0 {
|
||||
for _, artistMbid := range t.MbidMapping.ArtistMbids {
|
||||
track.ArtistMbids = append(track.ArtistMbids, models.MBID(artistMbid))
|
||||
if t.MBIDMapping != nil && len(track.ArtistMBIDs) == 0 {
|
||||
for _, artistMBID := range t.MBIDMapping.ArtistMBIDs {
|
||||
track.ArtistMBIDs = append(track.ArtistMBIDs, artistMBID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,17 +23,18 @@ import (
|
|||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
func TestInitConfig(t *testing.T) {
|
||||
c := viper.New()
|
||||
c.Set("token", "thetoken")
|
||||
service := config.NewServiceConfig("test", c)
|
||||
backend := (&listenbrainz.ListenBrainzApiBackend{}).FromConfig(&service)
|
||||
assert.IsType(t, &listenbrainz.ListenBrainzApiBackend{}, backend)
|
||||
backend := listenbrainz.ListenBrainzApiBackend{}
|
||||
err := backend.InitConfig(&service)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestListenBrainzListenAsListen(t *testing.T) {
|
||||
|
@ -65,30 +66,30 @@ func TestListenBrainzListenAsListen(t *testing.T) {
|
|||
assert.Equal(t, []string{lbListen.TrackMetadata.ArtistName}, listen.ArtistNames)
|
||||
assert.Equal(t, 5, listen.TrackNumber)
|
||||
assert.Equal(t, 1, listen.DiscNumber)
|
||||
assert.Equal(t, models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMbid)
|
||||
assert.Equal(t, models.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMbid)
|
||||
assert.Equal(t, models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMbid)
|
||||
assert.Equal(t, "DES561620801", listen.ISRC)
|
||||
assert.Equal(t, mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMBID)
|
||||
assert.Equal(t, mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMBID)
|
||||
assert.Equal(t, mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMBID)
|
||||
assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC)
|
||||
assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"])
|
||||
}
|
||||
|
||||
func TestListenBrainzFeedbackAsLove(t *testing.T) {
|
||||
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
|
||||
releaseMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
|
||||
artistMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
|
||||
recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12")
|
||||
releaseMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68")
|
||||
artistMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68")
|
||||
feedback := listenbrainz.Feedback{
|
||||
Created: 1699859066,
|
||||
RecordingMbid: recordingMbid,
|
||||
RecordingMBID: recordingMBID,
|
||||
Score: 1,
|
||||
UserName: "ousidecontext",
|
||||
TrackMetadata: &listenbrainz.Track{
|
||||
TrackName: "Oweynagat",
|
||||
ArtistName: "Dool",
|
||||
ReleaseName: "Here Now, There Then",
|
||||
MbidMapping: &listenbrainz.MbidMapping{
|
||||
RecordingMbid: recordingMbid,
|
||||
ReleaseMbid: releaseMbid,
|
||||
ArtistMbids: []string{artistMbid},
|
||||
MBIDMapping: &listenbrainz.MBIDMapping{
|
||||
RecordingMBID: recordingMBID,
|
||||
ReleaseMBID: releaseMBID,
|
||||
ArtistMBIDs: []mbtypes.MBID{artistMBID},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -99,24 +100,24 @@ func TestListenBrainzFeedbackAsLove(t *testing.T) {
|
|||
assert.Equal(feedback.TrackMetadata.TrackName, love.TrackName)
|
||||
assert.Equal(feedback.TrackMetadata.ReleaseName, love.ReleaseName)
|
||||
assert.Equal([]string{feedback.TrackMetadata.ArtistName}, love.ArtistNames)
|
||||
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
|
||||
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
|
||||
assert.Equal(models.MBID(releaseMbid), love.Track.ReleaseMbid)
|
||||
require.Len(t, love.Track.ArtistMbids, 1)
|
||||
assert.Equal(models.MBID(artistMbid), love.Track.ArtistMbids[0])
|
||||
assert.Equal(recordingMBID, love.RecordingMBID)
|
||||
assert.Equal(recordingMBID, love.Track.RecordingMBID)
|
||||
assert.Equal(releaseMBID, love.Track.ReleaseMBID)
|
||||
require.Len(t, love.Track.ArtistMBIDs, 1)
|
||||
assert.Equal(artistMBID, love.Track.ArtistMBIDs[0])
|
||||
}
|
||||
|
||||
func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
|
||||
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
|
||||
recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12")
|
||||
feedback := listenbrainz.Feedback{
|
||||
Created: 1699859066,
|
||||
RecordingMbid: recordingMbid,
|
||||
RecordingMBID: recordingMBID,
|
||||
Score: 1,
|
||||
}
|
||||
love := feedback.AsLove()
|
||||
assert := assert.New(t)
|
||||
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
|
||||
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
|
||||
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
|
||||
assert.Equal(recordingMBID, love.RecordingMBID)
|
||||
assert.Equal(recordingMBID, love.Track.RecordingMBID)
|
||||
assert.Empty(love.Track.TrackName)
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"golang.org/x/exp/constraints"
|
||||
)
|
||||
|
||||
|
@ -56,7 +57,7 @@ type ListenSubmission struct {
|
|||
type Listen struct {
|
||||
InsertedAt int64 `json:"inserted_at,omitempty"`
|
||||
ListenedAt int64 `json:"listened_at"`
|
||||
RecordingMsid string `json:"recording_msid,omitempty"`
|
||||
RecordingMSID string `json:"recording_msid,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
TrackMetadata Track `json:"track_metadata"`
|
||||
}
|
||||
|
@ -66,20 +67,20 @@ type Track struct {
|
|||
ArtistName string `json:"artist_name,omitempty"`
|
||||
ReleaseName string `json:"release_name,omitempty"`
|
||||
AdditionalInfo map[string]any `json:"additional_info,omitempty"`
|
||||
MbidMapping *MbidMapping `json:"mbid_mapping,omitempty"`
|
||||
MBIDMapping *MBIDMapping `json:"mbid_mapping,omitempty"`
|
||||
}
|
||||
|
||||
type MbidMapping struct {
|
||||
RecordingName string `json:"recording_name,omitempty"`
|
||||
RecordingMbid string `json:"recording_mbid,omitempty"`
|
||||
ReleaseMbid string `json:"release_mbid,omitempty"`
|
||||
ArtistMbids []string `json:"artist_mbids,omitempty"`
|
||||
Artists []Artist `json:"artists,omitempty"`
|
||||
type MBIDMapping struct {
|
||||
RecordingName string `json:"recording_name,omitempty"`
|
||||
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
|
||||
ReleaseMBID mbtypes.MBID `json:"release_mbid,omitempty"`
|
||||
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids,omitempty"`
|
||||
Artists []Artist `json:"artists,omitempty"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
ArtistCreditName string `json:"artist_credit_name,omitempty"`
|
||||
ArtistMbid string `json:"artist_mbid,omitempty"`
|
||||
ArtistMBID string `json:"artist_mbid,omitempty"`
|
||||
JoinPhrase string `json:"join_phrase,omitempty"`
|
||||
}
|
||||
|
||||
|
@ -91,21 +92,21 @@ type GetFeedbackResult struct {
|
|||
}
|
||||
|
||||
type Feedback struct {
|
||||
Created int64 `json:"created,omitempty"`
|
||||
RecordingMbid string `json:"recording_mbid,omitempty"`
|
||||
RecordingMsid string `json:"recording_msid,omitempty"`
|
||||
Score int `json:"score,omitempty"`
|
||||
TrackMetadata *Track `json:"track_metadata,omitempty"`
|
||||
UserName string `json:"user_id,omitempty"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
|
||||
RecordingMSID mbtypes.MBID `json:"recording_msid,omitempty"`
|
||||
Score int `json:"score,omitempty"`
|
||||
TrackMetadata *Track `json:"track_metadata,omitempty"`
|
||||
UserName string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type LookupResult struct {
|
||||
ArtistCreditName string `json:"artist_credit_name"`
|
||||
ReleaseName string `json:"release_name"`
|
||||
RecordingName string `json:"recording_name"`
|
||||
RecordingMbid string `json:"recording_mbid"`
|
||||
ReleaseMbid string `json:"release_mbid"`
|
||||
ArtistMbids []string `json:"artist_mbids"`
|
||||
ArtistCreditName string `json:"artist_credit_name"`
|
||||
ReleaseName string `json:"release_name"`
|
||||
RecordingName string `json:"recording_name"`
|
||||
RecordingMBID mbtypes.MBID `json:"recording_mbid"`
|
||||
ReleaseMBID mbtypes.MBID `json:"release_mbid"`
|
||||
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids"`
|
||||
}
|
||||
|
||||
type StatusResult struct {
|
||||
|
@ -158,30 +159,30 @@ func (t Track) DiscNumber() int {
|
|||
return 0
|
||||
}
|
||||
|
||||
func (t Track) ISRC() string {
|
||||
return tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc")
|
||||
func (t Track) ISRC() mbtypes.ISRC {
|
||||
return mbtypes.ISRC(tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc"))
|
||||
}
|
||||
|
||||
func (t Track) RecordingMbid() string {
|
||||
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid")
|
||||
if mbid == "" && t.MbidMapping != nil {
|
||||
return t.MbidMapping.RecordingMbid
|
||||
func (t Track) RecordingMBID() mbtypes.MBID {
|
||||
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid"))
|
||||
if mbid == "" && t.MBIDMapping != nil {
|
||||
return t.MBIDMapping.RecordingMBID
|
||||
} else {
|
||||
return mbid
|
||||
}
|
||||
}
|
||||
|
||||
func (t Track) ReleaseMbid() string {
|
||||
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid")
|
||||
if mbid == "" && t.MbidMapping != nil {
|
||||
return t.MbidMapping.ReleaseMbid
|
||||
func (t Track) ReleaseMBID() mbtypes.MBID {
|
||||
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid"))
|
||||
if mbid == "" && t.MBIDMapping != nil {
|
||||
return t.MBIDMapping.ReleaseMBID
|
||||
} else {
|
||||
return mbid
|
||||
}
|
||||
}
|
||||
|
||||
func (t Track) ReleaseGroupMbid() string {
|
||||
return tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid")
|
||||
func (t Track) ReleaseGroupMBID() mbtypes.MBID {
|
||||
return mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid"))
|
||||
}
|
||||
|
||||
func tryGetValueOrEmpty[T any](dict map[string]any, key string) T {
|
||||
|
|
|
@ -28,6 +28,7 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||
)
|
||||
|
||||
|
@ -130,50 +131,50 @@ func TestTrackTrackNumberString(t *testing.T) {
|
|||
assert.Equal(t, 12, track.TrackNumber())
|
||||
}
|
||||
|
||||
func TestTrackIsrc(t *testing.T) {
|
||||
expected := "TCAEJ1934417"
|
||||
func TestTrackISRC(t *testing.T) {
|
||||
expected := mbtypes.ISRC("TCAEJ1934417")
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"isrc": expected,
|
||||
"isrc": string(expected),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.ISRC())
|
||||
}
|
||||
|
||||
func TestTrackRecordingMbid(t *testing.T) {
|
||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
||||
func TestTrackRecordingMBID(t *testing.T) {
|
||||
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"recording_mbid": expected,
|
||||
"recording_mbid": string(expected),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.RecordingMbid())
|
||||
assert.Equal(t, expected, track.RecordingMBID())
|
||||
}
|
||||
|
||||
func TestTrackReleaseMbid(t *testing.T) {
|
||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
||||
func TestTrackReleaseMBID(t *testing.T) {
|
||||
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"release_mbid": expected,
|
||||
"release_mbid": string(expected),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.ReleaseMbid())
|
||||
assert.Equal(t, expected, track.ReleaseMBID())
|
||||
}
|
||||
|
||||
func TestReleaseGroupMbid(t *testing.T) {
|
||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
||||
func TestReleaseGroupMBID(t *testing.T) {
|
||||
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||
track := listenbrainz.Track{
|
||||
AdditionalInfo: map[string]any{
|
||||
"release_group_mbid": expected,
|
||||
"release_group_mbid": string(expected),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, track.ReleaseGroupMbid())
|
||||
assert.Equal(t, expected, track.ReleaseGroupMBID())
|
||||
}
|
||||
|
||||
func TestMarshalPartialFeedback(t *testing.T) {
|
||||
feedback := listenbrainz.Feedback{
|
||||
Created: 1699859066,
|
||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||
}
|
||||
b, err := json.Marshal(feedback)
|
||||
require.NoError(t, err)
|
||||
|
|
|
@ -32,25 +32,25 @@ import (
|
|||
const MaxItemsPerGet = 1000
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
HTTPClient *resty.Client
|
||||
token string
|
||||
}
|
||||
|
||||
func NewClient(serverUrl string, token string) Client {
|
||||
func NewClient(serverURL string, token string) Client {
|
||||
client := resty.New()
|
||||
client.SetBaseURL(serverUrl)
|
||||
client.SetBaseURL(serverURL)
|
||||
client.SetHeader("Accept", "application/json")
|
||||
client.SetHeader("User-Agent", version.UserAgent())
|
||||
client.SetRetryCount(5)
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
HTTPClient: client,
|
||||
token: token,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) {
|
||||
const path = "/apis/mlj_1/scrobbles"
|
||||
response, err := c.HttpClient.R().
|
||||
response, err := c.HTTPClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"page": strconv.Itoa(page),
|
||||
"perpage": strconv.Itoa(perPage),
|
||||
|
@ -58,7 +58,7 @@ func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult,
|
|||
SetResult(&result).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(response.String())
|
||||
return
|
||||
}
|
||||
|
@ -68,12 +68,12 @@ func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult,
|
|||
func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err error) {
|
||||
const path = "/apis/mlj_1/newscrobble"
|
||||
scrobble.Key = c.token
|
||||
response, err := c.HttpClient.R().
|
||||
response, err := c.HTTPClient.R().
|
||||
SetBody(scrobble).
|
||||
SetResult(&result).
|
||||
Post(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(response.String())
|
||||
return
|
||||
}
|
||||
|
|
|
@ -32,19 +32,19 @@ import (
|
|||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
serverUrl := "https://maloja.example.com"
|
||||
serverURL := "https://maloja.example.com"
|
||||
token := "foobar123"
|
||||
client := maloja.NewClient(serverUrl, token)
|
||||
assert.Equal(t, serverUrl, client.HttpClient.BaseURL)
|
||||
client := maloja.NewClient(serverURL, token)
|
||||
assert.Equal(t, serverURL, client.HTTPClient.BaseURL)
|
||||
}
|
||||
|
||||
func TestGetScrobbles(t *testing.T) {
|
||||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
serverUrl := "https://maloja.example.com"
|
||||
serverURL := "https://maloja.example.com"
|
||||
token := "thetoken"
|
||||
client := maloja.NewClient(serverUrl, token)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
client := maloja.NewClient(serverURL, token)
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://maloja.example.com/apis/mlj_1/scrobbles",
|
||||
"testdata/scrobbles.json")
|
||||
|
||||
|
@ -60,7 +60,7 @@ func TestGetScrobbles(t *testing.T) {
|
|||
func TestNewScrobble(t *testing.T) {
|
||||
server := "https://maloja.example.com"
|
||||
client := maloja.NewClient(server, "thetoken")
|
||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
||||
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json"))
|
||||
if err != nil {
|
||||
|
@ -80,7 +80,7 @@ func TestNewScrobble(t *testing.T) {
|
|||
assert.Equal(t, "success", result.Status)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
|
|
|
@ -51,13 +51,13 @@ func (b *MalojaApiBackend) Options() []models.BackendOption {
|
|||
}}
|
||||
}
|
||||
|
||||
func (b *MalojaApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
func (b *MalojaApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
b.client = NewClient(
|
||||
config.GetString("server-url"),
|
||||
config.GetString("token"),
|
||||
)
|
||||
b.nofix = config.GetBool("nofix", false)
|
||||
return b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *MalojaApiBackend) StartImport() error { return nil }
|
||||
|
|
|
@ -26,12 +26,13 @@ import (
|
|||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
func TestInitConfig(t *testing.T) {
|
||||
c := viper.New()
|
||||
c.Set("token", "thetoken")
|
||||
service := config.NewServiceConfig("test", c)
|
||||
backend := (&maloja.MalojaApiBackend{}).FromConfig(&service)
|
||||
assert.IsType(t, &maloja.MalojaApiBackend{}, backend)
|
||||
backend := maloja.MalojaApiBackend{}
|
||||
err := backend.InitConfig(&service)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestScrobbleAsListen(t *testing.T) {
|
||||
|
|
|
@ -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 (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"go.uploadedlobster.com/scotty/pkg/scrobblerlog"
|
||||
)
|
||||
|
||||
type ScrobblerLogBackend struct {
|
||||
filePath string
|
||||
includeSkipped bool
|
||||
append bool
|
||||
file *os.File
|
||||
log ScrobblerLog
|
||||
filePath string
|
||||
ignoreSkipped bool
|
||||
append bool
|
||||
file *os.File
|
||||
timezone *time.Location
|
||||
log scrobblerlog.ScrobblerLog
|
||||
}
|
||||
|
||||
func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" }
|
||||
|
@ -43,26 +47,39 @@ func (b *ScrobblerLogBackend) Options() []models.BackendOption {
|
|||
Label: i18n.Tr("File path"),
|
||||
Type: models.String,
|
||||
}, {
|
||||
Name: "include-skipped",
|
||||
Label: i18n.Tr("Include skipped listens"),
|
||||
Type: models.Bool,
|
||||
Name: "ignore-skipped",
|
||||
Label: i18n.Tr("Ignore skipped listens"),
|
||||
Type: models.Bool,
|
||||
Default: "true",
|
||||
}, {
|
||||
Name: "append",
|
||||
Label: i18n.Tr("Append to file"),
|
||||
Type: models.Bool,
|
||||
Default: "true",
|
||||
}, {
|
||||
Name: "time-zone",
|
||||
Label: i18n.Tr("Specify a time zone for the listen timestamps"),
|
||||
Type: models.String,
|
||||
}}
|
||||
}
|
||||
|
||||
func (b *ScrobblerLogBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
func (b *ScrobblerLogBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
b.filePath = config.GetString("file-path")
|
||||
b.includeSkipped = config.GetBool("include-skipped", false)
|
||||
b.ignoreSkipped = config.GetBool("ignore-skipped", true)
|
||||
b.append = config.GetBool("append", true)
|
||||
b.log = ScrobblerLog{
|
||||
Timezone: "UNKNOWN",
|
||||
Client: "Rockbox unknown $Revision$",
|
||||
timezone := config.GetString("time-zone")
|
||||
if timezone != "" {
|
||||
location, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Invalid time-zone %q: %w", timezone, err)
|
||||
}
|
||||
b.log.FallbackTimezone = location
|
||||
}
|
||||
return b
|
||||
b.log = scrobblerlog.ScrobblerLog{
|
||||
TZ: scrobblerlog.TimezoneUTC,
|
||||
Client: "Rockbox unknown $Revision$",
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *ScrobblerLogBackend) StartImport() error {
|
||||
|
@ -88,7 +105,7 @@ func (b *ScrobblerLogBackend) StartImport() error {
|
|||
} else {
|
||||
// Verify existing file is a scrobbler log
|
||||
reader := bufio.NewReader(file)
|
||||
if err = ReadHeader(reader, &b.log); err != nil {
|
||||
if err = b.log.ReadHeader(reader); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
|
@ -99,7 +116,7 @@ func (b *ScrobblerLogBackend) StartImport() error {
|
|||
}
|
||||
|
||||
if !b.append {
|
||||
if err = WriteHeader(file, &b.log); err != nil {
|
||||
if err = b.log.WriteHeader(file); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
|
@ -124,21 +141,29 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c
|
|||
|
||||
defer file.Close()
|
||||
|
||||
log, err := Parse(file, b.includeSkipped)
|
||||
err = b.log.Parse(file, b.ignoreSkipped)
|
||||
if err != nil {
|
||||
progress <- models.Progress{}.Complete()
|
||||
results <- models.ListensResult{Error: err}
|
||||
return
|
||||
}
|
||||
|
||||
listens := log.Listens.NewerThan(oldestTimestamp)
|
||||
sort.Sort(listens)
|
||||
progress <- models.Progress{Elapsed: int64(len(listens))}.Complete()
|
||||
listens := make(models.ListensList, 0, len(b.log.Records))
|
||||
client := strings.Split(b.log.Client, " ")[0]
|
||||
for _, record := range b.log.Records {
|
||||
listens = append(listens, recordToListen(record, client))
|
||||
}
|
||||
sort.Sort(listens.NewerThan(oldestTimestamp))
|
||||
progress <- models.Progress{Total: int64(len(listens))}.Complete()
|
||||
results <- models.ListensResult{Items: listens}
|
||||
}
|
||||
|
||||
func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||
lastTimestamp, err := Write(b.file, export.Items)
|
||||
records := make([]scrobblerlog.Record, len(export.Items))
|
||||
for i, listen := range export.Items {
|
||||
records[i] = listenToRecord(listen)
|
||||
}
|
||||
lastTimestamp, err := b.log.Append(b.file, records)
|
||||
if err != nil {
|
||||
return importResult, err
|
||||
}
|
||||
|
@ -149,3 +174,42 @@ func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importR
|
|||
|
||||
return importResult, nil
|
||||
}
|
||||
|
||||
func recordToListen(record scrobblerlog.Record, client string) models.Listen {
|
||||
return models.Listen{
|
||||
ListenedAt: record.Timestamp,
|
||||
Track: models.Track{
|
||||
ArtistNames: []string{record.ArtistName},
|
||||
ReleaseName: record.AlbumName,
|
||||
TrackName: record.TrackName,
|
||||
TrackNumber: record.TrackNumber,
|
||||
Duration: record.Duration,
|
||||
RecordingMBID: record.MusicBrainzRecordingID,
|
||||
AdditionalInfo: models.AdditionalInfo{
|
||||
"rockbox_rating": record.Rating,
|
||||
"media_player": client,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func listenToRecord(listen models.Listen) scrobblerlog.Record {
|
||||
var rating scrobblerlog.Rating
|
||||
rockboxRating, ok := listen.AdditionalInfo["rockbox_rating"].(string)
|
||||
if !ok || rockboxRating == "" {
|
||||
rating = scrobblerlog.RatingListened
|
||||
} else {
|
||||
rating = scrobblerlog.Rating(rating)
|
||||
}
|
||||
|
||||
return scrobblerlog.Record{
|
||||
ArtistName: listen.ArtistName(),
|
||||
AlbumName: listen.ReleaseName,
|
||||
TrackName: listen.TrackName,
|
||||
TrackNumber: listen.TrackNumber,
|
||||
Duration: listen.Duration,
|
||||
Rating: rating,
|
||||
Timestamp: listen.ListenedAt,
|
||||
MusicBrainzRecordingID: listen.RecordingMBID,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,10 +25,21 @@ import (
|
|||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
func TestInitConfig(t *testing.T) {
|
||||
c := viper.New()
|
||||
c.Set("token", "thetoken")
|
||||
service := config.NewServiceConfig("test", c)
|
||||
backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(&service)
|
||||
assert.IsType(t, &scrobblerlog.ScrobblerLogBackend{}, backend)
|
||||
backend := scrobblerlog.ScrobblerLogBackend{}
|
||||
err := backend.InitConfig(&service)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestInitConfigInvalidTimezone(t *testing.T) {
|
||||
c := viper.New()
|
||||
configuredTimezone := "Invalid/Timezone"
|
||||
c.Set("time-zone", configuredTimezone)
|
||||
service := config.NewServiceConfig("test", c)
|
||||
backend := scrobblerlog.ScrobblerLogBackend{}
|
||||
err := backend.InitConfig(&service)
|
||||
assert.ErrorContains(t, err, `Invalid time-zone "Invalid/Timezone"`)
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ const (
|
|||
)
|
||||
|
||||
type Client struct {
|
||||
HttpClient *resty.Client
|
||||
HTTPClient *resty.Client
|
||||
}
|
||||
|
||||
func NewClient(token oauth2.TokenSource) Client {
|
||||
|
@ -55,7 +55,7 @@ func NewClient(token oauth2.TokenSource) Client {
|
|||
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
||||
|
||||
return Client{
|
||||
HttpClient: client,
|
||||
HTTPClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -69,7 +69,7 @@ func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlaye
|
|||
|
||||
func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) {
|
||||
const path = "/me/player/recently-played"
|
||||
request := c.HttpClient.R().
|
||||
request := c.HTTPClient.R().
|
||||
SetQueryParam("limit", strconv.Itoa(limit)).
|
||||
SetResult(&result)
|
||||
if after != nil {
|
||||
|
@ -79,7 +79,7 @@ func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (
|
|||
}
|
||||
response, err := request.Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(response.String())
|
||||
}
|
||||
return
|
||||
|
@ -87,7 +87,7 @@ func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (
|
|||
|
||||
func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) {
|
||||
const path = "/me/tracks"
|
||||
response, err := c.HttpClient.R().
|
||||
response, err := c.HTTPClient.R().
|
||||
SetQueryParams(map[string]string{
|
||||
"offset": strconv.Itoa(offset),
|
||||
"limit": strconv.Itoa(limit),
|
||||
|
@ -95,7 +95,7 @@ func (c Client) UserTracks(offset int, limit int) (result TracksResult, err erro
|
|||
SetResult(&result).
|
||||
Get(path)
|
||||
|
||||
if response.StatusCode() != 200 {
|
||||
if !response.IsSuccess() {
|
||||
err = errors.New(response.String())
|
||||
}
|
||||
return
|
||||
|
|
|
@ -43,7 +43,7 @@ func TestRecentlyPlayedAfter(t *testing.T) {
|
|||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := spotify.NewClient(nil)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://api.spotify.com/v1/me/player/recently-played",
|
||||
"testdata/recently-played.json")
|
||||
|
||||
|
@ -63,7 +63,7 @@ func TestGetUserTracks(t *testing.T) {
|
|||
defer httpmock.DeactivateAndReset()
|
||||
|
||||
client := spotify.NewClient(nil)
|
||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
||||
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||
"https://api.spotify.com/v1/me/tracks",
|
||||
"testdata/user-tracks.json")
|
||||
|
||||
|
@ -79,7 +79,7 @@ func TestGetUserTracks(t *testing.T) {
|
|||
assert.Equal("Zeal & Ardor", track1.Track.Album.Name)
|
||||
}
|
||||
|
||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||
httpmock.ActivateNonDefault(client)
|
||||
|
||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||
|
|
|
@ -22,6 +22,8 @@ THE SOFTWARE.
|
|||
|
||||
package spotify
|
||||
|
||||
import "go.uploadedlobster.com/mbtypes"
|
||||
|
||||
type TracksResult struct {
|
||||
Href string `json:"href"`
|
||||
Limit int `json:"limit"`
|
||||
|
@ -56,7 +58,7 @@ type Listen struct {
|
|||
}
|
||||
|
||||
type Track struct {
|
||||
Id string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Uri string `json:"uri"`
|
||||
|
@ -67,14 +69,14 @@ type Track struct {
|
|||
Explicit bool `json:"explicit"`
|
||||
IsLocal bool `json:"is_local"`
|
||||
Popularity int `json:"popularity"`
|
||||
ExternalIds ExternalIds `json:"external_ids"`
|
||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
||||
ExternalIDs ExternalIDs `json:"external_ids"`
|
||||
ExternalURLs ExternalURLs `json:"external_urls"`
|
||||
Album Album `json:"album"`
|
||||
Artists []Artist `json:"artists"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
Id string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Uri string `json:"uri"`
|
||||
|
@ -83,32 +85,32 @@ type Album struct {
|
|||
ReleaseDate string `json:"release_date"`
|
||||
ReleaseDatePrecision string `json:"release_date_precision"`
|
||||
AlbumType string `json:"album_type"`
|
||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
||||
ExternalURLs ExternalURLs `json:"external_urls"`
|
||||
Artists []Artist `json:"artists"`
|
||||
Images []Image `json:"images"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Id string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Href string `json:"href"`
|
||||
Uri string `json:"uri"`
|
||||
Type string `json:"type"`
|
||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
||||
ExternalURLs ExternalURLs `json:"external_urls"`
|
||||
}
|
||||
|
||||
type ExternalIds struct {
|
||||
ISRC string `json:"isrc"`
|
||||
EAN string `json:"ean"`
|
||||
UPC string `json:"upc"`
|
||||
type ExternalIDs struct {
|
||||
ISRC mbtypes.ISRC `json:"isrc"`
|
||||
EAN string `json:"ean"`
|
||||
UPC string `json:"upc"`
|
||||
}
|
||||
|
||||
type ExternalUrls struct {
|
||||
type ExternalURLs struct {
|
||||
Spotify string `json:"spotify"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
Url string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
}
|
||||
|
|
|
@ -28,14 +28,13 @@ import (
|
|||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"go.uploadedlobster.com/scotty/internal/util"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/spotify"
|
||||
)
|
||||
|
||||
type SpotifyApiBackend struct {
|
||||
client Client
|
||||
clientId string
|
||||
clientID string
|
||||
clientSecret string
|
||||
}
|
||||
|
||||
|
@ -53,15 +52,15 @@ func (b *SpotifyApiBackend) Options() []models.BackendOption {
|
|||
}}
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
b.clientId = config.GetString("client-id")
|
||||
func (b *SpotifyApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
b.clientID = config.GetString("client-id")
|
||||
b.clientSecret = config.GetString("client-secret")
|
||||
return b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
||||
func (b *SpotifyApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
|
||||
conf := oauth2.Config{
|
||||
ClientID: b.clientId,
|
||||
ClientID: b.clientID,
|
||||
ClientSecret: b.clientSecret,
|
||||
Scopes: []string{
|
||||
"user-read-currently-playing",
|
||||
|
@ -69,16 +68,16 @@ func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Stra
|
|||
"user-library-read",
|
||||
"user-library-modify",
|
||||
},
|
||||
RedirectURL: redirectUrl.String(),
|
||||
RedirectURL: redirectURL.String(),
|
||||
Endpoint: spotify.Endpoint,
|
||||
}
|
||||
|
||||
return auth.NewStandardStrategy(conf)
|
||||
}
|
||||
|
||||
func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config {
|
||||
func (b *SpotifyApiBackend) OAuth2Config(redirectURL *url.URL) oauth2.Config {
|
||||
return oauth2.Config{
|
||||
ClientID: b.clientId,
|
||||
ClientID: b.clientID,
|
||||
ClientSecret: b.clientSecret,
|
||||
Scopes: []string{
|
||||
"user-read-currently-playing",
|
||||
|
@ -86,7 +85,7 @@ func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config {
|
|||
"user-library-read",
|
||||
"user-library-modify",
|
||||
},
|
||||
RedirectURL: redirectUrl.String(),
|
||||
RedirectURL: redirectURL.String(),
|
||||
Endpoint: spotify.Endpoint,
|
||||
}
|
||||
}
|
||||
|
@ -184,7 +183,7 @@ out:
|
|||
if offset >= result.Total {
|
||||
p.Total = int64(result.Total)
|
||||
totalCount = result.Total
|
||||
offset = util.Max(result.Total-perPage, 0)
|
||||
offset = max(result.Total-perPage, 0)
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -252,7 +251,7 @@ func (t Track) AsTrack() models.Track {
|
|||
Duration: time.Duration(t.DurationMs * int(time.Millisecond)),
|
||||
TrackNumber: t.TrackNumber,
|
||||
DiscNumber: t.DiscNumber,
|
||||
ISRC: t.ExternalIds.ISRC,
|
||||
ISRC: t.ExternalIDs.ISRC,
|
||||
AdditionalInfo: map[string]any{},
|
||||
}
|
||||
|
||||
|
@ -265,30 +264,30 @@ func (t Track) AsTrack() models.Track {
|
|||
info["music_service"] = "spotify.com"
|
||||
}
|
||||
|
||||
if t.ExternalUrls.Spotify != "" {
|
||||
info["origin_url"] = t.ExternalUrls.Spotify
|
||||
info["spotify_id"] = t.ExternalUrls.Spotify
|
||||
if t.ExternalURLs.Spotify != "" {
|
||||
info["origin_url"] = t.ExternalURLs.Spotify
|
||||
info["spotify_id"] = t.ExternalURLs.Spotify
|
||||
}
|
||||
|
||||
if t.Album.ExternalUrls.Spotify != "" {
|
||||
info["spotify_album_id"] = t.Album.ExternalUrls.Spotify
|
||||
if t.Album.ExternalURLs.Spotify != "" {
|
||||
info["spotify_album_id"] = t.Album.ExternalURLs.Spotify
|
||||
}
|
||||
|
||||
if len(t.Artists) > 0 {
|
||||
info["spotify_artist_ids"] = extractArtistIds(t.Artists)
|
||||
info["spotify_artist_ids"] = extractArtistIDs(t.Artists)
|
||||
}
|
||||
|
||||
if len(t.Album.Artists) > 0 {
|
||||
info["spotify_album_artist_ids"] = extractArtistIds(t.Album.Artists)
|
||||
info["spotify_album_artist_ids"] = extractArtistIDs(t.Album.Artists)
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
func extractArtistIds(artists []Artist) []string {
|
||||
artistIds := make([]string, len(artists))
|
||||
func extractArtistIDs(artists []Artist) []string {
|
||||
artistIDs := make([]string, len(artists))
|
||||
for i, artist := range artists {
|
||||
artistIds[i] = artist.ExternalUrls.Spotify
|
||||
artistIDs[i] = artist.ExternalURLs.Spotify
|
||||
}
|
||||
return artistIds
|
||||
return artistIDs
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import (
|
|||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
)
|
||||
|
@ -37,13 +38,14 @@ var (
|
|||
testTrack []byte
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
func TestInitConfig(t *testing.T) {
|
||||
c := viper.New()
|
||||
c.Set("client-id", "someclientid")
|
||||
c.Set("client-secret", "someclientsecret")
|
||||
service := config.NewServiceConfig("test", c)
|
||||
backend := (&spotify.SpotifyApiBackend{}).FromConfig(&service)
|
||||
assert.IsType(t, &spotify.SpotifyApiBackend{}, backend)
|
||||
backend := spotify.SpotifyApiBackend{}
|
||||
err := backend.InitConfig(&service)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSpotifyListenAsListen(t *testing.T) {
|
||||
|
@ -59,7 +61,7 @@ func TestSpotifyListenAsListen(t *testing.T) {
|
|||
assert.Equal(t, []string{"Dool"}, listen.ArtistNames)
|
||||
assert.Equal(t, 5, listen.TrackNumber)
|
||||
assert.Equal(t, 1, listen.DiscNumber)
|
||||
assert.Equal(t, "DES561620801", listen.ISRC)
|
||||
assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC)
|
||||
info := listen.AdditionalInfo
|
||||
assert.Equal(t, "spotify.com", info["music_service"])
|
||||
assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["origin_url"])
|
||||
|
|
|
@ -92,8 +92,8 @@ func (i HistoryItem) AsListen() models.Listen {
|
|||
PlaybackDuration: time.Duration(i.MillisecondsPlayed * int(time.Millisecond)),
|
||||
UserName: i.UserName,
|
||||
}
|
||||
if trackUrl, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil {
|
||||
listen.AdditionalInfo["spotify_id"] = trackUrl
|
||||
if trackURL, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil {
|
||||
listen.AdditionalInfo["spotify_id"] = trackURL
|
||||
}
|
||||
return listen
|
||||
}
|
||||
|
|
|
@ -64,12 +64,12 @@ func (b *SpotifyHistoryBackend) Options() []models.BackendOption {
|
|||
}}
|
||||
}
|
||||
|
||||
func (b *SpotifyHistoryBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
func (b *SpotifyHistoryBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
b.dirPath = config.GetString("dir-path")
|
||||
b.ignoreIncognito = config.GetBool("ignore-incognito", true)
|
||||
b.ignoreSkipped = config.GetBool("ignore-skipped", false)
|
||||
b.skippedMinSeconds = config.GetInt("ignore-min-duration-seconds", 30)
|
||||
return b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||
|
|
|
@ -21,7 +21,8 @@ import (
|
|||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/delucks/go-subsonic"
|
||||
"github.com/supersonic-app/go-subsonic/subsonic"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
|
@ -51,7 +52,7 @@ func (b *SubsonicApiBackend) Options() []models.BackendOption {
|
|||
}}
|
||||
}
|
||||
|
||||
func (b *SubsonicApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
||||
func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||
b.client = subsonic.Client{
|
||||
Client: &http.Client{},
|
||||
BaseUrl: config.GetString("server-url"),
|
||||
|
@ -59,7 +60,7 @@ func (b *SubsonicApiBackend) FromConfig(config *config.ServiceConfig) models.Bac
|
|||
ClientName: version.AppName,
|
||||
}
|
||||
b.password = config.GetString("token")
|
||||
return b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||
|
@ -78,8 +79,11 @@ func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan
|
|||
return
|
||||
}
|
||||
|
||||
progress <- models.Progress{Elapsed: int64(len(starred.Song))}.Complete()
|
||||
results <- models.LovesResult{Items: b.filterSongs(starred.Song, oldestTimestamp)}
|
||||
loves := b.filterSongs(starred.Song, oldestTimestamp)
|
||||
progress <- models.Progress{
|
||||
Total: int64(loves.Len()),
|
||||
}.Complete()
|
||||
results <- models.LovesResult{Items: loves}
|
||||
}
|
||||
|
||||
func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestamp time.Time) models.LovesList {
|
||||
|
@ -96,21 +100,33 @@ func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestam
|
|||
}
|
||||
|
||||
func SongAsLove(song subsonic.Child, username string) models.Love {
|
||||
recordingMBID := mbtypes.MBID(song.MusicBrainzID)
|
||||
love := models.Love{
|
||||
UserName: username,
|
||||
Created: song.Starred,
|
||||
UserName: username,
|
||||
Created: song.Starred,
|
||||
RecordingMBID: recordingMBID,
|
||||
Track: models.Track{
|
||||
TrackName: song.Title,
|
||||
ReleaseName: song.Album,
|
||||
ArtistNames: []string{song.Artist},
|
||||
TrackNumber: song.Track,
|
||||
DiscNumber: song.DiscNumber,
|
||||
AdditionalInfo: map[string]any{},
|
||||
Duration: time.Duration(song.Duration * int(time.Second)),
|
||||
TrackName: song.Title,
|
||||
ReleaseName: song.Album,
|
||||
ArtistNames: []string{song.Artist},
|
||||
TrackNumber: song.Track,
|
||||
DiscNumber: song.DiscNumber,
|
||||
RecordingMBID: recordingMBID,
|
||||
Tags: []string{},
|
||||
AdditionalInfo: map[string]any{
|
||||
"subsonic_id": song.ID,
|
||||
},
|
||||
Duration: time.Duration(song.Duration * int(time.Second)),
|
||||
},
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,25 +20,27 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
go_subsonic "github.com/delucks/go-subsonic"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
go_subsonic "github.com/supersonic-app/go-subsonic/subsonic"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
|
||||
"go.uploadedlobster.com/scotty/internal/config"
|
||||
)
|
||||
|
||||
func TestFromConfig(t *testing.T) {
|
||||
func TestInitConfig(t *testing.T) {
|
||||
c := viper.New()
|
||||
c.Set("server-url", "https://subsonic.example.com")
|
||||
c.Set("token", "thetoken")
|
||||
service := config.NewServiceConfig("test", c)
|
||||
backend := (&subsonic.SubsonicApiBackend{}).FromConfig(&service)
|
||||
assert.IsType(t, &subsonic.SubsonicApiBackend{}, backend)
|
||||
backend := subsonic.SubsonicApiBackend{}
|
||||
err := backend.InitConfig(&service)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSongToLove(t *testing.T) {
|
||||
user := "outsidecontext"
|
||||
song := go_subsonic.Child{
|
||||
ID: "foo123",
|
||||
Starred: time.Unix(1699574369, 0),
|
||||
Title: "Oweynagat",
|
||||
Album: "Here Now, There Then",
|
||||
|
@ -59,4 +61,5 @@ func TestSongToLove(t *testing.T) {
|
|||
assert.Equal(song.Track, love.Track.TrackNumber)
|
||||
assert.Equal(song.DiscNumber, love.Track.DiscNumber)
|
||||
assert.Equal([]string{song.Genre}, love.Track.Tags)
|
||||
assert.Equal(song.ID, love.AdditionalInfo["subsonic_id"])
|
||||
}
|
||||
|
|
|
@ -43,20 +43,20 @@ func AuthenticationFlow(service config.ServiceConfig, backend auth.OAuth2Authent
|
|||
|
||||
state := auth.RandomState()
|
||||
// Redirect user to consent page to ask for permission specified scopes.
|
||||
authUrl := strategy.AuthCodeURL(verifier, state)
|
||||
authURL := strategy.AuthCodeURL(verifier, state)
|
||||
|
||||
// Start an HTTP server to listen for the response
|
||||
responseChan := make(chan auth.CodeResponse)
|
||||
auth.RunOauth2CallbackServer(*redirectURL, authUrl.Param, responseChan)
|
||||
auth.RunOauth2CallbackServer(*redirectURL, authURL.Param, responseChan)
|
||||
|
||||
// Open the URL
|
||||
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authUrl.Url))
|
||||
err = browser.OpenURL(authUrl.Url)
|
||||
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authURL.URL))
|
||||
err = browser.OpenURL(authURL.URL)
|
||||
cobra.CheckErr(err)
|
||||
|
||||
// Retrieve the code from the authentication callback
|
||||
code := <-responseChan
|
||||
if code.State != authUrl.State {
|
||||
if code.State != authURL.State {
|
||||
cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch"))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
|
@ -88,7 +88,7 @@ func (c *TransferCmd[E, I, R]) resolveBackends(source string, target string) err
|
|||
}
|
||||
|
||||
func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp backends.ImportProcessor[R]) error {
|
||||
fmt.Println(i18n.Tr("Transferring %s from %s to %s...", c.entity, c.sourceName, c.targetName))
|
||||
fmt.Println(i18n.Tr("Transferring %s from %s to %s…", c.entity, c.sourceName, c.targetName))
|
||||
|
||||
// Authenticate backends, if needed
|
||||
config := viper.GetViper()
|
||||
|
|
|
@ -16,11 +16,10 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
|||
package i18n
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/Xuanwo/go-locale"
|
||||
_ "go.uploadedlobster.com/scotty/internal/translations"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
|
@ -29,7 +28,7 @@ var localizer Localizer
|
|||
func init() {
|
||||
tag, err := locale.Detect()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
tag = language.English
|
||||
}
|
||||
localizer = New(tag)
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ type Backend interface {
|
|||
Name() string
|
||||
|
||||
// Initialize the backend from a config.
|
||||
FromConfig(config *config.ServiceConfig) Backend
|
||||
InitConfig(config *config.ServiceConfig) error
|
||||
|
||||
// Return configuration options
|
||||
Options() []BackendOption
|
||||
|
|
|
@ -24,9 +24,10 @@ package models
|
|||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
)
|
||||
|
||||
type MBID string
|
||||
type Entity string
|
||||
|
||||
const (
|
||||
|
@ -43,12 +44,12 @@ type Track struct {
|
|||
TrackNumber int
|
||||
DiscNumber int
|
||||
Duration time.Duration
|
||||
ISRC string
|
||||
RecordingMbid MBID
|
||||
ReleaseMbid MBID
|
||||
ReleaseGroupMbid MBID
|
||||
ArtistMbids []MBID
|
||||
WorkMbids []MBID
|
||||
ISRC mbtypes.ISRC
|
||||
RecordingMBID mbtypes.MBID
|
||||
ReleaseMBID mbtypes.MBID
|
||||
ReleaseGroupMBID mbtypes.MBID
|
||||
ArtistMBIDs []mbtypes.MBID
|
||||
WorkMBIDs []mbtypes.MBID
|
||||
Tags []string
|
||||
AdditionalInfo AdditionalInfo
|
||||
}
|
||||
|
@ -62,20 +63,20 @@ func (t *Track) FillAdditionalInfo() {
|
|||
if t.AdditionalInfo == nil {
|
||||
t.AdditionalInfo = make(AdditionalInfo, 5)
|
||||
}
|
||||
if t.RecordingMbid != "" {
|
||||
t.AdditionalInfo["recording_mbid"] = t.RecordingMbid
|
||||
if t.RecordingMBID != "" {
|
||||
t.AdditionalInfo["recording_mbid"] = t.RecordingMBID
|
||||
}
|
||||
if t.ReleaseGroupMbid != "" {
|
||||
t.AdditionalInfo["release_group_mbid"] = t.ReleaseGroupMbid
|
||||
if t.ReleaseGroupMBID != "" {
|
||||
t.AdditionalInfo["release_group_mbid"] = t.ReleaseGroupMBID
|
||||
}
|
||||
if t.ReleaseMbid != "" {
|
||||
t.AdditionalInfo["release_mbid"] = t.ReleaseMbid
|
||||
if t.ReleaseMBID != "" {
|
||||
t.AdditionalInfo["release_mbid"] = t.ReleaseMBID
|
||||
}
|
||||
if len(t.ArtistMbids) > 0 {
|
||||
t.AdditionalInfo["artist_mbids"] = t.ArtistMbids
|
||||
if len(t.ArtistMBIDs) > 0 {
|
||||
t.AdditionalInfo["artist_mbids"] = t.ArtistMBIDs
|
||||
}
|
||||
if len(t.WorkMbids) > 0 {
|
||||
t.AdditionalInfo["work_mbids"] = t.WorkMbids
|
||||
if len(t.WorkMBIDs) > 0 {
|
||||
t.AdditionalInfo["work_mbids"] = t.WorkMBIDs
|
||||
}
|
||||
if t.ISRC != "" {
|
||||
t.AdditionalInfo["isrc"] = t.ISRC
|
||||
|
@ -110,8 +111,8 @@ type Love struct {
|
|||
Track
|
||||
Created time.Time
|
||||
UserName string
|
||||
RecordingMbid MBID
|
||||
RecordingMsid MBID
|
||||
RecordingMBID mbtypes.MBID
|
||||
RecordingMSID mbtypes.MBID
|
||||
}
|
||||
|
||||
type ListensList []Listen
|
||||
|
|
|
@ -28,6 +28,7 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
)
|
||||
|
||||
|
@ -44,25 +45,25 @@ func TestTrackArtistName(t *testing.T) {
|
|||
|
||||
func TestTrackFillAdditionalInfo(t *testing.T) {
|
||||
track := models.Track{
|
||||
RecordingMbid: models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
|
||||
ReleaseGroupMbid: models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
|
||||
ReleaseMbid: models.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
|
||||
ArtistMbids: []models.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
|
||||
WorkMbids: []models.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"},
|
||||
RecordingMBID: mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
|
||||
ReleaseGroupMBID: mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
|
||||
ReleaseMBID: mbtypes.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
|
||||
ArtistMBIDs: []mbtypes.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
|
||||
WorkMBIDs: []mbtypes.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"},
|
||||
TrackNumber: 5,
|
||||
DiscNumber: 1,
|
||||
Duration: time.Duration(413787 * time.Millisecond),
|
||||
ISRC: "DES561620801",
|
||||
ISRC: mbtypes.ISRC("DES561620801"),
|
||||
Tags: []string{"rock", "psychedelic rock"},
|
||||
}
|
||||
track.FillAdditionalInfo()
|
||||
i := track.AdditionalInfo
|
||||
assert := assert.New(t)
|
||||
assert.Equal(track.RecordingMbid, i["recording_mbid"])
|
||||
assert.Equal(track.ReleaseGroupMbid, i["release_group_mbid"])
|
||||
assert.Equal(track.ReleaseMbid, i["release_mbid"])
|
||||
assert.Equal(track.ArtistMbids, i["artist_mbids"])
|
||||
assert.Equal(track.WorkMbids, i["work_mbids"])
|
||||
assert.Equal(track.RecordingMBID, i["recording_mbid"])
|
||||
assert.Equal(track.ReleaseGroupMBID, i["release_group_mbid"])
|
||||
assert.Equal(track.ReleaseMBID, i["release_mbid"])
|
||||
assert.Equal(track.ArtistMBIDs, i["artist_mbids"])
|
||||
assert.Equal(track.WorkMBIDs, i["work_mbids"])
|
||||
assert.Equal(track.TrackNumber, i["tracknumber"])
|
||||
assert.Equal(track.DiscNumber, i["discnumber"])
|
||||
assert.Equal(track.Duration.Milliseconds(), i["duration_ms"])
|
||||
|
|
|
@ -33,7 +33,7 @@ func Similarity(s1 string, s2 string) float64 {
|
|||
s2 = norm.NFKC.String(s2)
|
||||
l1 := len([]rune(s1))
|
||||
l2 := len([]rune(s2))
|
||||
maxLen := util.Max(l1, l2)
|
||||
maxLen := max(l1, l2)
|
||||
// Empty strings always compare full equal
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
|
@ -63,7 +63,7 @@ func NormalizeTitle(s string) string {
|
|||
// Compare two tracks for similarity.
|
||||
func CompareTracks(t1 models.Track, t2 models.Track) float64 {
|
||||
// Identical recording MBID always compares 100%
|
||||
if t1.RecordingMbid == t2.RecordingMbid && t1.RecordingMbid != "" {
|
||||
if t1.RecordingMBID == t2.RecordingMBID && t1.RecordingMBID != "" {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"go.uploadedlobster.com/scotty/internal/similarity"
|
||||
)
|
||||
|
@ -74,13 +75,13 @@ func TestCompareTracksSameMBID(t *testing.T) {
|
|||
t1 := models.Track{
|
||||
ArtistNames: []string{"Paradise Lost"},
|
||||
TrackName: "Forever After",
|
||||
RecordingMbid: models.MBID("2886d15c-09b0-43c6-af56-932f70dde164"),
|
||||
RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"),
|
||||
}
|
||||
t2 := models.Track{
|
||||
ArtistNames: []string{"Paradise Lost"},
|
||||
TrackName: "Forever Failure (radio edit)",
|
||||
ReleaseName: "Draconian Times",
|
||||
RecordingMbid: models.MBID("2886d15c-09b0-43c6-af56-932f70dde164"),
|
||||
RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"),
|
||||
}
|
||||
assert.Equal(t, 1.0, similarity.CompareTracks(t1, t2))
|
||||
}
|
||||
|
|
|
@ -42,56 +42,56 @@ var messageKeyToIndex = map[string]int{
|
|||
"\tbackend: %v": 11,
|
||||
"\texport: %s": 0,
|
||||
"\timport: %s\n": 1,
|
||||
"%v: %v": 52,
|
||||
"%v: %v": 48,
|
||||
"Aborted": 8,
|
||||
"Access token": 19,
|
||||
"Access token received, you can use %v now.\n": 28,
|
||||
"Access token received, you can use %v now.\n": 34,
|
||||
"Append to file": 21,
|
||||
"Backend": 36,
|
||||
"Check for duplicate listens on import (slower)": 45,
|
||||
"Backend": 42,
|
||||
"Check for duplicate listens on import (slower)": 24,
|
||||
"Client ID": 15,
|
||||
"Client secret": 16,
|
||||
"Delete the service configuration \"%v\"?": 7,
|
||||
"Directory path": 47,
|
||||
"Disable auto correction of submitted listens": 24,
|
||||
"Error: OAuth state mismatch": 27,
|
||||
"Directory path": 29,
|
||||
"Disable auto correction of submitted listens": 26,
|
||||
"Error: OAuth state mismatch": 33,
|
||||
"Failed reading config: %v": 2,
|
||||
"File path": 20,
|
||||
"From timestamp: %v (%v)": 38,
|
||||
"Ignore listens in incognito mode": 48,
|
||||
"Ignore skipped listens": 49,
|
||||
"Ignored duplicate listen %v: \"%v\" by %v (%v)": 46,
|
||||
"Import failed, last reported timestamp was %v (%s)": 39,
|
||||
"Import log:": 51,
|
||||
"Imported %v of %v %s into %v.": 40,
|
||||
"Include skipped listens": 25,
|
||||
"Latest timestamp: %v (%v)": 41,
|
||||
"Minimum playback duration for skipped tracks (seconds)": 50,
|
||||
"No": 33,
|
||||
"From timestamp: %v (%v)": 44,
|
||||
"Ignore listens in incognito mode": 30,
|
||||
"Ignore skipped listens": 27,
|
||||
"Ignored duplicate listen %v: \"%v\" by %v (%v)": 25,
|
||||
"Import failed, last reported timestamp was %v (%s)": 45,
|
||||
"Import log:": 47,
|
||||
"Imported %v of %v %s into %v.": 46,
|
||||
"Latest timestamp: %v (%v)": 50,
|
||||
"Minimum playback duration for skipped tracks (seconds)": 31,
|
||||
"No": 39,
|
||||
"Playlist title": 22,
|
||||
"Saved service %v using backend %v": 5,
|
||||
"Server URL": 17,
|
||||
"Service": 35,
|
||||
"Service": 41,
|
||||
"Service \"%v\" deleted\n": 9,
|
||||
"Service name": 3,
|
||||
"Specify a time zone for the listen timestamps": 28,
|
||||
"The backend %v requires authentication. Authenticate now?": 6,
|
||||
"Token received, you can close this window now.": 12,
|
||||
"Transferring %s from %s to %s...": 37,
|
||||
"Transferring %s from %s to %s…": 43,
|
||||
"Unique playlist identifier": 23,
|
||||
"Updated service %v using backend %v\n": 10,
|
||||
"User name": 18,
|
||||
"Visit the URL for authorization: %v": 26,
|
||||
"Yes": 32,
|
||||
"Visit the URL for authorization: %v": 32,
|
||||
"Yes": 38,
|
||||
"a service with this name already exists": 4,
|
||||
"backend %s does not implement %s": 13,
|
||||
"done": 31,
|
||||
"exporting": 29,
|
||||
"importing": 30,
|
||||
"invalid timestamp string \"%v\"": 53,
|
||||
"key must only consist of A-Za-z0-9_-": 43,
|
||||
"no configuration file defined, cannot write config": 42,
|
||||
"no existing service configurations": 34,
|
||||
"no service configuration \"%v\"": 44,
|
||||
"done": 37,
|
||||
"exporting": 35,
|
||||
"importing": 36,
|
||||
"invalid timestamp string \"%v\"": 49,
|
||||
"key must only consist of A-Za-z0-9_-": 52,
|
||||
"no configuration file defined, cannot write config": 51,
|
||||
"no existing service configurations": 40,
|
||||
"no service configuration \"%v\"": 53,
|
||||
"unknown backend \"%s\"": 14,
|
||||
}
|
||||
|
||||
|
@ -103,18 +103,18 @@ var deIndex = []uint32{ // 55 elements
|
|||
0x000001ac, 0x000001e7, 0x00000213, 0x00000233,
|
||||
0x0000023d, 0x0000024b, 0x00000256, 0x00000263,
|
||||
0x00000271, 0x0000027b, 0x0000028e, 0x000002a1,
|
||||
0x000002b8, 0x000002ec, 0x0000030d, 0x00000333,
|
||||
0x0000035d, 0x0000039d, 0x000003a8, 0x000003b3,
|
||||
0x000002b8, 0x000002ed, 0x00000328, 0x0000035c,
|
||||
0x0000037e, 0x000003a4, 0x000003b4, 0x000003da,
|
||||
// Entry 20 - 3F
|
||||
0x000003ba, 0x000003bd, 0x000003c2, 0x000003eb,
|
||||
0x000003f3, 0x000003fb, 0x00000424, 0x00000442,
|
||||
0x0000047f, 0x000004aa, 0x000004cd, 0x0000051e,
|
||||
0x00000555, 0x0000057c, 0x0000057c, 0x0000057c,
|
||||
0x0000057c, 0x0000057c, 0x0000057c, 0x0000057c,
|
||||
0x0000057c, 0x0000057c, 0x0000057c,
|
||||
0x00000418, 0x00000443, 0x0000046d, 0x000004ad,
|
||||
0x000004b8, 0x000004c3, 0x000004ca, 0x000004cd,
|
||||
0x000004d2, 0x000004fb, 0x00000503, 0x0000050b,
|
||||
0x00000534, 0x00000552, 0x0000058f, 0x000005ba,
|
||||
0x000005c5, 0x000005d2, 0x000005f6, 0x00000619,
|
||||
0x0000066a, 0x000006a1, 0x000006c8,
|
||||
} // Size: 244 bytes
|
||||
|
||||
const deData string = "" + // Size: 1404 bytes
|
||||
const deData string = "" + // Size: 1736 bytes
|
||||
"\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" +
|
||||
" %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" +
|
||||
"in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" +
|
||||
|
@ -123,21 +123,26 @@ const deData string = "" + // Size: 1404 bytes
|
|||
"\x02Abgebrochen\x04\x00\x01\x0a\x1e\x02Service „%[1]v“ gelöscht\x04\x00" +
|
||||
"\x01\x0a1\x02Service %[1]v mit dem Backend %[2]v aktualisiert\x04\x01" +
|
||||
"\x09\x00\x0f\x02Backend: %[1]v\x02Token erhalten, das Fenster kann jetzt" +
|
||||
" geschlossen werden.\x02das backend %[1]s implementiert %[2]s nicht\x02u" +
|
||||
" geschlossen werden.\x02das Backend %[1]s implementiert %[2]s nicht\x02u" +
|
||||
"nbekanntes Backend „%[1]s“\x02Client-ID\x02Client-Secret\x02Server-URL" +
|
||||
"\x02Benutzername\x02Zugriffstoken\x02Dateipfad\x02An Datei anhängen\x02T" +
|
||||
"itel der Playlist\x02Eindeutige Playlist-ID\x02Autokorrektur für übermit" +
|
||||
"telte Titel deaktivieren\x02Übersprungene Titel einbeziehen\x02URL für A" +
|
||||
"utorisierung öffnen: %[1]v\x02Fehler: OAuth-State stimmt nicht überein" +
|
||||
"\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwendet " +
|
||||
"werden.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine bes" +
|
||||
"tehenden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %[1]s" +
|
||||
" von %[2]s nach %[3]s...\x02Ab Zeitstempel: %[1]v (%[2]v)\x02Import fehl" +
|
||||
"geschlagen, letzter Zeitstempel war %[1]v (%[2]s)\x02%[1]v von %[2]v %[3" +
|
||||
"]s in %[4]v importiert.\x02Letzter Zeitstempel: %[1]v (%[2]v)\x02keine K" +
|
||||
"onfigurationsdatei definiert, Konfiguration kann nicht geschrieben werde" +
|
||||
"n\x02Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten\x02keine Serv" +
|
||||
"icekonfiguration „%[1]v“"
|
||||
"itel der Playlist\x02Eindeutige Playlist-ID\x02Beim Import auf Listen-Du" +
|
||||
"plikate prüfen (langsamer)\x02Listen-Duplikat ignoriert %[1]v: \x22%[2]v" +
|
||||
"\x22 von %[3]v (%[4]v)\x02Autokorrektur für übermittelte Titel deaktivie" +
|
||||
"ren\x02Übersprungene Listens ignorieren\x02Zeitzone für den Abspiel-Zeit" +
|
||||
"stempel\x02Verzeichnispfad\x02Listens im Inkognito-Modus ignorieren\x02M" +
|
||||
"inimale Wiedergabedauer für übersprungene Titel (Sekunden)\x02Zur Anmeld" +
|
||||
"ung folgende URL aufrufen: %[1]v\x02Fehler: OAuth-State stimmt nicht übe" +
|
||||
"rein\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwen" +
|
||||
"det werden.\x02exportiere\x02importiere\x02fertig\x02Ja\x02Nein\x02keine" +
|
||||
" bestehenden Servicekonfigurationen\x02Service\x02Backend\x02Übertrage %" +
|
||||
"[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[2]v)\x02Import fe" +
|
||||
"hlgeschlagen, letzter Zeitstempel war %[1]v (%[2]s)\x02%[1]v von %[2]v %" +
|
||||
"[3]s in %[4]v importiert.\x02Importlog:\x02%[1]v: %[2]v\x02ungültiger Ze" +
|
||||
"itstempel „%[1]v“\x02Letzter Zeitstempel: %[1]v (%[2]v)\x02keine Konfigu" +
|
||||
"rationsdatei definiert, Konfiguration kann nicht geschrieben werden\x02S" +
|
||||
"chlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten\x02keine Servicekon" +
|
||||
"figuration „%[1]v“"
|
||||
|
||||
var enIndex = []uint32{ // 55 elements
|
||||
// Entry 0 - 1F
|
||||
|
@ -147,18 +152,18 @@ var enIndex = []uint32{ // 55 elements
|
|||
0x00000170, 0x0000019f, 0x000001c6, 0x000001de,
|
||||
0x000001e8, 0x000001f6, 0x00000201, 0x0000020b,
|
||||
0x00000218, 0x00000222, 0x00000231, 0x00000240,
|
||||
0x0000025b, 0x00000288, 0x000002a0, 0x000002c7,
|
||||
0x000002e3, 0x00000316, 0x00000320, 0x0000032a,
|
||||
0x0000025b, 0x0000028a, 0x000002c3, 0x000002f0,
|
||||
0x00000307, 0x00000335, 0x00000344, 0x00000365,
|
||||
// Entry 20 - 3F
|
||||
0x0000032f, 0x00000333, 0x00000336, 0x00000359,
|
||||
0x00000361, 0x00000369, 0x00000393, 0x000003b1,
|
||||
0x000003ea, 0x00000414, 0x00000434, 0x00000467,
|
||||
0x0000048c, 0x000004ad, 0x000004dc, 0x00000515,
|
||||
0x00000524, 0x00000545, 0x0000055c, 0x00000593,
|
||||
0x0000059f, 0x000005ac, 0x000005cd,
|
||||
0x0000039c, 0x000003c3, 0x000003df, 0x00000412,
|
||||
0x0000041c, 0x00000426, 0x0000042b, 0x0000042f,
|
||||
0x00000432, 0x00000455, 0x0000045d, 0x00000465,
|
||||
0x0000048f, 0x000004ad, 0x000004e6, 0x00000510,
|
||||
0x0000051c, 0x00000529, 0x0000054a, 0x0000056a,
|
||||
0x0000059d, 0x000005c2, 0x000005e3,
|
||||
} // Size: 244 bytes
|
||||
|
||||
const enData string = "" + // Size: 1485 bytes
|
||||
const enData string = "" + // Size: 1507 bytes
|
||||
"\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" +
|
||||
" %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" +
|
||||
" this name already exists\x02Saved service %[1]v using backend %[2]v\x02" +
|
||||
|
@ -169,20 +174,20 @@ const enData string = "" + // Size: 1485 bytes
|
|||
"eceived, you can close this window now.\x02backend %[1]s does not implem" +
|
||||
"ent %[2]s\x02unknown backend \x22%[1]s\x22\x02Client ID\x02Client secret" +
|
||||
"\x02Server URL\x02User name\x02Access token\x02File path\x02Append to fi" +
|
||||
"le\x02Playlist title\x02Unique playlist identifier\x02Disable auto corre" +
|
||||
"ction of submitted listens\x02Include skipped listens\x02Visit the URL f" +
|
||||
"or authorization: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a." +
|
||||
"\x02Access token received, you can use %[1]v now.\x02exporting\x02import" +
|
||||
"ing\x02done\x02Yes\x02No\x02no existing service configurations\x02Servic" +
|
||||
"e\x02Backend\x02Transferring %[1]s from %[2]s to %[3]s...\x02From timest" +
|
||||
"amp: %[1]v (%[2]v)\x02Import failed, last reported timestamp was %[1]v (" +
|
||||
"%[2]s)\x02Imported %[1]v of %[2]v %[3]s into %[4]v.\x02Latest timestamp:" +
|
||||
" %[1]v (%[2]v)\x02no configuration file defined, cannot write config\x02" +
|
||||
"key must only consist of A-Za-z0-9_-\x02no service configuration \x22%[1" +
|
||||
"]v\x22\x02Check for duplicate listens on import (slower)\x02Ignored dupl" +
|
||||
"icate listen %[1]v: \x22%[2]v\x22 by %[3]v (%[4]v)\x02Directory path\x02" +
|
||||
"Ignore listens in incognito mode\x02Ignore skipped listens\x02Minimum pl" +
|
||||
"ayback duration for skipped tracks (seconds)\x02Import log:\x02%[1]v: %[" +
|
||||
"2]v\x02invalid timestamp string \x22%[1]v\x22"
|
||||
"le\x02Playlist title\x02Unique playlist identifier\x02Check for duplicat" +
|
||||
"e listens on import (slower)\x02Ignored duplicate listen %[1]v: \x22%[2]" +
|
||||
"v\x22 by %[3]v (%[4]v)\x02Disable auto correction of submitted listens" +
|
||||
"\x02Ignore skipped listens\x02Specify a time zone for the listen timesta" +
|
||||
"mps\x02Directory path\x02Ignore listens in incognito mode\x02Minimum pla" +
|
||||
"yback duration for skipped tracks (seconds)\x02Visit the URL for authori" +
|
||||
"zation: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Access " +
|
||||
"token received, you can use %[1]v now.\x02exporting\x02importing\x02done" +
|
||||
"\x02Yes\x02No\x02no existing service configurations\x02Service\x02Backen" +
|
||||
"d\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestamp: %[1]v (%" +
|
||||
"[2]v)\x02Import failed, last reported timestamp was %[1]v (%[2]s)\x02Imp" +
|
||||
"orted %[1]v of %[2]v %[3]s into %[4]v.\x02Import log:\x02%[1]v: %[2]v" +
|
||||
"\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: %[1]v (%" +
|
||||
"[2]v)\x02no configuration file defined, cannot write config\x02key must " +
|
||||
"only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]v\x22"
|
||||
|
||||
// Total table size 3377 bytes (3KiB); checksum: 6715024
|
||||
// Total table size 3731 bytes (3KiB); checksum: F7951710
|
||||
|
|
|
@ -175,7 +175,7 @@
|
|||
{
|
||||
"id": "backend {Backend} does not implement {InterfaceName}",
|
||||
"message": "backend {Backend} does not implement {InterfaceName}",
|
||||
"translation": "das backend {Backend} implementiert {InterfaceName} nicht",
|
||||
"translation": "das Backend {Backend} implementiert {InterfaceName} nicht",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "Backend",
|
||||
|
@ -261,9 +261,9 @@
|
|||
"translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)"
|
||||
},
|
||||
{
|
||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
||||
"translation": "Listen-Duplikat ignoriert {ListenedAt}: „{TrackName}“ von {ArtistName} ({RecordingMbid})",
|
||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||
"translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "ListenedAt",
|
||||
|
@ -290,12 +290,12 @@
|
|||
"expr": "l.ArtistName()"
|
||||
},
|
||||
{
|
||||
"id": "RecordingMbid",
|
||||
"id": "RecordingMBID",
|
||||
"string": "%[4]v",
|
||||
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
|
||||
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||
"underlyingType": "string",
|
||||
"argNum": 4,
|
||||
"expr": "l.RecordingMbid"
|
||||
"expr": "l.RecordingMBID"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -305,9 +305,14 @@
|
|||
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
|
||||
},
|
||||
{
|
||||
"id": "Include skipped listens",
|
||||
"message": "Include skipped listens",
|
||||
"translation": "Übersprungene Titel einbeziehen"
|
||||
"id": "Ignore skipped listens",
|
||||
"message": "Ignore skipped listens",
|
||||
"translation": "Übersprungene Listens ignorieren"
|
||||
},
|
||||
{
|
||||
"id": "Specify a time zone for the listen timestamps",
|
||||
"message": "Specify a time zone for the listen timestamps",
|
||||
"translation": "Zeitzone für den Abspiel-Zeitstempel"
|
||||
},
|
||||
{
|
||||
"id": "Directory path",
|
||||
|
@ -319,28 +324,23 @@
|
|||
"message": "Ignore listens in incognito mode",
|
||||
"translation": "Listens im Inkognito-Modus ignorieren"
|
||||
},
|
||||
{
|
||||
"id": "Ignore skipped listens",
|
||||
"message": "Ignore skipped listens",
|
||||
"translation": "Übersprungene Listens ignorieren"
|
||||
},
|
||||
{
|
||||
"id": "Minimum playback duration for skipped tracks (seconds)",
|
||||
"message": "Minimum playback duration for skipped tracks (seconds)",
|
||||
"translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)"
|
||||
},
|
||||
{
|
||||
"id": "Visit the URL for authorization: {Url}",
|
||||
"message": "Visit the URL for authorization: {Url}",
|
||||
"translation": "URL für Autorisierung öffnen: {Url}",
|
||||
"id": "Visit the URL for authorization: {URL}",
|
||||
"message": "Visit the URL for authorization: {URL}",
|
||||
"translation": "Zur Anmeldung folgende URL aufrufen: {URL}",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "Url",
|
||||
"id": "URL",
|
||||
"string": "%[1]v",
|
||||
"type": "string",
|
||||
"underlyingType": "string",
|
||||
"argNum": 1,
|
||||
"expr": "authUrl.Url"
|
||||
"expr": "authURL.URL"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -411,9 +411,9 @@
|
|||
"translation": "Backend"
|
||||
},
|
||||
{
|
||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
||||
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...",
|
||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}…",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "Entity",
|
||||
|
|
|
@ -175,7 +175,7 @@
|
|||
{
|
||||
"id": "backend {Backend} does not implement {InterfaceName}",
|
||||
"message": "backend {Backend} does not implement {InterfaceName}",
|
||||
"translation": "das backend {Backend} implementiert {InterfaceName} nicht",
|
||||
"translation": "das Backend {Backend} implementiert {InterfaceName} nicht",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "Backend",
|
||||
|
@ -258,12 +258,12 @@
|
|||
{
|
||||
"id": "Check for duplicate listens on import (slower)",
|
||||
"message": "Check for duplicate listens on import (slower)",
|
||||
"translation": ""
|
||||
"translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)"
|
||||
},
|
||||
{
|
||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
||||
"translation": "",
|
||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||
"translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "ListenedAt",
|
||||
|
@ -290,12 +290,12 @@
|
|||
"expr": "l.ArtistName()"
|
||||
},
|
||||
{
|
||||
"id": "RecordingMbid",
|
||||
"id": "RecordingMBID",
|
||||
"string": "%[4]v",
|
||||
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
|
||||
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||
"underlyingType": "string",
|
||||
"argNum": 4,
|
||||
"expr": "l.RecordingMbid"
|
||||
"expr": "l.RecordingMBID"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -305,42 +305,42 @@
|
|||
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
|
||||
},
|
||||
{
|
||||
"id": "Include skipped listens",
|
||||
"message": "Include skipped listens",
|
||||
"translation": "Übersprungene Titel einbeziehen"
|
||||
"id": "Ignore skipped listens",
|
||||
"message": "Ignore skipped listens",
|
||||
"translation": "Übersprungene Listens ignorieren"
|
||||
},
|
||||
{
|
||||
"id": "Specify a time zone for the listen timestamps",
|
||||
"message": "Specify a time zone for the listen timestamps",
|
||||
"translation": "Zeitzone für den Abspiel-Zeitstempel"
|
||||
},
|
||||
{
|
||||
"id": "Directory path",
|
||||
"message": "Directory path",
|
||||
"translation": ""
|
||||
"translation": "Verzeichnispfad"
|
||||
},
|
||||
{
|
||||
"id": "Ignore listens in incognito mode",
|
||||
"message": "Ignore listens in incognito mode",
|
||||
"translation": ""
|
||||
},
|
||||
{
|
||||
"id": "Ignore skipped listens",
|
||||
"message": "Ignore skipped listens",
|
||||
"translation": ""
|
||||
"translation": "Listens im Inkognito-Modus ignorieren"
|
||||
},
|
||||
{
|
||||
"id": "Minimum playback duration for skipped tracks (seconds)",
|
||||
"message": "Minimum playback duration for skipped tracks (seconds)",
|
||||
"translation": ""
|
||||
"translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)"
|
||||
},
|
||||
{
|
||||
"id": "Visit the URL for authorization: {Url}",
|
||||
"message": "Visit the URL for authorization: {Url}",
|
||||
"translation": "URL für Autorisierung öffnen: {Url}",
|
||||
"id": "Visit the URL for authorization: {URL}",
|
||||
"message": "Visit the URL for authorization: {URL}",
|
||||
"translation": "Zur Anmeldung folgende URL aufrufen: {URL}",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "Url",
|
||||
"id": "URL",
|
||||
"string": "%[1]v",
|
||||
"type": "string",
|
||||
"underlyingType": "string",
|
||||
"argNum": 1,
|
||||
"expr": "authUrl.Url"
|
||||
"expr": "authURL.URL"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -411,9 +411,9 @@
|
|||
"translation": "Backend"
|
||||
},
|
||||
{
|
||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
||||
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...",
|
||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}…",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "Entity",
|
||||
|
@ -525,12 +525,12 @@
|
|||
{
|
||||
"id": "Import log:",
|
||||
"message": "Import log:",
|
||||
"translation": ""
|
||||
"translation": "Importlog:"
|
||||
},
|
||||
{
|
||||
"id": "{Type}: {Message}",
|
||||
"message": "{Type}: {Message}",
|
||||
"translation": "",
|
||||
"translation": "{Type}: {Message}",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "Type",
|
||||
|
@ -553,7 +553,7 @@
|
|||
{
|
||||
"id": "invalid timestamp string \"{FlagValue}\"",
|
||||
"message": "invalid timestamp string \"{FlagValue}\"",
|
||||
"translation": "",
|
||||
"translation": "ungültiger Zeitstempel „{FlagValue}“",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "FlagValue",
|
||||
|
|
|
@ -311,9 +311,9 @@
|
|||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
||||
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||
"translatorComment": "Copied from source.",
|
||||
"placeholders": [
|
||||
{
|
||||
|
@ -341,12 +341,12 @@
|
|||
"expr": "l.ArtistName()"
|
||||
},
|
||||
{
|
||||
"id": "RecordingMbid",
|
||||
"id": "RecordingMBID",
|
||||
"string": "%[4]v",
|
||||
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
|
||||
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||
"underlyingType": "string",
|
||||
"argNum": 4,
|
||||
"expr": "l.RecordingMbid"
|
||||
"expr": "l.RecordingMBID"
|
||||
}
|
||||
],
|
||||
"fuzzy": true
|
||||
|
@ -359,9 +359,16 @@
|
|||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Include skipped listens",
|
||||
"message": "Include skipped listens",
|
||||
"translation": "Include skipped listens",
|
||||
"id": "Ignore skipped listens",
|
||||
"message": "Ignore skipped listens",
|
||||
"translation": "Ignore skipped listens",
|
||||
"translatorComment": "Copied from source.",
|
||||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Specify a time zone for the listen timestamps",
|
||||
"message": "Specify a time zone for the listen timestamps",
|
||||
"translation": "Specify a time zone for the listen timestamps",
|
||||
"translatorComment": "Copied from source.",
|
||||
"fuzzy": true
|
||||
},
|
||||
|
@ -379,13 +386,6 @@
|
|||
"translatorComment": "Copied from source.",
|
||||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Ignore skipped listens",
|
||||
"message": "Ignore skipped listens",
|
||||
"translation": "Ignore skipped listens",
|
||||
"translatorComment": "Copied from source.",
|
||||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Minimum playback duration for skipped tracks (seconds)",
|
||||
"message": "Minimum playback duration for skipped tracks (seconds)",
|
||||
|
@ -394,18 +394,18 @@
|
|||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Visit the URL for authorization: {Url}",
|
||||
"message": "Visit the URL for authorization: {Url}",
|
||||
"translation": "Visit the URL for authorization: {Url}",
|
||||
"id": "Visit the URL for authorization: {URL}",
|
||||
"message": "Visit the URL for authorization: {URL}",
|
||||
"translation": "Visit the URL for authorization: {URL}",
|
||||
"translatorComment": "Copied from source.",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "Url",
|
||||
"id": "URL",
|
||||
"string": "%[1]v",
|
||||
"type": "string",
|
||||
"underlyingType": "string",
|
||||
"argNum": 1,
|
||||
"expr": "authUrl.Url"
|
||||
"expr": "authURL.URL"
|
||||
}
|
||||
],
|
||||
"fuzzy": true
|
||||
|
@ -491,9 +491,9 @@
|
|||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
||||
"translation": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||
"translation": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||
"translatorComment": "Copied from source.",
|
||||
"placeholders": [
|
||||
{
|
||||
|
|
|
@ -311,9 +311,9 @@
|
|||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
||||
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
|
||||
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||
"translatorComment": "Copied from source.",
|
||||
"placeholders": [
|
||||
{
|
||||
|
@ -341,12 +341,12 @@
|
|||
"expr": "l.ArtistName()"
|
||||
},
|
||||
{
|
||||
"id": "RecordingMbid",
|
||||
"id": "RecordingMBID",
|
||||
"string": "%[4]v",
|
||||
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
|
||||
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||
"underlyingType": "string",
|
||||
"argNum": 4,
|
||||
"expr": "l.RecordingMbid"
|
||||
"expr": "l.RecordingMBID"
|
||||
}
|
||||
],
|
||||
"fuzzy": true
|
||||
|
@ -359,9 +359,16 @@
|
|||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Include skipped listens",
|
||||
"message": "Include skipped listens",
|
||||
"translation": "Include skipped listens",
|
||||
"id": "Ignore skipped listens",
|
||||
"message": "Ignore skipped listens",
|
||||
"translation": "Ignore skipped listens",
|
||||
"translatorComment": "Copied from source.",
|
||||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Specify a time zone for the listen timestamps",
|
||||
"message": "Specify a time zone for the listen timestamps",
|
||||
"translation": "Specify a time zone for the listen timestamps",
|
||||
"translatorComment": "Copied from source.",
|
||||
"fuzzy": true
|
||||
},
|
||||
|
@ -379,13 +386,6 @@
|
|||
"translatorComment": "Copied from source.",
|
||||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Ignore skipped listens",
|
||||
"message": "Ignore skipped listens",
|
||||
"translation": "Ignore skipped listens",
|
||||
"translatorComment": "Copied from source.",
|
||||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Minimum playback duration for skipped tracks (seconds)",
|
||||
"message": "Minimum playback duration for skipped tracks (seconds)",
|
||||
|
@ -394,18 +394,18 @@
|
|||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Visit the URL for authorization: {Url}",
|
||||
"message": "Visit the URL for authorization: {Url}",
|
||||
"translation": "Visit the URL for authorization: {Url}",
|
||||
"id": "Visit the URL for authorization: {URL}",
|
||||
"message": "Visit the URL for authorization: {URL}",
|
||||
"translation": "Visit the URL for authorization: {URL}",
|
||||
"translatorComment": "Copied from source.",
|
||||
"placeholders": [
|
||||
{
|
||||
"id": "Url",
|
||||
"id": "URL",
|
||||
"string": "%[1]v",
|
||||
"type": "string",
|
||||
"underlyingType": "string",
|
||||
"argNum": 1,
|
||||
"expr": "authUrl.Url"
|
||||
"expr": "authURL.URL"
|
||||
}
|
||||
],
|
||||
"fuzzy": true
|
||||
|
@ -491,9 +491,9 @@
|
|||
"fuzzy": true
|
||||
},
|
||||
{
|
||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
||||
"translation": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||
"translation": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||
"translatorComment": "Copied from source.",
|
||||
"placeholders": [
|
||||
{
|
||||
|
|
|
@ -6,4 +6,4 @@ package are published under the conditions of CC0 1.0 Universal (CC0 1.0)
|
|||
|
||||
package translations
|
||||
|
||||
//go:generate gotext -srclang=en update -out=catalog.go -lang=en,de go.uploadedlobster.com/scotty
|
||||
//go:generate go tool gotext -srclang=en update -out=catalog.go -lang=en,de go.uploadedlobster.com/scotty
|
||||
|
|
|
@ -17,22 +17,6 @@ package util
|
|||
|
||||
import "golang.org/x/exp/constraints"
|
||||
|
||||
func Max[T constraints.Ordered](m, n T) T {
|
||||
if n > m {
|
||||
return n
|
||||
} else {
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
func Min[T constraints.Ordered](m, n T) T {
|
||||
if n < m {
|
||||
return n
|
||||
} else {
|
||||
return m
|
||||
}
|
||||
}
|
||||
|
||||
func Sum[T constraints.Integer | constraints.Float](v ...T) T {
|
||||
var sum T
|
||||
for _, i := range v {
|
||||
|
|
|
@ -23,18 +23,6 @@ import (
|
|||
"go.uploadedlobster.com/scotty/internal/util"
|
||||
)
|
||||
|
||||
func ExampleMax() {
|
||||
v := util.Max(2, 5)
|
||||
fmt.Print(v)
|
||||
// Output: 5
|
||||
}
|
||||
|
||||
func ExampleMin() {
|
||||
v := util.Min(2, 5)
|
||||
fmt.Print(v)
|
||||
// Output: 2
|
||||
}
|
||||
|
||||
func ExampleSum() {
|
||||
values := []float64{1.4, 2.2}
|
||||
sum := util.Sum(values...)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright © 2023-2024 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
|
||||
|
||||
Scotty is free software: you can redistribute it and/or modify it under the
|
||||
terms of the GNU General Public License as published by the Free Software
|
||||
|
@ -17,7 +17,8 @@ package version
|
|||
|
||||
const (
|
||||
AppName = "scotty"
|
||||
AppVersion = "0.4.0"
|
||||
AppVersion = "0.5.0"
|
||||
AppURL = "https://git.sr.ht/~phw/scotty/"
|
||||
)
|
||||
|
||||
func UserAgent() string {
|
||||
|
|
|
@ -26,9 +26,9 @@ import "time"
|
|||
|
||||
const (
|
||||
// The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension
|
||||
MusicBrainzPlaylistExtensionId = "https://musicbrainz.org/doc/jspf#playlist"
|
||||
MusicBrainzPlaylistExtensionID = "https://musicbrainz.org/doc/jspf#playlist"
|
||||
// The identifier for the MusicBrainz / ListenBrainz JSPF track extension
|
||||
MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track"
|
||||
MusicBrainzTrackExtensionID = "https://musicbrainz.org/doc/jspf#track"
|
||||
)
|
||||
|
||||
// MusicBrainz / ListenBrainz JSPF track extension
|
||||
|
|
|
@ -39,7 +39,7 @@ func ExampleMusicBrainzTrackExtension() {
|
|||
{
|
||||
Title: "Oweynagat",
|
||||
Extension: map[string]any{
|
||||
jspf.MusicBrainzTrackExtensionId: jspf.MusicBrainzTrackExtension{
|
||||
jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{
|
||||
AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC),
|
||||
AddedBy: "scotty",
|
||||
},
|
||||
|
|
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/require"
|
||||
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
|
||||
"go.uploadedlobster.com/scotty/internal/models"
|
||||
"go.uploadedlobster.com/mbtypes"
|
||||
"go.uploadedlobster.com/scotty/pkg/scrobblerlog"
|
||||
)
|
||||
|
||||
var testScrobblerLog = `#AUDIOSCROBBLER/1.1
|
||||
|
@ -47,67 +47,84 @@ Teeth Agency You Don't Have To Live In Pain Wolfs Jam 2 107 L 1260359404 1262bea
|
|||
func TestParser(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
data := bytes.NewBufferString(testScrobblerLog)
|
||||
result, err := scrobblerlog.Parse(data, true)
|
||||
result := scrobblerlog.ScrobblerLog{}
|
||||
err := result.Parse(data, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal("UNKNOWN", result.Timezone)
|
||||
assert.Equal(scrobblerlog.TimezoneUnknown, result.TZ)
|
||||
assert.Equal("Rockbox sansaclipplus $Revision$", result.Client)
|
||||
assert.Len(result.Listens, 5)
|
||||
listen1 := result.Listens[0]
|
||||
assert.Equal("Özcan Deniz", listen1.ArtistName())
|
||||
assert.Equal("Ses ve Ayrilik", listen1.ReleaseName)
|
||||
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", listen1.TrackName)
|
||||
assert.Equal(5, listen1.TrackNumber)
|
||||
assert.Equal(time.Duration(306*time.Second), listen1.Duration)
|
||||
assert.Equal("L", listen1.AdditionalInfo["rockbox_rating"])
|
||||
assert.Equal(time.Unix(1260342084, 0), listen1.ListenedAt)
|
||||
assert.Equal(models.MBID(""), listen1.RecordingMbid)
|
||||
listen4 := result.Listens[3]
|
||||
assert.Equal("S", listen4.AdditionalInfo["rockbox_rating"])
|
||||
assert.Equal(models.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), listen4.RecordingMbid)
|
||||
assert.Len(result.Records, 5)
|
||||
record1 := result.Records[0]
|
||||
assert.Equal("Özcan Deniz", record1.ArtistName)
|
||||
assert.Equal("Ses ve Ayrilik", record1.AlbumName)
|
||||
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", record1.TrackName)
|
||||
assert.Equal(5, record1.TrackNumber)
|
||||
assert.Equal(time.Duration(306*time.Second), record1.Duration)
|
||||
assert.Equal(scrobblerlog.RatingListened, record1.Rating)
|
||||
assert.Equal(time.Unix(1260342084, 0), record1.Timestamp)
|
||||
assert.Equal(mbtypes.MBID(""), record1.MusicBrainzRecordingID)
|
||||
record4 := result.Records[3]
|
||||
assert.Equal(scrobblerlog.RatingSkipped, record4.Rating)
|
||||
assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"),
|
||||
record4.MusicBrainzRecordingID)
|
||||
}
|
||||
|
||||
func TestParserExcludeSkipped(t *testing.T) {
|
||||
func TestParserIgnoreSkipped(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
data := bytes.NewBufferString(testScrobblerLog)
|
||||
result, err := scrobblerlog.Parse(data, false)
|
||||
result := scrobblerlog.ScrobblerLog{}
|
||||
err := result.Parse(data, true)
|
||||
require.NoError(t, err)
|
||||
assert.Len(result.Listens, 4)
|
||||
listen4 := result.Listens[3]
|
||||
assert.Equal("L", listen4.AdditionalInfo["rockbox_rating"])
|
||||
assert.Equal(models.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMbid)
|
||||
assert.Len(result.Records, 4)
|
||||
record4 := result.Records[3]
|
||||
assert.Equal(scrobblerlog.RatingListened, record4.Rating)
|
||||
assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"),
|
||||
record4.MusicBrainzRecordingID)
|
||||
}
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
func TestParserFallbackTimezone(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
data := bytes.NewBufferString(testScrobblerLog)
|
||||
result := scrobblerlog.ScrobblerLog{
|
||||
FallbackTimezone: time.FixedZone("UTC+2", 7200),
|
||||
}
|
||||
err := result.Parse(data, false)
|
||||
require.NoError(t, err)
|
||||
record1 := result.Records[0]
|
||||
assert.Equal(
|
||||
time.Unix(1260342084, 0).Add(2*time.Hour),
|
||||
record1.Timestamp,
|
||||
)
|
||||
}
|
||||
|
||||
func TestAppend(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
data := make([]byte, 0, 10)
|
||||
buffer := bytes.NewBuffer(data)
|
||||
log := scrobblerlog.ScrobblerLog{
|
||||
Timezone: "Unknown",
|
||||
Client: "Rockbox foo $Revision$",
|
||||
Listens: []models.Listen{
|
||||
{
|
||||
ListenedAt: time.Unix(1699572072, 0),
|
||||
Track: models.Track{
|
||||
ArtistNames: []string{"Prinzhorn Dance School"},
|
||||
ReleaseName: "Home Economics",
|
||||
TrackName: "Reign",
|
||||
TrackNumber: 1,
|
||||
Duration: 271 * time.Second,
|
||||
RecordingMbid: models.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
|
||||
AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"},
|
||||
},
|
||||
},
|
||||
TZ: scrobblerlog.TimezoneUnknown,
|
||||
Client: "Rockbox foo $Revision$",
|
||||
}
|
||||
records := []scrobblerlog.Record{
|
||||
{
|
||||
ArtistName: "Prinzhorn Dance School",
|
||||
AlbumName: "Home Economics",
|
||||
TrackName: "Reign",
|
||||
TrackNumber: 1,
|
||||
Duration: 271 * time.Second,
|
||||
Rating: scrobblerlog.RatingListened,
|
||||
Timestamp: time.Unix(1699572072, 0),
|
||||
MusicBrainzRecordingID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
|
||||
},
|
||||
}
|
||||
err := scrobblerlog.WriteHeader(buffer, &log)
|
||||
err := log.WriteHeader(buffer)
|
||||
require.NoError(t, err)
|
||||
lastTimestamp, err := scrobblerlog.Write(buffer, log.Listens)
|
||||
lastTimestamp, err := log.Append(buffer, records)
|
||||
require.NoError(t, err)
|
||||
result := buffer.String()
|
||||
lines := strings.Split(result, "\n")
|
||||
assert.Equal(5, len(lines))
|
||||
assert.Equal("#AUDIOSCROBBLER/1.1", lines[0])
|
||||
assert.Equal("#TZ/Unknown", lines[1])
|
||||
assert.Equal("#TZ/UNKNOWN", lines[1])
|
||||
assert.Equal("#CLIENT/Rockbox foo $Revision$", lines[2])
|
||||
assert.Equal(
|
||||
"Prinzhorn Dance School\tHome Economics\tReign\t1\t271\tL\t1699572072\tb59cf4e7-caee-4019-a844-79d2c58d4dff",
|
||||
|
@ -120,9 +137,9 @@ func TestReadHeader(t *testing.T) {
|
|||
data := bytes.NewBufferString(testScrobblerLog)
|
||||
reader := bufio.NewReader(data)
|
||||
log := scrobblerlog.ScrobblerLog{}
|
||||
err := scrobblerlog.ReadHeader(reader, &log)
|
||||
err := log.ReadHeader(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, log.Timezone, "UNKNOWN")
|
||||
assert.Equal(t, log.TZ, scrobblerlog.TimezoneUnknown)
|
||||
assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$")
|
||||
assert.Empty(t, log.Listens)
|
||||
assert.Empty(t, log.Records)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue