1
0
Fork 0
mirror of https://git.sr.ht/~phw/scotty synced 2025-04-29 21:27:05 +02:00

Compare commits

..

52 commits
v0.4.0 ... main

Author SHA1 Message Date
Philipp Wolfer
0a411fe2fa
If locale detection fails fall back to English 2025-04-29 17:25:10 +02:00
Philipp Wolfer
1e91b684cb
Release 0.5.0 2025-04-29 16:16:43 +02:00
Philipp Wolfer
19852be68b
Updated translations 2025-04-29 16:12:42 +02:00
Philipp Wolfer
a6cc8d49ac Translated using Weblate (German)
Currently translated at 100.0% (54 of 54 strings)

Co-authored-by: Philipp Wolfer <phw@uploadedlobster.com>
Translate-URL: https://translate.uploadedlobster.com/projects/scotty/app/de/
Translation: Scotty/app
2025-04-29 14:11:31 +00:00
Philipp Wolfer
a5442b477e
Sync translations with new strings 2025-04-29 16:06:13 +02:00
Philipp Wolfer
90e101080f Translated using Weblate (German)
Currently translated at 100.0% (54 of 54 strings)

Co-authored-by: Philipp Wolfer <phw@uploadedlobster.com>
Translate-URL: https://translate.uploadedlobster.com/projects/scotty/app/de/
Translation: Scotty/app
2025-04-29 13:51:09 +00:00
Philipp Wolfer
dff34b249c
Updated translation files 2025-04-29 15:46:14 +02:00
Philipp Wolfer
bcb1834994
scrobblerlog: use camelcase for constants 2025-04-29 13:29:00 +02:00
Philipp Wolfer
d51c97c648
Code style: All uppercase acronyms URL, ISRC, ID, HTTP 2025-04-29 13:23:41 +02:00
Philipp Wolfer
39b31fc664
Update changelog 2025-04-29 13:01:54 +02:00
Philipp Wolfer
1516a3a9d6
scrobblerlog: renamed setting include-skipped to ignore-skipped
This makes the setting consistent with the similar setting for spotify
2025-04-29 12:57:28 +02:00
Philipp Wolfer
82858315fa
Disable Linux 386 builds
Compilaton fails with latest gorm
2025-04-29 11:44:04 +02:00
Philipp Wolfer
e135ea5fa9
Update goreleaser config file format 2025-04-29 11:43:42 +02:00
Philipp Wolfer
597914e6db
Announce new releases to Go Module Index 2025-04-29 11:15:49 +02:00
Philipp Wolfer
c817480809
Updated Weblate CI secret and fixed build 2025-04-29 11:12:28 +02:00
Philipp Wolfer
47486ff659
Update weblate configuration 2025-04-29 11:05:37 +02:00
Philipp Wolfer
159f486cdc
Upgrade musicbrainzws2 2025-04-29 10:32:59 +02:00
Philipp Wolfer
b104c2bc42
scrobblerlog: fixed listen export progress 2025-04-29 10:10:32 +02:00
Philipp Wolfer
ed191d2f15
scrobblerlog: Allow configuring fallback time zone
Fixes 
2025-04-29 10:05:40 +02:00
Philipp Wolfer
0f4b04c641
Renamed Backend.FromConfig to Backend.InitConfig and added error handling 2025-04-29 10:03:28 +02:00
Philipp Wolfer
aad542850a
scrobblerlog: Use specific Record type
This makes the interface more generic and easier to reuse in other
projects.
2025-04-29 09:18:57 +02:00
Philipp Wolfer
aeb3a56982
Moved scrobblerlog parsing to separate package 2025-04-29 08:44:31 +02:00
Philipp Wolfer
69665bc286
scrobblerlog: consider timezone from parsed file 2025-04-29 07:54:27 +02:00
Philipp Wolfer
9184d2c3cf
Update changelog for next version 2025-04-28 08:09:36 +02:00
Philipp Wolfer
4a30bdf9d9
Update go.mod 2025-04-28 08:03:33 +02:00
Philipp Wolfer
91f78d04dd
ListenBrainz: Handle missing loves metadata for merged recordings
If a loved recording MBID got merged into another recording, the love on
ListenBrainz has no metadata. Lookup the metadata directly from
MusicBrainz.
2025-04-27 18:59:26 +02:00
Philipp Wolfer
9e1c2d8435
Remove github.com/delucks/go-subsonic from go.mod 2025-04-27 18:17:19 +02:00
Philipp Wolfer
db78bfe457
Fixed subsonic test 2025-04-27 18:16:27 +02:00
Philipp Wolfer
20c9ada6ec
RecordingMsid -> RecordingMSID 2025-04-27 18:14:48 +02:00
Philipp Wolfer
7c0774fb8d
ListenBrainz: Fixed loves export 2025-04-27 18:11:58 +02:00
Philipp Wolfer
90bf51a00b
ListenBrainz: Log missing recording MBID on love import 2025-04-27 17:54:29 +02:00
Philipp Wolfer
910056b0a6
Subsonic: Support for some OpenSubsonic tags
Mainly this makes the MusicBrainz recording ID available
2025-04-27 17:53:51 +02:00
Philipp Wolfer
bed60c7cdf
Update dependencies 2025-04-27 17:22:29 +02:00
Philipp Wolfer
2d66d41873
ListenBrainz: Fix love import progress
Exporting existing loves must not mark the progress as completed.
2025-04-27 16:57:30 +02:00
Philipp Wolfer
da6c920789
ListenBrainz: Fix loves import loading all existing loves
Fixes import if the user had more than 1000 loves already
2025-04-27 16:57:13 +02:00
Philipp Wolfer
01e7569051
Fixed progress for subsonic loves export 2025-04-27 16:21:59 +02:00
Philipp Wolfer
1ea90d2d2b Update translation files 2025-04-09 22:31:34 +02:00
Philipp Wolfer
329f696b55 Manage gotext as a tool with go.mod 2025-04-09 22:30:06 +02:00
Philipp Wolfer
5f9c0f24ab Updated dependencies 2025-04-09 22:11:59 +02:00
Philipp Wolfer
dc834e9b6f
update dependencies 2025-04-07 08:46:46 +02:00
Philipp Wolfer
0d9bc74bc0
More conversion to mbtypes.MBID 2025-04-03 15:19:26 +02:00
Philipp Wolfer
13eb8342ab
Use mbtypes.ISRC type 2025-04-03 15:08:02 +02:00
Philipp Wolfer
ad1644672c
Write acronym MBID all uppercase 2025-04-03 15:00:45 +02:00
Philipp Wolfer
8fff19ceac
Use MBID type from go.uploadedlobster.com/mbtypes 2025-04-03 14:56:39 +02:00
Philipp Wolfer
04eddfda33
Release 0.4.1 2024-09-16 19:07:07 +02:00
Philipp Wolfer
1c1ce224f7
Update dependencies 2024-09-16 19:02:14 +02:00
Philipp Wolfer
7175d3453d
Fix go version definition in go.mod 2024-09-16 19:00:24 +02:00
Philipp Wolfer
cdf20728ae
Update and tidy dependencies 2024-04-15 15:47:55 +02:00
Philipp Wolfer
bcc7bf3167
Replaced util Min/Max functions with builtin 2024-04-15 15:47:16 +02:00
Philipp Wolfer
357932f9b0
Use resty response.IsSuccess() instead of checking for status code 200 2024-03-24 16:36:53 +01:00
Philipp Wolfer
3f1bebd8ed
deezer: fix artist and album ID URIs
Fixes 
2024-01-29 08:27:51 +01:00
Philipp Wolfer
1aa7b61649
subsonic: include subsonic_id as additional metadata 2024-01-26 12:21:11 +01:00
69 changed files with 1373 additions and 1097 deletions

