mirror of
https://git.sr.ht/~phw/scotty
synced 2025-04-29 21:27:05 +02:00
Compare commits
91 commits
Author | SHA1 | Date | |
---|---|---|---|
|
0a411fe2fa | ||
|
1e91b684cb | ||
|
19852be68b | ||
|
a6cc8d49ac | ||
|
a5442b477e | ||
|
90e101080f | ||
|
dff34b249c | ||
|
bcb1834994 | ||
|
d51c97c648 | ||
|
39b31fc664 | ||
|
1516a3a9d6 | ||
|
82858315fa | ||
|
e135ea5fa9 | ||
|
597914e6db | ||
|
c817480809 | ||
|
47486ff659 | ||
|
159f486cdc | ||
|
b104c2bc42 | ||
|
ed191d2f15 | ||
|
0f4b04c641 | ||
|
aad542850a | ||
|
aeb3a56982 | ||
|
69665bc286 | ||
|
9184d2c3cf | ||
|
4a30bdf9d9 | ||
|
91f78d04dd | ||
|
9e1c2d8435 | ||
|
db78bfe457 | ||
|
20c9ada6ec | ||
|
7c0774fb8d | ||
|
90bf51a00b | ||
|
910056b0a6 | ||
|
bed60c7cdf | ||
|
2d66d41873 | ||
|
da6c920789 | ||
|
01e7569051 | ||
|
1ea90d2d2b | ||
|
329f696b55 | ||
|
5f9c0f24ab | ||
|
dc834e9b6f | ||
|
0d9bc74bc0 | ||
|
13eb8342ab | ||
|
ad1644672c | ||
|
8fff19ceac | ||
|
04eddfda33 | ||
|
1c1ce224f7 | ||
|
7175d3453d | ||
|
cdf20728ae | ||
|
bcc7bf3167 | ||
|
357932f9b0 | ||
|
3f1bebd8ed | ||
|
1aa7b61649 | ||
|
fee1eba080 | ||
|
757aeed7b5 | ||
|
df423acdeb | ||
|
c69097434c | ||
|
84443d0e69 | ||
|
1cea9bd301 | ||
|
8a2ddb7772 | ||
|
91f9b62db3 | ||
|
210fe928fd | ||
|
6281554248 | ||
|
66242d0057 | ||
|
d704e4d3cb | ||
|
60bbbb9f15 | ||
|
01380bd730 | ||
|
fa316b3025 | ||
|
0d04b73338 | ||
|
b2b5c69278 | ||
|
bace31471e | ||
|
d9d83a4282 | ||
|
925c21893b | ||
|
97e93553a1 | ||
|
8c459f4d2f | ||
|
7666ca53a7 | ||
|
6ac2b4f142 | ||
|
c4da3a40cc | ||
|
be1cfdac9e | ||
|
c6be6c558f | ||
|
788fa3828d | ||
|
ba4825aae9 | ||
|
53f7dbb568 | ||
|
78baba8154 | ||
|
086bf25616 | ||
|
c4587b80af | ||
|
a59a542967 | ||
|
dd501df5c5 | ||
|
c4193f42a1 | ||
|
6eaef18188 | ||
|
acb0e9cb11 | ||
|
4d07a39b64 |
90 changed files with 3630 additions and 1826 deletions
.build.yml.goreleaser.yaml.weblateCHANGES.mdREADME.md
cmd
config.example.tomlgo.modgo.suminternal
auth
backends
auth.gobackends.gobackends_test.go
deezer
dump
export.gofunkwhale
jspf
lastfm
listenbrainz
maloja
scrobblerlog
spotify
spotifyhistory
subsonic
cli
config
i18n
models
similarity
storage
translations
util
version
pkg
13
.build.yml
13
.build.yml
|
@ -5,10 +5,11 @@ packages:
|
||||||
- hut
|
- hut
|
||||||
- weblate-wlc
|
- weblate-wlc
|
||||||
secrets:
|
secrets:
|
||||||
- 2a17e258-3e99-4093-9527-832c350d9c53
|
- 0e2ad815-6c46-4cea-878e-70fc33f71e77
|
||||||
oauth: pages.sr.ht/PAGES:RW
|
oauth: pages.sr.ht/PAGES:RW
|
||||||
tasks:
|
tasks:
|
||||||
- weblate-update: |
|
- weblate-update: |
|
||||||
|
cd scotty
|
||||||
wlc --format text pull scotty
|
wlc --format text pull scotty
|
||||||
- test: |
|
- test: |
|
||||||
cd scotty
|
cd scotty
|
||||||
|
@ -28,5 +29,15 @@ tasks:
|
||||||
- publish-redirect: |
|
- publish-redirect: |
|
||||||
# Update redirect on https://go.uploadedlobster.com/scotty
|
# Update redirect on https://go.uploadedlobster.com/scotty
|
||||||
./scotty/pages/publish.sh
|
./scotty/pages/publish.sh
|
||||||
|
# Skip releasing if this is not a tagged release
|
||||||
|
- only-tags: |
|
||||||
|
cd scotty
|
||||||
|
GIT_REF=$(git describe --always)
|
||||||
|
[[ "$GIT_REF" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || complete-build
|
||||||
|
- announce-release: |
|
||||||
|
# Announce new release to Go Module Index
|
||||||
|
cd scotty
|
||||||
|
VERSION=$(git describe --exact-match)
|
||||||
|
curl "https://proxy.golang.org/go.uploadedlobster.com/scotty/@v/${VERSION}.info"
|
||||||
artifacts:
|
artifacts:
|
||||||
- scotty/dist/artifacts.tar
|
- scotty/dist/artifacts.tar
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||||
|
|
||||||
version: 1
|
version: 2
|
||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
|
@ -21,6 +21,8 @@ builds:
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
ignore:
|
ignore:
|
||||||
|
- goos: linux
|
||||||
|
goarch: "386"
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: "386"
|
goarch: "386"
|
||||||
|
|
||||||
|
@ -28,7 +30,7 @@ universal_binaries:
|
||||||
- replace: true
|
- replace: true
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: tar.gz
|
- formats: ['tar.gz']
|
||||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .ProjectName }}-{{ .Version }}_
|
{{ .ProjectName }}-{{ .Version }}_
|
||||||
|
@ -42,7 +44,7 @@ archives:
|
||||||
# use zip for windows archives
|
# use zip for windows archives
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats: ['zip']
|
||||||
files:
|
files:
|
||||||
- COPYING
|
- COPYING
|
||||||
- README.md
|
- README.md
|
||||||
|
|
3
.weblate
Normal file
3
.weblate
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[weblate]
|
||||||
|
url = https://translate.uploadedlobster.com/api/
|
||||||
|
translation = scotty/app
|
59
CHANGES.md
59
CHANGES.md
|
@ -1,7 +1,62 @@
|
||||||
# Scotty Changelog
|
# Scotty Changelog
|
||||||
|
|
||||||
## 0.3.0 - unreleased
|
## 0.5.0 - 2025-04-29
|
||||||
- listenbrainz: fetch listens in reverse listen time order
|
- 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
|
||||||
|
- Subsonic: Only set tags if genre is not empty
|
||||||
|
- ListenBrainz: Listen import can be configured to check for duplicate listens
|
||||||
|
- spotify-history: New backend for importing from Spotify extended streaming
|
||||||
|
history JSON files
|
||||||
|
- Allow date time string for `--timestamp` parameter
|
||||||
|
- Enabled output for "dump" backend again
|
||||||
|
- Fixed completed progress bar showing empty
|
||||||
|
- Fixed crash in case of importer returning an error on import start
|
||||||
|
|
||||||
|
|
||||||
|
## 0.3.1 - 2023-12-10
|
||||||
|
- Prompt user to authenticate after service requiring authentication added
|
||||||
|
- Commands service auth, edit and delete now all support `--service` flag
|
||||||
|
- Do not apply locale formatting in UI output to Unix timestamps
|
||||||
|
- Default for service delete confirmation is now "no"
|
||||||
|
- Default for Maloja "nofix" option is now "no"
|
||||||
|
- Fixed last stored timestamp for beam loves not getting loaded
|
||||||
|
- Fixed crash with invalid target config name in beam commands
|
||||||
|
|
||||||
|
|
||||||
|
## 0.3.0 - 2023-12-09
|
||||||
|
- Initialize config if it does not exist
|
||||||
|
- Set database path relative to config file location
|
||||||
|
- Implemented service configuration commands
|
||||||
|
- Use positional arguments for source and target in beam commands
|
||||||
|
- Allow specifying `--timestamp 0`
|
||||||
|
- Subsonic: fixed filtering songs based on timestamp
|
||||||
|
- JSPF: add MB playlist extension
|
||||||
|
- Spotify: fixed loves export count
|
||||||
|
- Deezer: fixed listen export count
|
||||||
|
- ListenBrainz: fetch listens in reverse listen time order
|
||||||
|
- Initial localization of user interface
|
||||||
|
- Documented general configuration and usage
|
||||||
|
|
||||||
|
|
||||||
## 0.2.0 - 2023-11-28
|
## 0.2.0 - 2023-11-28
|
||||||
|
|
10
README.md
10
README.md
|
@ -10,6 +10,7 @@ Scotty transfers your listens/scrobbles and favorite tracks between various musi
|
||||||
- Submit listens from ListenBrainz to Maloja or Last.fm
|
- Submit listens from ListenBrainz to Maloja or Last.fm
|
||||||
- Transfer loved tracks from Funkwhale to ListenBrainz
|
- Transfer loved tracks from Funkwhale to ListenBrainz
|
||||||
- Submit listens stored in a Rockbox `.scrobbler.log` file to ListenBrainz, Last.fm or Maloja
|
- Submit listens stored in a Rockbox `.scrobbler.log` file to ListenBrainz, Last.fm or Maloja
|
||||||
|
- Submit listens from Spotify extended history files to ListenBrainz, Last.fm or Maloja
|
||||||
- Store your favorite tracks from Deezer as a JSPF playlist
|
- Store your favorite tracks from Deezer as a JSPF playlist
|
||||||
- Backup your listening history from ListenBrainz or Last.fm
|
- Backup your listening history from ListenBrainz or Last.fm
|
||||||
|
|
||||||
|
@ -104,12 +105,12 @@ Imported 4 of 4 loves into listenbrainz.
|
||||||
Latest timestamp: 2023-11-23 14:44:46 +0100 CET (1700747086)
|
Latest timestamp: 2023-11-23 14:44:46 +0100 CET (1700747086)
|
||||||
```
|
```
|
||||||
|
|
||||||
Scotty will remember the latest timestamp for which it transferred data between the two services. The next time you run `scotty beam loves deezer listenbrainz` it will only consider tracks loved after the previous import. If you for some reason want to override this and start importing at an earlier time again, you can specify an earlier start time with the `--timestamp` parameter, which expects a Unix timestamp.
|
Scotty will remember the latest timestamp for which it transferred data between the two services. The next time you run `scotty beam loves deezer listenbrainz` it will only consider tracks loved after the previous import. If you for some reason want to override this and start importing at an earlier time again, you can specify an earlier start time with the `--timestamp` parameter, which can be either a Unix timestamp (seconds since 1970-01-01 00:00:00) or a date time string like "2023-12-10 16:12:00".
|
||||||
|
|
||||||
For example to import listens starting at a specific timestamp use:
|
For example to import listens starting at a specific timestamp use:
|
||||||
|
|
||||||
```
|
```
|
||||||
scotty beam listens deezer listenbrainz --timestamp 1701872784
|
scotty beam listens deezer listenbrainz --timestamp "2023-12-06 14:26:24"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
@ -117,7 +118,7 @@ scotty beam listens deezer listenbrainz --timestamp 1701872784
|
||||||
The following table lists the available backends and the currently supported features.
|
The following table lists the available backends and the currently supported features.
|
||||||
|
|
||||||
Backend | Listens Export | Listens Import | Loves Export | Loves Import
|
Backend | Listens Export | Listens Import | Loves Export | Loves Import
|
||||||
---------------|----------------|----------------|--------------|-------------
|
----------------|----------------|----------------|--------------|-------------
|
||||||
deezer | ✓ | ⨯ | ✓ | -
|
deezer | ✓ | ⨯ | ✓ | -
|
||||||
funkwhale | ✓ | ⨯ | ✓ | -
|
funkwhale | ✓ | ⨯ | ✓ | -
|
||||||
jspf | - | ✓ | - | ✓
|
jspf | - | ✓ | - | ✓
|
||||||
|
@ -126,6 +127,7 @@ listenbrainz | ✓ | ✓ | ✓ | ✓
|
||||||
maloja | ✓ | ✓ | ⨯ | ⨯
|
maloja | ✓ | ✓ | ⨯ | ⨯
|
||||||
scrobbler-log | ✓ | ✓ | ⨯ | ⨯
|
scrobbler-log | ✓ | ✓ | ⨯ | ⨯
|
||||||
spotify | ✓ | ⨯ | ✓ | -
|
spotify | ✓ | ⨯ | ✓ | -
|
||||||
|
spotify-history | ✓ | ⨯ | ⨯ | ⨯
|
||||||
subsonic | ⨯ | ⨯ | ✓ | -
|
subsonic | ⨯ | ⨯ | ✓ | -
|
||||||
|
|
||||||
**✓** implemented **-** not yet implemented **⨯** unavailable / not planned
|
**✓** implemented **-** not yet implemented **⨯** unavailable / not planned
|
||||||
|
@ -143,7 +145,7 @@ You can help translate this project into your language with [Weblate](https://tr
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Scotty © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
Scotty © 2023-2024 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
|
|
@ -17,8 +17,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends"
|
"go.uploadedlobster.com/scotty/internal/backends"
|
||||||
"go.uploadedlobster.com/scotty/internal/cli"
|
"go.uploadedlobster.com/scotty/internal/cli"
|
||||||
|
@ -60,5 +58,5 @@ func init() {
|
||||||
// Cobra supports local flags which will only run when this command
|
// Cobra supports local flags which will only run when this command
|
||||||
// is called directly, e.g.:
|
// is called directly, e.g.:
|
||||||
// beamListensCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
// beamListensCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
||||||
beamListensCmd.Flags().Int64P("timestamp", "t", math.MinInt64, "Only import listens newer then given Unix timestamp")
|
beamListensCmd.Flags().StringP("timestamp", "t", "", "only import listens newer then given timestamp")
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,8 +17,6 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends"
|
"go.uploadedlobster.com/scotty/internal/backends"
|
||||||
"go.uploadedlobster.com/scotty/internal/cli"
|
"go.uploadedlobster.com/scotty/internal/cli"
|
||||||
|
@ -60,5 +58,5 @@ func init() {
|
||||||
// Cobra supports local flags which will only run when this command
|
// Cobra supports local flags which will only run when this command
|
||||||
// is called directly, e.g.:
|
// is called directly, e.g.:
|
||||||
// beamLovesCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
// beamLovesCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
||||||
beamLovesCmd.Flags().Int64P("timestamp", "t", math.MinInt64, "Only import loves newer then given Unix timestamp")
|
beamLovesCmd.Flags().StringP("timestamp", "t", "", "only import loves newer then given timestamp")
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,11 +27,11 @@ import (
|
||||||
|
|
||||||
"github.com/manifoldco/promptui"
|
"github.com/manifoldco/promptui"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/auth"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends"
|
"go.uploadedlobster.com/scotty/internal/backends"
|
||||||
"go.uploadedlobster.com/scotty/internal/cli"
|
"go.uploadedlobster.com/scotty/internal/cli"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var serviceAddCmd = &cobra.Command{
|
var serviceAddCmd = &cobra.Command{
|
||||||
|
@ -95,7 +95,7 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func promptForAuth(service config.ServiceConfig) error {
|
func promptForAuth(service config.ServiceConfig) error {
|
||||||
backend, err := backends.ResolveBackend[models.OAuth2Authenticator](service)
|
backend, err := backends.ResolveBackend[auth.OAuth2Authenticator](service)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No authentication required, return
|
// No authentication required, return
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -18,9 +18,9 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/auth"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends"
|
"go.uploadedlobster.com/scotty/internal/backends"
|
||||||
"go.uploadedlobster.com/scotty/internal/cli"
|
"go.uploadedlobster.com/scotty/internal/cli"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var serviceAuthCmd = &cobra.Command{
|
var serviceAuthCmd = &cobra.Command{
|
||||||
|
@ -33,7 +33,7 @@ multiple services using the same backend but different authentication.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
serviceConfig, err := cli.SelectService(cmd)
|
serviceConfig, err := cli.SelectService(cmd)
|
||||||
cobra.CheckErr(err)
|
cobra.CheckErr(err)
|
||||||
backend, err := backends.ResolveBackend[models.OAuth2Authenticator](serviceConfig)
|
backend, err := backends.ResolveBackend[auth.OAuth2Authenticator](serviceConfig)
|
||||||
cobra.CheckErr(err)
|
cobra.CheckErr(err)
|
||||||
cli.AuthenticationFlow(serviceConfig, backend)
|
cli.AuthenticationFlow(serviceConfig, backend)
|
||||||
},
|
},
|
||||||
|
|
|
@ -11,6 +11,13 @@ backend = "listenbrainz"
|
||||||
username = ""
|
username = ""
|
||||||
# Your ListenBrainz access token from https://listenbrainz.org/profile/
|
# Your ListenBrainz access token from https://listenbrainz.org/profile/
|
||||||
token = ""
|
token = ""
|
||||||
|
# If true, for each listen to submit it is checked whether this listen already
|
||||||
|
# exists on ListenBrainz. For this similar listens are searched at the listen
|
||||||
|
# time +/- the duration of the track. The default is false and this check is not
|
||||||
|
# perfomed. Note that this verification significanly slows down the import and
|
||||||
|
# is only recommended if you are importing historic listens which might or might
|
||||||
|
# not already exists in your ListenBrainz profile.
|
||||||
|
check-duplicate-listens = false
|
||||||
|
|
||||||
[service.maloja]
|
[service.maloja]
|
||||||
# Maloja is a self hosted listening service (https://github.com/krateng/maloja)
|
# Maloja is a self hosted listening service (https://github.com/krateng/maloja)
|
||||||
|
@ -46,24 +53,36 @@ token = ""
|
||||||
[service.scrobbler-log]
|
[service.scrobbler-log]
|
||||||
# Read or write listens from a Rockbox .scobbler.log file
|
# Read or write listens from a Rockbox .scobbler.log file
|
||||||
backend = "scrobbler-log"
|
backend = "scrobbler-log"
|
||||||
# The file path to the .scrobbler.log file
|
# The file path to the .scrobbler.log file. Relative paths are resolved against
|
||||||
file-path = "data/.scrobbler.log"
|
# the current working directory when running scotty.
|
||||||
# If true, reading listens from the file also returns listens marked as "skipped"
|
file-path = "./.scrobbler.log"
|
||||||
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
|
# If true (default), new listens will be appended to the existing file. Set to
|
||||||
# false to overwrite the file and create a new scrobbler log on every run.
|
# false to overwrite the file and create a new scrobbler log on every run.
|
||||||
append = true
|
append = true
|
||||||
|
# Specify the time zone of the listens in the scrobbler log. While the log files
|
||||||
|
# are supposed to contain Unix timestamps, which are always in UTC, the player
|
||||||
|
# writing the log might not be time zone aware. This can cause the timestamps
|
||||||
|
# to be in a different time zone. Use the time-zone setting to specify a
|
||||||
|
# different time zone, e.g. "Europe/Berlin" or "America/New_York".
|
||||||
|
# The default is UTC.
|
||||||
|
time-zone = "UTC"
|
||||||
|
|
||||||
[service.jspf]
|
[service.jspf]
|
||||||
# Write listens and loves to JSPF playlist files (https://xspf.org/jspf)
|
# Write listens and loves to JSPF playlist files (https://xspf.org/jspf)
|
||||||
backend = "jspf"
|
backend = "jspf"
|
||||||
# The file path to the XSPF file
|
# The file path to the JSPF file. Relative paths are resolved against
|
||||||
file-path = "data/playlist.jspf"
|
# the current working directory when running scotty.
|
||||||
# Title of the playlist
|
file-path = "./playlist.jspf"
|
||||||
|
# If true (default), new listens will be appended to the existing file. Set to
|
||||||
|
# false to overwrite the file and create a new JSPF playlist on every run.
|
||||||
|
append = true
|
||||||
|
# Title of the playlist. Not used in append mode.
|
||||||
title = "My Playlist"
|
title = "My Playlist"
|
||||||
# Creator of the playlist (only informational)
|
# Creator of the playlist (only informational). Not used in append mode.
|
||||||
username = ""
|
username = ""
|
||||||
# A unique identifier for your playlist
|
# A unique identifier for your playlist. Not used in append mode.
|
||||||
identifier = ""
|
identifier = ""
|
||||||
|
|
||||||
[service.spotify]
|
[service.spotify]
|
||||||
|
@ -76,6 +95,21 @@ backend = "spotify"
|
||||||
client-id = ""
|
client-id = ""
|
||||||
client-secret = ""
|
client-secret = ""
|
||||||
|
|
||||||
|
[service.spotify-history]
|
||||||
|
# Read listens from a Spotify extended history export
|
||||||
|
backend = "spotify-history"
|
||||||
|
# Directory where the extended history JSON files are located. The files must
|
||||||
|
# follow the naming scheme "Streaming_History_Audio_*.json".
|
||||||
|
dir-path = "./my_spotify_data_extended/Spotify Extended Streaming History"
|
||||||
|
# If true (default), ignore listens from a Spotify "private session".
|
||||||
|
ignore-incognito = true
|
||||||
|
# If true, ignore listens marked as skipped. Default is false.
|
||||||
|
ignore-skipped = false
|
||||||
|
# Only consider skipped listens with a playback duration longer than 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]
|
[service.deezer]
|
||||||
# Read listens and loves from a Deezer account
|
# Read listens and loves from a Deezer account
|
||||||
backend = "deezer"
|
backend = "deezer"
|
||||||
|
|
95
go.mod
95
go.mod
|
@ -1,70 +1,75 @@
|
||||||
module go.uploadedlobster.com/scotty
|
module go.uploadedlobster.com/scotty
|
||||||
|
|
||||||
go 1.21.1
|
go 1.23.0
|
||||||
|
|
||||||
|
toolchain go1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Xuanwo/go-locale v1.1.0
|
github.com/Xuanwo/go-locale v1.1.3
|
||||||
|
github.com/agnivade/levenshtein v1.2.1
|
||||||
github.com/cli/browser v1.3.0
|
github.com/cli/browser v1.3.0
|
||||||
github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5
|
github.com/fatih/color v1.18.0
|
||||||
github.com/fatih/color v1.16.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/glebarez/sqlite v1.10.0
|
github.com/go-resty/resty/v2 v2.16.5
|
||||||
github.com/go-resty/resty/v2 v2.10.0
|
|
||||||
github.com/jarcoal/httpmock v1.3.1
|
github.com/jarcoal/httpmock v1.3.1
|
||||||
github.com/manifoldco/promptui v0.9.0
|
github.com/manifoldco/promptui v0.9.0
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0
|
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0
|
||||||
github.com/spf13/cast v1.5.1
|
github.com/spf13/cast v1.7.1
|
||||||
github.com/spf13/cobra v1.8.0
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/viper v1.17.0
|
github.com/spf13/viper v1.20.1
|
||||||
github.com/stretchr/testify v1.8.4
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/vbauerster/mpb/v8 v8.6.2
|
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
|
github.com/vbauerster/mpb/v8 v8.9.3
|
||||||
golang.org/x/oauth2 v0.14.0
|
go.uploadedlobster.com/mbtypes v0.4.0
|
||||||
golang.org/x/text v0.14.0
|
go.uploadedlobster.com/musicbrainzws2 v0.14.0
|
||||||
gorm.io/datatypes v1.2.0
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
|
||||||
gorm.io/gorm v1.25.5
|
golang.org/x/oauth2 v0.29.0
|
||||||
|
golang.org/x/text v0.24.0
|
||||||
|
gorm.io/datatypes v1.2.5
|
||||||
|
gorm.io/gorm v1.26.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/VividCortex/ewma v1.2.0 // indirect
|
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
|
github.com/chzyer/readline v1.5.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||||
github.com/golang/protobuf v1.5.3 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/google/uuid v1.4.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.4 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.3.0 // indirect
|
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
github.com/spf13/afero v1.10.0 // indirect
|
github.com/spf13/afero v1.14.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
golang.org/x/image v0.26.0 // indirect
|
||||||
golang.org/x/net v0.18.0 // indirect
|
golang.org/x/mod v0.24.0 // indirect
|
||||||
golang.org/x/sys v0.14.0 // indirect
|
golang.org/x/net v0.39.0 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
golang.org/x/tools v0.32.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/driver/mysql v1.4.7 // indirect
|
gorm.io/driver/mysql v1.5.7 // indirect
|
||||||
modernc.org/libc v1.34.3 // indirect
|
modernc.org/libc v1.64.0 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.7.2 // indirect
|
modernc.org/memory v1.10.0 // indirect
|
||||||
modernc.org/sqlite v1.27.0 // indirect
|
modernc.org/sqlite v1.37.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tool golang.org/x/text/cmd/gotext
|
||||||
|
|
697
go.sum
697
go.sum
|
@ -1,651 +1,234 @@
|
||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
|
||||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
|
||||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
|
||||||
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
|
||||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
|
||||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
|
||||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
|
||||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
|
||||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
|
||||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
|
||||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
|
||||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
|
||||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
|
||||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
|
||||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
|
||||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
|
||||||
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
|
|
||||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
|
||||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
|
||||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
|
||||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
|
||||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
|
||||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
|
||||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
|
||||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
|
||||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
|
||||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
|
||||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
|
||||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
|
||||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
|
||||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
|
||||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
|
||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
|
||||||
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
|
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
|
||||||
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
|
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
|
||||||
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
|
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
|
||||||
github.com/Xuanwo/go-locale v1.1.0 h1:51gUxhxl66oXAjI9uPGb2O0qwPECpriKQb2hl35mQkg=
|
github.com/Xuanwo/go-locale v1.1.3 h1:EWZZJJt5rqPHHbqPRH1zFCn5D7xHjjebODctA4aUO3A=
|
||||||
github.com/Xuanwo/go-locale v1.1.0/go.mod h1:UKrHoZB3FPIk9wIG2/tVSobnHgNnceGSH3Y8DY5cASs=
|
github.com/Xuanwo/go-locale v1.1.3/go.mod h1:REn+F/c+AtGSWYACBSYZgl23AP+0lfQC+SEFPN+hj30=
|
||||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||||
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||||
|
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
|
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||||
|
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||||
|
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
|
||||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
|
||||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5 h1:RuuxidatioSKGOiBzL1mTY4X22DQD8weEbS3iRLHnAg=
|
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||||
github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5/go.mod h1:vnbEuj6Z20PLcHB4rrLQAOXGMjtULfMGhRVSFPcSdUo=
|
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||||
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
||||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
|
||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
|
||||||
github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
|
|
||||||
github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
|
|
||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
|
||||||
github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo=
|
|
||||||
github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
|
|
||||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
|
||||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/go-sql-driver/mysql v1.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 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
|
||||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
|
||||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
|
||||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
|
||||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
|
||||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
|
||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
|
||||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
|
||||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
|
||||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
|
||||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
|
||||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
|
||||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
|
||||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
|
||||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
|
||||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
|
||||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
|
||||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
|
||||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA=
|
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||||
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
|
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
|
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
|
||||||
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
|
||||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
|
||||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
|
||||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
|
||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
|
||||||
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
||||||
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
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 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
|
||||||
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
|
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
|
||||||
github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
|
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||||
github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
|
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
|
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||||
github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
|
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
|
||||||
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 h1:cgqwZtnR+IQfUYDLJ3Kiy4aE+O/wExTzEIg8xwC4Qfs=
|
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 h1:cgqwZtnR+IQfUYDLJ3Kiy4aE+O/wExTzEIg8xwC4Qfs=
|
||||||
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0/go.mod h1:n3nudMl178cEvD44PaopxH9jhJaQzthSxUzLO5iKMy4=
|
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0/go.mod h1:n3nudMl178cEvD44PaopxH9jhJaQzthSxUzLO5iKMy4=
|
||||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
|
||||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
|
||||||
github.com/smartystreets/goconvey v1.6.7 h1:I6tZjLXD2Q1kjvNbIzB1wvQBsXmKXiVrhpRE8ZjP5jY=
|
|
||||||
github.com/smartystreets/goconvey v1.6.7/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
|
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||||
github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
|
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||||
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
|
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/vbauerster/mpb/v8 v8.6.2 h1:9EhnJGQRtvgDVCychJgR96EDCOqgg2NsMuk5JUcX4DA=
|
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4=
|
||||||
github.com/vbauerster/mpb/v8 v8.6.2/go.mod h1:oVJ7T+dib99kZ/VBjoBaC8aPXiSAihnzuKmotuihyFo=
|
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/vbauerster/mpb/v8 v8.9.3 h1:PnMeF+sMvYv9u23l6DO6Q3+Mdj408mjLRXIzmUmU2Z8=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/vbauerster/mpb/v8 v8.9.3/go.mod h1:hxS8Hz4C6ijnppDSIX6LjG8FYJSoPo9iIOcE53Zik0c=
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
go.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s=
|
||||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
go.uploadedlobster.com/mbtypes v0.4.0/go.mod h1:Bu1K1Hl77QTAE2Z7QKiW/JAp9KqYWQebkRRfG02dlZM=
|
||||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
go.uploadedlobster.com/musicbrainzws2 v0.14.0 h1:YaEtxNwLSNT1gzFipQ4XlaThNfXjBpzzb4I6WhIeUwg=
|
||||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
go.uploadedlobster.com/musicbrainzws2 v0.14.0/go.mod h1:T6sYE7ZHRH3mJWT3g9jdSUPKJLZubnBjKyjMPNdkgao=
|
||||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
|
||||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
|
||||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
|
||||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
|
||||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
|
||||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
|
||||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
|
||||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
|
||||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
|
||||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
|
||||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
|
||||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
|
||||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
|
||||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
|
||||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
|
||||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
|
||||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
|
||||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
|
||||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
|
||||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
|
||||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
|
||||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
|
||||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
|
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||||
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
|
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
|
||||||
golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0=
|
|
||||||
golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM=
|
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
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-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
|
||||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
|
||||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
|
||||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
|
||||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
|
||||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
|
||||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
|
||||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
|
||||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
|
||||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
|
||||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
|
||||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
|
||||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
|
||||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
|
||||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
|
||||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
|
||||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
|
||||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
|
||||||
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
|
||||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
|
||||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
|
||||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
|
||||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
|
||||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
|
||||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
|
||||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
|
||||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
|
||||||
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
|
||||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
|
||||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
|
||||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
|
||||||
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
|
||||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
|
||||||
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
|
||||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
|
||||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
|
||||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
|
||||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
|
||||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
|
||||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
|
||||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
|
||||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
|
||||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
|
||||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
|
||||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
|
||||||
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
|
||||||
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
|
||||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
|
||||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
|
||||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
|
||||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
|
||||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
|
||||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
|
||||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
|
||||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
|
||||||
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
|
||||||
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
|
||||||
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
|
||||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
|
||||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
|
||||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
|
||||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
|
||||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
|
||||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco=
|
gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I=
|
||||||
gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04=
|
gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4=
|
||||||
gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y=
|
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||||
gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
|
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||||
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
|
||||||
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
|
||||||
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
|
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
|
||||||
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||||
gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
|
gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g=
|
||||||
gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
|
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g=
|
||||||
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
gorm.io/gorm v1.26.0 h1:9lqQVPG5aNNS6AyHdRiwScAVnXHg/L/Srzx55G5fOgs=
|
||||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/gorm v1.26.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
||||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/libc v1.34.3 h1:ag+3JIGF0o009YKhKjkqAG3N36X6ctUv2V85hGM45WA=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/libc v1.34.3/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
modernc.org/libc v1.64.0 h1:U0k8BD2d3cD3e9I8RLcZgJBHAcsJzbXx5mKGSb5pyJA=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/libc v1.64.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||||
modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8=
|
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||||
|
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|
34
internal/auth/auth.go
Normal file
34
internal/auth/auth.go
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
|
Scotty is free software: you can redistribute it and/or modify it under the
|
||||||
|
terms of the GNU General Public License as published by the Free Software
|
||||||
|
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with
|
||||||
|
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Must be implemented by backends requiring OAuth2 authentication
|
||||||
|
type OAuth2Authenticator interface {
|
||||||
|
models.Backend
|
||||||
|
|
||||||
|
// Returns OAuth2 config suitable for this backend
|
||||||
|
OAuth2Strategy(redirectURL *url.URL) OAuth2Strategy
|
||||||
|
|
||||||
|
// Setup the OAuth2 client
|
||||||
|
OAuth2Setup(token oauth2.TokenSource) error
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
@ -34,5 +35,12 @@ func RunOauth2CallbackServer(redirectURL url.URL, param string, responseChan cha
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
go http.ListenAndServe(redirectURL.Host, nil)
|
go runServer(redirectURL.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServer(addr string) {
|
||||||
|
err := http.ListenAndServe(addr, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,14 +24,14 @@ import (
|
||||||
type OAuth2Strategy interface {
|
type OAuth2Strategy interface {
|
||||||
Config() oauth2.Config
|
Config() oauth2.Config
|
||||||
|
|
||||||
AuthCodeURL(verifier string, state string) AuthUrl
|
AuthCodeURL(verifier string, state string) AuthURL
|
||||||
|
|
||||||
ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error)
|
ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthUrl struct {
|
type AuthURL struct {
|
||||||
// The URL the user must visit to approve access
|
// The URL the user must visit to approve access
|
||||||
Url string
|
URL string
|
||||||
// Random state string passed on to the callback.
|
// Random state string passed on to the callback.
|
||||||
// Leave empty if the service does not support state.
|
// Leave empty if the service does not support state.
|
||||||
State string
|
State string
|
||||||
|
@ -56,10 +56,10 @@ func (s StandardStrategy) Config() oauth2.Config {
|
||||||
return s.conf
|
return s.conf
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthUrl {
|
func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthURL {
|
||||||
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
||||||
return AuthUrl{
|
return AuthURL{
|
||||||
Url: url,
|
URL: url,
|
||||||
State: state,
|
State: state,
|
||||||
Param: "code",
|
Param: "code",
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ func BuildRedirectURL(config *viper.Viper, backend string) (*url.URL, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Authenticate(service string, backend models.Backend, db storage.Database, config *viper.Viper) (bool, error) {
|
func Authenticate(service string, backend models.Backend, db storage.Database, config *viper.Viper) (bool, error) {
|
||||||
authenticator, needAuth := backend.(models.OAuth2Authenticator)
|
authenticator, needAuth := backend.(auth.OAuth2Authenticator)
|
||||||
if needAuth {
|
if needAuth {
|
||||||
redirectURL, err := BuildRedirectURL(config, backend.Name())
|
redirectURL, err := BuildRedirectURL(config, backend.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -31,6 +31,7 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/maloja"
|
"go.uploadedlobster.com/scotty/internal/backends/maloja"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
|
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/backends/spotifyhistory"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
|
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
|
@ -113,6 +114,7 @@ var knownBackends = map[string]func() models.Backend{
|
||||||
"maloja": func() models.Backend { return &maloja.MalojaApiBackend{} },
|
"maloja": func() models.Backend { return &maloja.MalojaApiBackend{} },
|
||||||
"scrobbler-log": func() models.Backend { return &scrobblerlog.ScrobblerLogBackend{} },
|
"scrobbler-log": func() models.Backend { return &scrobblerlog.ScrobblerLogBackend{} },
|
||||||
"spotify": func() models.Backend { return &spotify.SpotifyApiBackend{} },
|
"spotify": func() models.Backend { return &spotify.SpotifyApiBackend{} },
|
||||||
|
"spotify-history": func() models.Backend { return &spotifyhistory.SpotifyHistoryBackend{} },
|
||||||
"subsonic": func() models.Backend { return &subsonic.SubsonicApiBackend{} },
|
"subsonic": func() models.Backend { return &subsonic.SubsonicApiBackend{} },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -121,7 +123,11 @@ func backendWithConfig(config config.ServiceConfig) (models.Backend, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return backend.FromConfig(&config), nil
|
err = backend.InitConfig(&config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return backend, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ImplementsInterface[T interface{}](backend *models.Backend) (bool, string) {
|
func ImplementsInterface[T interface{}](backend *models.Backend) (bool, string) {
|
||||||
|
|
|
@ -35,6 +35,7 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
|
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -52,7 +53,7 @@ func TestResolveBackendUnknown(t *testing.T) {
|
||||||
c.Set("backend", "foo")
|
c.Set("backend", "foo")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
_, err := backends.ResolveBackend[models.ListensImport](service)
|
_, err := backends.ResolveBackend[models.ListensImport](service)
|
||||||
assert.EqualError(t, err, "unknown backend \"foo\"")
|
assert.EqualError(t, err, i18n.Tr("unknown backend \"%s\"", "foo"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveBackendInvalidInterface(t *testing.T) {
|
func TestResolveBackendInvalidInterface(t *testing.T) {
|
||||||
|
@ -60,7 +61,7 @@ func TestResolveBackendInvalidInterface(t *testing.T) {
|
||||||
c.Set("backend", "dump")
|
c.Set("backend", "dump")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
_, err := backends.ResolveBackend[models.ListensExport](service)
|
_, err := backends.ResolveBackend[models.ListensExport](service)
|
||||||
assert.EqualError(t, err, "backend dump does not implement ListensExport")
|
assert.EqualError(t, err, i18n.Tr("backend %s does not implement %s", "dump", "ListensExport"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBackends(t *testing.T) {
|
func TestGetBackends(t *testing.T) {
|
||||||
|
@ -76,7 +77,7 @@ func TestGetBackends(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we got here the "dump" backend was not included
|
// If we got here the "dump" backend was not included
|
||||||
t.Errorf("GetBackends() did not return expected bacend \"dump\"")
|
t.Errorf("GetBackends() did not return expected backend \"dump\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImplementsInterfaces(t *testing.T) {
|
func TestImplementsInterfaces(t *testing.T) {
|
||||||
|
|
|
@ -33,10 +33,10 @@ func (s deezerStrategy) Config() oauth2.Config {
|
||||||
return s.conf
|
return s.conf
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl {
|
func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL {
|
||||||
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
||||||
return auth.AuthUrl{
|
return auth.AuthURL{
|
||||||
Url: url,
|
URL: url,
|
||||||
State: state,
|
State: state,
|
||||||
Param: "code",
|
Param: "code",
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ const MaxItemsPerGet = 1000
|
||||||
const DefaultRateLimitWaitSeconds = 5
|
const DefaultRateLimitWaitSeconds = 5
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HttpClient *resty.Client
|
HTTPClient *resty.Client
|
||||||
token oauth2.TokenSource
|
token oauth2.TokenSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ func NewClient(token oauth2.TokenSource) Client {
|
||||||
client.SetHeader("User-Agent", version.UserAgent())
|
client.SetHeader("User-Agent", version.UserAgent())
|
||||||
client.SetRetryCount(5)
|
client.SetRetryCount(5)
|
||||||
return Client{
|
return Client{
|
||||||
HttpClient: client,
|
HTTPClient: client,
|
||||||
token: token,
|
token: token,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -73,16 +73,19 @@ func (c Client) setToken(req *resty.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) {
|
func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) {
|
||||||
request := c.HttpClient.R().
|
request := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"index": strconv.Itoa(offset),
|
"index": strconv.Itoa(offset),
|
||||||
"limit": strconv.Itoa(limit),
|
"limit": strconv.Itoa(limit),
|
||||||
}).
|
}).
|
||||||
SetResult(&result)
|
SetResult(&result)
|
||||||
c.setToken(request)
|
err = c.setToken(request)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
response, err := request.Get(path)
|
response, err := request.Get(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(response.String())
|
err = errors.New(response.String())
|
||||||
} else if result.Error() != nil {
|
} else if result.Error() != nil {
|
||||||
err = errors.New(result.Error().Message)
|
err = errors.New(result.Error().Message)
|
||||||
|
|
|
@ -44,7 +44,7 @@ func TestGetUserHistory(t *testing.T) {
|
||||||
|
|
||||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||||
client := deezer.NewClient(token)
|
client := deezer.NewClient(token)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.deezer.com/user/me/history",
|
"https://api.deezer.com/user/me/history",
|
||||||
"testdata/user-history.json")
|
"testdata/user-history.json")
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ func TestGetUserTracks(t *testing.T) {
|
||||||
|
|
||||||
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
token := oauth2.StaticTokenSource(&oauth2.Token{})
|
||||||
client := deezer.NewClient(token)
|
client := deezer.NewClient(token)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.deezer.com/user/me/tracks",
|
"https://api.deezer.com/user/me/tracks",
|
||||||
"testdata/user-tracks.json")
|
"testdata/user-tracks.json")
|
||||||
|
|
||||||
|
@ -81,7 +81,7 @@ func TestGetUserTracks(t *testing.T) {
|
||||||
assert.Equal("Outland", track1.Track.Album.Title)
|
assert.Equal("Outland", track1.Track.Album.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||||
httpmock.ActivateNonDefault(client)
|
httpmock.ActivateNonDefault(client)
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||||
|
|
|
@ -31,7 +31,7 @@ import (
|
||||||
|
|
||||||
type DeezerApiBackend struct {
|
type DeezerApiBackend struct {
|
||||||
client Client
|
client Client
|
||||||
clientId string
|
clientID string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,20 +49,20 @@ func (b *DeezerApiBackend) Options() []models.BackendOption {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *DeezerApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *DeezerApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
b.clientId = config.GetString("client-id")
|
b.clientID = config.GetString("client-id")
|
||||||
b.clientSecret = config.GetString("client-secret")
|
b.clientSecret = config.GetString("client-secret")
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *DeezerApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
func (b *DeezerApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
|
||||||
conf := oauth2.Config{
|
conf := oauth2.Config{
|
||||||
ClientID: b.clientId,
|
ClientID: b.clientID,
|
||||||
ClientSecret: b.clientSecret,
|
ClientSecret: b.clientSecret,
|
||||||
Scopes: []string{
|
Scopes: []string{
|
||||||
"offline_access,basic_access,listening_history",
|
"offline_access,basic_access,listening_history",
|
||||||
},
|
},
|
||||||
RedirectURL: redirectUrl.String(),
|
RedirectURL: redirectURL.String(),
|
||||||
Endpoint: oauth2.Endpoint{
|
Endpoint: oauth2.Endpoint{
|
||||||
AuthURL: "https://connect.deezer.com/oauth/auth.php",
|
AuthURL: "https://connect.deezer.com/oauth/auth.php",
|
||||||
TokenURL: "https://connect.deezer.com/oauth/access_token.php",
|
TokenURL: "https://connect.deezer.com/oauth/access_token.php",
|
||||||
|
@ -105,10 +105,7 @@ out:
|
||||||
// and continue.
|
// and continue.
|
||||||
if offset >= result.Total {
|
if offset >= result.Total {
|
||||||
p.Total = int64(result.Total)
|
p.Total = int64(result.Total)
|
||||||
offset = result.Total - perPage
|
offset = max(result.Total-perPage, 0)
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -177,10 +174,7 @@ out:
|
||||||
if offset >= result.Total {
|
if offset >= result.Total {
|
||||||
p.Total = int64(result.Total)
|
p.Total = int64(result.Total)
|
||||||
totalCount = result.Total
|
totalCount = result.Total
|
||||||
offset = result.Total - perPage
|
offset = max(result.Total-perPage, 0)
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -250,8 +244,8 @@ func (t Track) AsTrack() models.Track {
|
||||||
info["music_service"] = "deezer.com"
|
info["music_service"] = "deezer.com"
|
||||||
info["origin_url"] = t.Link
|
info["origin_url"] = t.Link
|
||||||
info["deezer_id"] = t.Link
|
info["deezer_id"] = t.Link
|
||||||
info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/track/%v", t.Album.Id)
|
info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/album/%v", t.Album.ID)
|
||||||
info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/track/%v", t.Artist.Id)
|
info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/artist/%v", t.Artist.ID)
|
||||||
|
|
||||||
return track
|
return track
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,8 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
package deezer_test
|
package deezer_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -28,20 +28,26 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
var (
|
||||||
|
//go:embed testdata/listen.json
|
||||||
|
testListen []byte
|
||||||
|
//go:embed testdata/track.json
|
||||||
|
testTrack []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("client-id", "someclientid")
|
c.Set("client-id", "someclientid")
|
||||||
c.Set("client-secret", "someclientsecret")
|
c.Set("client-secret", "someclientsecret")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&deezer.DeezerApiBackend{}).FromConfig(&service)
|
backend := deezer.DeezerApiBackend{}
|
||||||
assert.IsType(t, &deezer.DeezerApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListenAsListen(t *testing.T) {
|
func TestListenAsListen(t *testing.T) {
|
||||||
data, err := os.ReadFile("testdata/listen.json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
track := deezer.Listen{}
|
track := deezer.Listen{}
|
||||||
err = json.Unmarshal(data, &track)
|
err := json.Unmarshal(testListen, &track)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
listen := track.AsListen()
|
listen := track.AsListen()
|
||||||
assert.Equal(t, time.Unix(1700753817, 0), listen.ListenedAt)
|
assert.Equal(t, time.Unix(1700753817, 0), listen.ListenedAt)
|
||||||
|
@ -52,13 +58,13 @@ func TestListenAsListen(t *testing.T) {
|
||||||
assert.Equal(t, "deezer.com", listen.AdditionalInfo["music_service"])
|
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["origin_url"])
|
||||||
assert.Equal(t, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["deezer_id"])
|
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) {
|
func TestLovedTrackAsLove(t *testing.T) {
|
||||||
data, err := os.ReadFile("testdata/track.json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
track := deezer.LovedTrack{}
|
track := deezer.LovedTrack{}
|
||||||
err = json.Unmarshal(data, &track)
|
err := json.Unmarshal(testTrack, &track)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
love := track.AsLove()
|
love := track.AsLove()
|
||||||
assert.Equal(t, time.Unix(1700743848, 0), love.Created)
|
assert.Equal(t, time.Unix(1700743848, 0), love.Created)
|
||||||
|
|
|
@ -51,7 +51,7 @@ type HistoryResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
Id int `json:"id"`
|
ID int `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
@ -75,7 +75,7 @@ type LovedTrack struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Album struct {
|
type Album struct {
|
||||||
Id int `json:"id"`
|
ID int `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
@ -83,7 +83,7 @@ type Album struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
Id int `json:"id"`
|
ID int `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
|
@ -16,8 +16,8 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
package deezer_test
|
package deezer_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
@ -25,11 +25,16 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/deezer"
|
"go.uploadedlobster.com/scotty/internal/backends/deezer"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed testdata/user-tracks.json
|
||||||
|
testUserTracks []byte
|
||||||
|
//go:embed testdata/user-history.json
|
||||||
|
testUserHistory []byte
|
||||||
|
)
|
||||||
|
|
||||||
func TestUserTracksResult(t *testing.T) {
|
func TestUserTracksResult(t *testing.T) {
|
||||||
data, err := os.ReadFile("testdata/user-tracks.json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
result := deezer.TracksResult{}
|
result := deezer.TracksResult{}
|
||||||
err = json.Unmarshal(data, &result)
|
err := json.Unmarshal(testUserTracks, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
@ -45,10 +50,8 @@ func TestUserTracksResult(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserHistoryResult(t *testing.T) {
|
func TestUserHistoryResult(t *testing.T) {
|
||||||
data, err := os.ReadFile("testdata/user-history.json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
result := deezer.HistoryResult{}
|
result := deezer.HistoryResult{}
|
||||||
err = json.Unmarshal(data, &result)
|
err := json.Unmarshal(testUserHistory, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
|
@ -17,6 +17,8 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
package dump
|
package dump
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
)
|
)
|
||||||
|
@ -27,8 +29,8 @@ func (b *DumpBackend) Name() string { return "dump" }
|
||||||
|
|
||||||
func (b *DumpBackend) Options() []models.BackendOption { return nil }
|
func (b *DumpBackend) Options() []models.BackendOption { return nil }
|
||||||
|
|
||||||
func (b *DumpBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *DumpBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *DumpBackend) StartImport() error { return nil }
|
func (b *DumpBackend) StartImport() error { return nil }
|
||||||
|
@ -38,9 +40,10 @@ func (b *DumpBackend) ImportListens(export models.ListensResult, importResult mo
|
||||||
for _, listen := range export.Items {
|
for _, listen := range export.Items {
|
||||||
importResult.UpdateTimestamp(listen.ListenedAt)
|
importResult.UpdateTimestamp(listen.ListenedAt)
|
||||||
importResult.ImportCount += 1
|
importResult.ImportCount += 1
|
||||||
|
msg := fmt.Sprintf("🎶 %v: \"%v\" by %v (%v)",
|
||||||
|
listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMBID)
|
||||||
|
importResult.Log(models.Info, msg)
|
||||||
progress <- models.Progress{}.FromImportResult(importResult)
|
progress <- models.Progress{}.FromImportResult(importResult)
|
||||||
// fmt.Printf("🎶 %v: \"%v\" by %v (%v)\n",
|
|
||||||
// listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMbid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return importResult, nil
|
return importResult, nil
|
||||||
|
@ -50,9 +53,10 @@ func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models
|
||||||
for _, love := range export.Items {
|
for _, love := range export.Items {
|
||||||
importResult.UpdateTimestamp(love.Created)
|
importResult.UpdateTimestamp(love.Created)
|
||||||
importResult.ImportCount += 1
|
importResult.ImportCount += 1
|
||||||
|
msg := fmt.Sprintf("❤️ %v: \"%v\" by %v (%v)",
|
||||||
|
love.Created, love.TrackName, love.ArtistName(), love.RecordingMBID)
|
||||||
|
importResult.Log(models.Info, msg)
|
||||||
progress <- models.Progress{}.FromImportResult(importResult)
|
progress <- models.Progress{}.FromImportResult(importResult)
|
||||||
// fmt.Printf("❤️ %v: \"%v\" by %v (%v)\n",
|
|
||||||
// love.Created, love.TrackName, love.ArtistName(), love.RecordingMbid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return importResult, nil
|
return importResult, nil
|
||||||
|
|
|
@ -36,6 +36,7 @@ func (p ListensExportProcessor) ExportBackend() models.Backend {
|
||||||
|
|
||||||
func (p ListensExportProcessor) Process(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
func (p ListensExportProcessor) Process(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||||
p.Backend.ExportListens(oldestTimestamp, results, progress)
|
p.Backend.ExportListens(oldestTimestamp, results, progress)
|
||||||
|
close(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
type LovesExportProcessor struct {
|
type LovesExportProcessor struct {
|
||||||
|
@ -48,4 +49,5 @@ func (p LovesExportProcessor) ExportBackend() models.Backend {
|
||||||
|
|
||||||
func (p LovesExportProcessor) Process(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
func (p LovesExportProcessor) Process(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||||
p.Backend.ExportLoves(oldestTimestamp, results, progress)
|
p.Backend.ExportLoves(oldestTimestamp, results, progress)
|
||||||
|
close(progress)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,20 +26,20 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-resty/resty/v2"
|
"github.com/go-resty/resty/v2"
|
||||||
"go.uploadedlobster.com/scotty/internal/ratelimit"
|
|
||||||
"go.uploadedlobster.com/scotty/internal/version"
|
"go.uploadedlobster.com/scotty/internal/version"
|
||||||
|
"go.uploadedlobster.com/scotty/pkg/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
const MaxItemsPerGet = 50
|
const MaxItemsPerGet = 50
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HttpClient *resty.Client
|
HTTPClient *resty.Client
|
||||||
token string
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(serverUrl string, token string) Client {
|
func NewClient(serverURL string, token string) Client {
|
||||||
client := resty.New()
|
client := resty.New()
|
||||||
client.SetBaseURL(serverUrl)
|
client.SetBaseURL(serverURL)
|
||||||
client.SetAuthScheme("Bearer")
|
client.SetAuthScheme("Bearer")
|
||||||
client.SetAuthToken(token)
|
client.SetAuthToken(token)
|
||||||
client.SetHeader("Accept", "application/json")
|
client.SetHeader("Accept", "application/json")
|
||||||
|
@ -49,14 +49,14 @@ func NewClient(serverUrl string, token string) Client {
|
||||||
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
||||||
|
|
||||||
return Client{
|
return Client{
|
||||||
HttpClient: client,
|
HTTPClient: client,
|
||||||
token: token,
|
token: token,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) GetHistoryListenings(user string, page int, perPage int) (result ListeningsResult, err error) {
|
func (c Client) GetHistoryListenings(user string, page int, perPage int) (result ListeningsResult, err error) {
|
||||||
const path = "/api/v1/history/listenings"
|
const path = "/api/v1/history/listenings"
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"username": user,
|
"username": user,
|
||||||
"page": strconv.Itoa(page),
|
"page": strconv.Itoa(page),
|
||||||
|
@ -66,7 +66,7 @@ func (c Client) GetHistoryListenings(user string, page int, perPage int) (result
|
||||||
SetResult(&result).
|
SetResult(&result).
|
||||||
Get(path)
|
Get(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(response.String())
|
err = errors.New(response.String())
|
||||||
return
|
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) {
|
func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksResult, err error) {
|
||||||
const path = "/api/v1/favorites/tracks"
|
const path = "/api/v1/favorites/tracks"
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"page": strconv.Itoa(page),
|
"page": strconv.Itoa(page),
|
||||||
"page_size": strconv.Itoa(perPage),
|
"page_size": strconv.Itoa(perPage),
|
||||||
|
@ -84,7 +84,7 @@ func (c Client) GetFavoriteTracks(page int, perPage int) (result FavoriteTracksR
|
||||||
SetResult(&result).
|
SetResult(&result).
|
||||||
Get(path)
|
Get(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(response.String())
|
err = errors.New(response.String())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,20 +32,20 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
func TestNewClient(t *testing.T) {
|
||||||
serverUrl := "https://funkwhale.example.com"
|
serverURL := "https://funkwhale.example.com"
|
||||||
token := "foobar123"
|
token := "foobar123"
|
||||||
client := funkwhale.NewClient(serverUrl, token)
|
client := funkwhale.NewClient(serverURL, token)
|
||||||
assert.Equal(t, serverUrl, client.HttpClient.BaseURL)
|
assert.Equal(t, serverURL, client.HTTPClient.BaseURL)
|
||||||
assert.Equal(t, token, client.HttpClient.Token)
|
assert.Equal(t, token, client.HTTPClient.Token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHistoryListenings(t *testing.T) {
|
func TestGetHistoryListenings(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
serverUrl := "https://funkwhale.example.com"
|
serverURL := "https://funkwhale.example.com"
|
||||||
token := "thetoken"
|
token := "thetoken"
|
||||||
client := funkwhale.NewClient(serverUrl, token)
|
client := funkwhale.NewClient(serverURL, token)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://funkwhale.example.com/api/v1/history/listenings",
|
"https://funkwhale.example.com/api/v1/history/listenings",
|
||||||
"testdata/listenings.json")
|
"testdata/listenings.json")
|
||||||
|
|
||||||
|
@ -67,9 +67,9 @@ func TestGetFavoriteTracks(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
token := "thetoken"
|
token := "thetoken"
|
||||||
serverUrl := "https://funkwhale.example.com"
|
serverURL := "https://funkwhale.example.com"
|
||||||
client := funkwhale.NewClient(serverUrl, token)
|
client := funkwhale.NewClient(serverURL, token)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://funkwhale.example.com/api/v1/favorites/tracks",
|
"https://funkwhale.example.com/api/v1/favorites/tracks",
|
||||||
"testdata/favorite-tracks.json")
|
"testdata/favorite-tracks.json")
|
||||||
|
|
||||||
|
@ -87,7 +87,7 @@ func TestGetFavoriteTracks(t *testing.T) {
|
||||||
assert.Equal("phw", fav1.User.UserName)
|
assert.Equal("phw", fav1.User.UserName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||||
httpmock.ActivateNonDefault(client)
|
httpmock.ActivateNonDefault(client)
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||||
|
|
|
@ -20,6 +20,7 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
@ -50,13 +51,13 @@ func (b *FunkwhaleApiBackend) Options() []models.BackendOption {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *FunkwhaleApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *FunkwhaleApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
b.client = NewClient(
|
b.client = NewClient(
|
||||||
config.GetString("server-url"),
|
config.GetString("server-url"),
|
||||||
config.GetString("token"),
|
config.GetString("token"),
|
||||||
)
|
)
|
||||||
b.username = config.GetString("username")
|
b.username = config.GetString("username")
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||||
|
@ -175,7 +176,7 @@ func (f FavoriteTrack) AsLove() models.Love {
|
||||||
track := f.Track.AsTrack()
|
track := f.Track.AsTrack()
|
||||||
love := models.Love{
|
love := models.Love{
|
||||||
UserName: f.User.UserName,
|
UserName: f.User.UserName,
|
||||||
RecordingMbid: track.RecordingMbid,
|
RecordingMBID: track.RecordingMBID,
|
||||||
Track: track,
|
Track: track,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,16 +189,15 @@ func (f FavoriteTrack) AsLove() models.Love {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Track) AsTrack() models.Track {
|
func (t Track) AsTrack() models.Track {
|
||||||
recordingMbid := models.MBID(t.RecordingMbid)
|
|
||||||
track := models.Track{
|
track := models.Track{
|
||||||
TrackName: t.Title,
|
TrackName: t.Title,
|
||||||
ReleaseName: t.Album.Title,
|
ReleaseName: t.Album.Title,
|
||||||
ArtistNames: []string{t.Artist.Name},
|
ArtistNames: []string{t.Artist.Name},
|
||||||
TrackNumber: t.Position,
|
TrackNumber: t.Position,
|
||||||
DiscNumber: t.DiscNumber,
|
DiscNumber: t.DiscNumber,
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: t.RecordingMBID,
|
||||||
ReleaseMbid: models.MBID(t.Album.ReleaseMbid),
|
ReleaseMBID: t.Album.ReleaseMBID,
|
||||||
ArtistMbids: []models.MBID{models.MBID(t.Artist.ArtistMbid)},
|
ArtistMBIDs: []mbtypes.MBID{t.Artist.ArtistMBID},
|
||||||
Tags: t.Tags,
|
Tags: t.Tags,
|
||||||
AdditionalInfo: map[string]any{
|
AdditionalInfo: map[string]any{
|
||||||
"media_player": FunkwhaleClientName,
|
"media_player": FunkwhaleClientName,
|
||||||
|
|
|
@ -25,15 +25,15 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/funkwhale"
|
"go.uploadedlobster.com/scotty/internal/backends/funkwhale"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("token", "thetoken")
|
c.Set("token", "thetoken")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&funkwhale.FunkwhaleApiBackend{}).FromConfig(&service)
|
backend := funkwhale.FunkwhaleApiBackend{}
|
||||||
assert.IsType(t, &funkwhale.FunkwhaleApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFunkwhaleListeningAsListen(t *testing.T) {
|
func TestFunkwhaleListeningAsListen(t *testing.T) {
|
||||||
|
@ -44,17 +44,17 @@ func TestFunkwhaleListeningAsListen(t *testing.T) {
|
||||||
},
|
},
|
||||||
Track: funkwhale.Track{
|
Track: funkwhale.Track{
|
||||||
Title: "Oweynagat",
|
Title: "Oweynagat",
|
||||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||||
Position: 5,
|
Position: 5,
|
||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
Tags: []string{"foo", "bar"},
|
Tags: []string{"foo", "bar"},
|
||||||
Artist: funkwhale.Artist{
|
Artist: funkwhale.Artist{
|
||||||
Name: "Dool",
|
Name: "Dool",
|
||||||
ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131",
|
ArtistMBID: "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||||
},
|
},
|
||||||
Album: funkwhale.Album{
|
Album: funkwhale.Album{
|
||||||
Title: "Here Now, There Then",
|
Title: "Here Now, There Then",
|
||||||
ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
ReleaseMBID: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||||
},
|
},
|
||||||
Uploads: []funkwhale.Upload{
|
Uploads: []funkwhale.Upload{
|
||||||
{
|
{
|
||||||
|
@ -75,9 +75,9 @@ func TestFunkwhaleListeningAsListen(t *testing.T) {
|
||||||
assert.Equal(fwListen.Track.DiscNumber, listen.Track.DiscNumber)
|
assert.Equal(fwListen.Track.DiscNumber, listen.Track.DiscNumber)
|
||||||
assert.Equal(fwListen.Track.Tags, listen.Track.Tags)
|
assert.Equal(fwListen.Track.Tags, listen.Track.Tags)
|
||||||
// assert.Equal(backends.FunkwhaleClientName, listen.AdditionalInfo["disc_number"])
|
// assert.Equal(backends.FunkwhaleClientName, listen.AdditionalInfo["disc_number"])
|
||||||
assert.Equal(models.MBID(fwListen.Track.RecordingMbid), listen.RecordingMbid)
|
assert.Equal(fwListen.Track.RecordingMBID, listen.RecordingMBID)
|
||||||
assert.Equal(models.MBID(fwListen.Track.Album.ReleaseMbid), listen.ReleaseMbid)
|
assert.Equal(fwListen.Track.Album.ReleaseMBID, listen.ReleaseMBID)
|
||||||
assert.Equal(models.MBID(fwListen.Track.Artist.ArtistMbid), listen.ArtistMbids[0])
|
assert.Equal(fwListen.Track.Artist.ArtistMBID, listen.ArtistMBIDs[0])
|
||||||
assert.Equal(funkwhale.FunkwhaleClientName, listen.AdditionalInfo["media_player"])
|
assert.Equal(funkwhale.FunkwhaleClientName, listen.AdditionalInfo["media_player"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,17 +89,17 @@ func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) {
|
||||||
},
|
},
|
||||||
Track: funkwhale.Track{
|
Track: funkwhale.Track{
|
||||||
Title: "Oweynagat",
|
Title: "Oweynagat",
|
||||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||||
Position: 5,
|
Position: 5,
|
||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
Tags: []string{"foo", "bar"},
|
Tags: []string{"foo", "bar"},
|
||||||
Artist: funkwhale.Artist{
|
Artist: funkwhale.Artist{
|
||||||
Name: "Dool",
|
Name: "Dool",
|
||||||
ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131",
|
ArtistMBID: "24412926-c7bd-48e8-afad-8a285b42e131",
|
||||||
},
|
},
|
||||||
Album: funkwhale.Album{
|
Album: funkwhale.Album{
|
||||||
Title: "Here Now, There Then",
|
Title: "Here Now, There Then",
|
||||||
ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
ReleaseMBID: "d7f22677-9803-4d21-ba42-081b633a6f68",
|
||||||
},
|
},
|
||||||
Uploads: []funkwhale.Upload{
|
Uploads: []funkwhale.Upload{
|
||||||
{
|
{
|
||||||
|
@ -119,10 +119,10 @@ func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) {
|
||||||
assert.Equal(favorite.Track.Position, love.Track.TrackNumber)
|
assert.Equal(favorite.Track.Position, love.Track.TrackNumber)
|
||||||
assert.Equal(favorite.Track.DiscNumber, love.Track.DiscNumber)
|
assert.Equal(favorite.Track.DiscNumber, love.Track.DiscNumber)
|
||||||
assert.Equal(favorite.Track.Tags, love.Track.Tags)
|
assert.Equal(favorite.Track.Tags, love.Track.Tags)
|
||||||
assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.RecordingMbid)
|
assert.Equal(favorite.Track.RecordingMBID, love.RecordingMBID)
|
||||||
assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.Track.RecordingMbid)
|
assert.Equal(favorite.Track.RecordingMBID, love.Track.RecordingMBID)
|
||||||
assert.Equal(models.MBID(favorite.Track.Album.ReleaseMbid), love.ReleaseMbid)
|
assert.Equal(favorite.Track.Album.ReleaseMBID, love.ReleaseMBID)
|
||||||
require.Len(t, love.Track.ArtistMbids, 1)
|
require.Len(t, love.Track.ArtistMBIDs, 1)
|
||||||
assert.Equal(models.MBID(favorite.Track.Artist.ArtistMbid), love.ArtistMbids[0])
|
assert.Equal(favorite.Track.Artist.ArtistMBID, love.ArtistMBIDs[0])
|
||||||
assert.Equal(funkwhale.FunkwhaleClientName, love.AdditionalInfo["media_player"])
|
assert.Equal(funkwhale.FunkwhaleClientName, love.AdditionalInfo["media_player"])
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,8 @@ THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
package funkwhale
|
package funkwhale
|
||||||
|
|
||||||
|
import "go.uploadedlobster.com/mbtypes"
|
||||||
|
|
||||||
type ListeningsResult struct {
|
type ListeningsResult struct {
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Previous string `json:"previous"`
|
Previous string `json:"previous"`
|
||||||
|
@ -29,7 +31,7 @@ type ListeningsResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Listening struct {
|
type Listening struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
Track Track `json:"track"`
|
Track Track `json:"track"`
|
||||||
CreationDate string `json:"creation_date"`
|
CreationDate string `json:"creation_date"`
|
||||||
|
@ -43,41 +45,41 @@ type FavoriteTracksResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type FavoriteTrack struct {
|
type FavoriteTrack struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
Track Track `json:"track"`
|
Track Track `json:"track"`
|
||||||
CreationDate string `json:"creation_date"`
|
CreationDate string `json:"creation_date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
Artist Artist `json:"artist"`
|
Artist Artist `json:"artist"`
|
||||||
Album Album `json:"album"`
|
Album Album `json:"album"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Position int `json:"position"`
|
Position int `json:"position"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
RecordingMbid string `json:"mbid"`
|
RecordingMBID mbtypes.MBID `json:"mbid"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Uploads []Upload `json:"uploads"`
|
Uploads []Upload `json:"uploads"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ArtistMbid string `json:"mbid"`
|
ArtistMBID mbtypes.MBID `json:"mbid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Album struct {
|
type Album struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
AlbumArtist Artist `json:"artist"`
|
AlbumArtist Artist `json:"artist"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
TrackCount int `json:"track_count"`
|
TrackCount int `json:"track_count"`
|
||||||
ReleaseMbid string `json:"mbid"`
|
ReleaseMBID mbtypes.MBID `json:"mbid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int `json:"int"`
|
ID int `json:"int"`
|
||||||
UserName string `json:"username"`
|
UserName string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
Copyright © 2023-2024 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
This file is part of Scotty.
|
This file is part of Scotty.
|
||||||
|
|
||||||
|
@ -29,10 +29,8 @@ import (
|
||||||
|
|
||||||
type JSPFBackend struct {
|
type JSPFBackend struct {
|
||||||
filePath string
|
filePath string
|
||||||
title string
|
playlist jspf.Playlist
|
||||||
creator string
|
append bool
|
||||||
identifier string
|
|
||||||
tracks []jspf.Track
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *JSPFBackend) Name() string { return "jspf" }
|
func (b *JSPFBackend) Name() string { return "jspf" }
|
||||||
|
@ -42,6 +40,11 @@ func (b *JSPFBackend) Options() []models.BackendOption {
|
||||||
Name: "file-path",
|
Name: "file-path",
|
||||||
Label: i18n.Tr("File path"),
|
Label: i18n.Tr("File path"),
|
||||||
Type: models.String,
|
Type: models.String,
|
||||||
|
}, {
|
||||||
|
Name: "append",
|
||||||
|
Label: i18n.Tr("Append to file"),
|
||||||
|
Type: models.Bool,
|
||||||
|
Default: "true",
|
||||||
}, {
|
}, {
|
||||||
Name: "title",
|
Name: "title",
|
||||||
Label: i18n.Tr("Playlist title"),
|
Label: i18n.Tr("Playlist title"),
|
||||||
|
@ -57,25 +60,36 @@ func (b *JSPFBackend) Options() []models.BackendOption {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *JSPFBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
b.filePath = config.GetString("file-path")
|
b.filePath = config.GetString("file-path")
|
||||||
b.title = config.GetString("title")
|
b.append = config.GetBool("append", true)
|
||||||
b.creator = config.GetString("username")
|
b.playlist = jspf.Playlist{
|
||||||
b.identifier = config.GetString("identifier")
|
Title: config.GetString("title"),
|
||||||
b.tracks = make([]jspf.Track, 0)
|
Creator: config.GetString("username"),
|
||||||
return b
|
Identifier: config.GetString("identifier"),
|
||||||
|
Tracks: make([]jspf.Track, 0),
|
||||||
|
Extension: map[string]any{
|
||||||
|
jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{
|
||||||
|
LastModifiedAt: time.Now(),
|
||||||
|
Public: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSPFBackend) StartImport() error {
|
||||||
|
return b.readJSPF()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *JSPFBackend) StartImport() error { return nil }
|
|
||||||
func (b *JSPFBackend) FinishImport() error {
|
func (b *JSPFBackend) FinishImport() error {
|
||||||
err := b.writeJSPF(b.tracks)
|
return b.writeJSPF()
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||||
for _, listen := range export.Items {
|
for _, listen := range export.Items {
|
||||||
track := listenAsTrack(listen)
|
track := listenAsTrack(listen)
|
||||||
b.tracks = append(b.tracks, track)
|
b.playlist.Tracks = append(b.playlist.Tracks, track)
|
||||||
importResult.ImportCount += 1
|
importResult.ImportCount += 1
|
||||||
importResult.UpdateTimestamp(listen.ListenedAt)
|
importResult.UpdateTimestamp(listen.ListenedAt)
|
||||||
}
|
}
|
||||||
|
@ -87,7 +101,7 @@ func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult mo
|
||||||
func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||||
for _, love := range export.Items {
|
for _, love := range export.Items {
|
||||||
track := loveAsTrack(love)
|
track := loveAsTrack(love)
|
||||||
b.tracks = append(b.tracks, track)
|
b.playlist.Tracks = append(b.playlist.Tracks, track)
|
||||||
importResult.ImportCount += 1
|
importResult.ImportCount += 1
|
||||||
importResult.UpdateTimestamp(love.Created)
|
importResult.UpdateTimestamp(love.Created)
|
||||||
}
|
}
|
||||||
|
@ -102,10 +116,10 @@ func listenAsTrack(l models.Listen) jspf.Track {
|
||||||
extension := makeMusicBrainzExtension(l.Track)
|
extension := makeMusicBrainzExtension(l.Track)
|
||||||
extension.AddedAt = l.ListenedAt
|
extension.AddedAt = l.ListenedAt
|
||||||
extension.AddedBy = l.UserName
|
extension.AddedBy = l.UserName
|
||||||
track.Extension[jspf.MusicBrainzTrackExtensionId] = extension
|
track.Extension[jspf.MusicBrainzTrackExtensionID] = extension
|
||||||
|
|
||||||
if l.RecordingMbid != "" {
|
if l.RecordingMBID != "" {
|
||||||
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid))
|
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMBID))
|
||||||
}
|
}
|
||||||
|
|
||||||
return track
|
return track
|
||||||
|
@ -117,14 +131,14 @@ func loveAsTrack(l models.Love) jspf.Track {
|
||||||
extension := makeMusicBrainzExtension(l.Track)
|
extension := makeMusicBrainzExtension(l.Track)
|
||||||
extension.AddedAt = l.Created
|
extension.AddedAt = l.Created
|
||||||
extension.AddedBy = l.UserName
|
extension.AddedBy = l.UserName
|
||||||
track.Extension[jspf.MusicBrainzTrackExtensionId] = extension
|
track.Extension[jspf.MusicBrainzTrackExtensionID] = extension
|
||||||
|
|
||||||
recordingMbid := l.Track.RecordingMbid
|
recordingMBID := l.Track.RecordingMBID
|
||||||
if l.RecordingMbid != "" {
|
if l.RecordingMBID != "" {
|
||||||
recordingMbid = l.RecordingMbid
|
recordingMBID = l.RecordingMBID
|
||||||
}
|
}
|
||||||
if recordingMbid != "" {
|
if recordingMBID != "" {
|
||||||
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMbid))
|
track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMBID))
|
||||||
}
|
}
|
||||||
|
|
||||||
return track
|
return track
|
||||||
|
@ -145,15 +159,15 @@ func trackAsTrack(t models.Track) jspf.Track {
|
||||||
func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension {
|
func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension {
|
||||||
extension := jspf.MusicBrainzTrackExtension{
|
extension := jspf.MusicBrainzTrackExtension{
|
||||||
AdditionalMetadata: t.AdditionalInfo,
|
AdditionalMetadata: t.AdditionalInfo,
|
||||||
ArtistIdentifiers: make([]string, len(t.ArtistMbids)),
|
ArtistIdentifiers: make([]string, len(t.ArtistMBIDs)),
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, mbid := range t.ArtistMbids {
|
for i, mbid := range t.ArtistMBIDs {
|
||||||
extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid)
|
extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid)
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.ReleaseMbid != "" {
|
if t.ReleaseMBID != "" {
|
||||||
extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMbid)
|
extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMBID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tracknumber tag would be redundant
|
// The tracknumber tag would be redundant
|
||||||
|
@ -162,21 +176,38 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension {
|
||||||
return extension
|
return extension
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b JSPFBackend) writeJSPF(tracks []jspf.Track) error {
|
func (b *JSPFBackend) readJSPF() error {
|
||||||
|
if b.append {
|
||||||
|
file, err := os.Open(b.filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer file.Close()
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat.Size() == 0 {
|
||||||
|
// Zero length file, treat as a new file
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
playlist := jspf.JSPF{}
|
||||||
|
err := playlist.Read(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.playlist = playlist.Playlist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSPFBackend) writeJSPF() error {
|
||||||
playlist := jspf.JSPF{
|
playlist := jspf.JSPF{
|
||||||
Playlist: jspf.Playlist{
|
Playlist: b.playlist,
|
||||||
Title: b.title,
|
|
||||||
Creator: b.creator,
|
|
||||||
Identifier: b.identifier,
|
|
||||||
Date: time.Now(),
|
|
||||||
Tracks: tracks,
|
|
||||||
Extension: map[string]any{
|
|
||||||
jspf.MusicBrainzPlaylistExtensionId: jspf.MusicBrainzPlaylistExtension{
|
|
||||||
LastModifiedAt: time.Now(),
|
|
||||||
Public: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Create(b.filePath)
|
file, err := os.Create(b.filePath)
|
||||||
|
|
|
@ -26,13 +26,14 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("file-path", "/foo/bar.jspf")
|
c.Set("file-path", "/foo/bar.jspf")
|
||||||
c.Set("title", "My Playlist")
|
c.Set("title", "My Playlist")
|
||||||
c.Set("username", "outsidecontext")
|
c.Set("username", "outsidecontext")
|
||||||
c.Set("identifier", "http://example.com/playlist1")
|
c.Set("identifier", "http://example.com/playlist1")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&jspf.JSPFBackend{}).FromConfig(&service)
|
backend := jspf.JSPFBackend{}
|
||||||
assert.IsType(t, &jspf.JSPFBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,21 +25,21 @@ import (
|
||||||
|
|
||||||
type lastfmStrategy struct {
|
type lastfmStrategy struct {
|
||||||
client *lastfm.Api
|
client *lastfm.Api
|
||||||
redirectUrl *url.URL
|
redirectURL *url.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s lastfmStrategy) Config() oauth2.Config {
|
func (s lastfmStrategy) Config() oauth2.Config {
|
||||||
return oauth2.Config{}
|
return oauth2.Config{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl {
|
func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL {
|
||||||
// Last.fm does not use OAuth2, but the provided authorization flow with
|
// Last.fm does not use OAuth2, but the provided authorization flow with
|
||||||
// callback URL is close enough we can shoehorn it into the existing
|
// callback URL is close enough we can shoehorn it into the existing
|
||||||
// authentication strategy.
|
// authentication strategy.
|
||||||
// TODO: Investigate and use callback-less flow with api.GetAuthTokenUrl(token)
|
// TODO: Investigate and use callback-less flow with api.GetAuthTokenUrl(token)
|
||||||
url := s.client.GetAuthRequestUrl(s.redirectUrl.String())
|
url := s.client.GetAuthRequestUrl(s.redirectURL.String())
|
||||||
return auth.AuthUrl{
|
return auth.AuthURL{
|
||||||
Url: url,
|
URL: url,
|
||||||
State: "", // last.fm does not use state
|
State: "", // last.fm does not use state
|
||||||
Param: "token",
|
Param: "token",
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shkh/lastfm-go/lastfm"
|
"github.com/shkh/lastfm-go/lastfm"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/auth"
|
"go.uploadedlobster.com/scotty/internal/auth"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
|
@ -60,21 +61,21 @@ func (b *LastfmApiBackend) Options() []models.BackendOption {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *LastfmApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
clientId := config.GetString("client-id")
|
clientID := config.GetString("client-id")
|
||||||
clientSecret := config.GetString("client-secret")
|
clientSecret := config.GetString("client-secret")
|
||||||
b.client = lastfm.New(clientId, clientSecret)
|
b.client = lastfm.New(clientID, clientSecret)
|
||||||
b.username = config.GetString("username")
|
b.username = config.GetString("username")
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *LastfmApiBackend) StartImport() error { return nil }
|
func (b *LastfmApiBackend) StartImport() error { return nil }
|
||||||
func (b *LastfmApiBackend) FinishImport() error { return nil }
|
func (b *LastfmApiBackend) FinishImport() error { return nil }
|
||||||
|
|
||||||
func (b *LastfmApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
func (b *LastfmApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
|
||||||
return lastfmStrategy{
|
return lastfmStrategy{
|
||||||
client: b.client,
|
client: b.client,
|
||||||
redirectUrl: redirectUrl,
|
redirectURL: redirectURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,16 +141,16 @@ out:
|
||||||
TrackName: scrobble.Name,
|
TrackName: scrobble.Name,
|
||||||
ArtistNames: []string{},
|
ArtistNames: []string{},
|
||||||
ReleaseName: scrobble.Album.Name,
|
ReleaseName: scrobble.Album.Name,
|
||||||
RecordingMbid: models.MBID(scrobble.Mbid),
|
RecordingMBID: mbtypes.MBID(scrobble.Mbid),
|
||||||
ArtistMbids: []models.MBID{},
|
ArtistMBIDs: []mbtypes.MBID{},
|
||||||
ReleaseMbid: models.MBID(scrobble.Album.Mbid),
|
ReleaseMBID: mbtypes.MBID(scrobble.Album.Mbid),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if scrobble.Artist.Name != "" {
|
if scrobble.Artist.Name != "" {
|
||||||
listen.Track.ArtistNames = []string{scrobble.Artist.Name}
|
listen.Track.ArtistNames = []string{scrobble.Artist.Name}
|
||||||
}
|
}
|
||||||
if scrobble.Artist.Mbid != "" {
|
if scrobble.Artist.Mbid != "" {
|
||||||
listen.Track.ArtistMbids = []models.MBID{models.MBID(scrobble.Artist.Mbid)}
|
listen.Track.ArtistMBIDs = []mbtypes.MBID{mbtypes.MBID(scrobble.Artist.Mbid)}
|
||||||
}
|
}
|
||||||
listens = append(listens, listen)
|
listens = append(listens, listen)
|
||||||
} else {
|
} else {
|
||||||
|
@ -203,8 +204,8 @@ func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResu
|
||||||
if l.TrackNumber > 0 {
|
if l.TrackNumber > 0 {
|
||||||
trackNumbers = append(trackNumbers, strconv.Itoa(l.TrackNumber))
|
trackNumbers = append(trackNumbers, strconv.Itoa(l.TrackNumber))
|
||||||
}
|
}
|
||||||
if l.RecordingMbid != "" {
|
if l.RecordingMBID != "" {
|
||||||
mbids = append(mbids, string(l.RecordingMbid))
|
mbids = append(mbids, string(l.RecordingMBID))
|
||||||
}
|
}
|
||||||
// if l.ReleaseArtist != "" {
|
// if l.ReleaseArtist != "" {
|
||||||
// albumArtists = append(albums, l.ReleaseArtist)
|
// albumArtists = append(albums, l.ReleaseArtist)
|
||||||
|
@ -236,7 +237,7 @@ func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResu
|
||||||
for _, s := range result.Scrobbles {
|
for _, s := range result.Scrobbles {
|
||||||
ignoreMsg := s.IgnoredMessage.Body
|
ignoreMsg := s.IgnoredMessage.Body
|
||||||
if ignoreMsg != "" {
|
if ignoreMsg != "" {
|
||||||
importResult.ImportErrors = append(importResult.ImportErrors, ignoreMsg)
|
importResult.Log(models.Warning, ignoreMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err := fmt.Errorf("last.fm import ignored %v scrobbles", count-accepted)
|
err := fmt.Errorf("last.fm import ignored %v scrobbles", count-accepted)
|
||||||
|
@ -294,12 +295,12 @@ out:
|
||||||
love := models.Love{
|
love := models.Love{
|
||||||
Created: time.Unix(timestamp, 0),
|
Created: time.Unix(timestamp, 0),
|
||||||
UserName: result.User,
|
UserName: result.User,
|
||||||
RecordingMbid: models.MBID(track.Mbid),
|
RecordingMBID: mbtypes.MBID(track.Mbid),
|
||||||
Track: models.Track{
|
Track: models.Track{
|
||||||
TrackName: track.Name,
|
TrackName: track.Name,
|
||||||
ArtistNames: []string{track.Artist.Name},
|
ArtistNames: []string{track.Artist.Name},
|
||||||
RecordingMbid: models.MBID(track.Mbid),
|
RecordingMBID: mbtypes.MBID(track.Mbid),
|
||||||
ArtistMbids: []models.MBID{models.MBID(track.Artist.Mbid)},
|
ArtistMBIDs: []mbtypes.MBID{mbtypes.MBID(track.Artist.Mbid)},
|
||||||
AdditionalInfo: models.AdditionalInfo{
|
AdditionalInfo: models.AdditionalInfo{
|
||||||
"lastfm_url": track.Url,
|
"lastfm_url": track.Url,
|
||||||
},
|
},
|
||||||
|
@ -335,7 +336,7 @@ func (b *LastfmApiBackend) ImportLoves(export models.LovesResult, importResult m
|
||||||
} else {
|
} else {
|
||||||
msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v",
|
msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v",
|
||||||
love.TrackName, love.ArtistName(), err.Error())
|
love.TrackName, love.ArtistName(), err.Error())
|
||||||
importResult.ImportErrors = append(importResult.ImportErrors, msg)
|
importResult.Log(models.Error, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
progress <- models.Progress{}.FromImportResult(importResult)
|
progress <- models.Progress{}.FromImportResult(importResult)
|
||||||
|
|
|
@ -27,8 +27,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-resty/resty/v2"
|
"github.com/go-resty/resty/v2"
|
||||||
"go.uploadedlobster.com/scotty/internal/ratelimit"
|
|
||||||
"go.uploadedlobster.com/scotty/internal/version"
|
"go.uploadedlobster.com/scotty/internal/version"
|
||||||
|
"go.uploadedlobster.com/scotty/pkg/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -39,7 +39,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HttpClient *resty.Client
|
HTTPClient *resty.Client
|
||||||
MaxResults int
|
MaxResults int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +55,7 @@ func NewClient(token string) Client {
|
||||||
ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In")
|
ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In")
|
||||||
|
|
||||||
return Client{
|
return Client{
|
||||||
HttpClient: client,
|
HTTPClient: client,
|
||||||
MaxResults: DefaultItemsPerGet,
|
MaxResults: DefaultItemsPerGet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -63,7 +63,7 @@ func NewClient(token string) Client {
|
||||||
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
|
func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (result GetListensResult, err error) {
|
||||||
const path = "/user/{username}/listens"
|
const path = "/user/{username}/listens"
|
||||||
errorResult := ErrorResult{}
|
errorResult := ErrorResult{}
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetPathParam("username", user).
|
SetPathParam("username", user).
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"max_ts": strconv.FormatInt(maxTime.Unix(), 10),
|
"max_ts": strconv.FormatInt(maxTime.Unix(), 10),
|
||||||
|
@ -74,7 +74,7 @@ func (c Client) GetListens(user string, maxTime time.Time, minTime time.Time) (r
|
||||||
SetError(&errorResult).
|
SetError(&errorResult).
|
||||||
Get(path)
|
Get(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(errorResult.Error)
|
err = errors.New(errorResult.Error)
|
||||||
return
|
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) {
|
func (c Client) SubmitListens(listens ListenSubmission) (result StatusResult, err error) {
|
||||||
const path = "/submit-listens"
|
const path = "/submit-listens"
|
||||||
errorResult := ErrorResult{}
|
errorResult := ErrorResult{}
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetBody(listens).
|
SetBody(listens).
|
||||||
SetResult(&result).
|
SetResult(&result).
|
||||||
SetError(&errorResult).
|
SetError(&errorResult).
|
||||||
Post(path)
|
Post(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(errorResult.Error)
|
err = errors.New(errorResult.Error)
|
||||||
return
|
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) {
|
func (c Client) GetFeedback(user string, status int, offset int) (result GetFeedbackResult, err error) {
|
||||||
const path = "/feedback/user/{username}/get-feedback"
|
const path = "/feedback/user/{username}/get-feedback"
|
||||||
errorResult := ErrorResult{}
|
errorResult := ErrorResult{}
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetPathParam("username", user).
|
SetPathParam("username", user).
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"status": strconv.Itoa(status),
|
"status": strconv.Itoa(status),
|
||||||
|
@ -112,7 +112,7 @@ func (c Client) GetFeedback(user string, status int, offset int) (result GetFeed
|
||||||
SetError(&errorResult).
|
SetError(&errorResult).
|
||||||
Get(path)
|
Get(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(errorResult.Error)
|
err = errors.New(errorResult.Error)
|
||||||
return
|
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) {
|
func (c Client) SendFeedback(feedback Feedback) (result StatusResult, err error) {
|
||||||
const path = "/feedback/recording-feedback"
|
const path = "/feedback/recording-feedback"
|
||||||
errorResult := ErrorResult{}
|
errorResult := ErrorResult{}
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetBody(feedback).
|
SetBody(feedback).
|
||||||
SetResult(&result).
|
SetResult(&result).
|
||||||
SetError(&errorResult).
|
SetError(&errorResult).
|
||||||
Post(path)
|
Post(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(errorResult.Error)
|
err = errors.New(errorResult.Error)
|
||||||
return
|
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) {
|
func (c Client) Lookup(recordingName string, artistName string) (result LookupResult, err error) {
|
||||||
const path = "/metadata/lookup"
|
const path = "/metadata/lookup"
|
||||||
errorResult := ErrorResult{}
|
errorResult := ErrorResult{}
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"recording_name": recordingName,
|
"recording_name": recordingName,
|
||||||
"artist_name": artistName,
|
"artist_name": artistName,
|
||||||
|
@ -147,7 +147,7 @@ func (c Client) Lookup(recordingName string, artistName string) (result LookupRe
|
||||||
SetError(&errorResult).
|
SetError(&errorResult).
|
||||||
Get(path)
|
Get(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(errorResult.Error)
|
err = errors.New(errorResult.Error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,13 +29,14 @@ import (
|
||||||
"github.com/jarcoal/httpmock"
|
"github.com/jarcoal/httpmock"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
func TestNewClient(t *testing.T) {
|
||||||
token := "foobar123"
|
token := "foobar123"
|
||||||
client := listenbrainz.NewClient(token)
|
client := listenbrainz.NewClient(token)
|
||||||
assert.Equal(t, token, client.HttpClient.Token)
|
assert.Equal(t, token, client.HTTPClient.Token)
|
||||||
assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults)
|
assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +45,7 @@ func TestGetListens(t *testing.T) {
|
||||||
|
|
||||||
client := listenbrainz.NewClient("thetoken")
|
client := listenbrainz.NewClient("thetoken")
|
||||||
client.MaxResults = 2
|
client.MaxResults = 2
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.listenbrainz.org/1/user/outsidecontext/listens",
|
"https://api.listenbrainz.org/1/user/outsidecontext/listens",
|
||||||
"testdata/listens.json")
|
"testdata/listens.json")
|
||||||
|
|
||||||
|
@ -61,7 +62,7 @@ func TestGetListens(t *testing.T) {
|
||||||
|
|
||||||
func TestSubmitListens(t *testing.T) {
|
func TestSubmitListens(t *testing.T) {
|
||||||
client := listenbrainz.NewClient("thetoken")
|
client := listenbrainz.NewClient("thetoken")
|
||||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
|
@ -102,7 +103,7 @@ func TestGetFeedback(t *testing.T) {
|
||||||
|
|
||||||
client := listenbrainz.NewClient("thetoken")
|
client := listenbrainz.NewClient("thetoken")
|
||||||
client.MaxResults = 2
|
client.MaxResults = 2
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback",
|
"https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback",
|
||||||
"testdata/feedback.json")
|
"testdata/feedback.json")
|
||||||
|
|
||||||
|
@ -114,12 +115,12 @@ func TestGetFeedback(t *testing.T) {
|
||||||
assert.Equal(302, result.TotalCount)
|
assert.Equal(302, result.TotalCount)
|
||||||
assert.Equal(3, result.Offset)
|
assert.Equal(3, result.Offset)
|
||||||
require.Len(t, result.Feedback, 2)
|
require.Len(t, result.Feedback, 2)
|
||||||
assert.Equal("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", result.Feedback[0].RecordingMbid)
|
assert.Equal(mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), result.Feedback[0].RecordingMBID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendFeedback(t *testing.T) {
|
func TestSendFeedback(t *testing.T) {
|
||||||
client := listenbrainz.NewClient("thetoken")
|
client := listenbrainz.NewClient("thetoken")
|
||||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
|
@ -131,7 +132,7 @@ func TestSendFeedback(t *testing.T) {
|
||||||
httpmock.RegisterResponder("POST", url, responder)
|
httpmock.RegisterResponder("POST", url, responder)
|
||||||
|
|
||||||
feedback := listenbrainz.Feedback{
|
feedback := listenbrainz.Feedback{
|
||||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||||
Score: 1,
|
Score: 1,
|
||||||
}
|
}
|
||||||
result, err := client.SendFeedback(feedback)
|
result, err := client.SendFeedback(feedback)
|
||||||
|
@ -144,7 +145,7 @@ func TestLookup(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
client := listenbrainz.NewClient("thetoken")
|
client := listenbrainz.NewClient("thetoken")
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.listenbrainz.org/1/metadata/lookup",
|
"https://api.listenbrainz.org/1/metadata/lookup",
|
||||||
"testdata/lookup.json")
|
"testdata/lookup.json")
|
||||||
|
|
||||||
|
@ -154,10 +155,10 @@ func TestLookup(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
assert.Equal("Say Just Words", result.RecordingName)
|
assert.Equal("Say Just Words", result.RecordingName)
|
||||||
assert.Equal("Paradise Lost", result.ArtistCreditName)
|
assert.Equal("Paradise Lost", result.ArtistCreditName)
|
||||||
assert.Equal("569436a1-234a-44bc-a370-8f4d252bef21", result.RecordingMbid)
|
assert.Equal(mbtypes.MBID("569436a1-234a-44bc-a370-8f4d252bef21"), result.RecordingMBID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||||
httpmock.ActivateNonDefault(client)
|
httpmock.ActivateNonDefault(client)
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||||
|
|
|
@ -21,16 +21,21 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
|
"go.uploadedlobster.com/musicbrainzws2"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/similarity"
|
||||||
"go.uploadedlobster.com/scotty/internal/version"
|
"go.uploadedlobster.com/scotty/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListenBrainzApiBackend struct {
|
type ListenBrainzApiBackend struct {
|
||||||
client Client
|
client Client
|
||||||
|
mbClient musicbrainzws2.Client
|
||||||
username string
|
username string
|
||||||
existingMbids map[string]bool
|
checkDuplicates bool
|
||||||
|
existingMBIDs map[mbtypes.MBID]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" }
|
func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" }
|
||||||
|
@ -44,14 +49,24 @@ func (b *ListenBrainzApiBackend) Options() []models.BackendOption {
|
||||||
Name: "token",
|
Name: "token",
|
||||||
Label: i18n.Tr("Access token"),
|
Label: i18n.Tr("Access token"),
|
||||||
Type: models.Secret,
|
Type: models.Secret,
|
||||||
|
}, {
|
||||||
|
Name: "check-duplicate-listens",
|
||||||
|
Label: i18n.Tr("Check for duplicate listens on import (slower)"),
|
||||||
|
Type: models.Bool,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ListenBrainzApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *ListenBrainzApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
b.client = NewClient(config.GetString("token"))
|
b.client = NewClient(config.GetString("token"))
|
||||||
|
b.mbClient = *musicbrainzws2.NewClient(musicbrainzws2.AppInfo{
|
||||||
|
Name: version.AppName,
|
||||||
|
Version: version.AppVersion,
|
||||||
|
URL: version.AppURL,
|
||||||
|
})
|
||||||
b.client.MaxResults = MaxItemsPerGet
|
b.client.MaxResults = MaxItemsPerGet
|
||||||
b.username = config.GetString("username")
|
b.username = config.GetString("username")
|
||||||
return b
|
b.checkDuplicates = config.GetBool("check-duplicate-listens", false)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ListenBrainzApiBackend) StartImport() error { return nil }
|
func (b *ListenBrainzApiBackend) StartImport() error { return nil }
|
||||||
|
@ -117,6 +132,7 @@ func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, result
|
||||||
|
|
||||||
func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||||
total := len(export.Items)
|
total := len(export.Items)
|
||||||
|
p := models.Progress{}.FromImportResult(importResult)
|
||||||
for i := 0; i < total; i += MaxListensPerRequest {
|
for i := 0; i < total; i += MaxListensPerRequest {
|
||||||
listens := export.Items[i:min(i+MaxListensPerRequest, total)]
|
listens := export.Items[i:min(i+MaxListensPerRequest, total)]
|
||||||
count := len(listens)
|
count := len(listens)
|
||||||
|
@ -130,6 +146,21 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, l := range listens {
|
for _, l := range listens {
|
||||||
|
if b.checkDuplicates {
|
||||||
|
isDupe, err := b.checkDuplicateListen(l)
|
||||||
|
p.Elapsed += 1
|
||||||
|
progress <- p
|
||||||
|
if err != nil {
|
||||||
|
return importResult, err
|
||||||
|
} else if isDupe {
|
||||||
|
count -= 1
|
||||||
|
msg := i18n.Tr("Ignored duplicate listen %v: \"%v\" by %v (%v)",
|
||||||
|
l.ListenedAt, l.TrackName, l.ArtistName(), l.RecordingMBID)
|
||||||
|
importResult.Log(models.Info, msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
l.FillAdditionalInfo()
|
l.FillAdditionalInfo()
|
||||||
listen := Listen{
|
listen := Listen{
|
||||||
ListenedAt: l.ListenedAt.Unix(),
|
ListenedAt: l.ListenedAt.Unix(),
|
||||||
|
@ -142,23 +173,49 @@ func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, impo
|
||||||
}
|
}
|
||||||
listen.TrackMetadata.AdditionalInfo["submission_client"] = version.AppName
|
listen.TrackMetadata.AdditionalInfo["submission_client"] = version.AppName
|
||||||
listen.TrackMetadata.AdditionalInfo["submission_client_version"] = version.AppVersion
|
listen.TrackMetadata.AdditionalInfo["submission_client_version"] = version.AppVersion
|
||||||
|
|
||||||
submission.Payload = append(submission.Payload, listen)
|
submission.Payload = append(submission.Payload, listen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(submission.Payload) > 0 {
|
||||||
_, err := b.client.SubmitListens(submission)
|
_, err := b.client.SubmitListens(submission)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return importResult, err
|
return importResult, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
importResult.UpdateTimestamp(listens[count-1].ListenedAt)
|
importResult.UpdateTimestamp(listens[count-1].ListenedAt)
|
||||||
|
}
|
||||||
importResult.ImportCount += count
|
importResult.ImportCount += count
|
||||||
progress <- models.Progress{}.FromImportResult(importResult)
|
progress <- p.FromImportResult(importResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
return importResult, nil
|
return importResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||||
|
defer close(results)
|
||||||
|
exportChan := make(chan models.LovesResult)
|
||||||
|
p := models.Progress{}
|
||||||
|
|
||||||
|
go b.exportLoves(time.Unix(0, 0), exportChan)
|
||||||
|
for existingLoves := range exportChan {
|
||||||
|
if existingLoves.Error != nil {
|
||||||
|
progress <- p.Complete()
|
||||||
|
results <- models.LovesResult{Error: existingLoves.Error}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Total = int64(existingLoves.Total)
|
||||||
|
p.Elapsed += int64(existingLoves.Items.Len())
|
||||||
|
progress <- p
|
||||||
|
results <- existingLoves
|
||||||
|
}
|
||||||
|
|
||||||
|
progress <- p.Complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListenBrainzApiBackend) exportLoves(oldestTimestamp time.Time, results chan models.LovesResult) {
|
||||||
offset := 0
|
offset := 0
|
||||||
defer close(results)
|
defer close(results)
|
||||||
loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
|
loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
|
||||||
|
@ -168,7 +225,6 @@ out:
|
||||||
for {
|
for {
|
||||||
result, err := b.client.GetFeedback(b.username, 1, offset)
|
result, err := b.client.GetFeedback(b.username, 1, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
progress <- p.Complete()
|
|
||||||
results <- models.LovesResult{Error: err}
|
results <- models.LovesResult{Error: err}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -179,11 +235,20 @@ out:
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, feedback := range result.Feedback {
|
for _, feedback := range result.Feedback {
|
||||||
|
// Missing track metadata indicates that the recording MBID is no
|
||||||
|
// longer available and might have been merged. Try fetching details
|
||||||
|
// from MusicBrainz.
|
||||||
|
if feedback.TrackMetadata == nil {
|
||||||
|
track, err := b.lookupRecording(feedback.RecordingMBID)
|
||||||
|
if err == nil {
|
||||||
|
feedback.TrackMetadata = track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
love := feedback.AsLove()
|
love := feedback.AsLove()
|
||||||
if love.Created.Unix() > oldestTimestamp.Unix() {
|
if love.Created.Unix() > oldestTimestamp.Unix() {
|
||||||
loves = append(loves, love)
|
loves = append(loves, love)
|
||||||
p.Elapsed += 1
|
p.Elapsed += 1
|
||||||
progress <- p
|
|
||||||
} else {
|
} else {
|
||||||
break out
|
break out
|
||||||
}
|
}
|
||||||
|
@ -196,49 +261,65 @@ out:
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Sort(loves)
|
sort.Sort(loves)
|
||||||
progress <- p.Complete()
|
results <- models.LovesResult{
|
||||||
results <- models.LovesResult{Items: loves}
|
Total: len(loves),
|
||||||
|
Items: loves,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||||
if len(b.existingMbids) == 0 {
|
if len(b.existingMBIDs) == 0 {
|
||||||
existingLovesChan := make(chan models.LovesResult)
|
existingLovesChan := make(chan models.LovesResult)
|
||||||
go b.ExportLoves(time.Unix(0, 0), existingLovesChan, progress)
|
go b.exportLoves(time.Unix(0, 0), existingLovesChan)
|
||||||
existingLoves := <-existingLovesChan
|
|
||||||
|
// TODO: Store MBIDs directly
|
||||||
|
b.existingMBIDs = make(map[mbtypes.MBID]bool, MaxItemsPerGet)
|
||||||
|
|
||||||
|
for existingLoves := range existingLovesChan {
|
||||||
if existingLoves.Error != nil {
|
if existingLoves.Error != nil {
|
||||||
return importResult, existingLoves.Error
|
return importResult, existingLoves.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Store MBIDs directly
|
|
||||||
b.existingMbids = make(map[string]bool, len(existingLoves.Items))
|
|
||||||
for _, love := range existingLoves.Items {
|
for _, love := range existingLoves.Items {
|
||||||
b.existingMbids[string(love.RecordingMbid)] = true
|
b.existingMBIDs[love.RecordingMBID] = true
|
||||||
|
// In case the loved MBID got merged the track MBID represents the
|
||||||
|
// actual recording MBID.
|
||||||
|
if love.Track.RecordingMBID != "" &&
|
||||||
|
love.Track.RecordingMBID != love.RecordingMBID {
|
||||||
|
b.existingMBIDs[love.Track.RecordingMBID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, love := range export.Items {
|
for _, love := range export.Items {
|
||||||
recordingMbid := string(love.RecordingMbid)
|
recordingMBID := love.RecordingMBID
|
||||||
|
if recordingMBID == "" {
|
||||||
|
recordingMBID = love.Track.RecordingMBID
|
||||||
|
}
|
||||||
|
|
||||||
if recordingMbid == "" {
|
if recordingMBID == "" {
|
||||||
lookup, err := b.client.Lookup(love.TrackName, love.ArtistName())
|
lookup, err := b.client.Lookup(love.TrackName, love.ArtistName())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
recordingMbid = lookup.RecordingMbid
|
recordingMBID = lookup.RecordingMBID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if recordingMbid != "" {
|
if recordingMBID != "" {
|
||||||
ok := false
|
ok := false
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if b.existingMbids[recordingMbid] {
|
if b.existingMBIDs[recordingMBID] {
|
||||||
ok = true
|
ok = true
|
||||||
} else {
|
} else {
|
||||||
resp, err := b.client.SendFeedback(Feedback{
|
resp, err := b.client.SendFeedback(Feedback{
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: recordingMBID,
|
||||||
Score: 1,
|
Score: 1,
|
||||||
})
|
})
|
||||||
ok = err == nil && resp.Status == "ok"
|
ok = err == nil && resp.Status == "ok"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
|
} else {
|
||||||
|
b.existingMBIDs[recordingMBID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,8 +329,12 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe
|
||||||
} else {
|
} else {
|
||||||
msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v",
|
msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v",
|
||||||
love.TrackName, love.ArtistName(), errMsg)
|
love.TrackName, love.ArtistName(), errMsg)
|
||||||
importResult.ImportErrors = append(importResult.ImportErrors, msg)
|
importResult.Log(models.Error, msg)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
msg := fmt.Sprintf("Failed import of \"%s\" by %s: no recording MBID",
|
||||||
|
love.TrackName, love.ArtistName())
|
||||||
|
importResult.Log(models.Error, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
progress <- models.Progress{}.FromImportResult(importResult)
|
progress <- models.Progress{}.FromImportResult(importResult)
|
||||||
|
@ -258,6 +343,58 @@ func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importRe
|
||||||
return importResult, nil
|
return importResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var defaultDuration = time.Duration(3 * time.Minute)
|
||||||
|
|
||||||
|
const trackSimilarityThreshold = 0.9
|
||||||
|
|
||||||
|
func (b *ListenBrainzApiBackend) checkDuplicateListen(listen models.Listen) (bool, error) {
|
||||||
|
// Find listens
|
||||||
|
duration := listen.Duration
|
||||||
|
if duration == 0 {
|
||||||
|
duration = defaultDuration
|
||||||
|
}
|
||||||
|
minTime := listen.ListenedAt.Add(-duration)
|
||||||
|
maxTime := listen.ListenedAt.Add(duration)
|
||||||
|
candidates, err := b.client.GetListens(b.username, maxTime, minTime)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range candidates.Payload.Listens {
|
||||||
|
sim := similarity.CompareTracks(listen.Track, c.TrackMetadata.AsTrack())
|
||||||
|
if sim >= trackSimilarityThreshold {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ListenBrainzApiBackend) lookupRecording(mbid mbtypes.MBID) (*Track, error) {
|
||||||
|
filter := musicbrainzws2.IncludesFilter{
|
||||||
|
Includes: []string{"artist-credits"},
|
||||||
|
}
|
||||||
|
recording, err := b.mbClient.LookupRecording(mbid, filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artistMBIDs := make([]mbtypes.MBID, 0, len(recording.ArtistCredit))
|
||||||
|
for _, artist := range recording.ArtistCredit {
|
||||||
|
artistMBIDs = append(artistMBIDs, artist.Artist.ID)
|
||||||
|
}
|
||||||
|
track := Track{
|
||||||
|
TrackName: recording.Title,
|
||||||
|
ArtistName: recording.ArtistCredit.String(),
|
||||||
|
MBIDMapping: &MBIDMapping{
|
||||||
|
// In case of redirects this MBID differs from the looked up MBID
|
||||||
|
RecordingMBID: recording.ID,
|
||||||
|
ArtistMBIDs: artistMBIDs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &track, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (lbListen Listen) AsListen() models.Listen {
|
func (lbListen Listen) AsListen() models.Listen {
|
||||||
listen := models.Listen{
|
listen := models.Listen{
|
||||||
ListenedAt: time.Unix(lbListen.ListenedAt, 0),
|
ListenedAt: time.Unix(lbListen.ListenedAt, 0),
|
||||||
|
@ -268,20 +405,20 @@ func (lbListen Listen) AsListen() models.Listen {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f Feedback) AsLove() models.Love {
|
func (f Feedback) AsLove() models.Love {
|
||||||
recordingMbid := models.MBID(f.RecordingMbid)
|
recordingMBID := f.RecordingMBID
|
||||||
track := f.TrackMetadata
|
track := f.TrackMetadata
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track = &Track{}
|
track = &Track{}
|
||||||
}
|
}
|
||||||
love := models.Love{
|
love := models.Love{
|
||||||
UserName: f.UserName,
|
UserName: f.UserName,
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: recordingMBID,
|
||||||
Created: time.Unix(f.Created, 0),
|
Created: time.Unix(f.Created, 0),
|
||||||
Track: track.AsTrack(),
|
Track: track.AsTrack(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if love.Track.RecordingMbid == "" {
|
if love.Track.RecordingMBID == "" {
|
||||||
love.Track.RecordingMbid = love.RecordingMbid
|
love.Track.RecordingMBID = love.RecordingMBID
|
||||||
}
|
}
|
||||||
|
|
||||||
return love
|
return love
|
||||||
|
@ -295,16 +432,16 @@ func (t Track) AsTrack() models.Track {
|
||||||
Duration: t.Duration(),
|
Duration: t.Duration(),
|
||||||
TrackNumber: t.TrackNumber(),
|
TrackNumber: t.TrackNumber(),
|
||||||
DiscNumber: t.DiscNumber(),
|
DiscNumber: t.DiscNumber(),
|
||||||
RecordingMbid: models.MBID(t.RecordingMbid()),
|
RecordingMBID: t.RecordingMBID(),
|
||||||
ReleaseMbid: models.MBID(t.ReleaseMbid()),
|
ReleaseMBID: t.ReleaseMBID(),
|
||||||
ReleaseGroupMbid: models.MBID(t.ReleaseGroupMbid()),
|
ReleaseGroupMBID: t.ReleaseGroupMBID(),
|
||||||
ISRC: t.ISRC(),
|
ISRC: t.ISRC(),
|
||||||
AdditionalInfo: t.AdditionalInfo,
|
AdditionalInfo: t.AdditionalInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.MbidMapping != nil && len(track.ArtistMbids) == 0 {
|
if t.MBIDMapping != nil && len(track.ArtistMBIDs) == 0 {
|
||||||
for _, artistMbid := range t.MbidMapping.ArtistMbids {
|
for _, artistMBID := range t.MBIDMapping.ArtistMBIDs {
|
||||||
track.ArtistMbids = append(track.ArtistMbids, models.MBID(artistMbid))
|
track.ArtistMBIDs = append(track.ArtistMBIDs, artistMBID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,17 +23,18 @@ import (
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("token", "thetoken")
|
c.Set("token", "thetoken")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&listenbrainz.ListenBrainzApiBackend{}).FromConfig(&service)
|
backend := listenbrainz.ListenBrainzApiBackend{}
|
||||||
assert.IsType(t, &listenbrainz.ListenBrainzApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListenBrainzListenAsListen(t *testing.T) {
|
func TestListenBrainzListenAsListen(t *testing.T) {
|
||||||
|
@ -65,30 +66,30 @@ func TestListenBrainzListenAsListen(t *testing.T) {
|
||||||
assert.Equal(t, []string{lbListen.TrackMetadata.ArtistName}, listen.ArtistNames)
|
assert.Equal(t, []string{lbListen.TrackMetadata.ArtistName}, listen.ArtistNames)
|
||||||
assert.Equal(t, 5, listen.TrackNumber)
|
assert.Equal(t, 5, listen.TrackNumber)
|
||||||
assert.Equal(t, 1, listen.DiscNumber)
|
assert.Equal(t, 1, listen.DiscNumber)
|
||||||
assert.Equal(t, models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMbid)
|
assert.Equal(t, mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMBID)
|
||||||
assert.Equal(t, models.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMbid)
|
assert.Equal(t, mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMBID)
|
||||||
assert.Equal(t, models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMbid)
|
assert.Equal(t, mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMBID)
|
||||||
assert.Equal(t, "DES561620801", listen.ISRC)
|
assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC)
|
||||||
assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"])
|
assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListenBrainzFeedbackAsLove(t *testing.T) {
|
func TestListenBrainzFeedbackAsLove(t *testing.T) {
|
||||||
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
|
recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12")
|
||||||
releaseMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
|
releaseMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68")
|
||||||
artistMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
|
artistMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68")
|
||||||
feedback := listenbrainz.Feedback{
|
feedback := listenbrainz.Feedback{
|
||||||
Created: 1699859066,
|
Created: 1699859066,
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: recordingMBID,
|
||||||
Score: 1,
|
Score: 1,
|
||||||
UserName: "ousidecontext",
|
UserName: "ousidecontext",
|
||||||
TrackMetadata: &listenbrainz.Track{
|
TrackMetadata: &listenbrainz.Track{
|
||||||
TrackName: "Oweynagat",
|
TrackName: "Oweynagat",
|
||||||
ArtistName: "Dool",
|
ArtistName: "Dool",
|
||||||
ReleaseName: "Here Now, There Then",
|
ReleaseName: "Here Now, There Then",
|
||||||
MbidMapping: &listenbrainz.MbidMapping{
|
MBIDMapping: &listenbrainz.MBIDMapping{
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: recordingMBID,
|
||||||
ReleaseMbid: releaseMbid,
|
ReleaseMBID: releaseMBID,
|
||||||
ArtistMbids: []string{artistMbid},
|
ArtistMBIDs: []mbtypes.MBID{artistMBID},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -99,24 +100,24 @@ func TestListenBrainzFeedbackAsLove(t *testing.T) {
|
||||||
assert.Equal(feedback.TrackMetadata.TrackName, love.TrackName)
|
assert.Equal(feedback.TrackMetadata.TrackName, love.TrackName)
|
||||||
assert.Equal(feedback.TrackMetadata.ReleaseName, love.ReleaseName)
|
assert.Equal(feedback.TrackMetadata.ReleaseName, love.ReleaseName)
|
||||||
assert.Equal([]string{feedback.TrackMetadata.ArtistName}, love.ArtistNames)
|
assert.Equal([]string{feedback.TrackMetadata.ArtistName}, love.ArtistNames)
|
||||||
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
|
assert.Equal(recordingMBID, love.RecordingMBID)
|
||||||
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
|
assert.Equal(recordingMBID, love.Track.RecordingMBID)
|
||||||
assert.Equal(models.MBID(releaseMbid), love.Track.ReleaseMbid)
|
assert.Equal(releaseMBID, love.Track.ReleaseMBID)
|
||||||
require.Len(t, love.Track.ArtistMbids, 1)
|
require.Len(t, love.Track.ArtistMBIDs, 1)
|
||||||
assert.Equal(models.MBID(artistMbid), love.Track.ArtistMbids[0])
|
assert.Equal(artistMBID, love.Track.ArtistMBIDs[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
|
func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
|
||||||
recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
|
recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12")
|
||||||
feedback := listenbrainz.Feedback{
|
feedback := listenbrainz.Feedback{
|
||||||
Created: 1699859066,
|
Created: 1699859066,
|
||||||
RecordingMbid: recordingMbid,
|
RecordingMBID: recordingMBID,
|
||||||
Score: 1,
|
Score: 1,
|
||||||
}
|
}
|
||||||
love := feedback.AsLove()
|
love := feedback.AsLove()
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
|
assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix())
|
||||||
assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
|
assert.Equal(recordingMBID, love.RecordingMBID)
|
||||||
assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
|
assert.Equal(recordingMBID, love.Track.RecordingMBID)
|
||||||
assert.Empty(love.Track.TrackName)
|
assert.Empty(love.Track.TrackName)
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"golang.org/x/exp/constraints"
|
"golang.org/x/exp/constraints"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -56,7 +57,7 @@ type ListenSubmission struct {
|
||||||
type Listen struct {
|
type Listen struct {
|
||||||
InsertedAt int64 `json:"inserted_at,omitempty"`
|
InsertedAt int64 `json:"inserted_at,omitempty"`
|
||||||
ListenedAt int64 `json:"listened_at"`
|
ListenedAt int64 `json:"listened_at"`
|
||||||
RecordingMsid string `json:"recording_msid,omitempty"`
|
RecordingMSID string `json:"recording_msid,omitempty"`
|
||||||
UserName string `json:"user_name,omitempty"`
|
UserName string `json:"user_name,omitempty"`
|
||||||
TrackMetadata Track `json:"track_metadata"`
|
TrackMetadata Track `json:"track_metadata"`
|
||||||
}
|
}
|
||||||
|
@ -66,20 +67,20 @@ type Track struct {
|
||||||
ArtistName string `json:"artist_name,omitempty"`
|
ArtistName string `json:"artist_name,omitempty"`
|
||||||
ReleaseName string `json:"release_name,omitempty"`
|
ReleaseName string `json:"release_name,omitempty"`
|
||||||
AdditionalInfo map[string]any `json:"additional_info,omitempty"`
|
AdditionalInfo map[string]any `json:"additional_info,omitempty"`
|
||||||
MbidMapping *MbidMapping `json:"mbid_mapping,omitempty"`
|
MBIDMapping *MBIDMapping `json:"mbid_mapping,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MbidMapping struct {
|
type MBIDMapping struct {
|
||||||
RecordingName string `json:"recording_name,omitempty"`
|
RecordingName string `json:"recording_name,omitempty"`
|
||||||
RecordingMbid string `json:"recording_mbid,omitempty"`
|
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
|
||||||
ReleaseMbid string `json:"release_mbid,omitempty"`
|
ReleaseMBID mbtypes.MBID `json:"release_mbid,omitempty"`
|
||||||
ArtistMbids []string `json:"artist_mbids,omitempty"`
|
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids,omitempty"`
|
||||||
Artists []Artist `json:"artists,omitempty"`
|
Artists []Artist `json:"artists,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
ArtistCreditName string `json:"artist_credit_name,omitempty"`
|
ArtistCreditName string `json:"artist_credit_name,omitempty"`
|
||||||
ArtistMbid string `json:"artist_mbid,omitempty"`
|
ArtistMBID string `json:"artist_mbid,omitempty"`
|
||||||
JoinPhrase string `json:"join_phrase,omitempty"`
|
JoinPhrase string `json:"join_phrase,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,8 +93,8 @@ type GetFeedbackResult struct {
|
||||||
|
|
||||||
type Feedback struct {
|
type Feedback struct {
|
||||||
Created int64 `json:"created,omitempty"`
|
Created int64 `json:"created,omitempty"`
|
||||||
RecordingMbid string `json:"recording_mbid,omitempty"`
|
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
|
||||||
RecordingMsid string `json:"recording_msid,omitempty"`
|
RecordingMSID mbtypes.MBID `json:"recording_msid,omitempty"`
|
||||||
Score int `json:"score,omitempty"`
|
Score int `json:"score,omitempty"`
|
||||||
TrackMetadata *Track `json:"track_metadata,omitempty"`
|
TrackMetadata *Track `json:"track_metadata,omitempty"`
|
||||||
UserName string `json:"user_id,omitempty"`
|
UserName string `json:"user_id,omitempty"`
|
||||||
|
@ -103,9 +104,9 @@ type LookupResult struct {
|
||||||
ArtistCreditName string `json:"artist_credit_name"`
|
ArtistCreditName string `json:"artist_credit_name"`
|
||||||
ReleaseName string `json:"release_name"`
|
ReleaseName string `json:"release_name"`
|
||||||
RecordingName string `json:"recording_name"`
|
RecordingName string `json:"recording_name"`
|
||||||
RecordingMbid string `json:"recording_mbid"`
|
RecordingMBID mbtypes.MBID `json:"recording_mbid"`
|
||||||
ReleaseMbid string `json:"release_mbid"`
|
ReleaseMBID mbtypes.MBID `json:"release_mbid"`
|
||||||
ArtistMbids []string `json:"artist_mbids"`
|
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusResult struct {
|
type StatusResult struct {
|
||||||
|
@ -158,30 +159,30 @@ func (t Track) DiscNumber() int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Track) ISRC() string {
|
func (t Track) ISRC() mbtypes.ISRC {
|
||||||
return tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc")
|
return mbtypes.ISRC(tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Track) RecordingMbid() string {
|
func (t Track) RecordingMBID() mbtypes.MBID {
|
||||||
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid")
|
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid"))
|
||||||
if mbid == "" && t.MbidMapping != nil {
|
if mbid == "" && t.MBIDMapping != nil {
|
||||||
return t.MbidMapping.RecordingMbid
|
return t.MBIDMapping.RecordingMBID
|
||||||
} else {
|
} else {
|
||||||
return mbid
|
return mbid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Track) ReleaseMbid() string {
|
func (t Track) ReleaseMBID() mbtypes.MBID {
|
||||||
mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid")
|
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid"))
|
||||||
if mbid == "" && t.MbidMapping != nil {
|
if mbid == "" && t.MBIDMapping != nil {
|
||||||
return t.MbidMapping.ReleaseMbid
|
return t.MBIDMapping.ReleaseMBID
|
||||||
} else {
|
} else {
|
||||||
return mbid
|
return mbid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Track) ReleaseGroupMbid() string {
|
func (t Track) ReleaseGroupMBID() mbtypes.MBID {
|
||||||
return tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid")
|
return mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryGetValueOrEmpty[T any](dict map[string]any, key string) T {
|
func tryGetValueOrEmpty[T any](dict map[string]any, key string) T {
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -130,50 +131,50 @@ func TestTrackTrackNumberString(t *testing.T) {
|
||||||
assert.Equal(t, 12, track.TrackNumber())
|
assert.Equal(t, 12, track.TrackNumber())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrackIsrc(t *testing.T) {
|
func TestTrackISRC(t *testing.T) {
|
||||||
expected := "TCAEJ1934417"
|
expected := mbtypes.ISRC("TCAEJ1934417")
|
||||||
track := listenbrainz.Track{
|
track := listenbrainz.Track{
|
||||||
AdditionalInfo: map[string]any{
|
AdditionalInfo: map[string]any{
|
||||||
"isrc": expected,
|
"isrc": string(expected),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
assert.Equal(t, expected, track.ISRC())
|
assert.Equal(t, expected, track.ISRC())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrackRecordingMbid(t *testing.T) {
|
func TestTrackRecordingMBID(t *testing.T) {
|
||||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||||
track := listenbrainz.Track{
|
track := listenbrainz.Track{
|
||||||
AdditionalInfo: map[string]any{
|
AdditionalInfo: map[string]any{
|
||||||
"recording_mbid": expected,
|
"recording_mbid": string(expected),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
assert.Equal(t, expected, track.RecordingMbid())
|
assert.Equal(t, expected, track.RecordingMBID())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTrackReleaseMbid(t *testing.T) {
|
func TestTrackReleaseMBID(t *testing.T) {
|
||||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||||
track := listenbrainz.Track{
|
track := listenbrainz.Track{
|
||||||
AdditionalInfo: map[string]any{
|
AdditionalInfo: map[string]any{
|
||||||
"release_mbid": expected,
|
"release_mbid": string(expected),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
assert.Equal(t, expected, track.ReleaseMbid())
|
assert.Equal(t, expected, track.ReleaseMBID())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReleaseGroupMbid(t *testing.T) {
|
func TestReleaseGroupMBID(t *testing.T) {
|
||||||
expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
|
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b")
|
||||||
track := listenbrainz.Track{
|
track := listenbrainz.Track{
|
||||||
AdditionalInfo: map[string]any{
|
AdditionalInfo: map[string]any{
|
||||||
"release_group_mbid": expected,
|
"release_group_mbid": string(expected),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
assert.Equal(t, expected, track.ReleaseGroupMbid())
|
assert.Equal(t, expected, track.ReleaseGroupMBID())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarshalPartialFeedback(t *testing.T) {
|
func TestMarshalPartialFeedback(t *testing.T) {
|
||||||
feedback := listenbrainz.Feedback{
|
feedback := listenbrainz.Feedback{
|
||||||
Created: 1699859066,
|
Created: 1699859066,
|
||||||
RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12",
|
||||||
}
|
}
|
||||||
b, err := json.Marshal(feedback)
|
b, err := json.Marshal(feedback)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
@ -32,25 +32,25 @@ import (
|
||||||
const MaxItemsPerGet = 1000
|
const MaxItemsPerGet = 1000
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HttpClient *resty.Client
|
HTTPClient *resty.Client
|
||||||
token string
|
token string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(serverUrl string, token string) Client {
|
func NewClient(serverURL string, token string) Client {
|
||||||
client := resty.New()
|
client := resty.New()
|
||||||
client.SetBaseURL(serverUrl)
|
client.SetBaseURL(serverURL)
|
||||||
client.SetHeader("Accept", "application/json")
|
client.SetHeader("Accept", "application/json")
|
||||||
client.SetHeader("User-Agent", version.UserAgent())
|
client.SetHeader("User-Agent", version.UserAgent())
|
||||||
client.SetRetryCount(5)
|
client.SetRetryCount(5)
|
||||||
return Client{
|
return Client{
|
||||||
HttpClient: client,
|
HTTPClient: client,
|
||||||
token: token,
|
token: token,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) {
|
func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult, err error) {
|
||||||
const path = "/apis/mlj_1/scrobbles"
|
const path = "/apis/mlj_1/scrobbles"
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"page": strconv.Itoa(page),
|
"page": strconv.Itoa(page),
|
||||||
"perpage": strconv.Itoa(perPage),
|
"perpage": strconv.Itoa(perPage),
|
||||||
|
@ -58,7 +58,7 @@ func (c Client) GetScrobbles(page int, perPage int) (result GetScrobblesResult,
|
||||||
SetResult(&result).
|
SetResult(&result).
|
||||||
Get(path)
|
Get(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(response.String())
|
err = errors.New(response.String())
|
||||||
return
|
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) {
|
func (c Client) NewScrobble(scrobble NewScrobble) (result NewScrobbleResult, err error) {
|
||||||
const path = "/apis/mlj_1/newscrobble"
|
const path = "/apis/mlj_1/newscrobble"
|
||||||
scrobble.Key = c.token
|
scrobble.Key = c.token
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetBody(scrobble).
|
SetBody(scrobble).
|
||||||
SetResult(&result).
|
SetResult(&result).
|
||||||
Post(path)
|
Post(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(response.String())
|
err = errors.New(response.String())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,19 +32,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
func TestNewClient(t *testing.T) {
|
||||||
serverUrl := "https://maloja.example.com"
|
serverURL := "https://maloja.example.com"
|
||||||
token := "foobar123"
|
token := "foobar123"
|
||||||
client := maloja.NewClient(serverUrl, token)
|
client := maloja.NewClient(serverURL, token)
|
||||||
assert.Equal(t, serverUrl, client.HttpClient.BaseURL)
|
assert.Equal(t, serverURL, client.HTTPClient.BaseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetScrobbles(t *testing.T) {
|
func TestGetScrobbles(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
serverUrl := "https://maloja.example.com"
|
serverURL := "https://maloja.example.com"
|
||||||
token := "thetoken"
|
token := "thetoken"
|
||||||
client := maloja.NewClient(serverUrl, token)
|
client := maloja.NewClient(serverURL, token)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://maloja.example.com/apis/mlj_1/scrobbles",
|
"https://maloja.example.com/apis/mlj_1/scrobbles",
|
||||||
"testdata/scrobbles.json")
|
"testdata/scrobbles.json")
|
||||||
|
|
||||||
|
@ -60,7 +60,7 @@ func TestGetScrobbles(t *testing.T) {
|
||||||
func TestNewScrobble(t *testing.T) {
|
func TestNewScrobble(t *testing.T) {
|
||||||
server := "https://maloja.example.com"
|
server := "https://maloja.example.com"
|
||||||
client := maloja.NewClient(server, "thetoken")
|
client := maloja.NewClient(server, "thetoken")
|
||||||
httpmock.ActivateNonDefault(client.HttpClient.GetClient())
|
httpmock.ActivateNonDefault(client.HTTPClient.GetClient())
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json"))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -80,7 +80,7 @@ func TestNewScrobble(t *testing.T) {
|
||||||
assert.Equal(t, "success", result.Status)
|
assert.Equal(t, "success", result.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||||
httpmock.ActivateNonDefault(client)
|
httpmock.ActivateNonDefault(client)
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||||
|
|
|
@ -51,13 +51,13 @@ func (b *MalojaApiBackend) Options() []models.BackendOption {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *MalojaApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *MalojaApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
b.client = NewClient(
|
b.client = NewClient(
|
||||||
config.GetString("server-url"),
|
config.GetString("server-url"),
|
||||||
config.GetString("token"),
|
config.GetString("token"),
|
||||||
)
|
)
|
||||||
b.nofix = config.GetBool("nofix")
|
b.nofix = config.GetBool("nofix", false)
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *MalojaApiBackend) StartImport() error { return nil }
|
func (b *MalojaApiBackend) StartImport() error { return nil }
|
||||||
|
|
|
@ -26,12 +26,13 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("token", "thetoken")
|
c.Set("token", "thetoken")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&maloja.MalojaApiBackend{}).FromConfig(&service)
|
backend := maloja.MalojaApiBackend{}
|
||||||
assert.IsType(t, &maloja.MalojaApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScrobbleAsListen(t *testing.T) {
|
func TestScrobbleAsListen(t *testing.T) {
|
||||||
|
|
|
@ -1,210 +0,0 @@
|
||||||
/*
|
|
||||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
*/
|
|
||||||
package scrobblerlog
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ScrobblerLog struct {
|
|
||||||
Timezone string
|
|
||||||
Client string
|
|
||||||
Listens models.ListensList
|
|
||||||
}
|
|
||||||
|
|
||||||
func Parse(data io.Reader, includeSkipped bool) (ScrobblerLog, error) {
|
|
||||||
result := ScrobblerLog{
|
|
||||||
Listens: make(models.ListensList, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := bufio.NewReader(data)
|
|
||||||
err := ReadHeader(reader, &result)
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tsvReader := csv.NewReader(reader)
|
|
||||||
tsvReader.Comma = '\t'
|
|
||||||
// Row length is often flexible
|
|
||||||
tsvReader.FieldsPerRecord = -1
|
|
||||||
|
|
||||||
for {
|
|
||||||
// A row is:
|
|
||||||
// artistName releaseName trackName trackNumber duration rating timestamp recordingMbid
|
|
||||||
row, err := tsvReader.Read()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
} else if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// fmt.Printf("row: %v\n", row)
|
|
||||||
|
|
||||||
// We consider only the last field (recording MBID) optional
|
|
||||||
if len(row) < 7 {
|
|
||||||
line, _ := tsvReader.FieldPos(0)
|
|
||||||
return result, fmt.Errorf("invalid record in scrobblerlog line %v", line)
|
|
||||||
}
|
|
||||||
|
|
||||||
rating := row[5]
|
|
||||||
if !includeSkipped && rating == "S" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
client := strings.Split(result.Client, " ")[0]
|
|
||||||
listen, err := rowToListen(row, client)
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Listens = append(result.Listens, listen)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Write(data io.Writer, listens models.ListensList) (lastTimestamp time.Time, err error) {
|
|
||||||
tsvWriter := csv.NewWriter(data)
|
|
||||||
tsvWriter.Comma = '\t'
|
|
||||||
|
|
||||||
for _, listen := range listens {
|
|
||||||
if listen.ListenedAt.Unix() > lastTimestamp.Unix() {
|
|
||||||
lastTimestamp = listen.ListenedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
// A row is:
|
|
||||||
// artistName releaseName trackName trackNumber duration rating timestamp recordingMbid
|
|
||||||
rating, ok := listen.AdditionalInfo["rockbox_rating"].(string)
|
|
||||||
if !ok || rating == "" {
|
|
||||||
rating = "L"
|
|
||||||
}
|
|
||||||
tsvWriter.Write([]string{
|
|
||||||
listen.ArtistName(),
|
|
||||||
listen.ReleaseName,
|
|
||||||
listen.TrackName,
|
|
||||||
strconv.Itoa(listen.TrackNumber),
|
|
||||||
strconv.Itoa(int(listen.Duration.Seconds())),
|
|
||||||
rating,
|
|
||||||
strconv.Itoa(int(listen.ListenedAt.Unix())),
|
|
||||||
string(listen.RecordingMbid),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
tsvWriter.Flush()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReadHeader(reader *bufio.Reader, log *ScrobblerLog) error {
|
|
||||||
// Skip header
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
line, _, err := reader.ReadLine()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(line) == 0 || line[0] != '#' {
|
|
||||||
err = fmt.Errorf("unexpected header (line %v)", i)
|
|
||||||
} else {
|
|
||||||
text := string(line)
|
|
||||||
if i == 0 && !strings.HasPrefix(text, "#AUDIOSCROBBLER/1") {
|
|
||||||
err = fmt.Errorf("not a scrobbler log file")
|
|
||||||
}
|
|
||||||
|
|
||||||
timezone, found := strings.CutPrefix(text, "#TZ/")
|
|
||||||
if found {
|
|
||||||
log.Timezone = timezone
|
|
||||||
}
|
|
||||||
|
|
||||||
client, found := strings.CutPrefix(text, "#CLIENT/")
|
|
||||||
if found {
|
|
||||||
log.Client = client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func WriteHeader(writer io.Writer, log *ScrobblerLog) error {
|
|
||||||
headers := []string{
|
|
||||||
"#AUDIOSCROBBLER/1.1\n",
|
|
||||||
"#TZ/" + log.Timezone + "\n",
|
|
||||||
"#CLIENT/" + log.Client + "\n",
|
|
||||||
}
|
|
||||||
for _, line := range headers {
|
|
||||||
_, err := writer.Write([]byte(line))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rowToListen(row []string, client string) (models.Listen, error) {
|
|
||||||
var listen models.Listen
|
|
||||||
trackNumber, err := strconv.Atoi(row[3])
|
|
||||||
if err != nil {
|
|
||||||
return listen, err
|
|
||||||
}
|
|
||||||
|
|
||||||
duration, err := strconv.Atoi(row[4])
|
|
||||||
if err != nil {
|
|
||||||
return listen, err
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp, err := strconv.Atoi(row[6])
|
|
||||||
if err != nil {
|
|
||||||
return listen, err
|
|
||||||
}
|
|
||||||
|
|
||||||
listen = models.Listen{
|
|
||||||
Track: models.Track{
|
|
||||||
ArtistNames: []string{row[0]},
|
|
||||||
ReleaseName: row[1],
|
|
||||||
TrackName: row[2],
|
|
||||||
TrackNumber: trackNumber,
|
|
||||||
Duration: time.Duration(duration * int(time.Second)),
|
|
||||||
AdditionalInfo: models.AdditionalInfo{
|
|
||||||
"rockbox_rating": row[5],
|
|
||||||
"media_player": client,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ListenedAt: time.Unix(int64(timestamp), 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(row) > 7 {
|
|
||||||
listen.Track.RecordingMbid = models.MBID(row[7])
|
|
||||||
}
|
|
||||||
|
|
||||||
return listen, nil
|
|
||||||
}
|
|
|
@ -18,21 +18,25 @@ package scrobblerlog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
"go.uploadedlobster.com/scotty/pkg/scrobblerlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ScrobblerLogBackend struct {
|
type ScrobblerLogBackend struct {
|
||||||
filePath string
|
filePath string
|
||||||
includeSkipped bool
|
ignoreSkipped bool
|
||||||
append bool
|
append bool
|
||||||
file *os.File
|
file *os.File
|
||||||
log ScrobblerLog
|
timezone *time.Location
|
||||||
|
log scrobblerlog.ScrobblerLog
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" }
|
func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" }
|
||||||
|
@ -43,28 +47,39 @@ func (b *ScrobblerLogBackend) Options() []models.BackendOption {
|
||||||
Label: i18n.Tr("File path"),
|
Label: i18n.Tr("File path"),
|
||||||
Type: models.String,
|
Type: models.String,
|
||||||
}, {
|
}, {
|
||||||
Name: "include-skipped",
|
Name: "ignore-skipped",
|
||||||
Label: i18n.Tr("Include skipped listens"),
|
Label: i18n.Tr("Ignore skipped listens"),
|
||||||
Type: models.Bool,
|
Type: models.Bool,
|
||||||
|
Default: "true",
|
||||||
}, {
|
}, {
|
||||||
Name: "append",
|
Name: "append",
|
||||||
Label: i18n.Tr("Append to file"),
|
Label: i18n.Tr("Append to file"),
|
||||||
Type: models.Bool,
|
Type: models.Bool,
|
||||||
|
Default: "true",
|
||||||
|
}, {
|
||||||
|
Name: "time-zone",
|
||||||
|
Label: i18n.Tr("Specify a time zone for the listen timestamps"),
|
||||||
|
Type: models.String,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ScrobblerLogBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *ScrobblerLogBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
b.filePath = config.GetString("file-path")
|
b.filePath = config.GetString("file-path")
|
||||||
b.includeSkipped = config.GetBool("include-skipped")
|
b.ignoreSkipped = config.GetBool("ignore-skipped", true)
|
||||||
b.append = true
|
b.append = config.GetBool("append", true)
|
||||||
if config.IsSet("append") {
|
timezone := config.GetString("time-zone")
|
||||||
b.append = config.GetBool("append")
|
if timezone != "" {
|
||||||
|
location, err := time.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Invalid time-zone %q: %w", timezone, err)
|
||||||
}
|
}
|
||||||
b.log = ScrobblerLog{
|
b.log.FallbackTimezone = location
|
||||||
Timezone: "UNKNOWN",
|
}
|
||||||
|
b.log = scrobblerlog.ScrobblerLog{
|
||||||
|
TZ: scrobblerlog.TimezoneUTC,
|
||||||
Client: "Rockbox unknown $Revision$",
|
Client: "Rockbox unknown $Revision$",
|
||||||
}
|
}
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ScrobblerLogBackend) StartImport() error {
|
func (b *ScrobblerLogBackend) StartImport() error {
|
||||||
|
@ -90,18 +105,18 @@ func (b *ScrobblerLogBackend) StartImport() error {
|
||||||
} else {
|
} else {
|
||||||
// Verify existing file is a scrobbler log
|
// Verify existing file is a scrobbler log
|
||||||
reader := bufio.NewReader(file)
|
reader := bufio.NewReader(file)
|
||||||
err = ReadHeader(reader, &b.log)
|
if err = b.log.ReadHeader(reader); err != nil {
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
file.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
file.Seek(0, 2)
|
if _, err = file.Seek(0, 2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !b.append {
|
if !b.append {
|
||||||
err = WriteHeader(file, &b.log)
|
if err = b.log.WriteHeader(file); err != nil {
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
file.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -126,21 +141,29 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c
|
||||||
|
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
log, err := Parse(file, b.includeSkipped)
|
err = b.log.Parse(file, b.ignoreSkipped)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
progress <- models.Progress{}.Complete()
|
progress <- models.Progress{}.Complete()
|
||||||
results <- models.ListensResult{Error: err}
|
results <- models.ListensResult{Error: err}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
listens := log.Listens.NewerThan(oldestTimestamp)
|
listens := make(models.ListensList, 0, len(b.log.Records))
|
||||||
sort.Sort(listens)
|
client := strings.Split(b.log.Client, " ")[0]
|
||||||
progress <- models.Progress{Elapsed: int64(len(listens))}.Complete()
|
for _, record := range b.log.Records {
|
||||||
|
listens = append(listens, recordToListen(record, client))
|
||||||
|
}
|
||||||
|
sort.Sort(listens.NewerThan(oldestTimestamp))
|
||||||
|
progress <- models.Progress{Total: int64(len(listens))}.Complete()
|
||||||
results <- models.ListensResult{Items: listens}
|
results <- models.ListensResult{Items: listens}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
|
||||||
lastTimestamp, err := Write(b.file, export.Items)
|
records := make([]scrobblerlog.Record, len(export.Items))
|
||||||
|
for i, listen := range export.Items {
|
||||||
|
records[i] = listenToRecord(listen)
|
||||||
|
}
|
||||||
|
lastTimestamp, err := b.log.Append(b.file, records)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return importResult, err
|
return importResult, err
|
||||||
}
|
}
|
||||||
|
@ -151,3 +174,42 @@ func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importR
|
||||||
|
|
||||||
return importResult, nil
|
return importResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func recordToListen(record scrobblerlog.Record, client string) models.Listen {
|
||||||
|
return models.Listen{
|
||||||
|
ListenedAt: record.Timestamp,
|
||||||
|
Track: models.Track{
|
||||||
|
ArtistNames: []string{record.ArtistName},
|
||||||
|
ReleaseName: record.AlbumName,
|
||||||
|
TrackName: record.TrackName,
|
||||||
|
TrackNumber: record.TrackNumber,
|
||||||
|
Duration: record.Duration,
|
||||||
|
RecordingMBID: record.MusicBrainzRecordingID,
|
||||||
|
AdditionalInfo: models.AdditionalInfo{
|
||||||
|
"rockbox_rating": record.Rating,
|
||||||
|
"media_player": client,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenToRecord(listen models.Listen) scrobblerlog.Record {
|
||||||
|
var rating scrobblerlog.Rating
|
||||||
|
rockboxRating, ok := listen.AdditionalInfo["rockbox_rating"].(string)
|
||||||
|
if !ok || rockboxRating == "" {
|
||||||
|
rating = scrobblerlog.RatingListened
|
||||||
|
} else {
|
||||||
|
rating = scrobblerlog.Rating(rating)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scrobblerlog.Record{
|
||||||
|
ArtistName: listen.ArtistName(),
|
||||||
|
AlbumName: listen.ReleaseName,
|
||||||
|
TrackName: listen.TrackName,
|
||||||
|
TrackNumber: listen.TrackNumber,
|
||||||
|
Duration: listen.Duration,
|
||||||
|
Rating: rating,
|
||||||
|
Timestamp: listen.ListenedAt,
|
||||||
|
MusicBrainzRecordingID: listen.RecordingMBID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -25,10 +25,21 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("token", "thetoken")
|
c.Set("token", "thetoken")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(&service)
|
backend := scrobblerlog.ScrobblerLogBackend{}
|
||||||
assert.IsType(t, &scrobblerlog.ScrobblerLogBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitConfigInvalidTimezone(t *testing.T) {
|
||||||
|
c := viper.New()
|
||||||
|
configuredTimezone := "Invalid/Timezone"
|
||||||
|
c.Set("time-zone", configuredTimezone)
|
||||||
|
service := config.NewServiceConfig("test", c)
|
||||||
|
backend := scrobblerlog.ScrobblerLogBackend{}
|
||||||
|
err := backend.InitConfig(&service)
|
||||||
|
assert.ErrorContains(t, err, `Invalid time-zone "Invalid/Timezone"`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,8 +29,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-resty/resty/v2"
|
"github.com/go-resty/resty/v2"
|
||||||
"go.uploadedlobster.com/scotty/internal/ratelimit"
|
|
||||||
"go.uploadedlobster.com/scotty/internal/version"
|
"go.uploadedlobster.com/scotty/internal/version"
|
||||||
|
"go.uploadedlobster.com/scotty/pkg/ratelimit"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
HttpClient *resty.Client
|
HTTPClient *resty.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(token oauth2.TokenSource) Client {
|
func NewClient(token oauth2.TokenSource) Client {
|
||||||
|
@ -55,7 +55,7 @@ func NewClient(token oauth2.TokenSource) Client {
|
||||||
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
||||||
|
|
||||||
return Client{
|
return Client{
|
||||||
HttpClient: client,
|
HTTPClient: client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlaye
|
||||||
|
|
||||||
func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) {
|
func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (result RecentlyPlayedResult, err error) {
|
||||||
const path = "/me/player/recently-played"
|
const path = "/me/player/recently-played"
|
||||||
request := c.HttpClient.R().
|
request := c.HTTPClient.R().
|
||||||
SetQueryParam("limit", strconv.Itoa(limit)).
|
SetQueryParam("limit", strconv.Itoa(limit)).
|
||||||
SetResult(&result)
|
SetResult(&result)
|
||||||
if after != nil {
|
if after != nil {
|
||||||
|
@ -79,7 +79,7 @@ func (c Client) recentlyPlayed(after *time.Time, before *time.Time, limit int) (
|
||||||
}
|
}
|
||||||
response, err := request.Get(path)
|
response, err := request.Get(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(response.String())
|
err = errors.New(response.String())
|
||||||
}
|
}
|
||||||
return
|
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) {
|
func (c Client) UserTracks(offset int, limit int) (result TracksResult, err error) {
|
||||||
const path = "/me/tracks"
|
const path = "/me/tracks"
|
||||||
response, err := c.HttpClient.R().
|
response, err := c.HTTPClient.R().
|
||||||
SetQueryParams(map[string]string{
|
SetQueryParams(map[string]string{
|
||||||
"offset": strconv.Itoa(offset),
|
"offset": strconv.Itoa(offset),
|
||||||
"limit": strconv.Itoa(limit),
|
"limit": strconv.Itoa(limit),
|
||||||
|
@ -95,7 +95,7 @@ func (c Client) UserTracks(offset int, limit int) (result TracksResult, err erro
|
||||||
SetResult(&result).
|
SetResult(&result).
|
||||||
Get(path)
|
Get(path)
|
||||||
|
|
||||||
if response.StatusCode() != 200 {
|
if !response.IsSuccess() {
|
||||||
err = errors.New(response.String())
|
err = errors.New(response.String())
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
|
@ -43,7 +43,7 @@ func TestRecentlyPlayedAfter(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
client := spotify.NewClient(nil)
|
client := spotify.NewClient(nil)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.spotify.com/v1/me/player/recently-played",
|
"https://api.spotify.com/v1/me/player/recently-played",
|
||||||
"testdata/recently-played.json")
|
"testdata/recently-played.json")
|
||||||
|
|
||||||
|
@ -63,7 +63,7 @@ func TestGetUserTracks(t *testing.T) {
|
||||||
defer httpmock.DeactivateAndReset()
|
defer httpmock.DeactivateAndReset()
|
||||||
|
|
||||||
client := spotify.NewClient(nil)
|
client := spotify.NewClient(nil)
|
||||||
setupHttpMock(t, client.HttpClient.GetClient(),
|
setupHTTPMock(t, client.HTTPClient.GetClient(),
|
||||||
"https://api.spotify.com/v1/me/tracks",
|
"https://api.spotify.com/v1/me/tracks",
|
||||||
"testdata/user-tracks.json")
|
"testdata/user-tracks.json")
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ func TestGetUserTracks(t *testing.T) {
|
||||||
assert.Equal("Zeal & Ardor", track1.Track.Album.Name)
|
assert.Equal("Zeal & Ardor", track1.Track.Album.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) {
|
||||||
httpmock.ActivateNonDefault(client)
|
httpmock.ActivateNonDefault(client)
|
||||||
|
|
||||||
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath))
|
||||||
|
|
|
@ -22,6 +22,8 @@ THE SOFTWARE.
|
||||||
|
|
||||||
package spotify
|
package spotify
|
||||||
|
|
||||||
|
import "go.uploadedlobster.com/mbtypes"
|
||||||
|
|
||||||
type TracksResult struct {
|
type TracksResult struct {
|
||||||
Href string `json:"href"`
|
Href string `json:"href"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
|
@ -56,7 +58,7 @@ type Listen struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
Id string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Href string `json:"href"`
|
Href string `json:"href"`
|
||||||
Uri string `json:"uri"`
|
Uri string `json:"uri"`
|
||||||
|
@ -67,14 +69,14 @@ type Track struct {
|
||||||
Explicit bool `json:"explicit"`
|
Explicit bool `json:"explicit"`
|
||||||
IsLocal bool `json:"is_local"`
|
IsLocal bool `json:"is_local"`
|
||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
ExternalIds ExternalIds `json:"external_ids"`
|
ExternalIDs ExternalIDs `json:"external_ids"`
|
||||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
ExternalURLs ExternalURLs `json:"external_urls"`
|
||||||
Album Album `json:"album"`
|
Album Album `json:"album"`
|
||||||
Artists []Artist `json:"artists"`
|
Artists []Artist `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Album struct {
|
type Album struct {
|
||||||
Id string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Href string `json:"href"`
|
Href string `json:"href"`
|
||||||
Uri string `json:"uri"`
|
Uri string `json:"uri"`
|
||||||
|
@ -83,32 +85,32 @@ type Album struct {
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ReleaseDatePrecision string `json:"release_date_precision"`
|
ReleaseDatePrecision string `json:"release_date_precision"`
|
||||||
AlbumType string `json:"album_type"`
|
AlbumType string `json:"album_type"`
|
||||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
ExternalURLs ExternalURLs `json:"external_urls"`
|
||||||
Artists []Artist `json:"artists"`
|
Artists []Artist `json:"artists"`
|
||||||
Images []Image `json:"images"`
|
Images []Image `json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
Id string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Href string `json:"href"`
|
Href string `json:"href"`
|
||||||
Uri string `json:"uri"`
|
Uri string `json:"uri"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ExternalUrls ExternalUrls `json:"external_urls"`
|
ExternalURLs ExternalURLs `json:"external_urls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExternalIds struct {
|
type ExternalIDs struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC mbtypes.ISRC `json:"isrc"`
|
||||||
EAN string `json:"ean"`
|
EAN string `json:"ean"`
|
||||||
UPC string `json:"upc"`
|
UPC string `json:"upc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExternalUrls struct {
|
type ExternalURLs struct {
|
||||||
Spotify string `json:"spotify"`
|
Spotify string `json:"spotify"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Image struct {
|
type Image struct {
|
||||||
Url string `json:"url"`
|
URL string `json:"url"`
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,8 +23,8 @@ THE SOFTWARE.
|
||||||
package spotify_test
|
package spotify_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
@ -32,11 +32,12 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed testdata/recently-played.json
|
||||||
|
var testRecentlyPlayed []byte
|
||||||
|
|
||||||
func TestRecentlyPlayedResult(t *testing.T) {
|
func TestRecentlyPlayedResult(t *testing.T) {
|
||||||
data, err := os.ReadFile("testdata/recently-played.json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
result := spotify.RecentlyPlayedResult{}
|
result := spotify.RecentlyPlayedResult{}
|
||||||
err = json.Unmarshal(data, &result)
|
err := json.Unmarshal(testRecentlyPlayed, &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
|
@ -34,7 +34,7 @@ import (
|
||||||
|
|
||||||
type SpotifyApiBackend struct {
|
type SpotifyApiBackend struct {
|
||||||
client Client
|
client Client
|
||||||
clientId string
|
clientID string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,15 +52,15 @@ func (b *SpotifyApiBackend) Options() []models.BackendOption {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SpotifyApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *SpotifyApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
b.clientId = config.GetString("client-id")
|
b.clientID = config.GetString("client-id")
|
||||||
b.clientSecret = config.GetString("client-secret")
|
b.clientSecret = config.GetString("client-secret")
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy {
|
func (b *SpotifyApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy {
|
||||||
conf := oauth2.Config{
|
conf := oauth2.Config{
|
||||||
ClientID: b.clientId,
|
ClientID: b.clientID,
|
||||||
ClientSecret: b.clientSecret,
|
ClientSecret: b.clientSecret,
|
||||||
Scopes: []string{
|
Scopes: []string{
|
||||||
"user-read-currently-playing",
|
"user-read-currently-playing",
|
||||||
|
@ -68,16 +68,16 @@ func (b *SpotifyApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Stra
|
||||||
"user-library-read",
|
"user-library-read",
|
||||||
"user-library-modify",
|
"user-library-modify",
|
||||||
},
|
},
|
||||||
RedirectURL: redirectUrl.String(),
|
RedirectURL: redirectURL.String(),
|
||||||
Endpoint: spotify.Endpoint,
|
Endpoint: spotify.Endpoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
return auth.NewStandardStrategy(conf)
|
return auth.NewStandardStrategy(conf)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config {
|
func (b *SpotifyApiBackend) OAuth2Config(redirectURL *url.URL) oauth2.Config {
|
||||||
return oauth2.Config{
|
return oauth2.Config{
|
||||||
ClientID: b.clientId,
|
ClientID: b.clientID,
|
||||||
ClientSecret: b.clientSecret,
|
ClientSecret: b.clientSecret,
|
||||||
Scopes: []string{
|
Scopes: []string{
|
||||||
"user-read-currently-playing",
|
"user-read-currently-playing",
|
||||||
|
@ -85,7 +85,7 @@ func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config {
|
||||||
"user-library-read",
|
"user-library-read",
|
||||||
"user-library-modify",
|
"user-library-modify",
|
||||||
},
|
},
|
||||||
RedirectURL: redirectUrl.String(),
|
RedirectURL: redirectURL.String(),
|
||||||
Endpoint: spotify.Endpoint,
|
Endpoint: spotify.Endpoint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -183,10 +183,7 @@ out:
|
||||||
if offset >= result.Total {
|
if offset >= result.Total {
|
||||||
p.Total = int64(result.Total)
|
p.Total = int64(result.Total)
|
||||||
totalCount = result.Total
|
totalCount = result.Total
|
||||||
offset = result.Total - perPage
|
offset = max(result.Total-perPage, 0)
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -254,7 +251,7 @@ func (t Track) AsTrack() models.Track {
|
||||||
Duration: time.Duration(t.DurationMs * int(time.Millisecond)),
|
Duration: time.Duration(t.DurationMs * int(time.Millisecond)),
|
||||||
TrackNumber: t.TrackNumber,
|
TrackNumber: t.TrackNumber,
|
||||||
DiscNumber: t.DiscNumber,
|
DiscNumber: t.DiscNumber,
|
||||||
ISRC: t.ExternalIds.ISRC,
|
ISRC: t.ExternalIDs.ISRC,
|
||||||
AdditionalInfo: map[string]any{},
|
AdditionalInfo: map[string]any{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -267,30 +264,30 @@ func (t Track) AsTrack() models.Track {
|
||||||
info["music_service"] = "spotify.com"
|
info["music_service"] = "spotify.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.ExternalUrls.Spotify != "" {
|
if t.ExternalURLs.Spotify != "" {
|
||||||
info["origin_url"] = t.ExternalUrls.Spotify
|
info["origin_url"] = t.ExternalURLs.Spotify
|
||||||
info["spotify_id"] = t.ExternalUrls.Spotify
|
info["spotify_id"] = t.ExternalURLs.Spotify
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.Album.ExternalUrls.Spotify != "" {
|
if t.Album.ExternalURLs.Spotify != "" {
|
||||||
info["spotify_album_id"] = t.Album.ExternalUrls.Spotify
|
info["spotify_album_id"] = t.Album.ExternalURLs.Spotify
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.Artists) > 0 {
|
if len(t.Artists) > 0 {
|
||||||
info["spotify_artist_ids"] = extractArtistIds(t.Artists)
|
info["spotify_artist_ids"] = extractArtistIDs(t.Artists)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(t.Album.Artists) > 0 {
|
if len(t.Album.Artists) > 0 {
|
||||||
info["spotify_album_artist_ids"] = extractArtistIds(t.Album.Artists)
|
info["spotify_album_artist_ids"] = extractArtistIDs(t.Album.Artists)
|
||||||
}
|
}
|
||||||
|
|
||||||
return track
|
return track
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractArtistIds(artists []Artist) []string {
|
func extractArtistIDs(artists []Artist) []string {
|
||||||
artistIds := make([]string, len(artists))
|
artistIDs := make([]string, len(artists))
|
||||||
for i, artist := range artists {
|
for i, artist := range artists {
|
||||||
artistIds[i] = artist.ExternalUrls.Spotify
|
artistIDs[i] = artist.ExternalURLs.Spotify
|
||||||
}
|
}
|
||||||
return artistIds
|
return artistIDs
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,32 +18,39 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
package spotify_test
|
package spotify_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
"go.uploadedlobster.com/scotty/internal/backends/spotify"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
var (
|
||||||
|
//go:embed testdata/listen.json
|
||||||
|
testListen []byte
|
||||||
|
//go:embed testdata/track.json
|
||||||
|
testTrack []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("client-id", "someclientid")
|
c.Set("client-id", "someclientid")
|
||||||
c.Set("client-secret", "someclientsecret")
|
c.Set("client-secret", "someclientsecret")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&spotify.SpotifyApiBackend{}).FromConfig(&service)
|
backend := spotify.SpotifyApiBackend{}
|
||||||
assert.IsType(t, &spotify.SpotifyApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSpotifyListenAsListen(t *testing.T) {
|
func TestSpotifyListenAsListen(t *testing.T) {
|
||||||
data, err := os.ReadFile("testdata/listen.json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
spListen := spotify.Listen{}
|
spListen := spotify.Listen{}
|
||||||
err = json.Unmarshal(data, &spListen)
|
err := json.Unmarshal(testListen, &spListen)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
listen := spListen.AsListen()
|
listen := spListen.AsListen()
|
||||||
listenedAt, _ := time.Parse(time.RFC3339, "2023-11-21T15:24:33.361Z")
|
listenedAt, _ := time.Parse(time.RFC3339, "2023-11-21T15:24:33.361Z")
|
||||||
|
@ -54,7 +61,7 @@ func TestSpotifyListenAsListen(t *testing.T) {
|
||||||
assert.Equal(t, []string{"Dool"}, listen.ArtistNames)
|
assert.Equal(t, []string{"Dool"}, listen.ArtistNames)
|
||||||
assert.Equal(t, 5, listen.TrackNumber)
|
assert.Equal(t, 5, listen.TrackNumber)
|
||||||
assert.Equal(t, 1, listen.DiscNumber)
|
assert.Equal(t, 1, listen.DiscNumber)
|
||||||
assert.Equal(t, "DES561620801", listen.ISRC)
|
assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC)
|
||||||
info := listen.AdditionalInfo
|
info := listen.AdditionalInfo
|
||||||
assert.Equal(t, "spotify.com", info["music_service"])
|
assert.Equal(t, "spotify.com", info["music_service"])
|
||||||
assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["origin_url"])
|
assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["origin_url"])
|
||||||
|
@ -65,10 +72,8 @@ func TestSpotifyListenAsListen(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSavedTrackAsLove(t *testing.T) {
|
func TestSavedTrackAsLove(t *testing.T) {
|
||||||
data, err := os.ReadFile("testdata/track.json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
track := spotify.SavedTrack{}
|
track := spotify.SavedTrack{}
|
||||||
err = json.Unmarshal(data, &track)
|
err := json.Unmarshal(testTrack, &track)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
love := track.AsLove()
|
love := track.AsLove()
|
||||||
created, _ := time.Parse(time.RFC3339, "2022-02-13T21:46:08Z")
|
created, _ := time.Parse(time.RFC3339, "2022-02-13T21:46:08Z")
|
||||||
|
|
110
internal/backends/spotifyhistory/models.go
Normal file
110
internal/backends/spotifyhistory/models.go
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
/*
|
||||||
|
Copyright © 2024 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
|
Scotty is free software: you can redistribute it and/or modify it under the
|
||||||
|
terms of the GNU General Public License as published by the Free Software
|
||||||
|
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with
|
||||||
|
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package spotifyhistory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StreamingHistory []HistoryItem
|
||||||
|
|
||||||
|
type ListenListOptions struct {
|
||||||
|
IgnoreIncognito bool
|
||||||
|
IgnoreSkipped bool
|
||||||
|
skippedMinSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryItem struct {
|
||||||
|
Timestamp time.Time `json:"ts"`
|
||||||
|
UserName string `json:"username"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
MillisecondsPlayed int `json:"ms_played"`
|
||||||
|
ConnCountry string `json:"conn_country"`
|
||||||
|
IpAddrDecrypted string `json:"ip_addr_decrypted"`
|
||||||
|
UserAgentDecrypted string `json:"user_agent_decrypted"`
|
||||||
|
MasterMetadataTrackName string `json:"master_metadata_track_name"`
|
||||||
|
MasterMetadataAlbumArtistName string `json:"master_metadata_album_artist_name"`
|
||||||
|
MasterMetadataAlbumName string `json:"master_metadata_album_album_name"`
|
||||||
|
SpotifyTrackUri string `json:"spotify_track_uri"`
|
||||||
|
EpisodeName string `json:"episode_name"`
|
||||||
|
EpisodeShowName string `json:"episode_show_name"`
|
||||||
|
SpotifyEpisodeUri string `json:"spotify_episode_uri"`
|
||||||
|
ReasonStart string `json:"reason_start"`
|
||||||
|
ReasonEnd string `json:"reason_end"`
|
||||||
|
Shuffle bool `json:"shuffle"`
|
||||||
|
Skipped bool `json:"skipped"`
|
||||||
|
Offline bool `json:"offline"`
|
||||||
|
OfflineTimestamp int `json:"offline_timestamp"`
|
||||||
|
IncognitoMode bool `json:"incognito_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *StreamingHistory) Read(in io.Reader) error {
|
||||||
|
bytes, err := io.ReadAll(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(bytes, j)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StreamingHistory) AsListenList(opt ListenListOptions) models.ListensList {
|
||||||
|
listens := make(models.ListensList, 0, len(*h))
|
||||||
|
for _, item := range *h {
|
||||||
|
if item.MasterMetadataTrackName == "" ||
|
||||||
|
(opt.IgnoreIncognito && item.IncognitoMode) ||
|
||||||
|
(opt.IgnoreSkipped && item.Skipped) ||
|
||||||
|
(item.Skipped && item.MillisecondsPlayed < opt.skippedMinSeconds*1000) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
listens = append(listens, item.AsListen())
|
||||||
|
}
|
||||||
|
return listens
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i HistoryItem) AsListen() models.Listen {
|
||||||
|
listen := models.Listen{
|
||||||
|
Track: models.Track{
|
||||||
|
TrackName: i.MasterMetadataTrackName,
|
||||||
|
ReleaseName: i.MasterMetadataAlbumName,
|
||||||
|
ArtistNames: []string{i.MasterMetadataAlbumArtistName},
|
||||||
|
AdditionalInfo: models.AdditionalInfo{},
|
||||||
|
},
|
||||||
|
ListenedAt: i.Timestamp,
|
||||||
|
PlaybackDuration: time.Duration(i.MillisecondsPlayed * int(time.Millisecond)),
|
||||||
|
UserName: i.UserName,
|
||||||
|
}
|
||||||
|
if trackURL, err := formatSpotifyUri(i.SpotifyTrackUri); err != nil {
|
||||||
|
listen.AdditionalInfo["spotify_id"] = trackURL
|
||||||
|
}
|
||||||
|
return listen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a Spotify ID like "spotify:track:5jzma6gCzYtKB1DbEwFZKH" into an
|
||||||
|
// URL like "https://open.spotify.com/track/5jzma6gCzYtKB1DbEwFZKH"
|
||||||
|
func formatSpotifyUri(id string) (string, error) {
|
||||||
|
parts := strings.Split(id, ":")
|
||||||
|
if len(parts) == 3 && parts[0] == "spotify" {
|
||||||
|
return fmt.Sprintf("https://opem.spotify.com/%s/%s", parts[1], parts[2]), nil
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("Invalid Spotify ID \"%v\"", id)
|
||||||
|
}
|
||||||
|
}
|
123
internal/backends/spotifyhistory/spotifyhistory.go
Normal file
123
internal/backends/spotifyhistory/spotifyhistory.go
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
/*
|
||||||
|
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
|
This file is part of Scotty.
|
||||||
|
|
||||||
|
Scotty is free software: you can redistribute it and/or modify it under the
|
||||||
|
terms of the GNU General Public License as published by the Free Software
|
||||||
|
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with
|
||||||
|
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package spotifyhistory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const historyFileGlob = "Streaming_History_Audio_*.json"
|
||||||
|
|
||||||
|
type SpotifyHistoryBackend struct {
|
||||||
|
dirPath string
|
||||||
|
ignoreIncognito bool
|
||||||
|
ignoreSkipped bool
|
||||||
|
skippedMinSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *SpotifyHistoryBackend) Name() string { return "spotify-history" }
|
||||||
|
|
||||||
|
func (b *SpotifyHistoryBackend) Options() []models.BackendOption {
|
||||||
|
return []models.BackendOption{{
|
||||||
|
Name: "dir-path",
|
||||||
|
Label: i18n.Tr("Directory path"),
|
||||||
|
Type: models.String,
|
||||||
|
}, {
|
||||||
|
Name: "ignore-incognito",
|
||||||
|
Label: i18n.Tr("Ignore listens in incognito mode"),
|
||||||
|
Type: models.Bool,
|
||||||
|
Default: "true",
|
||||||
|
}, {
|
||||||
|
Name: "ignore-skipped",
|
||||||
|
Label: i18n.Tr("Ignore skipped listens"),
|
||||||
|
Type: models.Bool,
|
||||||
|
Default: "false",
|
||||||
|
}, {
|
||||||
|
Name: "ignore-min-duration-seconds",
|
||||||
|
Label: i18n.Tr("Minimum playback duration for skipped tracks (seconds)"),
|
||||||
|
Type: models.Int,
|
||||||
|
Default: "30",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *SpotifyHistoryBackend) 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 nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *SpotifyHistoryBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
|
||||||
|
defer close(results)
|
||||||
|
|
||||||
|
files, err := filepath.Glob(path.Join(b.dirPath, historyFileGlob))
|
||||||
|
if err != nil {
|
||||||
|
progress <- models.Progress{}.Complete()
|
||||||
|
results <- models.ListensResult{Error: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(files)
|
||||||
|
fileCount := int64(len(files))
|
||||||
|
p := models.Progress{Total: fileCount}
|
||||||
|
for i, filePath := range files {
|
||||||
|
history, err := readHistoryFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
progress <- models.Progress{}.Complete()
|
||||||
|
results <- models.ListensResult{Error: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listens := history.AsListenList(ListenListOptions{
|
||||||
|
IgnoreIncognito: b.ignoreIncognito,
|
||||||
|
IgnoreSkipped: b.ignoreSkipped,
|
||||||
|
skippedMinSeconds: b.skippedMinSeconds,
|
||||||
|
})
|
||||||
|
sort.Sort(listens)
|
||||||
|
results <- models.ListensResult{Items: listens}
|
||||||
|
p.Elapsed = int64(i)
|
||||||
|
progress <- p
|
||||||
|
}
|
||||||
|
|
||||||
|
progress <- p.Complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHistoryFile(filePath string) (StreamingHistory, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer file.Close()
|
||||||
|
history := StreamingHistory{}
|
||||||
|
err = history.Read(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return history, nil
|
||||||
|
}
|
|
@ -21,7 +21,8 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/delucks/go-subsonic"
|
"github.com/supersonic-app/go-subsonic/subsonic"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
@ -51,7 +52,7 @@ func (b *SubsonicApiBackend) Options() []models.BackendOption {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SubsonicApiBackend) FromConfig(config *config.ServiceConfig) models.Backend {
|
func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error {
|
||||||
b.client = subsonic.Client{
|
b.client = subsonic.Client{
|
||||||
Client: &http.Client{},
|
Client: &http.Client{},
|
||||||
BaseUrl: config.GetString("server-url"),
|
BaseUrl: config.GetString("server-url"),
|
||||||
|
@ -59,7 +60,7 @@ func (b *SubsonicApiBackend) FromConfig(config *config.ServiceConfig) models.Bac
|
||||||
ClientName: version.AppName,
|
ClientName: version.AppName,
|
||||||
}
|
}
|
||||||
b.password = config.GetString("token")
|
b.password = config.GetString("token")
|
||||||
return b
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
|
||||||
|
@ -78,8 +79,11 @@ func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
progress <- models.Progress{Elapsed: int64(len(starred.Song))}.Complete()
|
loves := b.filterSongs(starred.Song, oldestTimestamp)
|
||||||
results <- models.LovesResult{Items: b.filterSongs(starred.Song, oldestTimestamp)}
|
progress <- models.Progress{
|
||||||
|
Total: int64(loves.Len()),
|
||||||
|
}.Complete()
|
||||||
|
results <- models.LovesResult{Items: loves}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestamp time.Time) models.LovesList {
|
func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestamp time.Time) models.LovesList {
|
||||||
|
@ -96,20 +100,35 @@ func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestam
|
||||||
}
|
}
|
||||||
|
|
||||||
func SongAsLove(song subsonic.Child, username string) models.Love {
|
func SongAsLove(song subsonic.Child, username string) models.Love {
|
||||||
|
recordingMBID := mbtypes.MBID(song.MusicBrainzID)
|
||||||
love := models.Love{
|
love := models.Love{
|
||||||
UserName: username,
|
UserName: username,
|
||||||
Created: song.Starred,
|
Created: song.Starred,
|
||||||
|
RecordingMBID: recordingMBID,
|
||||||
Track: models.Track{
|
Track: models.Track{
|
||||||
TrackName: song.Title,
|
TrackName: song.Title,
|
||||||
ReleaseName: song.Album,
|
ReleaseName: song.Album,
|
||||||
ArtistNames: []string{song.Artist},
|
ArtistNames: []string{song.Artist},
|
||||||
TrackNumber: song.Track,
|
TrackNumber: song.Track,
|
||||||
DiscNumber: song.DiscNumber,
|
DiscNumber: song.DiscNumber,
|
||||||
Tags: []string{song.Genre},
|
RecordingMBID: recordingMBID,
|
||||||
AdditionalInfo: map[string]any{},
|
Tags: []string{},
|
||||||
|
AdditionalInfo: map[string]any{
|
||||||
|
"subsonic_id": song.ID,
|
||||||
|
},
|
||||||
Duration: time.Duration(song.Duration * int(time.Second)),
|
Duration: time.Duration(song.Duration * int(time.Second)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
return love
|
return love
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,25 +20,27 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
go_subsonic "github.com/delucks/go-subsonic"
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
go_subsonic "github.com/supersonic-app/go-subsonic/subsonic"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
|
"go.uploadedlobster.com/scotty/internal/backends/subsonic"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFromConfig(t *testing.T) {
|
func TestInitConfig(t *testing.T) {
|
||||||
c := viper.New()
|
c := viper.New()
|
||||||
c.Set("server-url", "https://subsonic.example.com")
|
c.Set("server-url", "https://subsonic.example.com")
|
||||||
c.Set("token", "thetoken")
|
c.Set("token", "thetoken")
|
||||||
service := config.NewServiceConfig("test", c)
|
service := config.NewServiceConfig("test", c)
|
||||||
backend := (&subsonic.SubsonicApiBackend{}).FromConfig(&service)
|
backend := subsonic.SubsonicApiBackend{}
|
||||||
assert.IsType(t, &subsonic.SubsonicApiBackend{}, backend)
|
err := backend.InitConfig(&service)
|
||||||
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSongToLove(t *testing.T) {
|
func TestSongToLove(t *testing.T) {
|
||||||
user := "outsidecontext"
|
user := "outsidecontext"
|
||||||
song := go_subsonic.Child{
|
song := go_subsonic.Child{
|
||||||
|
ID: "foo123",
|
||||||
Starred: time.Unix(1699574369, 0),
|
Starred: time.Unix(1699574369, 0),
|
||||||
Title: "Oweynagat",
|
Title: "Oweynagat",
|
||||||
Album: "Here Now, There Then",
|
Album: "Here Now, There Then",
|
||||||
|
@ -59,4 +61,5 @@ func TestSongToLove(t *testing.T) {
|
||||||
assert.Equal(song.Track, love.Track.TrackNumber)
|
assert.Equal(song.Track, love.Track.TrackNumber)
|
||||||
assert.Equal(song.DiscNumber, love.Track.DiscNumber)
|
assert.Equal(song.DiscNumber, love.Track.DiscNumber)
|
||||||
assert.Equal([]string{song.Genre}, love.Track.Tags)
|
assert.Equal([]string{song.Genre}, love.Track.Tags)
|
||||||
|
assert.Equal(song.ID, love.AdditionalInfo["subsonic_id"])
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,12 +26,11 @@ import (
|
||||||
"go.uploadedlobster.com/scotty/internal/backends"
|
"go.uploadedlobster.com/scotty/internal/backends"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
|
||||||
"go.uploadedlobster.com/scotty/internal/storage"
|
"go.uploadedlobster.com/scotty/internal/storage"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AuthenticationFlow(service config.ServiceConfig, backend models.OAuth2Authenticator) {
|
func AuthenticationFlow(service config.ServiceConfig, backend auth.OAuth2Authenticator) {
|
||||||
redirectURL, err := backends.BuildRedirectURL(viper.GetViper(), backend.Name())
|
redirectURL, err := backends.BuildRedirectURL(viper.GetViper(), backend.Name())
|
||||||
cobra.CheckErr(err)
|
cobra.CheckErr(err)
|
||||||
|
|
||||||
|
@ -44,20 +43,20 @@ func AuthenticationFlow(service config.ServiceConfig, backend models.OAuth2Authe
|
||||||
|
|
||||||
state := auth.RandomState()
|
state := auth.RandomState()
|
||||||
// Redirect user to consent page to ask for permission specified scopes.
|
// Redirect user to consent page to ask for permission specified scopes.
|
||||||
authUrl := strategy.AuthCodeURL(verifier, state)
|
authURL := strategy.AuthCodeURL(verifier, state)
|
||||||
|
|
||||||
// Start an HTTP server to listen for the response
|
// Start an HTTP server to listen for the response
|
||||||
responseChan := make(chan auth.CodeResponse)
|
responseChan := make(chan auth.CodeResponse)
|
||||||
auth.RunOauth2CallbackServer(*redirectURL, authUrl.Param, responseChan)
|
auth.RunOauth2CallbackServer(*redirectURL, authURL.Param, responseChan)
|
||||||
|
|
||||||
// Open the URL
|
// Open the URL
|
||||||
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authUrl.Url))
|
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authURL.URL))
|
||||||
err = browser.OpenURL(authUrl.Url)
|
err = browser.OpenURL(authURL.URL)
|
||||||
cobra.CheckErr(err)
|
cobra.CheckErr(err)
|
||||||
|
|
||||||
// Retrieve the code from the authentication callback
|
// Retrieve the code from the authentication callback
|
||||||
code := <-responseChan
|
code := <-responseChan
|
||||||
if code.State != authUrl.State {
|
if code.State != authURL.State {
|
||||||
cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch"))
|
cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch"))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,11 +24,3 @@ func GetServiceConfigFromFlag(cmd *cobra.Command, flagName string) (config.Servi
|
||||||
name := cmd.Flag(flagName).Value.String()
|
name := cmd.Flag(flagName).Value.String()
|
||||||
return config.GetService(name)
|
return config.GetService(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getInt64FromFlag(cmd *cobra.Command, flagName string) (result int64) {
|
|
||||||
result, err := cmd.Flags().GetInt64(flagName)
|
|
||||||
if err != nil {
|
|
||||||
result = 0
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
|
@ -51,7 +51,7 @@ func setupProgressBar(p *mpb.Progress, name string) *mpb.Bar {
|
||||||
mpb.PrependDecorators(
|
mpb.PrependDecorators(
|
||||||
decor.Name(" "),
|
decor.Name(" "),
|
||||||
decor.OnComplete(
|
decor.OnComplete(
|
||||||
decor.Spinner(nil, decor.WC{W: 2, C: decor.DidentRight}),
|
decor.Spinner(nil, decor.WC{W: 2, C: decor.DindentRight}),
|
||||||
green("✓ "),
|
green("✓ "),
|
||||||
),
|
),
|
||||||
decor.Name(name, decor.WCSyncWidthR),
|
decor.Name(name, decor.WCSyncWidthR),
|
||||||
|
|
|
@ -17,6 +17,7 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/manifoldco/promptui"
|
"github.com/manifoldco/promptui"
|
||||||
"go.uploadedlobster.com/scotty/internal/i18n"
|
"go.uploadedlobster.com/scotty/internal/i18n"
|
||||||
|
@ -31,6 +32,8 @@ func Prompt(opt models.BackendOption) (any, error) {
|
||||||
return PromptSecret(opt)
|
return PromptSecret(opt)
|
||||||
case models.String:
|
case models.String:
|
||||||
return PromptString(opt)
|
return PromptString(opt)
|
||||||
|
case models.Int:
|
||||||
|
return PromptInt(opt)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown prompt type %v", opt.Type)
|
return nil, fmt.Errorf("unknown prompt type %v", opt.Type)
|
||||||
}
|
}
|
||||||
|
@ -78,3 +81,28 @@ func PromptYesNo(label string, defaultValue bool) (bool, error) {
|
||||||
_, val, err := sel.Run()
|
_, val, err := sel.Run()
|
||||||
return val == yes, err
|
return val == yes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PromptInt(opt models.BackendOption) (int, error) {
|
||||||
|
validate := func(s string) error {
|
||||||
|
if opt.Validate != nil {
|
||||||
|
if err := opt.Validate(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prompt := promptui.Prompt{
|
||||||
|
Label: opt.Label,
|
||||||
|
Validate: validate,
|
||||||
|
Default: opt.Default,
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := prompt.Run()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return strconv.Atoi(val)
|
||||||
|
}
|
||||||
|
|
|
@ -16,8 +16,8 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
@ -38,7 +38,7 @@ func NewTransferCmd[
|
||||||
](
|
](
|
||||||
cmd *cobra.Command,
|
cmd *cobra.Command,
|
||||||
db *storage.Database,
|
db *storage.Database,
|
||||||
entity string,
|
entity models.Entity,
|
||||||
source string,
|
source string,
|
||||||
target string,
|
target string,
|
||||||
) (TransferCmd[E, I, R], error) {
|
) (TransferCmd[E, I, R], error) {
|
||||||
|
@ -57,7 +57,7 @@ func NewTransferCmd[
|
||||||
type TransferCmd[E models.Backend, I models.ImportBackend, R models.ListensResult | models.LovesResult] struct {
|
type TransferCmd[E models.Backend, I models.ImportBackend, R models.ListensResult | models.LovesResult] struct {
|
||||||
cmd *cobra.Command
|
cmd *cobra.Command
|
||||||
db *storage.Database
|
db *storage.Database
|
||||||
entity string
|
entity models.Entity
|
||||||
sourceName string
|
sourceName string
|
||||||
targetName string
|
targetName string
|
||||||
ExpBackend E
|
ExpBackend E
|
||||||
|
@ -88,7 +88,7 @@ func (c *TransferCmd[E, I, R]) resolveBackends(source string, target string) err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp backends.ImportProcessor[R]) error {
|
func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp backends.ImportProcessor[R]) error {
|
||||||
fmt.Println(i18n.Tr("Transferring %s from %s to %s...", c.entity, c.sourceName, c.targetName))
|
fmt.Println(i18n.Tr("Transferring %s from %s to %s…", c.entity, c.sourceName, c.targetName))
|
||||||
|
|
||||||
// Authenticate backends, if needed
|
// Authenticate backends, if needed
|
||||||
config := viper.GetViper()
|
config := viper.GetViper()
|
||||||
|
@ -126,7 +126,6 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac
|
||||||
if result.LastTimestamp.Unix() < timestamp.Unix() {
|
if result.LastTimestamp.Unix() < timestamp.Unix() {
|
||||||
result.LastTimestamp = timestamp
|
result.LastTimestamp = timestamp
|
||||||
}
|
}
|
||||||
close(exportProgress)
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
progress.Wait()
|
progress.Wait()
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
|
@ -143,11 +142,11 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print errors
|
// Print errors
|
||||||
if len(result.ImportErrors) > 0 {
|
if len(result.ImportLog) > 0 {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println(i18n.Tr("During the import the following errors occurred:"))
|
fmt.Println(i18n.Tr("Import log:"))
|
||||||
for _, err := range result.ImportErrors {
|
for _, entry := range result.ImportLog {
|
||||||
fmt.Println(i18n.Tr("Error: %v\n", err))
|
fmt.Println(i18n.Tr("%v: %v", entry.Type, entry.Message))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,13 +154,29 @@ func (c *TransferCmd[E, I, R]) Transfer(exp backends.ExportProcessor[R], imp bac
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TransferCmd[E, I, R]) timestamp() (time.Time, error) {
|
func (c *TransferCmd[E, I, R]) timestamp() (time.Time, error) {
|
||||||
flagValue, err := c.cmd.Flags().GetInt64("timestamp")
|
flagValue, err := c.cmd.Flags().GetString("timestamp")
|
||||||
if err == nil && flagValue > math.MinInt64 {
|
if err != nil {
|
||||||
return time.Unix(flagValue, 0), nil
|
return time.Time{}, err
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
// No timestamp given, read from database
|
||||||
|
if flagValue == "" {
|
||||||
timestamp, err := c.db.GetImportTimestamp(c.sourceName, c.targetName, c.entity)
|
timestamp, err := c.db.GetImportTimestamp(c.sourceName, c.targetName, c.entity)
|
||||||
return timestamp, err
|
return timestamp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try using given value as a Unix timestamp
|
||||||
|
if timestamp, err := strconv.ParseInt(flagValue, 10, 64); err == nil {
|
||||||
|
return time.Unix(timestamp, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse datetime string
|
||||||
|
for _, format := range []string{time.DateTime, time.RFC3339} {
|
||||||
|
if t, err := time.Parse(format, flagValue); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, errors.New(i18n.Tr("invalid timestamp string \"%v\"", flagValue))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TransferCmd[E, I, R]) updateTimestamp(result models.ImportResult, oldTimestamp time.Time) error {
|
func (c *TransferCmd[E, I, R]) updateTimestamp(result models.ImportResult, oldTimestamp time.Time) error {
|
||||||
|
|
|
@ -61,7 +61,8 @@ func InitConfig(cfgFile string) error {
|
||||||
// Create global config if it does not exist
|
// Create global config if it does not exist
|
||||||
if viper.ConfigFileUsed() == "" && cfgFile == "" {
|
if viper.ConfigFileUsed() == "" && cfgFile == "" {
|
||||||
if err := os.MkdirAll(configDir, 0750); err == nil {
|
if err := os.MkdirAll(configDir, 0750); err == nil {
|
||||||
viper.SafeWriteConfig()
|
// This call is expected to return an error if the file already exists
|
||||||
|
viper.SafeWriteConfig() //nolint:errcheck
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,7 +83,7 @@ func WriteConfig(removedKeys ...string) error {
|
||||||
configMap := viper.AllSettings()
|
configMap := viper.AllSettings()
|
||||||
for _, key := range removedKeys {
|
for _, key := range removedKeys {
|
||||||
c := configMap
|
c := configMap
|
||||||
ok := true
|
var ok bool
|
||||||
subKeys := strings.Split(key, ".")
|
subKeys := strings.Split(key, ".")
|
||||||
keyLen := len(subKeys)
|
keyLen := len(subKeys)
|
||||||
// Deep search the key in the config and delete the deepest key, if it exists
|
// Deep search the key in the config and delete the deepest key, if it exists
|
||||||
|
|
|
@ -54,8 +54,20 @@ func (c *ServiceConfig) GetString(key string) string {
|
||||||
return cast.ToString(c.ConfigValues[key])
|
return cast.ToString(c.ConfigValues[key])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ServiceConfig) GetBool(key string) bool {
|
func (c *ServiceConfig) GetBool(key string, defaultValue bool) bool {
|
||||||
|
if c.IsSet(key) {
|
||||||
return cast.ToBool(c.ConfigValues[key])
|
return cast.ToBool(c.ConfigValues[key])
|
||||||
|
} else {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ServiceConfig) GetInt(key string, defaultValue int) int {
|
||||||
|
if c.IsSet(key) {
|
||||||
|
return cast.ToInt(c.ConfigValues[key])
|
||||||
|
} else {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ServiceConfig) IsSet(key string) bool {
|
func (c *ServiceConfig) IsSet(key string) bool {
|
||||||
|
|
|
@ -16,11 +16,10 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
package i18n
|
package i18n
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/Xuanwo/go-locale"
|
"github.com/Xuanwo/go-locale"
|
||||||
_ "go.uploadedlobster.com/scotty/internal/translations"
|
_ "go.uploadedlobster.com/scotty/internal/translations"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
"golang.org/x/text/message"
|
"golang.org/x/text/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -29,7 +28,7 @@ var localizer Localizer
|
||||||
func init() {
|
func init() {
|
||||||
tag, err := locale.Detect()
|
tag, err := locale.Detect()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
tag = language.English
|
||||||
}
|
}
|
||||||
localizer = New(tag)
|
localizer = New(tag)
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,12 +17,10 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uploadedlobster.com/scotty/internal/auth"
|
// "go.uploadedlobster.com/scotty/internal/auth"
|
||||||
"go.uploadedlobster.com/scotty/internal/config"
|
"go.uploadedlobster.com/scotty/internal/config"
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// A listen service backend.
|
// A listen service backend.
|
||||||
|
@ -32,7 +30,7 @@ type Backend interface {
|
||||||
Name() string
|
Name() string
|
||||||
|
|
||||||
// Initialize the backend from a config.
|
// Initialize the backend from a config.
|
||||||
FromConfig(config *config.ServiceConfig) Backend
|
InitConfig(config *config.ServiceConfig) error
|
||||||
|
|
||||||
// Return configuration options
|
// Return configuration options
|
||||||
Options() []BackendOption
|
Options() []BackendOption
|
||||||
|
@ -85,14 +83,3 @@ type LovesImport interface {
|
||||||
// Imports the given list of loves.
|
// Imports the given list of loves.
|
||||||
ImportLoves(export LovesResult, importResult ImportResult, progress chan Progress) (ImportResult, error)
|
ImportLoves(export LovesResult, importResult ImportResult, progress chan Progress) (ImportResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must be implemented by backends requiring OAuth2 authentication
|
|
||||||
type OAuth2Authenticator interface {
|
|
||||||
Backend
|
|
||||||
|
|
||||||
// Returns OAuth2 config suitable for this backend
|
|
||||||
OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy
|
|
||||||
|
|
||||||
// Setup the OAuth2 client
|
|
||||||
OAuth2Setup(token oauth2.TokenSource) error
|
|
||||||
}
|
|
||||||
|
|
|
@ -24,9 +24,16 @@ package models
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MBID string
|
type Entity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Listens Entity = "listens"
|
||||||
|
Loves Entity = "loves"
|
||||||
|
)
|
||||||
|
|
||||||
type AdditionalInfo map[string]any
|
type AdditionalInfo map[string]any
|
||||||
|
|
||||||
|
@ -37,12 +44,12 @@ type Track struct {
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
ISRC string
|
ISRC mbtypes.ISRC
|
||||||
RecordingMbid MBID
|
RecordingMBID mbtypes.MBID
|
||||||
ReleaseMbid MBID
|
ReleaseMBID mbtypes.MBID
|
||||||
ReleaseGroupMbid MBID
|
ReleaseGroupMBID mbtypes.MBID
|
||||||
ArtistMbids []MBID
|
ArtistMBIDs []mbtypes.MBID
|
||||||
WorkMbids []MBID
|
WorkMBIDs []mbtypes.MBID
|
||||||
Tags []string
|
Tags []string
|
||||||
AdditionalInfo AdditionalInfo
|
AdditionalInfo AdditionalInfo
|
||||||
}
|
}
|
||||||
|
@ -56,20 +63,20 @@ func (t *Track) FillAdditionalInfo() {
|
||||||
if t.AdditionalInfo == nil {
|
if t.AdditionalInfo == nil {
|
||||||
t.AdditionalInfo = make(AdditionalInfo, 5)
|
t.AdditionalInfo = make(AdditionalInfo, 5)
|
||||||
}
|
}
|
||||||
if t.RecordingMbid != "" {
|
if t.RecordingMBID != "" {
|
||||||
t.AdditionalInfo["recording_mbid"] = t.RecordingMbid
|
t.AdditionalInfo["recording_mbid"] = t.RecordingMBID
|
||||||
}
|
}
|
||||||
if t.ReleaseGroupMbid != "" {
|
if t.ReleaseGroupMBID != "" {
|
||||||
t.AdditionalInfo["release_group_mbid"] = t.ReleaseGroupMbid
|
t.AdditionalInfo["release_group_mbid"] = t.ReleaseGroupMBID
|
||||||
}
|
}
|
||||||
if t.ReleaseMbid != "" {
|
if t.ReleaseMBID != "" {
|
||||||
t.AdditionalInfo["release_mbid"] = t.ReleaseMbid
|
t.AdditionalInfo["release_mbid"] = t.ReleaseMBID
|
||||||
}
|
}
|
||||||
if len(t.ArtistMbids) > 0 {
|
if len(t.ArtistMBIDs) > 0 {
|
||||||
t.AdditionalInfo["artist_mbids"] = t.ArtistMbids
|
t.AdditionalInfo["artist_mbids"] = t.ArtistMBIDs
|
||||||
}
|
}
|
||||||
if len(t.WorkMbids) > 0 {
|
if len(t.WorkMBIDs) > 0 {
|
||||||
t.AdditionalInfo["work_mbids"] = t.WorkMbids
|
t.AdditionalInfo["work_mbids"] = t.WorkMBIDs
|
||||||
}
|
}
|
||||||
if t.ISRC != "" {
|
if t.ISRC != "" {
|
||||||
t.AdditionalInfo["isrc"] = t.ISRC
|
t.AdditionalInfo["isrc"] = t.ISRC
|
||||||
|
@ -104,8 +111,8 @@ type Love struct {
|
||||||
Track
|
Track
|
||||||
Created time.Time
|
Created time.Time
|
||||||
UserName string
|
UserName string
|
||||||
RecordingMbid MBID
|
RecordingMBID mbtypes.MBID
|
||||||
RecordingMsid MBID
|
RecordingMSID mbtypes.MBID
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListensList []Listen
|
type ListensList []Listen
|
||||||
|
@ -158,11 +165,24 @@ type ListensResult ExportResult[ListensList]
|
||||||
|
|
||||||
type LovesResult ExportResult[LovesList]
|
type LovesResult ExportResult[LovesList]
|
||||||
|
|
||||||
|
type LogEntryType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Info LogEntryType = "Info"
|
||||||
|
Warning LogEntryType = "Warning"
|
||||||
|
Error LogEntryType = "Error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogEntry struct {
|
||||||
|
Type LogEntryType
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
type ImportResult struct {
|
type ImportResult struct {
|
||||||
TotalCount int
|
TotalCount int
|
||||||
ImportCount int
|
ImportCount int
|
||||||
LastTimestamp time.Time
|
LastTimestamp time.Time
|
||||||
ImportErrors []string
|
ImportLog []LogEntry
|
||||||
|
|
||||||
// Error is only set if an unrecoverable import error occurred
|
// Error is only set if an unrecoverable import error occurred
|
||||||
Error error
|
Error error
|
||||||
|
@ -179,7 +199,14 @@ func (i *ImportResult) Update(from ImportResult) {
|
||||||
i.TotalCount = from.TotalCount
|
i.TotalCount = from.TotalCount
|
||||||
i.ImportCount = from.ImportCount
|
i.ImportCount = from.ImportCount
|
||||||
i.UpdateTimestamp(from.LastTimestamp)
|
i.UpdateTimestamp(from.LastTimestamp)
|
||||||
i.ImportErrors = append(i.ImportErrors, from.ImportErrors...)
|
i.ImportLog = append(i.ImportLog, from.ImportLog...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *ImportResult) Log(t LogEntryType, msg string) {
|
||||||
|
i.ImportLog = append(i.ImportLog, LogEntry{
|
||||||
|
Type: t,
|
||||||
|
Message: msg,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
|
@ -195,7 +222,7 @@ func (p Progress) FromImportResult(result ImportResult) Progress {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p Progress) Complete() Progress {
|
func (p Progress) Complete() Progress {
|
||||||
p.Total = p.Elapsed
|
p.Elapsed = p.Total
|
||||||
p.Completed = true
|
p.Completed = true
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -44,25 +45,25 @@ func TestTrackArtistName(t *testing.T) {
|
||||||
|
|
||||||
func TestTrackFillAdditionalInfo(t *testing.T) {
|
func TestTrackFillAdditionalInfo(t *testing.T) {
|
||||||
track := models.Track{
|
track := models.Track{
|
||||||
RecordingMbid: models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
|
RecordingMBID: mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
|
||||||
ReleaseGroupMbid: models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
|
ReleaseGroupMBID: mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
|
||||||
ReleaseMbid: models.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
|
ReleaseMBID: mbtypes.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
|
||||||
ArtistMbids: []models.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
|
ArtistMBIDs: []mbtypes.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
|
||||||
WorkMbids: []models.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"},
|
WorkMBIDs: []mbtypes.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"},
|
||||||
TrackNumber: 5,
|
TrackNumber: 5,
|
||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
Duration: time.Duration(413787 * time.Millisecond),
|
Duration: time.Duration(413787 * time.Millisecond),
|
||||||
ISRC: "DES561620801",
|
ISRC: mbtypes.ISRC("DES561620801"),
|
||||||
Tags: []string{"rock", "psychedelic rock"},
|
Tags: []string{"rock", "psychedelic rock"},
|
||||||
}
|
}
|
||||||
track.FillAdditionalInfo()
|
track.FillAdditionalInfo()
|
||||||
i := track.AdditionalInfo
|
i := track.AdditionalInfo
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
assert.Equal(track.RecordingMbid, i["recording_mbid"])
|
assert.Equal(track.RecordingMBID, i["recording_mbid"])
|
||||||
assert.Equal(track.ReleaseGroupMbid, i["release_group_mbid"])
|
assert.Equal(track.ReleaseGroupMBID, i["release_group_mbid"])
|
||||||
assert.Equal(track.ReleaseMbid, i["release_mbid"])
|
assert.Equal(track.ReleaseMBID, i["release_mbid"])
|
||||||
assert.Equal(track.ArtistMbids, i["artist_mbids"])
|
assert.Equal(track.ArtistMBIDs, i["artist_mbids"])
|
||||||
assert.Equal(track.WorkMbids, i["work_mbids"])
|
assert.Equal(track.WorkMBIDs, i["work_mbids"])
|
||||||
assert.Equal(track.TrackNumber, i["tracknumber"])
|
assert.Equal(track.TrackNumber, i["tracknumber"])
|
||||||
assert.Equal(track.DiscNumber, i["discnumber"])
|
assert.Equal(track.DiscNumber, i["discnumber"])
|
||||||
assert.Equal(track.Duration.Milliseconds(), i["duration_ms"])
|
assert.Equal(track.Duration.Milliseconds(), i["duration_ms"])
|
||||||
|
@ -117,23 +118,45 @@ func TestLovesListSort(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImportResultUpdate(t *testing.T) {
|
func TestImportResultUpdate(t *testing.T) {
|
||||||
|
logEntry1 := models.LogEntry{
|
||||||
|
Type: models.Warning,
|
||||||
|
Message: "foo",
|
||||||
|
}
|
||||||
|
logEntry2 := models.LogEntry{
|
||||||
|
Type: models.Error,
|
||||||
|
Message: "bar",
|
||||||
|
}
|
||||||
result := models.ImportResult{
|
result := models.ImportResult{
|
||||||
TotalCount: 100,
|
TotalCount: 100,
|
||||||
ImportCount: 20,
|
ImportCount: 20,
|
||||||
LastTimestamp: time.Now(),
|
LastTimestamp: time.Now(),
|
||||||
ImportErrors: []string{"foo"},
|
ImportLog: []models.LogEntry{logEntry1},
|
||||||
}
|
}
|
||||||
newResult := models.ImportResult{
|
newResult := models.ImportResult{
|
||||||
TotalCount: 120,
|
TotalCount: 120,
|
||||||
ImportCount: 50,
|
ImportCount: 50,
|
||||||
LastTimestamp: time.Now().Add(1 * time.Hour),
|
LastTimestamp: time.Now().Add(1 * time.Hour),
|
||||||
ImportErrors: []string{"bar"},
|
ImportLog: []models.LogEntry{logEntry2},
|
||||||
}
|
}
|
||||||
result.Update(newResult)
|
result.Update(newResult)
|
||||||
assert.Equal(t, 120, result.TotalCount)
|
assert.Equal(t, 120, result.TotalCount)
|
||||||
assert.Equal(t, 50, result.ImportCount)
|
assert.Equal(t, 50, result.ImportCount)
|
||||||
assert.Equal(t, newResult.LastTimestamp, result.LastTimestamp)
|
assert.Equal(t, newResult.LastTimestamp, result.LastTimestamp)
|
||||||
assert.Equal(t, []string{"foo", "bar"}, result.ImportErrors)
|
assert.Equal(t, []models.LogEntry{logEntry1, logEntry2}, result.ImportLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportResultLog(t *testing.T) {
|
||||||
|
result := models.ImportResult{}
|
||||||
|
result.Log(models.Warning, "foo")
|
||||||
|
result.Log(models.Error, "bar")
|
||||||
|
expected := []models.LogEntry{{
|
||||||
|
Type: models.Warning,
|
||||||
|
Message: "foo",
|
||||||
|
}, {
|
||||||
|
Type: models.Error,
|
||||||
|
Message: "bar",
|
||||||
|
}}
|
||||||
|
assert.Equal(t, expected, result.ImportLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImportResultUpdateTimestamp(t *testing.T) {
|
func TestImportResultUpdateTimestamp(t *testing.T) {
|
||||||
|
|
|
@ -21,6 +21,7 @@ const (
|
||||||
Bool OptionType = "bool"
|
Bool OptionType = "bool"
|
||||||
Secret OptionType = "secret"
|
Secret OptionType = "secret"
|
||||||
String OptionType = "string"
|
String OptionType = "string"
|
||||||
|
Int OptionType = "int"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BackendOption struct {
|
type BackendOption struct {
|
||||||
|
|
82
internal/similarity/similarity.go
Normal file
82
internal/similarity/similarity.go
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
/*
|
||||||
|
Copyright © 2024 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
|
Scotty is free software: you can redistribute it and/or modify it under the
|
||||||
|
terms of the GNU General Public License as published by the Free Software
|
||||||
|
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with
|
||||||
|
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package similarity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/agnivade/levenshtein"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/util"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Returns the Levensthein distance between s1 and s2 relative to the length of
|
||||||
|
// the longer string.
|
||||||
|
// Unicode normalization on the strings is performed.
|
||||||
|
func Similarity(s1 string, s2 string) float64 {
|
||||||
|
s1 = norm.NFKC.String(s1)
|
||||||
|
s2 = norm.NFKC.String(s2)
|
||||||
|
l1 := len([]rune(s1))
|
||||||
|
l2 := len([]rune(s2))
|
||||||
|
maxLen := max(l1, l2)
|
||||||
|
// Empty strings always compare full equal
|
||||||
|
if maxLen == 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
dist := levenshtein.ComputeDistance(s1, s2)
|
||||||
|
// fmt.Printf("%v (%v) ~ %v (%v) = %v\n", s1, l1, s2, l2, dist)
|
||||||
|
return 1.0 - (float64(dist) / float64(maxLen))
|
||||||
|
}
|
||||||
|
|
||||||
|
var reMultiSpace = regexp.MustCompile(`\s+`)
|
||||||
|
var reIgnoredPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`\s+\([^)]+\)$`),
|
||||||
|
regexp.MustCompile(`\s+- (\d{4} )?remaster(ed)?$`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizes a track or release title.
|
||||||
|
func NormalizeTitle(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
s = reMultiSpace.ReplaceAllString(s, " ")
|
||||||
|
for _, re := range reIgnoredPatterns {
|
||||||
|
s = re.ReplaceAllString(s, "")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare two tracks for similarity.
|
||||||
|
func CompareTracks(t1 models.Track, t2 models.Track) float64 {
|
||||||
|
// Identical recording MBID always compares 100%
|
||||||
|
if t1.RecordingMBID == t2.RecordingMBID && t1.RecordingMBID != "" {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare track name and artist
|
||||||
|
sims := []float64{
|
||||||
|
Similarity(NormalizeTitle(t1.TrackName), NormalizeTitle(t2.TrackName)),
|
||||||
|
Similarity(NormalizeTitle(t1.ArtistName()), NormalizeTitle(t2.ArtistName())),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare release names only if they are set for both tracks
|
||||||
|
if t1.ReleaseName != "" && t2.ReleaseName != "" {
|
||||||
|
sims = append(sims, Similarity(NormalizeTitle(t1.ReleaseName), NormalizeTitle(t2.ReleaseName)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return util.Average(sims...)
|
||||||
|
}
|
87
internal/similarity/similarity_test.go
Normal file
87
internal/similarity/similarity_test.go
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
Copyright © 2024 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
|
Scotty is free software: you can redistribute it and/or modify it under the
|
||||||
|
terms of the GNU General Public License as published by the Free Software
|
||||||
|
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with
|
||||||
|
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package similarity_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/similarity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleSimilarity() {
|
||||||
|
s := similarity.Similarity("bar1", "bär1")
|
||||||
|
fmt.Println(s)
|
||||||
|
// Output: 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarity(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
assert.Equal(1.0, similarity.Similarity("", ""))
|
||||||
|
assert.Equal(0.0, similarity.Similarity("foo", ""))
|
||||||
|
assert.Equal(0.0, similarity.Similarity("foo", "bar"))
|
||||||
|
assert.Equal(0.5, similarity.Similarity("foobar", "bar"))
|
||||||
|
assert.Equal(1.0, similarity.Similarity("foo", "foo"))
|
||||||
|
assert.Equal(0.6, similarity.Similarity("Forever After", "Forever Failure"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleNormalizeTitle() {
|
||||||
|
s := similarity.NormalizeTitle(" Forever \tFailure (video edit) ")
|
||||||
|
fmt.Println(s)
|
||||||
|
// Output: forever failure
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTitle(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
assert.Equal("forever failure", similarity.NormalizeTitle("Forever Failure"))
|
||||||
|
assert.Equal("foo", similarity.NormalizeTitle(" \tfoo\t \t"))
|
||||||
|
assert.Equal("wasted years", similarity.NormalizeTitle("Wasted Years - 2015 Remaster"))
|
||||||
|
assert.Equal("london calling", similarity.NormalizeTitle("London Calling - Remastered"))
|
||||||
|
assert.Equal("london calling", similarity.NormalizeTitle("London Calling (Remastered)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleCompareTracks() {
|
||||||
|
t1 := models.Track{
|
||||||
|
ArtistNames: []string{"Paradise Lost"},
|
||||||
|
TrackName: "Forever After",
|
||||||
|
}
|
||||||
|
t2 := models.Track{
|
||||||
|
ArtistNames: []string{"Paradise Lost"},
|
||||||
|
TrackName: "Forever Failure (radio edit)",
|
||||||
|
ReleaseName: "Draconian Times",
|
||||||
|
}
|
||||||
|
sim := similarity.CompareTracks(t1, t2)
|
||||||
|
fmt.Println(sim)
|
||||||
|
// Output: 0.8333333333333334
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareTracksSameMBID(t *testing.T) {
|
||||||
|
t1 := models.Track{
|
||||||
|
ArtistNames: []string{"Paradise Lost"},
|
||||||
|
TrackName: "Forever After",
|
||||||
|
RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"),
|
||||||
|
}
|
||||||
|
t2 := models.Track{
|
||||||
|
ArtistNames: []string{"Paradise Lost"},
|
||||||
|
TrackName: "Forever Failure (radio edit)",
|
||||||
|
ReleaseName: "Draconian Times",
|
||||||
|
RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"),
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1.0, similarity.CompareTracks(t1, t2))
|
||||||
|
}
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
@ -54,7 +55,7 @@ func New(dsn string) (db Database, err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db Database) GetImportTimestamp(source string, target string, entity string) (time.Time, error) {
|
func (db Database) GetImportTimestamp(source string, target string, entity models.Entity) (time.Time, error) {
|
||||||
result := ImportTimestamp{
|
result := ImportTimestamp{
|
||||||
SourceService: source,
|
SourceService: source,
|
||||||
TargetService: target,
|
TargetService: target,
|
||||||
|
@ -64,7 +65,7 @@ func (db Database) GetImportTimestamp(source string, target string, entity strin
|
||||||
return result.Timestamp, err
|
return result.Timestamp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db Database) SetImportTimestamp(source string, target string, entity string, timestamp time.Time) error {
|
func (db Database) SetImportTimestamp(source string, target string, entity models.Entity, timestamp time.Time) error {
|
||||||
entry := ImportTimestamp{
|
entry := ImportTimestamp{
|
||||||
SourceService: source,
|
SourceService: source,
|
||||||
TargetService: target,
|
TargetService: target,
|
||||||
|
|
|
@ -23,6 +23,7 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
"go.uploadedlobster.com/scotty/internal/storage"
|
"go.uploadedlobster.com/scotty/internal/storage"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
@ -33,7 +34,7 @@ func TestTimestampUpdate(t *testing.T) {
|
||||||
|
|
||||||
source := "maloja"
|
source := "maloja"
|
||||||
target := "funkwhale"
|
target := "funkwhale"
|
||||||
entity := "loves"
|
entity := models.Loves
|
||||||
timestamp, err := db.GetImportTimestamp(source, target, entity)
|
timestamp, err := db.GetImportTimestamp(source, target, entity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, time.Time{}, timestamp)
|
assert.Equal(t, time.Time{}, timestamp)
|
||||||
|
|
|
@ -20,13 +20,14 @@ package storage
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/scotty/internal/models"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ImportTimestamp struct {
|
type ImportTimestamp struct {
|
||||||
SourceService string `gorm:"primaryKey"`
|
SourceService string `gorm:"primaryKey"`
|
||||||
TargetService string `gorm:"primaryKey"`
|
TargetService string `gorm:"primaryKey"`
|
||||||
Entity string `gorm:"primaryKey"`
|
Entity models.Entity `gorm:"primaryKey"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
Timestamp time.Time `gorm:"default:'1970-01-01T00:00:00'"`
|
Timestamp time.Time `gorm:"default:'1970-01-01T00:00:00'"`
|
||||||
|
|
|
@ -39,117 +39,155 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
var messageKeyToIndex = map[string]int{
|
var messageKeyToIndex = map[string]int{
|
||||||
"\tbackend: %v": 18,
|
"\tbackend: %v": 11,
|
||||||
"\texport: %s": 7,
|
"\texport: %s": 0,
|
||||||
"\timport: %s\n": 8,
|
"\timport: %s\n": 1,
|
||||||
"Aborted": 15,
|
"%v: %v": 48,
|
||||||
"Access token": 26,
|
"Aborted": 8,
|
||||||
"Access token received, you can use %v now.\n": 35,
|
"Access token": 19,
|
||||||
"Append to file": 32,
|
"Access token received, you can use %v now.\n": 34,
|
||||||
"Backend": 40,
|
"Append to file": 21,
|
||||||
"Client ID": 22,
|
"Backend": 42,
|
||||||
"Client secret": 23,
|
"Check for duplicate listens on import (slower)": 24,
|
||||||
"Delete the service configuration \"%v\"?": 14,
|
"Client ID": 15,
|
||||||
"Disable auto correction of submitted listens": 30,
|
"Client secret": 16,
|
||||||
"During the import the following errors occurred:": 5,
|
"Delete the service configuration \"%v\"?": 7,
|
||||||
"Error: %v\n": 6,
|
"Directory path": 29,
|
||||||
"Error: OAuth state mismatch": 34,
|
"Disable auto correction of submitted listens": 26,
|
||||||
"Failed reading config: %v": 9,
|
"Error: OAuth state mismatch": 33,
|
||||||
"File path": 27,
|
"Failed reading config: %v": 2,
|
||||||
"From timestamp: %v (%v)": 41,
|
"File path": 20,
|
||||||
"Import failed, last reported timestamp was %v (%s)": 42,
|
"From timestamp: %v (%v)": 44,
|
||||||
"Imported %v of %v %s into %v.": 4,
|
"Ignore listens in incognito mode": 30,
|
||||||
"Include skipped listens": 31,
|
"Ignore skipped listens": 27,
|
||||||
"Latest timestamp: %v (%v)": 43,
|
"Ignored duplicate listen %v: \"%v\" by %v (%v)": 25,
|
||||||
"No": 37,
|
"Import failed, last reported timestamp was %v (%s)": 45,
|
||||||
"Playlist title": 28,
|
"Import log:": 47,
|
||||||
"Saved service %v using backend %v": 12,
|
"Imported %v of %v %s into %v.": 46,
|
||||||
"Server URL": 24,
|
"Latest timestamp: %v (%v)": 50,
|
||||||
"Service": 39,
|
"Minimum playback duration for skipped tracks (seconds)": 31,
|
||||||
"Service \"%v\" deleted\n": 16,
|
"No": 39,
|
||||||
"Service name": 10,
|
"Playlist title": 22,
|
||||||
"The backend %v requires authentication. Authenticate now?": 13,
|
"Saved service %v using backend %v": 5,
|
||||||
"Token received, you can close this window now.": 19,
|
"Server URL": 17,
|
||||||
"Transferring %s from %s to %s...": 3,
|
"Service": 41,
|
||||||
"Unique playlist identifier": 29,
|
"Service \"%v\" deleted\n": 9,
|
||||||
"Updated service %v using backend %v\n": 17,
|
"Service name": 3,
|
||||||
"User name": 25,
|
"Specify a time zone for the listen timestamps": 28,
|
||||||
"Visit the URL for authorization: %v": 33,
|
"The backend %v requires authentication. Authenticate now?": 6,
|
||||||
"Yes": 36,
|
"Token received, you can close this window now.": 12,
|
||||||
"a service with this name already exists": 11,
|
"Transferring %s from %s to %s…": 43,
|
||||||
"backend %s does not implement %s": 20,
|
"Unique playlist identifier": 23,
|
||||||
"done": 2,
|
"Updated service %v using backend %v\n": 10,
|
||||||
"exporting": 0,
|
"User name": 18,
|
||||||
"importing": 1,
|
"Visit the URL for authorization: %v": 32,
|
||||||
"key must only consist of A-Za-z0-9_-": 45,
|
"Yes": 38,
|
||||||
"no configuration file defined, cannot write config": 44,
|
"a service with this name already exists": 4,
|
||||||
"no existing service configurations": 38,
|
"backend %s does not implement %s": 13,
|
||||||
"no service configuration \"%v\"": 46,
|
"done": 37,
|
||||||
"unknown backend \"%s\"": 21,
|
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
var deIndex = []uint32{ // 48 elements
|
var deIndex = []uint32{ // 55 elements
|
||||||
// Entry 0 - 1F
|
// Entry 0 - 1F
|
||||||
0x00000000, 0x0000000b, 0x00000016, 0x0000001d,
|
0x00000000, 0x00000013, 0x00000027, 0x00000052,
|
||||||
0x00000046, 0x00000071, 0x000000a8, 0x000000bb,
|
0x0000005e, 0x0000008d, 0x000000bd, 0x00000104,
|
||||||
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
|
0x00000133, 0x0000013f, 0x00000162, 0x00000198,
|
||||||
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
|
0x000001ac, 0x000001e7, 0x00000213, 0x00000233,
|
||||||
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
|
0x0000023d, 0x0000024b, 0x00000256, 0x00000263,
|
||||||
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
|
0x00000271, 0x0000027b, 0x0000028e, 0x000002a1,
|
||||||
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
|
0x000002b8, 0x000002ed, 0x00000328, 0x0000035c,
|
||||||
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
|
0x0000037e, 0x000003a4, 0x000003b4, 0x000003da,
|
||||||
// Entry 20 - 3F
|
// Entry 20 - 3F
|
||||||
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
|
0x00000418, 0x00000443, 0x0000046d, 0x000004ad,
|
||||||
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
|
0x000004b8, 0x000004c3, 0x000004ca, 0x000004cd,
|
||||||
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
|
0x000004d2, 0x000004fb, 0x00000503, 0x0000050b,
|
||||||
0x000000bb, 0x000000bb, 0x000000bb, 0x000000bb,
|
0x00000534, 0x00000552, 0x0000058f, 0x000005ba,
|
||||||
} // Size: 216 bytes
|
0x000005c5, 0x000005d2, 0x000005f6, 0x00000619,
|
||||||
|
0x0000066a, 0x000006a1, 0x000006c8,
|
||||||
|
} // Size: 244 bytes
|
||||||
|
|
||||||
const deData string = "" + // Size: 187 bytes
|
const deData string = "" + // Size: 1736 bytes
|
||||||
"\x02exportiere\x02importiere\x02fertig\x02Übertrage %[1]s von %[2]s nach" +
|
"\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" +
|
||||||
" %[3]s...\x02%[1]v von %[2]v %[3]s in %[4]v importiert.\x02Während des I" +
|
" %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" +
|
||||||
"mports sind folgende Fehler aufgetreten:\x04\x00\x01\x0a\x0e\x02Fehler: " +
|
"in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" +
|
||||||
"%[1]v"
|
"ackend %[2]v gespeichert\x02Das Backend %[1]v erfordert Authentifizierun" +
|
||||||
|
"g. Jetzt authentifizieren?\x02Die Servicekonfiguration „%[1]v“ löschen?" +
|
||||||
|
"\x02Abgebrochen\x04\x00\x01\x0a\x1e\x02Service „%[1]v“ gelöscht\x04\x00" +
|
||||||
|
"\x01\x0a1\x02Service %[1]v mit dem Backend %[2]v aktualisiert\x04\x01" +
|
||||||
|
"\x09\x00\x0f\x02Backend: %[1]v\x02Token erhalten, das Fenster kann jetzt" +
|
||||||
|
" geschlossen werden.\x02das Backend %[1]s implementiert %[2]s nicht\x02u" +
|
||||||
|
"nbekanntes Backend „%[1]s“\x02Client-ID\x02Client-Secret\x02Server-URL" +
|
||||||
|
"\x02Benutzername\x02Zugriffstoken\x02Dateipfad\x02An Datei anhängen\x02T" +
|
||||||
|
"itel der Playlist\x02Eindeutige Playlist-ID\x02Beim Import auf Listen-Du" +
|
||||||
|
"plikate prüfen (langsamer)\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{ // 48 elements
|
var enIndex = []uint32{ // 55 elements
|
||||||
// Entry 0 - 1F
|
// Entry 0 - 1F
|
||||||
0x00000000, 0x0000000a, 0x00000014, 0x00000019,
|
0x00000000, 0x00000013, 0x00000027, 0x00000044,
|
||||||
0x00000043, 0x0000006d, 0x0000009e, 0x000000b0,
|
0x00000051, 0x00000079, 0x000000a1, 0x000000de,
|
||||||
0x000000c3, 0x000000d7, 0x000000f4, 0x00000101,
|
0x00000108, 0x00000110, 0x0000012d, 0x0000015c,
|
||||||
0x00000129, 0x00000151, 0x0000018e, 0x000001b8,
|
0x00000170, 0x0000019f, 0x000001c6, 0x000001de,
|
||||||
0x000001c0, 0x000001dd, 0x0000020c, 0x00000220,
|
0x000001e8, 0x000001f6, 0x00000201, 0x0000020b,
|
||||||
0x0000024f, 0x00000276, 0x0000028e, 0x00000298,
|
0x00000218, 0x00000222, 0x00000231, 0x00000240,
|
||||||
0x000002a6, 0x000002b1, 0x000002bb, 0x000002c8,
|
0x0000025b, 0x0000028a, 0x000002c3, 0x000002f0,
|
||||||
0x000002d2, 0x000002e1, 0x000002fc, 0x00000329,
|
0x00000307, 0x00000335, 0x00000344, 0x00000365,
|
||||||
// Entry 20 - 3F
|
// Entry 20 - 3F
|
||||||
0x00000341, 0x00000350, 0x00000377, 0x00000393,
|
0x0000039c, 0x000003c3, 0x000003df, 0x00000412,
|
||||||
0x000003c6, 0x000003ca, 0x000003cd, 0x000003f0,
|
0x0000041c, 0x00000426, 0x0000042b, 0x0000042f,
|
||||||
0x000003f8, 0x00000400, 0x0000041e, 0x00000457,
|
0x00000432, 0x00000455, 0x0000045d, 0x00000465,
|
||||||
0x00000477, 0x000004aa, 0x000004cf, 0x000004f0,
|
0x0000048f, 0x000004ad, 0x000004e6, 0x00000510,
|
||||||
} // Size: 216 bytes
|
0x0000051c, 0x00000529, 0x0000054a, 0x0000056a,
|
||||||
|
0x0000059d, 0x000005c2, 0x000005e3,
|
||||||
|
} // Size: 244 bytes
|
||||||
|
|
||||||
const enData string = "" + // Size: 1264 bytes
|
const enData string = "" + // Size: 1507 bytes
|
||||||
"\x02exporting\x02importing\x02done\x02Transferring %[1]s from %[2]s to %" +
|
"\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" +
|
||||||
"[3]s...\x02Imported %[1]v of %[2]v %[3]s into %[4]v.\x02During the impor" +
|
" %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" +
|
||||||
"t the following errors occurred:\x04\x00\x01\x0a\x0d\x02Error: %[1]v\x04" +
|
" this name already exists\x02Saved service %[1]v using backend %[2]v\x02" +
|
||||||
"\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import: %[1" +
|
"The backend %[1]v requires authentication. Authenticate now?\x02Delete t" +
|
||||||
"]s\x02Failed reading config: %[1]v\x02Service name\x02a service with thi" +
|
"he service configuration \x22%[1]v\x22?\x02Aborted\x04\x00\x01\x0a\x18" +
|
||||||
"s name already exists\x02Saved service %[1]v using backend %[2]v\x02The " +
|
"\x02Service \x22%[1]v\x22 deleted\x04\x00\x01\x0a*\x02Updated service %[" +
|
||||||
"backend %[1]v requires authentication. Authenticate now?\x02Delete the s" +
|
"1]v using backend %[2]v\x04\x01\x09\x00\x0f\x02backend: %[1]v\x02Token r" +
|
||||||
"ervice configuration \x22%[1]v\x22?\x02Aborted\x04\x00\x01\x0a\x18\x02Se" +
|
"eceived, you can close this window now.\x02backend %[1]s does not implem" +
|
||||||
"rvice \x22%[1]v\x22 deleted\x04\x00\x01\x0a*\x02Updated service %[1]v us" +
|
"ent %[2]s\x02unknown backend \x22%[1]s\x22\x02Client ID\x02Client secret" +
|
||||||
"ing backend %[2]v\x04\x01\x09\x00\x0f\x02backend: %[1]v\x02Token receive" +
|
"\x02Server URL\x02User name\x02Access token\x02File path\x02Append to fi" +
|
||||||
"d, you can close this window now.\x02backend %[1]s does not implement %[" +
|
"le\x02Playlist title\x02Unique playlist identifier\x02Check for duplicat" +
|
||||||
"2]s\x02unknown backend \x22%[1]s\x22\x02Client ID\x02Client secret\x02Se" +
|
"e listens on import (slower)\x02Ignored duplicate listen %[1]v: \x22%[2]" +
|
||||||
"rver URL\x02User name\x02Access token\x02File path\x02Playlist title\x02" +
|
"v\x22 by %[3]v (%[4]v)\x02Disable auto correction of submitted listens" +
|
||||||
"Unique playlist identifier\x02Disable auto correction of submitted liste" +
|
"\x02Ignore skipped listens\x02Specify a time zone for the listen timesta" +
|
||||||
"ns\x02Include skipped listens\x02Append to file\x02Visit the URL for aut" +
|
"mps\x02Directory path\x02Ignore listens in incognito mode\x02Minimum pla" +
|
||||||
"horization: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Acc" +
|
"yback duration for skipped tracks (seconds)\x02Visit the URL for authori" +
|
||||||
"ess token received, you can use %[1]v now.\x02Yes\x02No\x02no existing s" +
|
"zation: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Access " +
|
||||||
"ervice configurations\x02Service\x02Backend\x02From timestamp: %[1]v (%[" +
|
"token received, you can use %[1]v now.\x02exporting\x02importing\x02done" +
|
||||||
"2]v)\x02Import failed, last reported timestamp was %[1]v (%[2]s)\x02Late" +
|
"\x02Yes\x02No\x02no existing service configurations\x02Service\x02Backen" +
|
||||||
"st timestamp: %[1]v (%[2]v)\x02no configuration file defined, cannot wri" +
|
"d\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestamp: %[1]v (%" +
|
||||||
"te config\x02key must only consist of A-Za-z0-9_-\x02no service configur" +
|
"[2]v)\x02Import failed, last reported timestamp was %[1]v (%[2]s)\x02Imp" +
|
||||||
"ation \x22%[1]v\x22"
|
"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 1883 bytes (1KiB); checksum: 6875B9DE
|
// Total table size 3731 bytes (3KiB); checksum: F7951710
|
||||||
|
|
|
@ -2,44 +2,64 @@
|
||||||
"language": "de",
|
"language": "de",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"id": "Authenticate a service",
|
"id": "export: {ExportCapabilities__}",
|
||||||
"message": "Authenticate a service",
|
"message": "export: {ExportCapabilities__}",
|
||||||
"translation": "An einem Service anmelden"
|
"translation": "Export: {ExportCapabilities__}",
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "For backends requiring authentication this command can be used to authenticate.\n\nAuthentication is always done per configured service. That means you can have\nmultiple services using the same backend but different authentication.",
|
|
||||||
"message": "For backends requiring authentication this command can be used to authenticate.\n\nAuthentication is always done per configured service. That means you can have\nmultiple services using the same backend but different authentication.",
|
|
||||||
"translation": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "failed loading service configuration",
|
|
||||||
"message": "failed loading service configuration",
|
|
||||||
"translation": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "Visit the URL for authorization: {Url}",
|
|
||||||
"message": "Visit the URL for authorization: {Url}",
|
|
||||||
"translation": "",
|
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Url",
|
"id": "ExportCapabilities__",
|
||||||
"string": "%[1]v",
|
"string": "%[1]s",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "authUrl.Url"
|
"expr": "strings.Join(info.ExportCapabilities, \", \")"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Error: OAuth state mismatch",
|
"id": "import: {ImportCapabilities__}",
|
||||||
"message": "Error: OAuth state mismatch",
|
"message": "import: {ImportCapabilities__}",
|
||||||
"translation": ""
|
"translation": "Import: {ImportCapabilities__}",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "ImportCapabilities__",
|
||||||
|
"string": "%[1]s",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "strings.Join(info.ImportCapabilities, \", \")"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Access token received, you can use {Name} now.",
|
"id": "Failed reading config: {Err}",
|
||||||
"message": "Access token received, you can use {Name} now.",
|
"message": "Failed reading config: {Err}",
|
||||||
"translation": "",
|
"translation": "Fehler beim Lesen der Konfiguration: {Err}",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Err",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "error",
|
||||||
|
"underlyingType": "interface{Error() string}",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "err"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Service name",
|
||||||
|
"message": "Service name",
|
||||||
|
"translation": "Servicename"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a service with this name already exists",
|
||||||
|
"message": "a service with this name already exists",
|
||||||
|
"translation": "ein Service mit diesem Namen existiert bereits"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Saved service {Name} using backend {Backend}",
|
||||||
|
"message": "Saved service {Name} using backend {Backend}",
|
||||||
|
"translation": "Service {Name} mit dem Backend {Backend} gespeichert",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Name",
|
"id": "Name",
|
||||||
|
@ -47,45 +67,358 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "serviceConfig.Name"
|
"expr": "service.Name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "service.Backend"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "service configuration (required)",
|
"id": "The backend {Backend} requires authentication. Authenticate now?",
|
||||||
"message": "service configuration (required)",
|
"message": "The backend {Backend} requires authentication. Authenticate now?",
|
||||||
"translation": "Servicekonfiguration (notwendig)"
|
"translation": "Das Backend {Backend} erfordert Authentifizierung. Jetzt authentifizieren?",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service.Backend"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Delete the service configuration \"{Service}\"?",
|
||||||
|
"message": "Delete the service configuration \"{Service}\"?",
|
||||||
|
"translation": "Die Servicekonfiguration „{Service}“ löschen?",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Service",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "go.uploadedlobster.com/scotty/internal/config.ServiceConfig",
|
||||||
|
"underlyingType": "struct{Name string; Backend string; ConfigValues map[string]any}",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Aborted",
|
||||||
|
"message": "Aborted",
|
||||||
|
"translation": "Abgebrochen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Service \"{Name}\" deleted",
|
||||||
|
"message": "Service \"{Name}\" deleted",
|
||||||
|
"translation": "Service „{Name}“ gelöscht",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Name",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service.Name"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Updated service {Name} using backend {Backend}",
|
||||||
|
"message": "Updated service {Name} using backend {Backend}",
|
||||||
|
"translation": "Service {Name} mit dem Backend {Backend} aktualisiert",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Name",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service.Name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "service.Backend"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend: {Backend}",
|
||||||
|
"message": "backend: {Backend}",
|
||||||
|
"translation": "Backend: {Backend}",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "s.Backend"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Token received, you can close this window now.",
|
||||||
|
"message": "Token received, you can close this window now.",
|
||||||
|
"translation": "Token erhalten, das Fenster kann jetzt geschlossen werden."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend {Backend} does not implement {InterfaceName}",
|
||||||
|
"message": "backend {Backend} does not implement {InterfaceName}",
|
||||||
|
"translation": "das Backend {Backend} implementiert {InterfaceName} nicht",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"string": "%[1]s",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "config.Backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "InterfaceName",
|
||||||
|
"string": "%[2]s",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "interfaceName"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unknown backend \"{BackendName}\"",
|
||||||
|
"message": "unknown backend \"{BackendName}\"",
|
||||||
|
"translation": "unbekanntes Backend „{BackendName}“",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "BackendName",
|
||||||
|
"string": "%[1]s",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "backendName"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Client ID",
|
||||||
|
"message": "Client ID",
|
||||||
|
"translation": "Client-ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Client secret",
|
||||||
|
"message": "Client secret",
|
||||||
|
"translation": "Client-Secret"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Server URL",
|
||||||
|
"message": "Server URL",
|
||||||
|
"translation": "Server-URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "User name",
|
||||||
|
"message": "User name",
|
||||||
|
"translation": "Benutzername"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Access token",
|
||||||
|
"message": "Access token",
|
||||||
|
"translation": "Zugriffstoken"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "File path",
|
||||||
|
"message": "File path",
|
||||||
|
"translation": "Dateipfad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Append to file",
|
||||||
|
"message": "Append to file",
|
||||||
|
"translation": "An Datei anhängen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Playlist title",
|
||||||
|
"message": "Playlist title",
|
||||||
|
"translation": "Titel der Playlist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Unique playlist identifier",
|
||||||
|
"message": "Unique playlist identifier",
|
||||||
|
"translation": "Eindeutige Playlist-ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Check for duplicate listens on import (slower)",
|
||||||
|
"message": "Check for duplicate listens on import (slower)",
|
||||||
|
"translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
|
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
|
"translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "ListenedAt",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "time.Time",
|
||||||
|
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "l.ListenedAt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TrackName",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "l.TrackName"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ArtistName",
|
||||||
|
"string": "%[3]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 3,
|
||||||
|
"expr": "l.ArtistName()"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "RecordingMBID",
|
||||||
|
"string": "%[4]v",
|
||||||
|
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 4,
|
||||||
|
"expr": "l.RecordingMBID"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Disable auto correction of submitted listens",
|
||||||
|
"message": "Disable auto correction of submitted listens",
|
||||||
|
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "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": "Verzeichnispfad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Ignore listens in incognito mode",
|
||||||
|
"message": "Ignore listens in incognito mode",
|
||||||
|
"translation": "Listens im Inkognito-Modus 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": "Zur Anmeldung folgende URL aufrufen: {URL}",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "URL",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "authURL.URL"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Error: OAuth state mismatch",
|
||||||
|
"message": "Error: OAuth state mismatch",
|
||||||
|
"translation": "Fehler: OAuth-State stimmt nicht überein"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Access token received, you can use {Name} now.",
|
||||||
|
"message": "Access token received, you can use {Name} now.",
|
||||||
|
"translation": "Zugriffstoken erhalten, {Name} kann jetzt verwendet werden.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Name",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service.Name"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "exporting",
|
"id": "exporting",
|
||||||
"message": "exporting",
|
"message": "exporting",
|
||||||
"translation": "exportiere",
|
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true,
|
||||||
|
"translation": "exportiere"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "importing",
|
"id": "importing",
|
||||||
"message": "importing",
|
"message": "importing",
|
||||||
"translation": "importiere",
|
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true,
|
||||||
|
"translation": "importiere"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "done",
|
"id": "done",
|
||||||
"message": "done",
|
"message": "done",
|
||||||
"translation": "fertig",
|
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true,
|
||||||
|
"translation": "fertig"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"id": "Yes",
|
||||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"message": "Yes",
|
||||||
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...",
|
"translation": "Ja"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "No",
|
||||||
|
"message": "No",
|
||||||
|
"translation": "Nein"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no existing service configurations",
|
||||||
|
"message": "no existing service configurations",
|
||||||
|
"translation": "keine bestehenden Servicekonfigurationen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Service",
|
||||||
|
"message": "Service",
|
||||||
|
"translation": "Service"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"message": "Backend",
|
||||||
|
"translation": "Backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
|
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
|
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}…",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Entity",
|
"id": "Entity",
|
||||||
"string": "%[1]s",
|
"string": "%[1]s",
|
||||||
"type": "string",
|
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "c.entity"
|
"expr": "c.entity"
|
||||||
|
@ -109,48 +442,44 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "From timestamp: {Timestamp} ({Unix})",
|
"id": "From timestamp: {Arg_1} ({Arg_2})",
|
||||||
"message": "From timestamp: {Timestamp} ({Unix})",
|
"message": "From timestamp: {Arg_1} ({Arg_2})",
|
||||||
"translation": "Ab Zeitstempel: {Timestamp} ({Unix})",
|
"translation": "Ab Zeitstempel: {Arg_1} ({Arg_2})",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Timestamp",
|
"id": "Arg_1",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "time.Time",
|
"type": "",
|
||||||
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
|
"underlyingType": "interface{}",
|
||||||
"argNum": 1,
|
"argNum": 1
|
||||||
"expr": "timestamp"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Unix",
|
"id": "Arg_2",
|
||||||
"string": "%[2]v",
|
"string": "%[2]v",
|
||||||
"type": "int64",
|
"type": "",
|
||||||
"underlyingType": "int64",
|
"underlyingType": "interface{}",
|
||||||
"argNum": 2,
|
"argNum": 2
|
||||||
"expr": "timestamp.Unix()"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Import failed, last reported timestamp was {LastTimestamp} ({Unix})",
|
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
|
||||||
"message": "Import failed, last reported timestamp was {LastTimestamp} ({Unix})",
|
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
|
||||||
"translation": "Import fehlgeschlagen, der letzte Zeitstempel war {LastTimestamp} ({Unix})",
|
"translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "LastTimestamp",
|
"id": "Arg_1",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "time.Time",
|
"type": "",
|
||||||
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
|
"underlyingType": "interface{}",
|
||||||
"argNum": 1,
|
"argNum": 1
|
||||||
"expr": "result.LastTimestamp"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Unix",
|
"id": "Arg_2",
|
||||||
"string": "%[2]v",
|
"string": "%[2]s",
|
||||||
"type": "int64",
|
"type": "",
|
||||||
"underlyingType": "int64",
|
"underlyingType": "string",
|
||||||
"argNum": 2,
|
"argNum": 2
|
||||||
"expr": "result.LastTimestamp.Unix()"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -178,7 +507,7 @@
|
||||||
{
|
{
|
||||||
"id": "Entity",
|
"id": "Entity",
|
||||||
"string": "%[3]s",
|
"string": "%[3]s",
|
||||||
"type": "string",
|
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 3,
|
"argNum": 3,
|
||||||
"expr": "c.entity"
|
"expr": "c.entity"
|
||||||
|
@ -194,45 +523,91 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "During the import the following errors occurred:",
|
"id": "Import log:",
|
||||||
"message": "During the import the following errors occurred:",
|
"message": "Import log:",
|
||||||
"translation": "Während des Imports sind folgende Fehler aufgetreten:"
|
"translation": "Importlog:"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Error: {Err}",
|
"id": "{Type}: {Message}",
|
||||||
"message": "Error: {Err}",
|
"message": "{Type}: {Message}",
|
||||||
"translation": "Fehler: {Err}",
|
"translation": "{Type}: {Message}",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Err",
|
"id": "Type",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "string",
|
"type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "err"
|
"expr": "entry.Type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Message",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "entry.Message"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Latest timestamp: {LastTimestamp} ({Unix})",
|
"id": "invalid timestamp string \"{FlagValue}\"",
|
||||||
"message": "Latest timestamp: {LastTimestamp} ({Unix})",
|
"message": "invalid timestamp string \"{FlagValue}\"",
|
||||||
"translation": "Neuester Zeitstempel: {LastTimestamp} ({Unix})",
|
"translation": "ungültiger Zeitstempel „{FlagValue}“",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "LastTimestamp",
|
"id": "FlagValue",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "time.Time",
|
"type": "string",
|
||||||
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "result.LastTimestamp"
|
"expr": "flagValue"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Unix",
|
"id": "Latest timestamp: {Arg_1} ({Arg_2})",
|
||||||
|
"message": "Latest timestamp: {Arg_1} ({Arg_2})",
|
||||||
|
"translation": "Letzter Zeitstempel: {Arg_1} ({Arg_2})",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Arg_1",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "",
|
||||||
|
"underlyingType": "interface{}",
|
||||||
|
"argNum": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Arg_2",
|
||||||
"string": "%[2]v",
|
"string": "%[2]v",
|
||||||
"type": "int64",
|
"type": "",
|
||||||
"underlyingType": "int64",
|
"underlyingType": "interface{}",
|
||||||
"argNum": 2,
|
"argNum": 2
|
||||||
"expr": "result.LastTimestamp.Unix()"
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no configuration file defined, cannot write config",
|
||||||
|
"message": "no configuration file defined, cannot write config",
|
||||||
|
"translation": "keine Konfigurationsdatei definiert, Konfiguration kann nicht geschrieben werden"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "key must only consist of A-Za-z0-9_-",
|
||||||
|
"message": "key must only consist of A-Za-z0-9_-",
|
||||||
|
"translation": "Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no service configuration \"{Name}\"",
|
||||||
|
"message": "no service configuration \"{Name}\"",
|
||||||
|
"translation": "keine Servicekonfiguration „{Name}“",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Name",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "name"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
{
|
{
|
||||||
"id": "export: {ExportCapabilities__}",
|
"id": "export: {ExportCapabilities__}",
|
||||||
"message": "export: {ExportCapabilities__}",
|
"message": "export: {ExportCapabilities__}",
|
||||||
"translation": "",
|
"translation": "Export: {ExportCapabilities__}",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "ExportCapabilities__",
|
"id": "ExportCapabilities__",
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
{
|
{
|
||||||
"id": "import: {ImportCapabilities__}",
|
"id": "import: {ImportCapabilities__}",
|
||||||
"message": "import: {ImportCapabilities__}",
|
"message": "import: {ImportCapabilities__}",
|
||||||
"translation": "",
|
"translation": "Import: {ImportCapabilities__}",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "ImportCapabilities__",
|
"id": "ImportCapabilities__",
|
||||||
|
@ -34,7 +34,7 @@
|
||||||
{
|
{
|
||||||
"id": "Failed reading config: {Err}",
|
"id": "Failed reading config: {Err}",
|
||||||
"message": "Failed reading config: {Err}",
|
"message": "Failed reading config: {Err}",
|
||||||
"translation": "",
|
"translation": "Fehler beim Lesen der Konfiguration: {Err}",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Err",
|
"id": "Err",
|
||||||
|
@ -49,17 +49,17 @@
|
||||||
{
|
{
|
||||||
"id": "Service name",
|
"id": "Service name",
|
||||||
"message": "Service name",
|
"message": "Service name",
|
||||||
"translation": ""
|
"translation": "Servicename"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "a service with this name already exists",
|
"id": "a service with this name already exists",
|
||||||
"message": "a service with this name already exists",
|
"message": "a service with this name already exists",
|
||||||
"translation": ""
|
"translation": "ein Service mit diesem Namen existiert bereits"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Saved service {Name} using backend {Backend}",
|
"id": "Saved service {Name} using backend {Backend}",
|
||||||
"message": "Saved service {Name} using backend {Backend}",
|
"message": "Saved service {Name} using backend {Backend}",
|
||||||
"translation": "",
|
"translation": "Service {Name} mit dem Backend {Backend} gespeichert",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Name",
|
"id": "Name",
|
||||||
|
@ -82,7 +82,7 @@
|
||||||
{
|
{
|
||||||
"id": "The backend {Backend} requires authentication. Authenticate now?",
|
"id": "The backend {Backend} requires authentication. Authenticate now?",
|
||||||
"message": "The backend {Backend} requires authentication. Authenticate now?",
|
"message": "The backend {Backend} requires authentication. Authenticate now?",
|
||||||
"translation": "",
|
"translation": "Das Backend {Backend} erfordert Authentifizierung. Jetzt authentifizieren?",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Backend",
|
"id": "Backend",
|
||||||
|
@ -97,7 +97,7 @@
|
||||||
{
|
{
|
||||||
"id": "Delete the service configuration \"{Service}\"?",
|
"id": "Delete the service configuration \"{Service}\"?",
|
||||||
"message": "Delete the service configuration \"{Service}\"?",
|
"message": "Delete the service configuration \"{Service}\"?",
|
||||||
"translation": "",
|
"translation": "Die Servicekonfiguration „{Service}“ löschen?",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Service",
|
"id": "Service",
|
||||||
|
@ -112,12 +112,12 @@
|
||||||
{
|
{
|
||||||
"id": "Aborted",
|
"id": "Aborted",
|
||||||
"message": "Aborted",
|
"message": "Aborted",
|
||||||
"translation": ""
|
"translation": "Abgebrochen"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Service \"{Name}\" deleted",
|
"id": "Service \"{Name}\" deleted",
|
||||||
"message": "Service \"{Name}\" deleted",
|
"message": "Service \"{Name}\" deleted",
|
||||||
"translation": "",
|
"translation": "Service „{Name}“ gelöscht",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Name",
|
"id": "Name",
|
||||||
|
@ -132,7 +132,7 @@
|
||||||
{
|
{
|
||||||
"id": "Updated service {Name} using backend {Backend}",
|
"id": "Updated service {Name} using backend {Backend}",
|
||||||
"message": "Updated service {Name} using backend {Backend}",
|
"message": "Updated service {Name} using backend {Backend}",
|
||||||
"translation": "",
|
"translation": "Service {Name} mit dem Backend {Backend} aktualisiert",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Name",
|
"id": "Name",
|
||||||
|
@ -155,7 +155,7 @@
|
||||||
{
|
{
|
||||||
"id": "backend: {Backend}",
|
"id": "backend: {Backend}",
|
||||||
"message": "backend: {Backend}",
|
"message": "backend: {Backend}",
|
||||||
"translation": "",
|
"translation": "Backend: {Backend}",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Backend",
|
"id": "Backend",
|
||||||
|
@ -170,12 +170,12 @@
|
||||||
{
|
{
|
||||||
"id": "Token received, you can close this window now.",
|
"id": "Token received, you can close this window now.",
|
||||||
"message": "Token received, you can close this window now.",
|
"message": "Token received, you can close this window now.",
|
||||||
"translation": ""
|
"translation": "Token erhalten, das Fenster kann jetzt geschlossen werden."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "backend {Backend} does not implement {InterfaceName}",
|
"id": "backend {Backend} does not implement {InterfaceName}",
|
||||||
"message": "backend {Backend} does not implement {InterfaceName}",
|
"message": "backend {Backend} does not implement {InterfaceName}",
|
||||||
"translation": "",
|
"translation": "das Backend {Backend} implementiert {InterfaceName} nicht",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Backend",
|
"id": "Backend",
|
||||||
|
@ -198,7 +198,7 @@
|
||||||
{
|
{
|
||||||
"id": "unknown backend \"{BackendName}\"",
|
"id": "unknown backend \"{BackendName}\"",
|
||||||
"message": "unknown backend \"{BackendName}\"",
|
"message": "unknown backend \"{BackendName}\"",
|
||||||
"translation": "",
|
"translation": "unbekanntes Backend „{BackendName}“",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "BackendName",
|
"id": "BackendName",
|
||||||
|
@ -213,82 +213,146 @@
|
||||||
{
|
{
|
||||||
"id": "Client ID",
|
"id": "Client ID",
|
||||||
"message": "Client ID",
|
"message": "Client ID",
|
||||||
"translation": ""
|
"translation": "Client-ID"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Client secret",
|
"id": "Client secret",
|
||||||
"message": "Client secret",
|
"message": "Client secret",
|
||||||
"translation": ""
|
"translation": "Client-Secret"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Server URL",
|
"id": "Server URL",
|
||||||
"message": "Server URL",
|
"message": "Server URL",
|
||||||
"translation": ""
|
"translation": "Server-URL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "User name",
|
"id": "User name",
|
||||||
"message": "User name",
|
"message": "User name",
|
||||||
"translation": ""
|
"translation": "Benutzername"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Access token",
|
"id": "Access token",
|
||||||
"message": "Access token",
|
"message": "Access token",
|
||||||
"translation": ""
|
"translation": "Zugriffstoken"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "File path",
|
"id": "File path",
|
||||||
"message": "File path",
|
"message": "File path",
|
||||||
"translation": ""
|
"translation": "Dateipfad"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "Playlist title",
|
|
||||||
"message": "Playlist title",
|
|
||||||
"translation": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "Unique playlist identifier",
|
|
||||||
"message": "Unique playlist identifier",
|
|
||||||
"translation": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "Disable auto correction of submitted listens",
|
|
||||||
"message": "Disable auto correction of submitted listens",
|
|
||||||
"translation": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "Include skipped listens",
|
|
||||||
"message": "Include skipped listens",
|
|
||||||
"translation": ""
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Append to file",
|
"id": "Append to file",
|
||||||
"message": "Append to file",
|
"message": "Append to file",
|
||||||
"translation": ""
|
"translation": "An Datei anhängen"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Visit the URL for authorization: {Url}",
|
"id": "Playlist title",
|
||||||
"message": "Visit the URL for authorization: {Url}",
|
"message": "Playlist title",
|
||||||
"translation": "",
|
"translation": "Titel der Playlist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Unique playlist identifier",
|
||||||
|
"message": "Unique playlist identifier",
|
||||||
|
"translation": "Eindeutige Playlist-ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Check for duplicate listens on import (slower)",
|
||||||
|
"message": "Check for duplicate listens on import (slower)",
|
||||||
|
"translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
|
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
|
"translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Url",
|
"id": "ListenedAt",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "time.Time",
|
||||||
|
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "l.ListenedAt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TrackName",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "l.TrackName"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ArtistName",
|
||||||
|
"string": "%[3]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 3,
|
||||||
|
"expr": "l.ArtistName()"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "RecordingMBID",
|
||||||
|
"string": "%[4]v",
|
||||||
|
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 4,
|
||||||
|
"expr": "l.RecordingMBID"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Disable auto correction of submitted listens",
|
||||||
|
"message": "Disable auto correction of submitted listens",
|
||||||
|
"translation": "Autokorrektur für übermittelte Titel deaktivieren"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "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": "Verzeichnispfad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Ignore listens in incognito mode",
|
||||||
|
"message": "Ignore listens in incognito mode",
|
||||||
|
"translation": "Listens im Inkognito-Modus 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": "Zur Anmeldung folgende URL aufrufen: {URL}",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "URL",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "authUrl.Url"
|
"expr": "authURL.URL"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Error: OAuth state mismatch",
|
"id": "Error: OAuth state mismatch",
|
||||||
"message": "Error: OAuth state mismatch",
|
"message": "Error: OAuth state mismatch",
|
||||||
"translation": ""
|
"translation": "Fehler: OAuth-State stimmt nicht überein"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Access token received, you can use {Name} now.",
|
"id": "Access token received, you can use {Name} now.",
|
||||||
"message": "Access token received, you can use {Name} now.",
|
"message": "Access token received, you can use {Name} now.",
|
||||||
"translation": "",
|
"translation": "Zugriffstoken erhalten, {Name} kann jetzt verwendet werden.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Name",
|
"id": "Name",
|
||||||
|
@ -324,37 +388,37 @@
|
||||||
{
|
{
|
||||||
"id": "Yes",
|
"id": "Yes",
|
||||||
"message": "Yes",
|
"message": "Yes",
|
||||||
"translation": ""
|
"translation": "Ja"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "No",
|
"id": "No",
|
||||||
"message": "No",
|
"message": "No",
|
||||||
"translation": ""
|
"translation": "Nein"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "no existing service configurations",
|
"id": "no existing service configurations",
|
||||||
"message": "no existing service configurations",
|
"message": "no existing service configurations",
|
||||||
"translation": ""
|
"translation": "keine bestehenden Servicekonfigurationen"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Service",
|
"id": "Service",
|
||||||
"message": "Service",
|
"message": "Service",
|
||||||
"translation": ""
|
"translation": "Service"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Backend",
|
"id": "Backend",
|
||||||
"message": "Backend",
|
"message": "Backend",
|
||||||
"translation": ""
|
"translation": "Backend"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}...",
|
"translation": "Übertrage {Entity} von {SourceName} nach {TargetName}…",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Entity",
|
"id": "Entity",
|
||||||
"string": "%[1]s",
|
"string": "%[1]s",
|
||||||
"type": "string",
|
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "c.entity"
|
"expr": "c.entity"
|
||||||
|
@ -380,7 +444,7 @@
|
||||||
{
|
{
|
||||||
"id": "From timestamp: {Arg_1} ({Arg_2})",
|
"id": "From timestamp: {Arg_1} ({Arg_2})",
|
||||||
"message": "From timestamp: {Arg_1} ({Arg_2})",
|
"message": "From timestamp: {Arg_1} ({Arg_2})",
|
||||||
"translation": "",
|
"translation": "Ab Zeitstempel: {Arg_1} ({Arg_2})",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Arg_1",
|
"id": "Arg_1",
|
||||||
|
@ -401,7 +465,7 @@
|
||||||
{
|
{
|
||||||
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
|
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
|
||||||
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
|
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
|
||||||
"translation": "",
|
"translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Arg_1",
|
"id": "Arg_1",
|
||||||
|
@ -443,7 +507,7 @@
|
||||||
{
|
{
|
||||||
"id": "Entity",
|
"id": "Entity",
|
||||||
"string": "%[3]s",
|
"string": "%[3]s",
|
||||||
"type": "string",
|
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 3,
|
"argNum": 3,
|
||||||
"expr": "c.entity"
|
"expr": "c.entity"
|
||||||
|
@ -459,29 +523,52 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "During the import the following errors occurred:",
|
"id": "Import log:",
|
||||||
"message": "During the import the following errors occurred:",
|
"message": "Import log:",
|
||||||
"translation": "Während des Imports sind folgende Fehler aufgetreten:"
|
"translation": "Importlog:"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Error: {Err}",
|
"id": "{Type}: {Message}",
|
||||||
"message": "Error: {Err}",
|
"message": "{Type}: {Message}",
|
||||||
"translation": "Fehler: {Err}",
|
"translation": "{Type}: {Message}",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Err",
|
"id": "Type",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "entry.Type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Message",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "entry.Message"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "invalid timestamp string \"{FlagValue}\"",
|
||||||
|
"message": "invalid timestamp string \"{FlagValue}\"",
|
||||||
|
"translation": "ungültiger Zeitstempel „{FlagValue}“",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "FlagValue",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "err"
|
"expr": "flagValue"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Latest timestamp: {Arg_1} ({Arg_2})",
|
"id": "Latest timestamp: {Arg_1} ({Arg_2})",
|
||||||
"message": "Latest timestamp: {Arg_1} ({Arg_2})",
|
"message": "Latest timestamp: {Arg_1} ({Arg_2})",
|
||||||
"translation": "",
|
"translation": "Letzter Zeitstempel: {Arg_1} ({Arg_2})",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Arg_1",
|
"id": "Arg_1",
|
||||||
|
@ -502,17 +589,17 @@
|
||||||
{
|
{
|
||||||
"id": "no configuration file defined, cannot write config",
|
"id": "no configuration file defined, cannot write config",
|
||||||
"message": "no configuration file defined, cannot write config",
|
"message": "no configuration file defined, cannot write config",
|
||||||
"translation": ""
|
"translation": "keine Konfigurationsdatei definiert, Konfiguration kann nicht geschrieben werden"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "key must only consist of A-Za-z0-9_-",
|
"id": "key must only consist of A-Za-z0-9_-",
|
||||||
"message": "key must only consist of A-Za-z0-9_-",
|
"message": "key must only consist of A-Za-z0-9_-",
|
||||||
"translation": ""
|
"translation": "Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "no service configuration \"{Name}\"",
|
"id": "no service configuration \"{Name}\"",
|
||||||
"message": "no service configuration \"{Name}\"",
|
"message": "no service configuration \"{Name}\"",
|
||||||
"translation": "",
|
"translation": "keine Servicekonfiguration „{Name}“",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Name",
|
"id": "Name",
|
||||||
|
|
|
@ -1,6 +1,439 @@
|
||||||
{
|
{
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"messages": [
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": "export: {ExportCapabilities__}",
|
||||||
|
"message": "export: {ExportCapabilities__}",
|
||||||
|
"translation": "export: {ExportCapabilities__}",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "ExportCapabilities__",
|
||||||
|
"string": "%[1]s",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "strings.Join(info.ExportCapabilities, \", \")"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "import: {ImportCapabilities__}",
|
||||||
|
"message": "import: {ImportCapabilities__}",
|
||||||
|
"translation": "import: {ImportCapabilities__}",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "ImportCapabilities__",
|
||||||
|
"string": "%[1]s",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "strings.Join(info.ImportCapabilities, \", \")"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Failed reading config: {Err}",
|
||||||
|
"message": "Failed reading config: {Err}",
|
||||||
|
"translation": "Failed reading config: {Err}",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Err",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "error",
|
||||||
|
"underlyingType": "interface{Error() string}",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "err"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Service name",
|
||||||
|
"message": "Service name",
|
||||||
|
"translation": "Service name",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a service with this name already exists",
|
||||||
|
"message": "a service with this name already exists",
|
||||||
|
"translation": "a service with this name already exists",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Saved service {Name} using backend {Backend}",
|
||||||
|
"message": "Saved service {Name} using backend {Backend}",
|
||||||
|
"translation": "Saved service {Name} using backend {Backend}",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Name",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service.Name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "service.Backend"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "The backend {Backend} requires authentication. Authenticate now?",
|
||||||
|
"message": "The backend {Backend} requires authentication. Authenticate now?",
|
||||||
|
"translation": "The backend {Backend} requires authentication. Authenticate now?",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service.Backend"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Delete the service configuration \"{Service}\"?",
|
||||||
|
"message": "Delete the service configuration \"{Service}\"?",
|
||||||
|
"translation": "Delete the service configuration \"{Service}\"?",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Service",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "go.uploadedlobster.com/scotty/internal/config.ServiceConfig",
|
||||||
|
"underlyingType": "struct{Name string; Backend string; ConfigValues map[string]any}",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Aborted",
|
||||||
|
"message": "Aborted",
|
||||||
|
"translation": "Aborted",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Service \"{Name}\" deleted",
|
||||||
|
"message": "Service \"{Name}\" deleted",
|
||||||
|
"translation": "Service \"{Name}\" deleted",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Name",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service.Name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Updated service {Name} using backend {Backend}",
|
||||||
|
"message": "Updated service {Name} using backend {Backend}",
|
||||||
|
"translation": "Updated service {Name} using backend {Backend}",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Name",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service.Name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "service.Backend"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend: {Backend}",
|
||||||
|
"message": "backend: {Backend}",
|
||||||
|
"translation": "backend: {Backend}",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "s.Backend"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Token received, you can close this window now.",
|
||||||
|
"message": "Token received, you can close this window now.",
|
||||||
|
"translation": "Token received, you can close this window now.",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "backend {Backend} does not implement {InterfaceName}",
|
||||||
|
"message": "backend {Backend} does not implement {InterfaceName}",
|
||||||
|
"translation": "backend {Backend} does not implement {InterfaceName}",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"string": "%[1]s",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "config.Backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "InterfaceName",
|
||||||
|
"string": "%[2]s",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "interfaceName"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unknown backend \"{BackendName}\"",
|
||||||
|
"message": "unknown backend \"{BackendName}\"",
|
||||||
|
"translation": "unknown backend \"{BackendName}\"",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "BackendName",
|
||||||
|
"string": "%[1]s",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "backendName"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Client ID",
|
||||||
|
"message": "Client ID",
|
||||||
|
"translation": "Client ID",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Client secret",
|
||||||
|
"message": "Client secret",
|
||||||
|
"translation": "Client secret",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Server URL",
|
||||||
|
"message": "Server URL",
|
||||||
|
"translation": "Server URL",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "User name",
|
||||||
|
"message": "User name",
|
||||||
|
"translation": "User name",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Access token",
|
||||||
|
"message": "Access token",
|
||||||
|
"translation": "Access token",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "File path",
|
||||||
|
"message": "File path",
|
||||||
|
"translation": "File path",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Append to file",
|
||||||
|
"message": "Append to file",
|
||||||
|
"translation": "Append to file",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Playlist title",
|
||||||
|
"message": "Playlist title",
|
||||||
|
"translation": "Playlist title",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Unique playlist identifier",
|
||||||
|
"message": "Unique playlist identifier",
|
||||||
|
"translation": "Unique playlist identifier",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Check for duplicate listens on import (slower)",
|
||||||
|
"message": "Check for duplicate listens on import (slower)",
|
||||||
|
"translation": "Check for duplicate listens on import (slower)",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
|
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
|
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "ListenedAt",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "time.Time",
|
||||||
|
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "l.ListenedAt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TrackName",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "l.TrackName"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ArtistName",
|
||||||
|
"string": "%[3]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 3,
|
||||||
|
"expr": "l.ArtistName()"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "RecordingMBID",
|
||||||
|
"string": "%[4]v",
|
||||||
|
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 4,
|
||||||
|
"expr": "l.RecordingMBID"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Disable auto correction of submitted listens",
|
||||||
|
"message": "Disable auto correction of submitted listens",
|
||||||
|
"translation": "Disable auto correction of submitted listens",
|
||||||
|
"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": "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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Directory path",
|
||||||
|
"message": "Directory path",
|
||||||
|
"translation": "Directory path",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Ignore listens in incognito mode",
|
||||||
|
"message": "Ignore listens in incognito mode",
|
||||||
|
"translation": "Ignore listens in incognito mode",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
|
"message": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
|
"translation": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Visit the URL for authorization: {URL}",
|
||||||
|
"message": "Visit the URL for authorization: {URL}",
|
||||||
|
"translation": "Visit the URL for authorization: {URL}",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "URL",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "authURL.URL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Error: OAuth state mismatch",
|
||||||
|
"message": "Error: OAuth state mismatch",
|
||||||
|
"translation": "Error: OAuth state mismatch",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Access token received, you can use {Name} now.",
|
||||||
|
"message": "Access token received, you can use {Name} now.",
|
||||||
|
"translation": "Access token received, you can use {Name} now.",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Name",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "service.Name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "exporting",
|
"id": "exporting",
|
||||||
"message": "exporting",
|
"message": "exporting",
|
||||||
|
@ -23,15 +456,50 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"id": "Yes",
|
||||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"message": "Yes",
|
||||||
"translation": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"translation": "Yes",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "No",
|
||||||
|
"message": "No",
|
||||||
|
"translation": "No",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no existing service configurations",
|
||||||
|
"message": "no existing service configurations",
|
||||||
|
"translation": "no existing service configurations",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Service",
|
||||||
|
"message": "Service",
|
||||||
|
"translation": "Service",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Backend",
|
||||||
|
"message": "Backend",
|
||||||
|
"translation": "Backend",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
|
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
|
"translation": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Entity",
|
"id": "Entity",
|
||||||
"string": "%[1]s",
|
"string": "%[1]s",
|
||||||
"type": "string",
|
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "c.entity"
|
"expr": "c.entity"
|
||||||
|
@ -56,51 +524,47 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "From timestamp: {Timestamp} ({Unix})",
|
"id": "From timestamp: {Arg_1} ({Arg_2})",
|
||||||
"message": "From timestamp: {Timestamp} ({Unix})",
|
"message": "From timestamp: {Arg_1} ({Arg_2})",
|
||||||
"translation": "From timestamp: {Timestamp} ({Unix})",
|
"translation": "From timestamp: {Arg_1} ({Arg_2})",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Timestamp",
|
"id": "Arg_1",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "time.Time",
|
"type": "",
|
||||||
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
|
"underlyingType": "interface{}",
|
||||||
"argNum": 1,
|
"argNum": 1
|
||||||
"expr": "timestamp"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Unix",
|
"id": "Arg_2",
|
||||||
"string": "%[2]v",
|
"string": "%[2]v",
|
||||||
"type": "int64",
|
"type": "",
|
||||||
"underlyingType": "int64",
|
"underlyingType": "interface{}",
|
||||||
"argNum": 2,
|
"argNum": 2
|
||||||
"expr": "timestamp.Unix()"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Import failed, last reported timestamp was {LastTimestamp} ({Unix})",
|
"id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
|
||||||
"message": "Import failed, last reported timestamp was {LastTimestamp} ({Unix})",
|
"message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
|
||||||
"translation": "Import failed, last reported timestamp was {LastTimestamp} ({Unix})",
|
"translation": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "LastTimestamp",
|
"id": "Arg_1",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "time.Time",
|
"type": "",
|
||||||
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
|
"underlyingType": "interface{}",
|
||||||
"argNum": 1,
|
"argNum": 1
|
||||||
"expr": "result.LastTimestamp"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Unix",
|
"id": "Arg_2",
|
||||||
"string": "%[2]v",
|
"string": "%[2]s",
|
||||||
"type": "int64",
|
"type": "",
|
||||||
"underlyingType": "int64",
|
"underlyingType": "string",
|
||||||
"argNum": 2,
|
"argNum": 2
|
||||||
"expr": "result.LastTimestamp.Unix()"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
|
@ -130,7 +594,7 @@
|
||||||
{
|
{
|
||||||
"id": "Entity",
|
"id": "Entity",
|
||||||
"string": "%[3]s",
|
"string": "%[3]s",
|
||||||
"type": "string",
|
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 3,
|
"argNum": 3,
|
||||||
"expr": "c.entity"
|
"expr": "c.entity"
|
||||||
|
@ -147,50 +611,104 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "During the import the following errors occurred:",
|
"id": "Import log:",
|
||||||
"message": "During the import the following errors occurred:",
|
"message": "Import log:",
|
||||||
"translation": "During the import the following errors occurred:",
|
"translation": "Import log:",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Error: {Err}",
|
"id": "{Type}: {Message}",
|
||||||
"message": "Error: {Err}",
|
"message": "{Type}: {Message}",
|
||||||
"translation": "Error: {Err}",
|
"translation": "{Type}: {Message}",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Err",
|
"id": "Type",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "string",
|
"type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "err"
|
"expr": "entry.Type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Message",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "entry.Message"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Latest timestamp: {LastTimestamp} ({Unix})",
|
"id": "invalid timestamp string \"{FlagValue}\"",
|
||||||
"message": "Latest timestamp: {LastTimestamp} ({Unix})",
|
"message": "invalid timestamp string \"{FlagValue}\"",
|
||||||
"translation": "Latest timestamp: {LastTimestamp} ({Unix})",
|
"translation": "invalid timestamp string \"{FlagValue}\"",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "LastTimestamp",
|
"id": "FlagValue",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "time.Time",
|
"type": "string",
|
||||||
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "result.LastTimestamp"
|
"expr": "flagValue"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Unix",
|
"id": "Latest timestamp: {Arg_1} ({Arg_2})",
|
||||||
|
"message": "Latest timestamp: {Arg_1} ({Arg_2})",
|
||||||
|
"translation": "Latest timestamp: {Arg_1} ({Arg_2})",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Arg_1",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "",
|
||||||
|
"underlyingType": "interface{}",
|
||||||
|
"argNum": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Arg_2",
|
||||||
"string": "%[2]v",
|
"string": "%[2]v",
|
||||||
"type": "int64",
|
"type": "",
|
||||||
"underlyingType": "int64",
|
"underlyingType": "interface{}",
|
||||||
"argNum": 2,
|
"argNum": 2
|
||||||
"expr": "result.LastTimestamp.Unix()"
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no configuration file defined, cannot write config",
|
||||||
|
"message": "no configuration file defined, cannot write config",
|
||||||
|
"translation": "no configuration file defined, cannot write config",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "key must only consist of A-Za-z0-9_-",
|
||||||
|
"message": "key must only consist of A-Za-z0-9_-",
|
||||||
|
"translation": "key must only consist of A-Za-z0-9_-",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "no service configuration \"{Name}\"",
|
||||||
|
"message": "no service configuration \"{Name}\"",
|
||||||
|
"translation": "no service configuration \"{Name}\"",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "Name",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "name"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
|
|
|
@ -282,6 +282,13 @@
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "Append to file",
|
||||||
|
"message": "Append to file",
|
||||||
|
"translation": "Append to file",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "Playlist title",
|
"id": "Playlist title",
|
||||||
"message": "Playlist title",
|
"message": "Playlist title",
|
||||||
|
@ -296,6 +303,54 @@
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "Check for duplicate listens on import (slower)",
|
||||||
|
"message": "Check for duplicate listens on import (slower)",
|
||||||
|
"translation": "Check for duplicate listens on import (slower)",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
|
"message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
|
"translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "ListenedAt",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "time.Time",
|
||||||
|
"underlyingType": "struct{wall uint64; ext int64; loc *time.Location}",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "l.ListenedAt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TrackName",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "l.TrackName"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ArtistName",
|
||||||
|
"string": "%[3]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 3,
|
||||||
|
"expr": "l.ArtistName()"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "RecordingMBID",
|
||||||
|
"string": "%[4]v",
|
||||||
|
"type": "go.uploadedlobster.com/mbtypes.MBID",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 4,
|
||||||
|
"expr": "l.RecordingMBID"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "Disable auto correction of submitted listens",
|
"id": "Disable auto correction of submitted listens",
|
||||||
"message": "Disable auto correction of submitted listens",
|
"message": "Disable auto correction of submitted listens",
|
||||||
|
@ -304,32 +359,53 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Include skipped listens",
|
"id": "Ignore skipped listens",
|
||||||
"message": "Include skipped listens",
|
"message": "Ignore skipped listens",
|
||||||
"translation": "Include skipped listens",
|
"translation": "Ignore skipped listens",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Append to file",
|
"id": "Specify a time zone for the listen timestamps",
|
||||||
"message": "Append to file",
|
"message": "Specify a time zone for the listen timestamps",
|
||||||
"translation": "Append to file",
|
"translation": "Specify a time zone for the listen timestamps",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Visit the URL for authorization: {Url}",
|
"id": "Directory path",
|
||||||
"message": "Visit the URL for authorization: {Url}",
|
"message": "Directory path",
|
||||||
"translation": "Visit the URL for authorization: {Url}",
|
"translation": "Directory path",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Ignore listens in incognito mode",
|
||||||
|
"message": "Ignore listens in incognito mode",
|
||||||
|
"translation": "Ignore listens in incognito mode",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
|
"message": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
|
"translation": "Minimum playback duration for skipped tracks (seconds)",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Visit the URL for authorization: {URL}",
|
||||||
|
"message": "Visit the URL for authorization: {URL}",
|
||||||
|
"translation": "Visit the URL for authorization: {URL}",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Url",
|
"id": "URL",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "authUrl.Url"
|
"expr": "authURL.URL"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
|
@ -415,15 +491,15 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"id": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"message": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"message": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"translation": "Transferring {Entity} from {SourceName} to {TargetName}...",
|
"translation": "Transferring {Entity} from {SourceName} to {TargetName}…",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Entity",
|
"id": "Entity",
|
||||||
"string": "%[1]s",
|
"string": "%[1]s",
|
||||||
"type": "string",
|
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "c.entity"
|
"expr": "c.entity"
|
||||||
|
@ -518,7 +594,7 @@
|
||||||
{
|
{
|
||||||
"id": "Entity",
|
"id": "Entity",
|
||||||
"string": "%[3]s",
|
"string": "%[3]s",
|
||||||
"type": "string",
|
"type": "go.uploadedlobster.com/scotty/internal/models.Entity",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 3,
|
"argNum": 3,
|
||||||
"expr": "c.entity"
|
"expr": "c.entity"
|
||||||
|
@ -535,25 +611,50 @@
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "During the import the following errors occurred:",
|
"id": "Import log:",
|
||||||
"message": "During the import the following errors occurred:",
|
"message": "Import log:",
|
||||||
"translation": "During the import the following errors occurred:",
|
"translation": "Import log:",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "Error: {Err}",
|
"id": "{Type}: {Message}",
|
||||||
"message": "Error: {Err}",
|
"message": "{Type}: {Message}",
|
||||||
"translation": "Error: {Err}",
|
"translation": "{Type}: {Message}",
|
||||||
"translatorComment": "Copied from source.",
|
"translatorComment": "Copied from source.",
|
||||||
"placeholders": [
|
"placeholders": [
|
||||||
{
|
{
|
||||||
"id": "Err",
|
"id": "Type",
|
||||||
|
"string": "%[1]v",
|
||||||
|
"type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 1,
|
||||||
|
"expr": "entry.Type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Message",
|
||||||
|
"string": "%[2]v",
|
||||||
|
"type": "string",
|
||||||
|
"underlyingType": "string",
|
||||||
|
"argNum": 2,
|
||||||
|
"expr": "entry.Message"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fuzzy": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "invalid timestamp string \"{FlagValue}\"",
|
||||||
|
"message": "invalid timestamp string \"{FlagValue}\"",
|
||||||
|
"translation": "invalid timestamp string \"{FlagValue}\"",
|
||||||
|
"translatorComment": "Copied from source.",
|
||||||
|
"placeholders": [
|
||||||
|
{
|
||||||
|
"id": "FlagValue",
|
||||||
"string": "%[1]v",
|
"string": "%[1]v",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"underlyingType": "string",
|
"underlyingType": "string",
|
||||||
"argNum": 1,
|
"argNum": 1,
|
||||||
"expr": "err"
|
"expr": "flagValue"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fuzzy": true
|
"fuzzy": true
|
||||||
|
|
|
@ -6,4 +6,4 @@ package are published under the conditions of CC0 1.0 Universal (CC0 1.0)
|
||||||
|
|
||||||
package translations
|
package translations
|
||||||
|
|
||||||
//go:generate gotext -srclang=en update -out=catalog.go -lang=en,de go.uploadedlobster.com/scotty
|
//go:generate go tool gotext -srclang=en update -out=catalog.go -lang=en,de go.uploadedlobster.com/scotty
|
||||||
|
|
34
internal/util/util.go
Normal file
34
internal/util/util.go
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
|
Scotty is free software: you can redistribute it and/or modify it under the
|
||||||
|
terms of the GNU General Public License as published by the Free Software
|
||||||
|
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with
|
||||||
|
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package util
|
||||||
|
|
||||||
|
import "golang.org/x/exp/constraints"
|
||||||
|
|
||||||
|
func Sum[T constraints.Integer | constraints.Float](v ...T) T {
|
||||||
|
var sum T
|
||||||
|
for _, i := range v {
|
||||||
|
sum += i
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
func Average[T constraints.Integer | constraints.Float](v ...T) float64 {
|
||||||
|
length := len(v)
|
||||||
|
if length == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
return float64(Sum(v...)) / float64(length)
|
||||||
|
}
|
52
internal/util/util_test.go
Normal file
52
internal/util/util_test.go
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
|
Scotty is free software: you can redistribute it and/or modify it under the
|
||||||
|
terms of the GNU General Public License as published by the Free Software
|
||||||
|
Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with
|
||||||
|
Scotty. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package util_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"go.uploadedlobster.com/scotty/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleSum() {
|
||||||
|
values := []float64{1.4, 2.2}
|
||||||
|
sum := util.Sum(values...)
|
||||||
|
fmt.Print(sum)
|
||||||
|
// Output: 3.6
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSumEmpty(t *testing.T) {
|
||||||
|
assert.Equal(t, 0, util.Sum([]int{}...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleAverage() {
|
||||||
|
values := []float64{1.4, 2.2, 0.9}
|
||||||
|
sum := util.Average(values...)
|
||||||
|
fmt.Print(sum)
|
||||||
|
// Output: 1.5
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageEmpty(t *testing.T) {
|
||||||
|
assert.Equal(t, 0.0, util.Average([]int{}...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageInt(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
assert.Equal(3.0, util.Average([]int{2, 4, 3}...))
|
||||||
|
assert.Equal(1.5, util.Average([]int{2, 1, 1, 2}...))
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
|
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
Scotty is free software: you can redistribute it and/or modify it under the
|
Scotty is free software: you can redistribute it and/or modify it under the
|
||||||
terms of the GNU General Public License as published by the Free Software
|
terms of the GNU General Public License as published by the Free Software
|
||||||
|
@ -17,7 +17,8 @@ package version
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AppName = "scotty"
|
AppName = "scotty"
|
||||||
AppVersion = "0.3.1"
|
AppVersion = "0.5.0"
|
||||||
|
AppURL = "https://git.sr.ht/~phw/scotty/"
|
||||||
)
|
)
|
||||||
|
|
||||||
func UserAgent() string {
|
func UserAgent() string {
|
||||||
|
|
|
@ -26,9 +26,9 @@ import "time"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension
|
// The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension
|
||||||
MusicBrainzPlaylistExtensionId = "https://musicbrainz.org/doc/jspf#playlist"
|
MusicBrainzPlaylistExtensionID = "https://musicbrainz.org/doc/jspf#playlist"
|
||||||
// The identifier for the MusicBrainz / ListenBrainz JSPF track extension
|
// The identifier for the MusicBrainz / ListenBrainz JSPF track extension
|
||||||
MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track"
|
MusicBrainzTrackExtensionID = "https://musicbrainz.org/doc/jspf#track"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MusicBrainz / ListenBrainz JSPF track extension
|
// MusicBrainz / ListenBrainz JSPF track extension
|
||||||
|
|
|
@ -39,7 +39,7 @@ func ExampleMusicBrainzTrackExtension() {
|
||||||
{
|
{
|
||||||
Title: "Oweynagat",
|
Title: "Oweynagat",
|
||||||
Extension: map[string]any{
|
Extension: map[string]any{
|
||||||
jspf.MusicBrainzTrackExtensionId: jspf.MusicBrainzTrackExtension{
|
jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{
|
||||||
AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC),
|
AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC),
|
||||||
AddedBy: "scotty",
|
AddedBy: "scotty",
|
||||||
},
|
},
|
||||||
|
|
|
@ -29,6 +29,15 @@ const (
|
||||||
MaxWaitTimeSeconds = 60
|
MaxWaitTimeSeconds = 60
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Implements rate HTTP header based limiting for resty.
|
||||||
|
//
|
||||||
|
// This works with servers that return the status code 429 (Too Many Requests)
|
||||||
|
// and an HTTP header indicating the time in seconds until rate limit resets.
|
||||||
|
// Common headers used are "X-RateLimit-Reset-In" or "Retry-After".
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
|
||||||
func EnableHTTPHeaderRateLimit(client *resty.Client, resetInHeader string) {
|
func EnableHTTPHeaderRateLimit(client *resty.Client, resetInHeader string) {
|
||||||
client.SetRetryCount(RetryCount)
|
client.SetRetryCount(RetryCount)
|
||||||
client.AddRetryCondition(
|
client.AddRetryCondition(
|
264
pkg/scrobblerlog/parser.go
Normal file
264
pkg/scrobblerlog/parser.go
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
/*
|
||||||
|
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Package to parse and writer .scrobbler.log files as written by Rockbox.
|
||||||
|
//
|
||||||
|
// See
|
||||||
|
// - https://www.rockbox.org/wiki/LastFMLog
|
||||||
|
// - https://git.rockbox.org/cgit/rockbox.git/tree/apps/plugins/lastfm_scrobbler.c
|
||||||
|
package scrobblerlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uploadedlobster.com/mbtypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TZInfo is the timezone information in the header of the scrobbler log file.
|
||||||
|
// It can be "UTC" or "UNKNOWN", if the device writing the scrobbler log file
|
||||||
|
// knows the time, but not the timezone.
|
||||||
|
type TZInfo string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TimezoneUnknown TZInfo = "UNKNOWN"
|
||||||
|
TimezoneUTC TZInfo = "UTC"
|
||||||
|
)
|
||||||
|
|
||||||
|
// L if listened at least 50% or S if skipped
|
||||||
|
type Rating string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RatingListened Rating = "L"
|
||||||
|
RatingSkipped Rating = "S"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A single entry of a track in the scrobbler log file.
|
||||||
|
type Record struct {
|
||||||
|
ArtistName string
|
||||||
|
AlbumName string
|
||||||
|
TrackName string
|
||||||
|
TrackNumber int
|
||||||
|
Duration time.Duration
|
||||||
|
Rating Rating
|
||||||
|
Timestamp time.Time
|
||||||
|
MusicBrainzRecordingID mbtypes.MBID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Represents a scrobbler log file.
|
||||||
|
type ScrobblerLog struct {
|
||||||
|
TZ TZInfo
|
||||||
|
Client string
|
||||||
|
Records []Record
|
||||||
|
// Timezone to be used for timestamps in the log file,
|
||||||
|
// if TZ is set to [TimezoneUnknown].
|
||||||
|
FallbackTimezone *time.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error {
|
||||||
|
l.Records = make([]Record, 0)
|
||||||
|
|
||||||
|
reader := bufio.NewReader(data)
|
||||||
|
err := l.ReadHeader(reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tsvReader := csv.NewReader(reader)
|
||||||
|
tsvReader.Comma = '\t'
|
||||||
|
// Row length is often flexible
|
||||||
|
tsvReader.FieldsPerRecord = -1
|
||||||
|
|
||||||
|
for {
|
||||||
|
// A row is:
|
||||||
|
// artistName releaseName trackName trackNumber duration rating timestamp recordingMBID
|
||||||
|
row, err := tsvReader.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmt.Printf("row: %v\n", row)
|
||||||
|
|
||||||
|
// We consider only the last field (recording MBID) optional
|
||||||
|
if len(row) < 7 {
|
||||||
|
line, _ := tsvReader.FieldPos(0)
|
||||||
|
return fmt.Errorf("invalid record in scrobblerlog line %v", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := l.rowToRecord(row)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ignoreSkipped && record.Rating == RatingSkipped {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Records = append(l.Records, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ScrobblerLog) Append(data io.Writer, records []Record) (lastTimestamp time.Time, err error) {
|
||||||
|
tsvWriter := csv.NewWriter(data)
|
||||||
|
tsvWriter.Comma = '\t'
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
if record.Timestamp.After(lastTimestamp) {
|
||||||
|
lastTimestamp = record.Timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row is:
|
||||||
|
// artistName releaseName trackName trackNumber duration rating timestamp recordingMBID
|
||||||
|
err = tsvWriter.Write([]string{
|
||||||
|
record.ArtistName,
|
||||||
|
record.AlbumName,
|
||||||
|
record.TrackName,
|
||||||
|
strconv.Itoa(record.TrackNumber),
|
||||||
|
strconv.Itoa(int(record.Duration.Seconds())),
|
||||||
|
string(record.Rating),
|
||||||
|
strconv.FormatInt(record.Timestamp.Unix(), 10),
|
||||||
|
string(record.MusicBrainzRecordingID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tsvWriter.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ScrobblerLog) ReadHeader(reader *bufio.Reader) error {
|
||||||
|
// Skip header
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
line, _, err := reader.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(line) == 0 || line[0] != '#' {
|
||||||
|
err = fmt.Errorf("unexpected header (line %v)", i)
|
||||||
|
} else {
|
||||||
|
text := string(line)
|
||||||
|
if i == 0 && !strings.HasPrefix(text, "#AUDIOSCROBBLER/1") {
|
||||||
|
err = fmt.Errorf("not a scrobbler log file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The timezone can be set to "UTC" or "UNKNOWN", if the device writing
|
||||||
|
// the log knows the time, but not the timezone.
|
||||||
|
timezone, found := strings.CutPrefix(text, "#TZ/")
|
||||||
|
if found {
|
||||||
|
l.TZ = TZInfo(timezone)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
client, found := strings.CutPrefix(text, "#CLIENT/")
|
||||||
|
if found {
|
||||||
|
l.Client = client
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ScrobblerLog) WriteHeader(writer io.Writer) error {
|
||||||
|
headers := []string{
|
||||||
|
"#AUDIOSCROBBLER/1.1\n",
|
||||||
|
"#TZ/" + string(l.TZ) + "\n",
|
||||||
|
"#CLIENT/" + l.Client + "\n",
|
||||||
|
}
|
||||||
|
for _, line := range headers {
|
||||||
|
_, err := writer.Write([]byte(line))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l ScrobblerLog) rowToRecord(row []string) (Record, error) {
|
||||||
|
var record Record
|
||||||
|
trackNumber, err := strconv.Atoi(row[3])
|
||||||
|
if err != nil {
|
||||||
|
return record, err
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, err := strconv.Atoi(row[4])
|
||||||
|
if err != nil {
|
||||||
|
return record, err
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp, err := strconv.ParseInt(row[6], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return record, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var timezone *time.Location = nil
|
||||||
|
if l.TZ == TimezoneUnknown {
|
||||||
|
timezone = l.FallbackTimezone
|
||||||
|
}
|
||||||
|
|
||||||
|
record = Record{
|
||||||
|
ArtistName: row[0],
|
||||||
|
AlbumName: row[1],
|
||||||
|
TrackName: row[2],
|
||||||
|
TrackNumber: trackNumber,
|
||||||
|
Duration: time.Duration(duration) * time.Second,
|
||||||
|
Rating: Rating(row[5]),
|
||||||
|
Timestamp: timeFromLocalTimestamp(timestamp, timezone),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(row) > 7 {
|
||||||
|
record.MusicBrainzRecordingID = mbtypes.MBID(row[7])
|
||||||
|
}
|
||||||
|
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a Unix timestamp to a [time.Time] object, but treat the timestamp
|
||||||
|
// as being in the given location's timezone instead of UTC.
|
||||||
|
// If location is nil, the timestamp is returned as UTC.
|
||||||
|
func timeFromLocalTimestamp(timestamp int64, location *time.Location) time.Time {
|
||||||
|
t := time.Unix(timestamp, 0)
|
||||||
|
|
||||||
|
// The time is now in UTC. Get the offset to the requested timezone
|
||||||
|
// and shift the time accordingly.
|
||||||
|
if location != nil {
|
||||||
|
_, offset := t.In(location).Zone()
|
||||||
|
if offset != 0 {
|
||||||
|
t = t.Add(time.Duration(offset) * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
|
@ -30,8 +30,8 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
|
"go.uploadedlobster.com/mbtypes"
|
||||||
"go.uploadedlobster.com/scotty/internal/models"
|
"go.uploadedlobster.com/scotty/pkg/scrobblerlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
var testScrobblerLog = `#AUDIOSCROBBLER/1.1
|
var testScrobblerLog = `#AUDIOSCROBBLER/1.1
|
||||||
|
@ -47,67 +47,84 @@ Teeth Agency You Don't Have To Live In Pain Wolfs Jam 2 107 L 1260359404 1262bea
|
||||||
func TestParser(t *testing.T) {
|
func TestParser(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
data := bytes.NewBufferString(testScrobblerLog)
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
result, err := scrobblerlog.Parse(data, true)
|
result := scrobblerlog.ScrobblerLog{}
|
||||||
|
err := result.Parse(data, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal("UNKNOWN", result.Timezone)
|
assert.Equal(scrobblerlog.TimezoneUnknown, result.TZ)
|
||||||
assert.Equal("Rockbox sansaclipplus $Revision$", result.Client)
|
assert.Equal("Rockbox sansaclipplus $Revision$", result.Client)
|
||||||
assert.Len(result.Listens, 5)
|
assert.Len(result.Records, 5)
|
||||||
listen1 := result.Listens[0]
|
record1 := result.Records[0]
|
||||||
assert.Equal("Özcan Deniz", listen1.ArtistName())
|
assert.Equal("Özcan Deniz", record1.ArtistName)
|
||||||
assert.Equal("Ses ve Ayrilik", listen1.ReleaseName)
|
assert.Equal("Ses ve Ayrilik", record1.AlbumName)
|
||||||
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", listen1.TrackName)
|
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", record1.TrackName)
|
||||||
assert.Equal(5, listen1.TrackNumber)
|
assert.Equal(5, record1.TrackNumber)
|
||||||
assert.Equal(time.Duration(306*time.Second), listen1.Duration)
|
assert.Equal(time.Duration(306*time.Second), record1.Duration)
|
||||||
assert.Equal("L", listen1.AdditionalInfo["rockbox_rating"])
|
assert.Equal(scrobblerlog.RatingListened, record1.Rating)
|
||||||
assert.Equal(time.Unix(1260342084, 0), listen1.ListenedAt)
|
assert.Equal(time.Unix(1260342084, 0), record1.Timestamp)
|
||||||
assert.Equal(models.MBID(""), listen1.RecordingMbid)
|
assert.Equal(mbtypes.MBID(""), record1.MusicBrainzRecordingID)
|
||||||
listen4 := result.Listens[3]
|
record4 := result.Records[3]
|
||||||
assert.Equal("S", listen4.AdditionalInfo["rockbox_rating"])
|
assert.Equal(scrobblerlog.RatingSkipped, record4.Rating)
|
||||||
assert.Equal(models.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), listen4.RecordingMbid)
|
assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"),
|
||||||
|
record4.MusicBrainzRecordingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParserExcludeSkipped(t *testing.T) {
|
func TestParserIgnoreSkipped(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
data := bytes.NewBufferString(testScrobblerLog)
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
result, err := scrobblerlog.Parse(data, false)
|
result := scrobblerlog.ScrobblerLog{}
|
||||||
|
err := result.Parse(data, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(result.Listens, 4)
|
assert.Len(result.Records, 4)
|
||||||
listen4 := result.Listens[3]
|
record4 := result.Records[3]
|
||||||
assert.Equal("L", listen4.AdditionalInfo["rockbox_rating"])
|
assert.Equal(scrobblerlog.RatingListened, record4.Rating)
|
||||||
assert.Equal(models.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMbid)
|
assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"),
|
||||||
|
record4.MusicBrainzRecordingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrite(t *testing.T) {
|
func TestParserFallbackTimezone(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
|
result := scrobblerlog.ScrobblerLog{
|
||||||
|
FallbackTimezone: time.FixedZone("UTC+2", 7200),
|
||||||
|
}
|
||||||
|
err := result.Parse(data, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
record1 := result.Records[0]
|
||||||
|
assert.Equal(
|
||||||
|
time.Unix(1260342084, 0).Add(2*time.Hour),
|
||||||
|
record1.Timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppend(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
data := make([]byte, 0, 10)
|
data := make([]byte, 0, 10)
|
||||||
buffer := bytes.NewBuffer(data)
|
buffer := bytes.NewBuffer(data)
|
||||||
log := scrobblerlog.ScrobblerLog{
|
log := scrobblerlog.ScrobblerLog{
|
||||||
Timezone: "Unknown",
|
TZ: scrobblerlog.TimezoneUnknown,
|
||||||
Client: "Rockbox foo $Revision$",
|
Client: "Rockbox foo $Revision$",
|
||||||
Listens: []models.Listen{
|
}
|
||||||
|
records := []scrobblerlog.Record{
|
||||||
{
|
{
|
||||||
ListenedAt: time.Unix(1699572072, 0),
|
ArtistName: "Prinzhorn Dance School",
|
||||||
Track: models.Track{
|
AlbumName: "Home Economics",
|
||||||
ArtistNames: []string{"Prinzhorn Dance School"},
|
|
||||||
ReleaseName: "Home Economics",
|
|
||||||
TrackName: "Reign",
|
TrackName: "Reign",
|
||||||
TrackNumber: 1,
|
TrackNumber: 1,
|
||||||
Duration: 271 * time.Second,
|
Duration: 271 * time.Second,
|
||||||
RecordingMbid: models.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
|
Rating: scrobblerlog.RatingListened,
|
||||||
AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"},
|
Timestamp: time.Unix(1699572072, 0),
|
||||||
},
|
MusicBrainzRecordingID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
err := scrobblerlog.WriteHeader(buffer, &log)
|
err := log.WriteHeader(buffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
lastTimestamp, err := scrobblerlog.Write(buffer, log.Listens)
|
lastTimestamp, err := log.Append(buffer, records)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
result := string(buffer.Bytes())
|
result := buffer.String()
|
||||||
lines := strings.Split(result, "\n")
|
lines := strings.Split(result, "\n")
|
||||||
assert.Equal(5, len(lines))
|
assert.Equal(5, len(lines))
|
||||||
assert.Equal("#AUDIOSCROBBLER/1.1", lines[0])
|
assert.Equal("#AUDIOSCROBBLER/1.1", lines[0])
|
||||||
assert.Equal("#TZ/Unknown", lines[1])
|
assert.Equal("#TZ/UNKNOWN", lines[1])
|
||||||
assert.Equal("#CLIENT/Rockbox foo $Revision$", lines[2])
|
assert.Equal("#CLIENT/Rockbox foo $Revision$", lines[2])
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
"Prinzhorn Dance School\tHome Economics\tReign\t1\t271\tL\t1699572072\tb59cf4e7-caee-4019-a844-79d2c58d4dff",
|
"Prinzhorn Dance School\tHome Economics\tReign\t1\t271\tL\t1699572072\tb59cf4e7-caee-4019-a844-79d2c58d4dff",
|
||||||
|
@ -120,9 +137,9 @@ func TestReadHeader(t *testing.T) {
|
||||||
data := bytes.NewBufferString(testScrobblerLog)
|
data := bytes.NewBufferString(testScrobblerLog)
|
||||||
reader := bufio.NewReader(data)
|
reader := bufio.NewReader(data)
|
||||||
log := scrobblerlog.ScrobblerLog{}
|
log := scrobblerlog.ScrobblerLog{}
|
||||||
err := scrobblerlog.ReadHeader(reader, &log)
|
err := log.ReadHeader(reader)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, log.Timezone, "UNKNOWN")
|
assert.Equal(t, log.TZ, scrobblerlog.TimezoneUnknown)
|
||||||
assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$")
|
assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$")
|
||||||
assert.Empty(t, log.Listens)
|
assert.Empty(t, log.Records)
|
||||||
}
|
}
|
Loading…
Add table
Add a link
Reference in a new issue