View file

@ -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

View file

@ -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
View file

@ -0,0 +1,3 @@
[weblate]
url = https://translate.uploadedlobster.com/api/
translation = scotty/app

View file

@ -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

View file

@ -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
View file

@ -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
View file

@ -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=

View file

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

View file

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

View file

@ -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) {

View file

@ -33,10 +33,10 @@ func (s deezerStrategy) Config() oauth2.Config {
return s.conf
}
func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl {
func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL {
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
return auth.AuthUrl{
Url: url,
return auth.AuthURL{
URL: url,
State: state,
Param: "code",
}

View file

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

View file

@ -44,7 +44,7 @@ func TestGetUserHistory(t *testing.T) {
token := oauth2.StaticTokenSource(&oauth2.Token{})
client := deezer.NewClient(token)
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.deezer.com/user/me/history",
"testdata/user-history.json")
@ -65,7 +65,7 @@ func TestGetUserTracks(t *testing.T) {
token := oauth2.StaticTokenSource(&oauth2.Token{})
client := deezer.NewClient(token)
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.deezer.com/user/me/tracks",
"testdata/user-tracks.json")
@ -81,7 +81,7 @@ func TestGetUserTracks(t *testing.T) {
assert.Equal("Outland", track1.Track.Album.Title)
}
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
httpmock.ActivateNonDefault(client)
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))

View file

@ -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
}

View file

@ -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) {

View file

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

View file

@ -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)
}

View file

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

View file

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

View file

@ -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,

View file

@ -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"])
}

View file

@ -21,6 +21,8 @@ THE SOFTWARE.
*/
package funkwhale
import "go.uploadedlobster.com/mbtypes"
type ListeningsResult struct {
Count int `json:"count"`
Previous string `json:"previous"`
@ -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"`
}

View file

@ -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

View file

@ -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)
}

View file

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

View file

@ -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,
},

View file

@ -39,7 +39,7 @@ const (
)
type Client struct {
HttpClient *resty.Client
HTTPClient *resty.Client
MaxResults int
}
@ -55,7 +55,7 @@ func NewClient(token string) Client {
ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In")
return Client{
HttpClient: client,
HTTPClient: client,
MaxResults: DefaultItemsPerGet,
}
}
@ -63,7 +63,7 @@ func NewClient(token string) Client {
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
const path = "/user/{username}/listens"
errorResult := ErrorResult{}
response, err := c.HttpClient.R().
response, err := c.HTTPClient.R().
SetPathParam("username", user).
SetQueryParams(map[string]string{
"max_ts": strconv.FormatInt(maxTime.Unix(), 10),
@ -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
}

View file

@ -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))

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
)
@ -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)

View file

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

View file

@ -32,19 +32,19 @@ import (
)
func TestNewClient(t *testing.T) {
serverUrl := "https://maloja.example.com"
serverURL := "https://maloja.example.com"
token := "foobar123"
client := maloja.NewClient(serverUrl, token)
assert.Equal(t, serverUrl, client.HttpClient.BaseURL)
client := maloja.NewClient(serverURL, token)
assert.Equal(t, serverURL, client.HTTPClient.BaseURL)
}
func TestGetScrobbles(t *testing.T) {
defer httpmock.DeactivateAndReset()
serverUrl := "https://maloja.example.com"
serverURL := "https://maloja.example.com"
token := "thetoken"
client := maloja.NewClient(serverUrl, token)
setupHttpMock(t, client.HttpClient.GetClient(),
client := maloja.NewClient(serverURL, token)
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://maloja.example.com/apis/mlj_1/scrobbles",
"testdata/scrobbles.json")
@ -60,7 +60,7 @@ func TestGetScrobbles(t *testing.T) {
func TestNewScrobble(t *testing.T) {
server := "https://maloja.example.com"
client := maloja.NewClient(server, "thetoken")
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json"))
if err != nil {
@ -80,7 +80,7 @@ func TestNewScrobble(t *testing.T) {
assert.Equal(t, "success", result.Status)
}
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
httpmock.ActivateNonDefault(client)
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))

View file

@ -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 }

View file

@ -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) {

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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"`)
}

View file

@ -40,7 +40,7 @@ const (
)
type Client struct {
HttpClient *resty.Client
HTTPClient *resty.Client
}
func NewClient(token oauth2.TokenSource) Client {
@ -55,7 +55,7 @@ func NewClient(token oauth2.TokenSource) Client {
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
return Client{
HttpClient: client,
HTTPClient: client,
}
}
@ -69,7 +69,7 @@ func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlaye
func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) {
const path = "/me/player/recently-played"
request := c.HttpClient.R().
request := c.HTTPClient.R().
SetQueryParam("limit", strconv.Itoa(limit)).
SetResult(&result)
if after != nil {
@ -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

View file

@ -43,7 +43,7 @@ func TestRecentlyPlayedAfter(t *testing.T) {
defer httpmock.DeactivateAndReset()
client := spotify.NewClient(nil)
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.spotify.com/v1/me/player/recently-played",
"testdata/recently-played.json")
@ -63,7 +63,7 @@ func TestGetUserTracks(t *testing.T) {
defer httpmock.DeactivateAndReset()
client := spotify.NewClient(nil)
setupHttpMock(t, client.HttpClient.GetClient(),
setupHTTPMock(t, client.HTTPClient.GetClient(),
"https://api.spotify.com/v1/me/tracks",
"testdata/user-tracks.json")
@ -79,7 +79,7 @@ func TestGetUserTracks(t *testing.T) {
assert.Equal("Zeal & Ardor", track1.Track.Album.Name)
}
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
httpmock.ActivateNonDefault(client)
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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"])

View file

@ -92,8 +92,8 @@ func (i HistoryItem) AsListen() models.Listen {
PlaybackDuration: time.Duration(i.MillisecondsPlayed * int(time.Millisecond)),
UserName: i.UserName,
}
if trackUrl, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil {
listen.AdditionalInfo["spotify_id"] = trackUrl
if trackURL, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil {
listen.AdditionalInfo["spotify_id"] = trackURL
}
return listen
}

View file

@ -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) {

View file

@ -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}
}

View file

@ -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"])
}

View file

@ -43,20 +43,20 @@ func AuthenticationFlow(service config.ServiceConfig, backend auth.OAuth2Authent
state := auth.RandomState()
// Redirect user to consent page to ask for permission specified scopes.
authUrl := strategy.AuthCodeURL(verifier, state)
authURL := strategy.AuthCodeURL(verifier, state)
// Start an HTTP server to listen for the response
responseChan := make(chan auth.CodeResponse)
auth.RunOauth2CallbackServer(*redirectURL, authUrl.Param, responseChan)
auth.RunOauth2CallbackServer(*redirectURL, authURL.Param, responseChan)
// Open the URL
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authUrl.Url))
err = browser.OpenURL(authUrl.Url)
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authURL.URL))
err = browser.OpenURL(authURL.URL)
cobra.CheckErr(err)
// Retrieve the code from the authentication callback
code := <-responseChan
if code.State != authUrl.State {
if code.State != authURL.State {
cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch"))
os.Exit(1)
}

View file

@ -88,7 +88,7 @@ func (c *TransferCmd[E, I, R]) resolveBackends(source string, target string) err
}
func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp backends.ImportProcessor[R]) error {
fmt.Println(i18n.Tr("Transferring %s from %s to %s...", c.entity, c.sourceName, c.targetName))
fmt.Println(i18n.Tr("Transferring %s from %s to %s", c.entity, c.sourceName, c.targetName))
// Authenticate backends, if needed
config := viper.GetViper()

View file

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

View file

@ -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

View file

@ -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

View file

@ -28,6 +28,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/models"
)
@ -44,25 +45,25 @@ func TestTrackArtistName(t *testing.T) {
func TestTrackFillAdditionalInfo(t *testing.T) {
track := models.Track{
RecordingMbid: models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
ReleaseGroupMbid: models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
ReleaseMbid: models.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
ArtistMbids: []models.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
WorkMbids: []models.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"},
RecordingMBID: mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
ReleaseGroupMBID: mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
ReleaseMBID: mbtypes.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
ArtistMBIDs: []mbtypes.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
WorkMBIDs: []mbtypes.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"},
TrackNumber: 5,
DiscNumber: 1,
Duration: time.Duration(413787 * time.Millisecond),
ISRC: "DES561620801",
ISRC: mbtypes.ISRC("DES561620801"),
Tags: []string{"rock", "psychedelic rock"},
}
track.FillAdditionalInfo()
i := track.AdditionalInfo
assert := assert.New(t)
assert.Equal(track.RecordingMbid, i["recording_mbid"])
assert.Equal(track.ReleaseGroupMbid, i["release_group_mbid"])
assert.Equal(track.ReleaseMbid, i["release_mbid"])
assert.Equal(track.ArtistMbids, i["artist_mbids"])
assert.Equal(track.WorkMbids, i["work_mbids"])
assert.Equal(track.RecordingMBID, i["recording_mbid"])
assert.Equal(track.ReleaseGroupMBID, i["release_group_mbid"])
assert.Equal(track.ReleaseMBID, i["release_mbid"])
assert.Equal(track.ArtistMBIDs, i["artist_mbids"])
assert.Equal(track.WorkMBIDs, i["work_mbids"])
assert.Equal(track.TrackNumber, i["tracknumber"])
assert.Equal(track.DiscNumber, i["discnumber"])
assert.Equal(track.Duration.Milliseconds(), i["duration_ms"])

View file

@ -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
}

View file

@ -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))
}

View file

@ -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

View file

@ -175,7 +175,7 @@
{
"id": "backend {Backend} does not implement {InterfaceName}",
"message": "backend {Backend} does not implement {InterfaceName}",
"translation": "das backend {Backend} implementiert {InterfaceName} nicht",
"translation": "das Backend {Backend} implementiert {InterfaceName} nicht",
"placeholders": [
{
"id": "Backend",
@ -261,9 +261,9 @@
"translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)"
},
{
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMbid})",
"translation": "Listen-Duplikat ignoriert {ListenedAt}: „{TrackName}“ von {ArtistName} ({RecordingMbid})",
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
"translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})",
"placeholders": [
{
"id": "ListenedAt",
@ -290,12 +290,12 @@
"expr": "l.ArtistName()"
},
{
"id": "RecordingMbid",
"id": "RecordingMBID",
"string": "%[4]v",
"type": "go.uploadedlobster.com/scotty/internal/models.MBID",
"type": "go.uploadedlobster.com/mbtypes.MBID",
"underlyingType": "string",
"argNum": 4,
"expr": "l.RecordingMbid"
"expr": "l.RecordingMBID"
}
]
},
@ -305,9 +305,14 @@
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
},
{
"id": "Include skipped listens",
"message": "Include skipped listens",
"translation": "Übersprungene Titel einbeziehen"
"id": "Ignore skipped listens",
"message": "Ignore skipped listens",
"translation": "Übersprungene Listens ignorieren"
},
{
"id": "Specify a time zone for the listen timestamps",
"message": "Specify a time zone for the listen timestamps",
"translation": "Zeitzone für den Abspiel-Zeitstempel"
},
{
"id": "Directory path",
@ -319,28 +324,23 @@
"message": "Ignore listens in incognito mode",
"translation": "Listens im Inkognito-Modus ignorieren"
},
{
"id": "Ignore skipped listens",
"message": "Ignore skipped listens",
"translation": "Übersprungene Listens ignorieren"
},
{
"id": "Minimum playback duration for skipped tracks (seconds)",
"message": "Minimum playback duration for skipped tracks (seconds)",
"translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)"
},
{
"id": "Visit the URL for authorization: {Url}",
"message": "Visit the URL for authorization: {Url}",
"translation": "URL für Autorisierung öffnen: {Url}",
"id": "Visit the URL for authorization: {URL}",
"message": "Visit the URL for authorization: {URL}",
"translation": "Zur Anmeldung folgende URL aufrufen: {URL}",
"placeholders": [
{
"id": "Url",
"id": "URL",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "authUrl.Url"
"expr": "authURL.URL"
}
]
},
@ -411,9 +411,9 @@
"translation": "Backend"
},
{
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...",
"id": "Transferring {Entity} from {SourceName} to {TargetName}",
"message": "Transferring {Entity} from {SourceName} to {TargetName}",
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}",
"placeholders": [
{
"id": "Entity",

View file

@ -175,7 +175,7 @@
{
"id": "backend {Backend} does not implement {InterfaceName}",
"message": "backend {Backend} does not implement {InterfaceName}",
"translation": "das backend {Backend} implementiert {InterfaceName} nicht",
"translation": "das Backend {Backend} implementiert {InterfaceName} nicht",
"placeholders": [
{
"id": "Backend",
@ -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",

View file

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

View file

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

View file

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

View file

@ -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 {

View file

@ -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...)

View file

@ -1,5 +1,5 @@
/*
Copyright © 2023-2024 Philipp Wolfer <phw@uploadedlobster.com>
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
@ -17,7 +17,8 @@ package version
const (
AppName = "scotty"
AppVersion = "0.4.0"
AppVersion = "0.5.0"
AppURL = "https://git.sr.ht/~phw/scotty/"
)
func UserAgent() string {

View file

@ -26,9 +26,9 @@ import "time"
const (
// The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension
MusicBrainzPlaylistExtensionId = "https://musicbrainz.org/doc/jspf#playlist"
MusicBrainzPlaylistExtensionID = "https://musicbrainz.org/doc/jspf#playlist"
// The identifier for the MusicBrainz / ListenBrainz JSPF track extension
MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track"
MusicBrainzTrackExtensionID = "https://musicbrainz.org/doc/jspf#track"
)
// MusicBrainz / ListenBrainz JSPF track extension

View file

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

264
pkg/scrobblerlog/parser.go Normal file
View 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
}

View file

@ -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)
}