Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

132 changed files with 2664 additions and 10068 deletions

View file

@ -3,41 +3,22 @@ packages:
- go - go
- goreleaser-bin - goreleaser-bin
- hut - hut
- weblate-wlc
secrets:
- 0e2ad815-6c46-4cea-878e-70fc33f71e77
oauth: pages.sr.ht/PAGES:RW oauth: pages.sr.ht/PAGES:RW
tasks: tasks:
- weblate-update: |
cd scotty
wlc --format text pull scotty
- test: | - test: |
cd scotty cd scotty
go build -v . go build -v .
go test -v ./... go test -v ./...
- build: | - build: |
cd scotty cd scotty
GIT_REF=$(git describe --always)
if [[ "$GIT_REF" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]
then
goreleaser release --clean
else
goreleaser release --snapshot --clean goreleaser release --snapshot --clean
fi
cd dist/
tar cvf artifacts.tar scotty-*.{gz,zip} scotty_*_checksums.txt
- 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/scotty_Darwin_all.tar.gz
- scotty/dist/scotty_Linux_arm64.tar.gz
- scotty/dist/scotty_Linux_i386.tar.gz
- scotty/dist/scotty_Linux_x86_64.tar.gz
- scotty/dist/scotty_Windows_arm64.zip
- scotty/dist/scotty_Windows_x86_64.zip

View file

@ -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: 2 version: 1
before: before:
hooks: hooks:
@ -21,8 +21,6 @@ builds:
- windows - windows
- darwin - darwin
ignore: ignore:
- goos: linux
goarch: "386"
- goos: windows - goos: windows
goarch: "386" goarch: "386"
@ -30,28 +28,22 @@ universal_binaries:
- replace: true - replace: true
archives: archives:
- formats: ['tar.gz'] - format: 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 }}_
{{- if eq .Os "darwin" }}macos {{- title .Os }}_
{{- else }}{{ .Os }}{{ end }}_
{{- if eq .Arch "amd64" }}x86_64 {{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386 {{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }} {{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }}
wrap_in_directory: true
# use zip for windows archives # use zip for windows archives
format_overrides: format_overrides:
- goos: windows - goos: windows
formats: ['zip'] format: zip
files: files:
- COPYING - COPYING
- README.md - README.md
- config.example.toml
release:
disable: true
# changelog: # changelog:
# sort: asc # sort: asc

View file

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

View file

@ -1,113 +1,5 @@
# Scotty Changelog # Scotty Changelog
## 0.7.0 - WIP
- listenbrainz-archive: new backend to load listens and loves from a
ListenBrainz export. The data can be read from the downloaded ZIP archive
or a directory where the contents of the archive have been extracted to.
- listenbrainz: faster loading of missing loves metadata using the ListenBrainz
API instead of MusicBrainz. Fallback to slower MusicBrainz query, if
ListenBrainz does not provide the data.
- listenbrainz: fixed issue were timestamp was not updated properly if
duplicate listens where detected during import.
- spotify-history: it is now possible to specify the path directly to the
`my_spotify_data_extended.zip` ZIP file as downloaded from Spotify.
- spotify-history: the parameter to the export archive path has been renamed to
`archive-path`. For backward compatibility the old `dir-path` parameter is
still read.
- deezer-history: new backend to import listens and loves from Deezer data export.
- deezer: fixed endless export loop if the user's listen history was empty.
- dump: it is now possible to specify a file to write the text output to.
- Fixed potential issues with MusicBrainz rate limiting.
- Fixed import log output duplicating.
## 0.6.0 - 2025-05-23
- Fully reworked progress report
- Cancel both export and import on error
- Show progress bars as aborted on export / import error
- The import progress is now aware of the total amount of exported items
- The import progress shows total items processed instead of time estimate
- Fix program hanging endlessly if import fails (#11)
- If import fails still store the last successfully imported timestamp
- More granular progress updates for JSPF and scrobblerlog
- JSPF: implemented export as loves and listens
- JSPF: write track duration
- JSPF: read username and recording MSID
- JSPF: add MusicBrainz playlist extension in append mode, if it does not
exist in the existing JSPF file
- scrobblerlog: fix timezone not being set from config (#6)
- scrobblerlog: fix listen export not considering latest timestamp
- Funkwhale: fix progress abort on error
## 0.5.2 - 2025-05-01
- ListenBrainz: fixed loves export not considering latest timestamp
## 0.5.1 - 2025-05-01
- scrobblerlog: fixed timezone offset calculation
- if system locale detection fails don't abort but fall back to English
## 0.5.0 - 2025-04-29
- ListenBrainz: handle missing loves metadata in case of merged recordings
- ListenBrainz: fix loves import loading all existing loves
- ListenBrainz: fixed progress for loves import
- ListenBrainz: log missing recording MBID on love import
- Subsonic: support OpenSubsonic fields for recording MBID and genres (#5)
- Subsonic: fixed progress for loves export
- scrobblerlog: add "time-zone" config option (#6)
- scrobblerlog: fixed progress for listen export
- scrobblerlog: renamed setting `include-skipped` to `ignore-skipped`
Note: 386 builds for Linux are not available with this release due to an
incompatibility with latest version of gorm.
## 0.4.1 - 2024-09-16
- Subsonic: include `subsonic_id` as additional metadata
- Deezer: fix artist and album ID URIs (#7)
- Fix installation issues due to wrong go version format in `go.mod`
## 0.4.0 - 2024-01-21
- JSPF: implement append mode
- scrobberlog: append mode is enabled by default
- 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
- lastfm: support for scrobble and love export/import - lastfm: support for scrobble and love export/import
- jspf: consider loved track MBID - jspf: consider loved track MBID

109
README.md
View file

@ -10,7 +10,6 @@ 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
@ -29,131 +28,43 @@ This requires `go` to be installed on your system. You can get it from https://g
## Configuration ## Configuration
To use Scotty you need to configure at least two services (e.g. ListenBrainz, Last.fm, Funkwhale or Spotify). Scotty requires the configuration of the services in a configuration file in TOML format. See [scotty.example.toml](./scotty.example.toml) for details.
By default Scotty stores the configuration in a platform dependent configuration directory (e.g. on Unix like system this is `$HOME/.config/scotty/scotty.toml`), but you can also run it with a different configuration file using the `--config` command line parameter.
New services can be configured interactively using the `service add`, `service edit` and `service delete` commands. Run `scotty service --help` for tails.
The configuration file in TOML format can also be edited manually. For a full example see [config.example.toml](./config.example.toml).
## Usage ## Usage
Run `scotty --help` for general command line help. Run `scotty --help` for command line help.
### Tutorial
As a full example consider that you want to transfer your listen history and loved tracks from Deezer to ListenBrainz. You first need to configure these services. Let's start with adding ListenBrainz. Run `scotty service add`. Scotty will allow you to interactively configure the service. First you need to select "listenbrainz" as the backend:
```
$ scotty service add
Use the arrow keys to navigate: ↓ ↑ → ←
? Backend:
deezer
dump
funkwhale
jspf
lastfm
▸ listenbrainz
maloja
scrobbler-log
spotify
subsonic
```
Next Scotty will ask how to name this service. You can accept the suggested name "listenbrainz". Naming services differently can be useful when you configure multiple services with the same backend (e.g. multiple separate accounts).
```
✔ listenbrainz
✔ Service name: listenbrainz█
```
Next you need to provide your ListenBrainz user name and [user token](https://listenbrainz.org/profile/):
```
✔ listenbrainz
✔ Service name: listenbrainz
✔ User name: outsidecontext
✔ Access token: *************************************
Saved service listenbrainz using backend listenbrainz
```
*Hint: If you made a mistake and want to change a value, run `scotty service edit` to change the configuration of existing services.*
For Deezer we need access to the Deezer API. You need a Deezer account for which you have to [register an application](https://developers.deezer.com/myapps) in the Deezer developer portal. Give this application any name (e.g. Scotty) and use `http://127.0.0.1:2369/callback/deezer` as the "Redirect URL after authentication". After creating the application note the "Application ID" and "Secret Key".
Now you can add a new service by running `scotty service add` again. Choose the "deezer" backend and set a name (let's use the default "deezer") as well as the Application ID and Secret Key you obtained before.
Before you can use Deezer you need to authorize Scotty to access your account. For this run `scotty service auth --service deezer`. If your Application ID and Secret Key were correct your browser should open with Deezer's login and authorization page. Confirm the access. On success the browser will show "Token received, you can close this window now.". Close the browser window and return to your terminal.
Running `scotty service list` should now show the two services "deezer" and "listenbrainz".
Now you can use these services to transfer data between them. To transfer the loved tracks from Deezer to ListenBrainz run:
```
scotty beam loves deezer listenbrainz
```
The output will look something like this:
```
Transferring loves from deezer to listenbrainz...
From timestamp: 1970-01-01 01:00:01 +0100 CET (1)
✓ exporting [=======================================================] done
✓ importing [=======================================================] done
Imported 4 of 4 loves into listenbrainz.
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 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:
```
scotty beam listens deezer listenbrainz --timestamp "2023-12-06 14:26:24"
```
### Supported backends ## Supported backends
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 | ✓ | | ✓ | -
deezer-history | ✓ | | ✓ | dump | | ✓ | | ✓
funkwhale | ✓ | | ✓ | - funkwhale | ✓ | | ✓ | -
jspf | ✓ | ✓ | ✓ | ✓ jspf | - | ✓ | - | ✓
lastfm | ✓ | ✓ | ✓ | ✓ lastfm | ✓ | ✓ | ✓ | ✓
listenbrainz | ✓ | ✓ | ✓ | ✓ listenbrainz | ✓ | ✓ | ✓ | ✓
listenbrainz-archive | ✓ | - | ✓ | -
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
See the comments in [config.example.toml](./config.example.toml) for a description of each backend's available configuration options.
**NOTE:** Some services, e.g. the Spotify and Deezer API, do not provide access
to the user's full listening history. Hence the API integrations are not suited
to do a full history export. They can however be well used for continuously
transfer recent listens to other services when running scotty frequently, e.g.
as a cron job.
## Contribute ## Contribute
The source code for Scotty is available on [SourceHut](https://sr.ht/~phw/scotty/). To report issues or feature requests please [create a ticket](https://todo.sr.ht/~phw/scotty). The source code for Scotty is available on [SourceHut](https://git.sr.ht/~phw/scotty). To report issues or feature requests please [create a ticket](https://todo.sr.ht/~phw/scotty).
Patches can be submitted to the mailing list [~phw/musicbrainz@lists.sr.ht](https://lists.sr.ht/~phw/musicbrainz). You can clone the repository directly on SourceHut and submit your changes with the "Prepare patchset" button. Please see SourceHut's [documentation for sending patches upstream](https://man.sr.ht/git.sr.ht/#sending-patches-upstream) for details. Patches can be submitted to the mailing list [~phw/musicbrainz@lists.sr.ht](https://lists.sr.ht/~phw/musicbrainz). You can clone the repository directly on SourceHut and submit your changes with the "Prepare patchset" button. Please see SourceHut's [documentation for sending patches upstream](https://man.sr.ht/git.sr.ht/#sending-patches-upstream) for details.
You can help translate this project into your language with [Weblate](https://translate.uploadedlobster.com/projects/scotty/). Please request new languages on the mailing list [~phw/musicbrainz@lists.sr.ht](https://lists.sr.ht/~phw/musicbrainz). See [internal/translations/README.md](internal/translations/README.md) for details.
## License ## License
Scotty © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Scotty © 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 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.
@ -164,5 +75,3 @@ You should have received a copy of the GNU General Public License along with Sco
See [COPYING](./COPYING) for details. See [COPYING](./COPYING) for details.
Some source files in Scotty are licensed under the MIT license. Please see the license notice in the headers of the individual files for more information. Some source files in Scotty are licensed under the MIT license. Please see the license notice in the headers of the individual files for more information.
All user interface strings and their translations are published under the conditions of [CC0 1.0 Universal (CC0 1.0)](https://creativecommons.org/publicdomain/zero/1.0/).

93
cmd/auth.go Normal file
View file

@ -0,0 +1,93 @@
/*
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 cmd
import (
"fmt"
"os"
"github.com/cli/browser"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/storage"
"golang.org/x/oauth2"
)
// authCmd represents the auth command
var authCmd = &cobra.Command{
Use: "auth",
Short: "Authenticate with a backend",
Long: `For backends requiring authentication this command can be used to authenticate.`,
Run: func(cmd *cobra.Command, args []string) {
serviceName, serviceConfig := getConfigFromFlag(cmd, "service")
backend, err := backends.ResolveBackend[models.OAuth2Authenticator](serviceConfig)
cobra.CheckErr(err)
redirectURL, err := backends.BuildRedirectURL(viper.GetViper(), backend.Name())
cobra.CheckErr(err)
// The backend must provide an authentication strategy
strategy := backend.OAuth2Strategy(redirectURL)
// use PKCE to protect against CSRF attacks
// https://www.ietf.org/archive/id/draft-ietf-oauth-security-topics-22.html#name-countermeasures-6
verifier := oauth2.GenerateVerifier()
state := "somestate" // FIXME: Should be a random string
// Redirect user to consent page to ask for permission specified scopes.
authUrl := strategy.AuthCodeURL(verifier, state)
// Start an HTTP server to listen for the response
responseChan := make(chan auth.CodeResponse)
auth.RunOauth2CallbackServer(*redirectURL, authUrl.Param, responseChan)
// Open the URL
fmt.Printf("Visit the URL for the auth dialog: %v\n", authUrl.Url)
err = browser.OpenURL(authUrl.Url)
cobra.CheckErr(err)
// Retrieve the code from the authentication callback
code := <-responseChan
if code.State != authUrl.State {
cobra.CompErrorln("Error: oauth state mismatch")
os.Exit(1)
}
// Exchange the code for the authentication token
tok, err := strategy.ExchangeToken(code, verifier)
cobra.CheckErr(err)
// Store the retrieved token in the database
db, err := storage.New(viper.GetString("database"))
cobra.CheckErr(err)
err = db.SetOAuth2Token(serviceName, tok)
cobra.CheckErr(err)
fmt.Printf("Access token received, you can use %v now.\n\n", serviceName)
},
}
func init() {
rootCmd.AddCommand(authCmd)
authCmd.Flags().StringP("service", "s", "", "Service configuration (required)")
authCmd.MarkFlagRequired("service")
}

View file

@ -22,9 +22,9 @@ import (
"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/i18n"
) )
// backendsCmd represents the backends command
var backendsCmd = &cobra.Command{ var backendsCmd = &cobra.Command{
Use: "backends", Use: "backends",
Short: "List available backends", Short: "List available backends",
@ -33,8 +33,8 @@ var backendsCmd = &cobra.Command{
backends := backends.GetBackends() backends := backends.GetBackends()
for _, info := range backends { for _, info := range backends {
fmt.Printf("%s:\n", info.Name) fmt.Printf("%s:\n", info.Name)
fmt.Println(i18n.Tr("\texport: %s", strings.Join(info.ExportCapabilities, ", "))) fmt.Printf("\texport: %s\n", strings.Join(info.ExportCapabilities, ", "))
fmt.Println(i18n.Tr("\timport: %s\n", strings.Join(info.ImportCapabilities, ", "))) fmt.Printf("\timport: %s\n\n", strings.Join(info.ImportCapabilities, ", "))
} }
}, },
} }

View file

@ -20,14 +20,14 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// beamCmd represents the beam command
var beamCmd = &cobra.Command{ var beamCmd = &cobra.Command{
Use: "beam", Use: "beam",
Short: "Transfer data between two services", Short: "Transfer data between two services",
Long: `Transfers data (listens, loves) between two configured services. Long: `Transfers data (listens, loves) between two configured services.
The services must be configured and be able to handle export and import of The services must be configured and be able to handle export and import of
the data. See "scotty backends" for a list of backends and their supported the data.`,
features.`,
// Run: func(cmd *cobra.Command, args []string) { }, // Run: func(cmd *cobra.Command, args []string) { },
} }
@ -38,7 +38,11 @@ func init() {
// Cobra supports Persistent Flags which will work for this command // Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.: // and all subcommands, e.g.:
// beamCmd.PersistentFlags().String("foo", "", "A help for foo") beamCmd.PersistentFlags().StringP("from", "f", "", "Source service configuration (required)")
beamCmd.MarkPersistentFlagRequired("from")
beamCmd.PersistentFlags().StringP("to", "t", "", "Target service configuration (required)")
beamCmd.MarkPersistentFlagRequired("to")
beamCmd.PersistentFlags().Int64P("timestamp", "s", 0, "Only import data newer then given Unix timestamp")
// 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.:

View file

@ -1,62 +0,0 @@
/*
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 cmd
import (
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/cli"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/storage"
)
var beamListensCmd = &cobra.Command{
Use: "listens SOURCE TARGET",
Short: "Transfer listens between two services",
Long: `Transfers listens between two configured services.`,
Args: cobra.ExactArgs(2),
ArgAliases: []string{"source", "target"},
Run: func(cmd *cobra.Command, args []string) {
db, err := storage.New(config.DatabasePath())
cobra.CheckErr(err)
c, err := cli.NewTransferCmd[
models.ListensExport,
models.ListensImport,
models.ListensResult,
](cmd, &db, "listens", args[0], args[1])
cobra.CheckErr(err)
exp := backends.ListensExportProcessor{Backend: c.ExpBackend}
imp := backends.ListensImportProcessor{Backend: c.ImpBackend}
err = c.Transfer(exp, imp)
cobra.CheckErr(err)
},
}
func init() {
beamCmd.AddCommand(beamListensCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// beamListensCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// beamListensCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
beamListensCmd.Flags().StringP("timestamp", "t", "", "only import listens newer then given timestamp")
}

View file

@ -1,62 +0,0 @@
/*
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 cmd
import (
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/cli"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/storage"
)
var beamLovesCmd = &cobra.Command{
Use: "loves SOURCE TARGET",
Short: "Transfer loves between two services",
Long: `Transfers loves between two configured services.`,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
db, err := storage.New(config.DatabasePath())
cobra.CheckErr(err)
c, err := cli.NewTransferCmd[
models.LovesExport,
models.LovesImport,
models.LovesResult,
](cmd, &db, "loves", args[0], args[1])
cobra.CheckErr(err)
exp := backends.LovesExportProcessor{Backend: c.ExpBackend}
imp := backends.LovesImportProcessor{Backend: c.ImpBackend}
err = c.Transfer(exp, imp)
cobra.CheckErr(err)
},
}
func init() {
beamCmd.AddCommand(beamLovesCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// beamLovesCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// beamLovesCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
beamLovesCmd.Flags().StringP("timestamp", "t", "", "only import loves newer then given timestamp")
}

View file

@ -1,6 +1,8 @@
/* /*
Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com> 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 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
Foundation, either version 3 of the License, or (at your option) any later version. Foundation, either version 3 of the License, or (at your option) any later version.
@ -12,15 +14,32 @@ 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 You should have received a copy of the GNU General Public License along with
Scotty. If not, see <https://www.gnu.org/licenses/>. Scotty. If not, see <https://www.gnu.org/licenses/>.
*/ */
package cmd
package cli
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/config" "github.com/spf13/viper"
) )
func GetServiceConfigFromFlag(cmd *cobra.Command, flagName string) (config.ServiceConfig, error) { func getConfigFromFlag(cmd *cobra.Command, flagName string) (string, *viper.Viper) {
name := cmd.Flag(flagName).Value.String() configName := cmd.Flag(flagName).Value.String()
return config.GetService(name) var config *viper.Viper
servicesConfig := viper.Sub("service")
if servicesConfig != nil {
config = servicesConfig.Sub(configName)
}
if config == nil {
cobra.CheckErr(fmt.Sprintf("Invalid source configuration \"%s\"", configName))
}
return configName, config
}
func getInt64FromFlag(cmd *cobra.Command, flagName string) (result int64) {
result, err := cmd.Flags().GetInt64(flagName)
if err != nil {
result = 0
}
return
} }

119
cmd/listens.go Normal file
View file

@ -0,0 +1,119 @@
/*
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 cmd
import (
"fmt"
"sync"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/storage"
)
// listensCmd represents the listens command
var listensCmd = &cobra.Command{
Use: "listens",
Short: "Transfer listens between two services",
Long: `Transfers listens between two configured services.`,
Run: func(cmd *cobra.Command, args []string) {
sourceName, sourceConfig := getConfigFromFlag(cmd, "from")
targetName, targetConfig := getConfigFromFlag(cmd, "to")
fmt.Printf("Transferring listens from %s to %s...\n", sourceName, targetName)
// Setup database
db, err := storage.New(viper.GetString("database"))
cobra.CheckErr(err)
// Initialize backends
exportBackend, err := backends.ResolveBackend[models.ListensExport](sourceConfig)
cobra.CheckErr(err)
importBackend, err := backends.ResolveBackend[models.ListensImport](targetConfig)
cobra.CheckErr(err)
// Authenticate backends, if needed
_, err = backends.Authenticate(sourceName, exportBackend, db, viper.GetViper())
cobra.CheckErr(err)
_, err = backends.Authenticate(targetName, importBackend, db, viper.GetViper())
cobra.CheckErr(err)
// Read timestamp
timestamp := time.Unix(getInt64FromFlag(cmd, "timestamp"), 0)
if timestamp == time.Unix(0, 0) {
timestamp, err = db.GetImportTimestamp(sourceName, targetName, "listens")
cobra.CheckErr(err)
}
fmt.Printf("From timestamp: %v (%v)\n", timestamp, timestamp.Unix())
// Prepare progress bars
exportProgress := make(chan models.Progress)
importProgress := make(chan models.Progress)
var wg sync.WaitGroup
progress := progressBar(&wg, exportProgress, importProgress)
// Export from source
listensChan := make(chan models.ListensResult, 1000)
go exportBackend.ExportListens(timestamp, listensChan, exportProgress)
// Import into target
resultChan := make(chan models.ImportResult)
go backends.ProcessListensImports(importBackend, listensChan, resultChan, importProgress)
result := <-resultChan
close(exportProgress)
wg.Wait()
progress.Wait()
if result.Error != nil {
fmt.Printf("Import failed, last reported timestamp was %v (%v)\n", result.LastTimestamp, result.LastTimestamp.Unix())
cobra.CheckErr(result.Error)
}
fmt.Printf("Imported %v of %v listens into %v.\n",
result.ImportCount, result.TotalCount, targetName)
// Update timestamp
if result.LastTimestamp.Unix() < timestamp.Unix() {
result.LastTimestamp = timestamp
}
fmt.Printf("Latest timestamp: %v (%v)\n", result.LastTimestamp, result.LastTimestamp.Unix())
err = db.SetImportTimestamp(sourceName, targetName, "listens", result.LastTimestamp)
cobra.CheckErr(err)
// Print errors
if len(result.ImportErrors) > 0 {
fmt.Printf("\nDuring the import the following errors occurred:\n")
for _, err := range result.ImportErrors {
fmt.Printf("Error: %v\n", err)
}
}
},
}
func init() {
beamCmd.AddCommand(listensCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// listensCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// listensCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

120
cmd/loves.go Normal file
View file

@ -0,0 +1,120 @@
/*
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 cmd
import (
"fmt"
"sync"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/storage"
)
// lovesCmd represents the loves command
var lovesCmd = &cobra.Command{
Use: "loves",
Short: "Transfer loves between two services",
Long: `Transfers loves between two configured services.`,
Run: func(cmd *cobra.Command, args []string) {
sourceName, sourceConfig := getConfigFromFlag(cmd, "from")
targetName, targetConfig := getConfigFromFlag(cmd, "to")
fmt.Printf("Transferring loves from %s to %s...\n", sourceName, targetName)
// Setup database
db, err := storage.New(viper.GetString("database"))
cobra.CheckErr(err)
// Initialize backends
exportBackend, err := backends.ResolveBackend[models.LovesExport](sourceConfig)
cobra.CheckErr(err)
importBackend, err := backends.ResolveBackend[models.LovesImport](targetConfig)
cobra.CheckErr(err)
// Authenticate backends, if needed
_, err = backends.Authenticate(sourceName, exportBackend, db, viper.GetViper())
cobra.CheckErr(err)
_, err = backends.Authenticate(targetName, importBackend, db, viper.GetViper())
cobra.CheckErr(err)
// Read timestamp
timestamp := time.Unix(getInt64FromFlag(cmd, "timestamp"), 0)
if timestamp == time.Unix(0, 0) {
timestamp, err = db.GetImportTimestamp(sourceName, targetName, "loves")
cobra.CheckErr(err)
}
fmt.Printf("From timestamp: %v (%v)\n", timestamp, timestamp.Unix())
// Prepare progress bars
exportProgress := make(chan models.Progress)
importProgress := make(chan models.Progress)
var wg sync.WaitGroup
progress := progressBar(&wg, exportProgress, importProgress)
// Export from source
lovesChan := make(chan models.LovesResult, 1000)
go exportBackend.ExportLoves(timestamp, lovesChan, exportProgress)
// Import into target
resultChan := make(chan models.ImportResult)
go backends.ProcessLovesImports(importBackend, lovesChan, resultChan, importProgress)
result := <-resultChan
close(exportProgress)
wg.Wait()
progress.Wait()
if result.Error != nil {
fmt.Printf("Import failed, last reported timestamp was %v (%v)\n", result.LastTimestamp, result.LastTimestamp.Unix())
cobra.CheckErr(result.Error)
}
fmt.Printf("Imported %v of %v loves into %v.\n",
result.ImportCount, result.TotalCount, targetName)
// Update timestamp
if result.LastTimestamp.Unix() < timestamp.Unix() {
result.LastTimestamp = timestamp
}
fmt.Printf("Latest timestamp: %v (%v)\n", result.LastTimestamp, result.LastTimestamp.Unix())
err = db.SetImportTimestamp(sourceName, targetName, "loves", result.LastTimestamp)
cobra.CheckErr(err)
// Print errors
if len(result.ImportErrors) > 0 {
fmt.Printf("\nDuring the import the following errors occurred:\n")
for _, err := range result.ImportErrors {
fmt.Printf("Error: %v\n", err)
}
}
},
}
func init() {
beamCmd.AddCommand(lovesCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// lovesCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// lovesCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

79
cmd/progress.go Normal file
View file

@ -0,0 +1,79 @@
/*
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 cmd
import (
"sync"
"time"
"github.com/fatih/color"
"github.com/vbauerster/mpb/v8"
"github.com/vbauerster/mpb/v8/decor"
"go.uploadedlobster.com/scotty/internal/models"
)
func progressBar(wg *sync.WaitGroup, exportProgress chan models.Progress, importProgress chan models.Progress) *mpb.Progress {
p := mpb.New(
mpb.WithWaitGroup(wg),
mpb.WithOutput(color.Output),
// mpb.WithWidth(64),
mpb.WithAutoRefresh(),
)
exportBar := setupProgressBar(p, "exporting")
importBar := setupProgressBar(p, "importing")
go updateProgressBar(exportBar, wg, exportProgress)
go updateProgressBar(importBar, wg, importProgress)
return p
}
func setupProgressBar(p *mpb.Progress, name string) *mpb.Bar {
green := color.New(color.FgGreen).SprintFunc()
return p.New(0,
mpb.BarStyle(),
mpb.PrependDecorators(
decor.Name(" "),
decor.OnComplete(
decor.Spinner(nil, decor.WC{W: 2, C: decor.DidentRight}),
green("✓ "),
),
decor.Name(name, decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.OnComplete(
decor.EwmaETA(decor.ET_STYLE_GO, 0, decor.WC{C: decor.DSyncWidth}),
"done",
),
// decor.OnComplete(decor.Percentage(decor.WC{W: 5, C: decor.DSyncWidthR}), "done"),
decor.Name(" "),
),
)
}
func updateProgressBar(bar *mpb.Bar, wg *sync.WaitGroup, progressChan chan models.Progress) {
wg.Add(1)
defer wg.Done()
lastIterTime := time.Now()
for progress := range progressChan {
oldIterTime := lastIterTime
lastIterTime = time.Now()
bar.EwmaSetCurrent(progress.Elapsed, lastIterTime.Sub(oldIterTime))
bar.SetTotal(progress.Total, progress.Completed)
}
}

View file

@ -19,10 +19,10 @@ package cmd
import ( import (
"fmt" "fmt"
"os" "os"
"path"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/config" "github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/version" "go.uploadedlobster.com/scotty/internal/version"
) )
@ -32,8 +32,8 @@ var cfgFile string
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: version.AppName, Use: version.AppName,
Short: "Beam data between music listening services", Short: "Beam data between music listening services",
Long: `Scotty transfers listens and loves between different listening and streaming Long: `Scotty transfers your listens/scrobbles between ListenBrainz and
services. Run "scotty backends" for a list of supported service backends.`, various other listening and streaming services.`,
Version: version.AppVersion, Version: version.AppVersion,
// Uncomment the following line if your bare application // Uncomment the following line if your bare application
// has an action associated with it: // has an action associated with it:
@ -56,19 +56,36 @@ func init() {
// Cobra supports persistent flags, which, if defined here, // Cobra supports persistent flags, which, if defined here,
// will be global for your application. // will be global for your application.
configDir := config.DefaultConfigDir() configDir := defaultConfigDir()
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
fmt.Sprintf("config file (default is %s/scotty.toml)", configDir)) fmt.Sprintf("config file (default is %s/scotty.yaml)", configDir))
// Cobra also supports local flags, which will only run // Cobra also supports local flags, which will only run
// when this action is called directly. // when this action is called directly.
// rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") // rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
} }
func defaultConfigDir() string {
configDir, err := os.UserConfigDir()
cobra.CheckErr(err)
return path.Join(configDir, version.AppName)
}
// initConfig reads in config file and ENV variables if set. // initConfig reads in config file and ENV variables if set.
func initConfig() { func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
viper.AddConfigPath(defaultConfigDir())
viper.SetConfigType("toml")
viper.SetConfigName(version.AppName)
}
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in. // If a config file is found, read it in.
if err := config.InitConfig(cfgFile); err != nil { if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, i18n.Tr("Failed reading config: %v", err)) fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
} }
} }

View file

@ -1,47 +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 cmd
import (
"github.com/spf13/cobra"
)
var serviceCmd = &cobra.Command{
Use: "service",
Short: "Manage the service configuration",
Long: `Manage the scotty configuration using the subcommands to add, remove
or edit services.`,
}
func init() {
rootCmd.AddCommand(serviceCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// serviceCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// serviceCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View file

@ -1,117 +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 cmd
import (
"errors"
"fmt"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/cli"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
)
var serviceAddCmd = &cobra.Command{
Use: "add",
Short: "Add a service configuration",
Long: `Interactively add a service to the configuration file.`,
Run: func(cmd *cobra.Command, args []string) {
// Select backend
backend, err := cli.SelectBackend("")
cobra.CheckErr(err)
// Set service name
prompt := promptui.Prompt{
Label: i18n.Tr("Service name"),
Default: backend,
Validate: func(s string) error {
_, err := config.GetService(s)
if err == nil {
return errors.New(i18n.Tr("a service with this name already exists"))
}
return config.ValidateKey(s)
},
}
name, err := prompt.Run()
cobra.CheckErr(err)
// Prepare service config
service := config.ServiceConfig{
Name: name,
Backend: backend,
ConfigValues: make(map[string]any),
}
// Additional options
service, err = cli.PromptExtraOptions(service)
cobra.CheckErr(err)
// Save the service config
err = service.Save()
cobra.CheckErr(err)
fmt.Println(i18n.Tr("Saved service %v using backend %v", service.Name, service.Backend))
// Check whether authentication is required
err = promptForAuth(service)
cobra.CheckErr(err)
},
}
func init() {
serviceCmd.AddCommand(serviceAddCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// serviceAddCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// serviceAddCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
func promptForAuth(service config.ServiceConfig) error {
backend, err := backends.ResolveBackend[auth.OAuth2Authenticator](service)
if err != nil {
// No authentication required, return
return nil
}
doAuth, err := cli.PromptYesNo(
i18n.Tr("The backend %v requires authentication. Authenticate now?", service.Backend),
true,
)
if err != nil {
return err
}
if !doAuth {
return nil
}
cli.AuthenticationFlow(service, backend)
return nil
}

View file

@ -1,45 +0,0 @@
/*
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 cmd
import (
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/cli"
)
var serviceAuthCmd = &cobra.Command{
Use: "auth",
Short: "Authenticate a service",
Long: `For backends requiring authentication this command can be used to authenticate.
Authentication is always done per configured service. That means you can have
multiple services using the same backend but different authentication.`,
Run: func(cmd *cobra.Command, args []string) {
serviceConfig, err := cli.SelectService(cmd)
cobra.CheckErr(err)
backend, err := backends.ResolveBackend[auth.OAuth2Authenticator](serviceConfig)
cobra.CheckErr(err)
cli.AuthenticationFlow(serviceConfig, backend)
},
}
func init() {
serviceCmd.AddCommand(serviceAuthCmd)
serviceAuthCmd.Flags().StringP("service", "s", "", "service configuration")
}

View file

@ -1,62 +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 cmd
import (
"fmt"
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/cli"
"go.uploadedlobster.com/scotty/internal/i18n"
)
var serviceDeleteCmd = &cobra.Command{
Use: "delete",
Short: "Delete existing service configuration",
Long: `Delete an existing service from the configuration file.`,
Run: func(cmd *cobra.Command, args []string) {
service, err := cli.SelectService(cmd)
cobra.CheckErr(err)
// Prompt for deletion
delete, err := cli.PromptYesNo(
i18n.Tr("Delete the service configuration \"%v\"?", service),
false,
)
cobra.CheckErr(err)
if !delete {
fmt.Println(i18n.Tr("Aborted"))
return
}
// Delete the service config
err = service.Delete()
cobra.CheckErr(err)
fmt.Println(i18n.Tr("Service \"%v\" deleted\n", service.Name))
},
}
func init() {
serviceCmd.AddCommand(serviceDeleteCmd)
serviceDeleteCmd.Flags().StringP("service", "s", "", "service configuration")
}

View file

@ -1,59 +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 cmd
import (
"fmt"
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/cli"
"go.uploadedlobster.com/scotty/internal/i18n"
)
var serviceEditCmd = &cobra.Command{
Use: "edit",
Short: "Edit existing service configuration",
Long: `Edit an existing service in the configuration file.`,
Run: func(cmd *cobra.Command, args []string) {
service, err := cli.SelectService(cmd)
cobra.CheckErr(err)
// Select backend
backend, err := cli.SelectBackend(service.Backend)
cobra.CheckErr(err)
service.Backend = backend
// Additional options
service, err = cli.PromptExtraOptions(service)
cobra.CheckErr(err)
// Save the service config
err = service.Save()
cobra.CheckErr(err)
fmt.Println(i18n.Tr("Updated service %v using backend %v\n", service.Name, service.Backend))
},
}
func init() {
serviceCmd.AddCommand(serviceEditCmd)
serviceEditCmd.Flags().StringP("service", "s", "", "service configuration")
}

View file

@ -1,63 +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 cmd
import (
"fmt"
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
)
var serviceListCmd = &cobra.Command{
Use: "list",
Short: "List existing service configurations",
Long: `List existing service configurations.`,
Run: func(cmd *cobra.Command, args []string) {
verbose, _ := cmd.Flags().GetBool("verbose")
for _, s := range config.AllServicesAsList() {
fmt.Printf("%v\n", s.Name)
if verbose {
fmt.Println(i18n.Tr("\tbackend: %v", s.Backend))
for k, v := range s.ConfigValues {
fmt.Printf("\t%v: %v\n", k, v)
}
fmt.Println()
}
}
},
}
func init() {
serviceCmd.AddCommand(serviceListCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// serviceListCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
serviceListCmd.Flags().BoolP("verbose", "v", false, "Verbose output")
}

View file

@ -1,167 +0,0 @@
# Path to the database file used to store recent import timestamps
database = "scotty.sqlite3"
# Host and port for OAuth authentication callbacks
oauth-host = "127.0.0.1:2369"
[service.listenbrainz]
# This backend supports listens and loves from https://listenbrainz.org/
backend = "listenbrainz"
# Your ListenBrainz username
username = ""
# Your ListenBrainz access token from https://listenbrainz.org/profile/
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.listenbrainz-archive]
# This backend supports listens from a ListenBrainz export archive
# (https://listenbrainz.org/settings/export/).
backend = "listenbrainz-archive"
# The file path to the ListenBrainz export archive. The path can either point
# to the ZIP file as downloaded from ListenBrainz or a directory were the
# ZIP was extracted to.
archive-path = "./listenbrainz_outsidecontext.zip"
[service.maloja]
# Maloja is a self hosted listening service (https://github.com/krateng/maloja)
backend = "maloja"
# Base URL of your Maloja instance
server-url = "https://maloja.example.com"
# A Maloja API key
token = ""
# Set to true to disable Malojas auto correction of submitted listens
nofix = false
[service.funkwhale]
# Funkwhale is a federated music server (https://www.funkwhale.audio/).
# You need to register a new application in your Funkwhale settings.
backend = "funkwhale"
# Base URL of your Funkwhale instance
server-url = "https://funkwhale.example.com"
# Your Funkwhale username
username = ""
# The "access token" from the Funkwhale application settings
token = ""
[service.your-music-server]
# The subsonic allows reading loves from any subsonic compatible music server.
backend = "subsonic"
# Base URL of your music server
server-url = "https://example.com"
# A valid username for logging into your server
username = ""
# Password for the username above
token = ""
[service.scrobbler-log]
# Read or write listens from a Rockbox .scobbler.log file
backend = "scrobbler-log"
# The file path to the .scrobbler.log file. Relative paths are resolved against
# the current working directory when running scotty.
file-path = "./.scrobbler.log"
# If true (default), ignore listens marked as skipped.
ignore-skipped = true
# If true (default), new listens will be appended to the existing file. Set to
# false to overwrite the file and create a new scrobbler log on every run.
append = true
# Specify the time zone of the listens in the scrobbler log. While the log files
# are supposed to contain Unix timestamps, which are always in UTC, the player
# writing the log might not be time zone aware. This can cause the timestamps
# to be in a different time zone. Use the time-zone setting to specify a
# different time zone, e.g. "Europe/Berlin" or "America/New_York".
# The default is UTC.
time-zone = "UTC"
[service.jspf]
# Write listens and loves to JSPF playlist files (https://xspf.org/jspf)
backend = "jspf"
# The file path to the JSPF file. Relative paths are resolved against
# the current working directory when running scotty.
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"
# Creator of the playlist (only informational). Not used in append mode.
username = ""
# A unique identifier for your playlist. Not used in append mode.
identifier = ""
[service.spotify]
# Read listens and loves from a Spotify account
# NOTE: The Spotify API does not allow access to the full listen history,
# but only to recent listens.
backend = "spotify"
# You need to register an application on https://developer.spotify.com/
# and set the client ID and client secret below.
# When registering use "http://127.0.0.1:2369/callback/spotify" as the
# callback URI and enable "Web API".
client-id = ""
client-secret = ""
[service.spotify-history]
# Read listens from a Spotify extended history export
backend = "spotify-history"
# Path to the Spotify extended history archive. This can either point directly
# to the "my_spotify_data_extended.zip" ZIP file provided by Spotify or a
# directory where this file has been extracted to. The history files are
# expected to follow the naming pattern "Streaming_History_Audio_*.json".
archive-path = "./my_spotify_data_extended.zip"
# 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]
# Read listens and loves from a Deezer account.
# NOTE: The Deezer API does not allow access to the full listen history,
# but only to recent listens.
backend = "deezer"
# You need to register an application on https://developers.deezer.com/myapps
# and set the client ID and client secret below.
# When registering use "http://127.0.0.1:2369/callback/deezer" as the
# callback URI.
client-id = ""
client-secret = ""
[service.deezer-history]
# Read listens from a Deezer data export.
# You can request a download of all your Deezer data, including the complete
# listen history, in the section "My information" in your Deezer
# "Account settings".
backend = "deezer-history"
# Path to XLSX file provided by Deezer, e.g. "deezer-data_520704045.xlsx".
file-path = ""
[service.lastfm]
backend = "lastfm"
# Your Last.fm username
username = ""
# You need to register an application on https://www.last.fm/api/account/create
# and set the API ID and shared secret below.
# When registering use "http://127.0.0.1:2369/callback/lastfm" as the
# callback URI.
client-id = ""
client-secret = ""
[service.dump]
# This backend allows writing listens and loves as console output. Useful for
# debugging the export from other services.
backend = "dump"
# Path to a file where the listens and loves are written to. If not set,
# the output is written to stdout.
file-path = ""
# If true (default), new listens will be appended to the existing file. Set to
# false to overwrite the file on every run.
append = true

104
go.mod
View file

@ -1,83 +1,67 @@
module go.uploadedlobster.com/scotty module go.uploadedlobster.com/scotty
go 1.23.0 go 1.21.1
toolchain go1.24.2
require ( require (
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/fatih/color v1.18.0 github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5
github.com/glebarez/sqlite v1.11.0 github.com/fatih/color v1.16.0
github.com/go-resty/resty/v2 v2.16.5 github.com/glebarez/sqlite v1.10.0
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/spf13/cobra v1.8.0
github.com/pelletier/go-toml/v2 v2.2.4 github.com/spf13/viper v1.17.0
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 github.com/stretchr/testify v1.8.4
github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740 github.com/vbauerster/mpb/v8 v8.6.2
github.com/spf13/cast v1.9.2 golang.org/x/exp v0.0.0-20230905200255-921286631fa9
github.com/spf13/cobra v1.9.1 golang.org/x/oauth2 v0.14.0
github.com/spf13/viper v1.20.1 gorm.io/datatypes v1.2.0
github.com/stretchr/testify v1.10.0 gorm.io/gorm v1.25.5
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
github.com/vbauerster/mpb/v8 v8.10.2
github.com/xuri/excelize/v2 v2.9.1
go.uploadedlobster.com/mbtypes v0.4.0
go.uploadedlobster.com/musicbrainzws2 v0.16.0
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476
golang.org/x/oauth2 v0.30.0
golang.org/x/text v0.26.0
gorm.io/datatypes v1.2.5
gorm.io/gorm v1.30.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 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.9.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.4.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/mattn/go-colorable v0.1.14 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // 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/richardlehane/mscfb v1.0.4 // indirect github.com/rivo/uniseg v0.4.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/tiendc/go-deepcopy v1.6.1 // indirect go.uber.org/atomic v1.9.0 // indirect
github.com/xuri/efp v0.0.1 // indirect go.uber.org/multierr v1.9.0 // indirect
github.com/xuri/nfp v0.0.1 // indirect golang.org/x/net v0.18.0 // indirect
go.uber.org/multierr v1.11.0 // indirect golang.org/x/sys v0.14.0 // indirect
golang.org/x/crypto v0.39.0 // indirect golang.org/x/text v0.14.0 // indirect
golang.org/x/image v0.28.0 // indirect google.golang.org/appengine v1.6.7 // indirect
golang.org/x/mod v0.25.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
golang.org/x/net v0.41.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/tools v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.6.0 // indirect gorm.io/driver/mysql v1.4.7 // indirect
modernc.org/libc v1.65.10 // indirect modernc.org/libc v1.34.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.38.0 // indirect modernc.org/sqlite v1.27.0 // indirect
) )
tool golang.org/x/text/cmd/gotext

700
go.sum
View file

@ -1,247 +1,633 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
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.3 h1:EWZZJJt5rqPHHbqPRH1zFCn5D7xHjjebODctA4aUO3A=
github.com/Xuanwo/go-locale v1.1.3/go.mod h1:REn+F/c+AtGSWYACBSYZgl23AP+0lfQC+SEFPN+hj30=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
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/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/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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
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/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5 h1:RuuxidatioSKGOiBzL1mTY4X22DQD8weEbS3iRLHnAg=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5/go.mod h1:vnbEuj6Z20PLcHB4rrLQAOXGMjtULfMGhRVSFPcSdUo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/envoyproxy/go-control-plane v0.9.0/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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/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-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/golang/mock v1.1.1/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.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
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/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-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
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/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/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/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 v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
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/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
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.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.4/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.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U=
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/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740 h1:CXJI+lliMiiEwzfgE8yt/38K0heYDgQ0L3f/3fxRnQU=
github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740/go.mod h1:G4w16caPmc6at7u4fmkj/8OAoOnM9mkmJr2fvL0vhaw=
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.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.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/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4= github.com/vbauerster/mpb/v8 v8.6.2 h1:9EhnJGQRtvgDVCychJgR96EDCOqgg2NsMuk5JUcX4DA=
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4= github.com/vbauerster/mpb/v8 v8.6.2/go.mod h1:oVJ7T+dib99kZ/VBjoBaC8aPXiSAihnzuKmotuihyFo=
github.com/tiendc/go-deepcopy v1.6.1 h1:uVRTItFeNHkMcLueHS7OCsxgxT9P8MzGB/taUa2Y4Tk= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/tiendc/go-deepcopy v1.6.1/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/vbauerster/mpb/v8 v8.10.2 h1:2uBykSHAYHekE11YvJhKxYmLATKHAGorZwFlyNw4hHM= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/vbauerster/mpb/v8 v8.10.2/go.mod h1:+Ja4P92E3/CorSZgfDtK46D7AVbDqmBQRTmyTqPElo0= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uploadedlobster.com/mbtypes v0.4.0/go.mod h1:Bu1K1Hl77QTAE2Z7QKiW/JAp9KqYWQebkRRfG02dlZM= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uploadedlobster.com/musicbrainzws2 v0.16.0 h1:Boux1cZg5S559G/pbQC35BoF+1H7I56oxhBwg8Nzhs0= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uploadedlobster.com/musicbrainzws2 v0.16.0/go.mod h1:T6sYE7ZHRH3mJWT3g9jdSUPKJLZubnBjKyjMPNdkgao= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
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/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
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/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-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-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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/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-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
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-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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
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.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I= gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco=
gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4= gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc=
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.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g= gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g= gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc= modernc.org/libc v1.34.3 h1:ag+3JIGF0o009YKhKjkqAG3N36X6ctUv2V85hGM45WA=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po= modernc.org/libc v1.34.3/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -1,34 +0,0 @@
/*
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
}

View file

@ -17,30 +17,20 @@ package auth
import ( import (
"fmt" "fmt"
"log"
"net/http" "net/http"
"net/url" "net/url"
"go.uploadedlobster.com/scotty/internal/i18n"
) )
func RunOauth2CallbackServer(redirectURL url.URL, param string, responseChan chan CodeResponse) { func RunOauth2CallbackServer(redirectURL url.URL, param string, responseChan chan CodeResponse) {
http.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) { http.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get(param) code := r.URL.Query().Get(param)
state := r.URL.Query().Get("state") state := r.URL.Query().Get("state")
fmt.Fprint(w, i18n.Tr("Token received, you can close this window now.")) fmt.Fprint(w, "Token received, you can close this window now.")
responseChan <- CodeResponse{ responseChan <- CodeResponse{
Code: code, Code: code,
State: state, State: state,
} }
}) })
go runServer(redirectURL.Host) go http.ListenAndServe(redirectURL.Host, nil)
}
func runServer(addr string) {
err := http.ListenAndServe(addr, nil)
if err != nil {
log.Fatal(err)
}
} }

View file

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

View file

@ -1,34 +0,0 @@
/*
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 "math/rand"
const stateLength = 10
func RandomState() string {
return randString(stateLength)
}
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func randString(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}

View file

@ -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.(auth.OAuth2Authenticator) authenticator, needAuth := backend.(models.OAuth2Authenticator)
if needAuth { if needAuth {
redirectURL, err := BuildRedirectURL(config, backend.Name()) redirectURL, err := BuildRedirectURL(config, backend.Name())
if err != nil { if err != nil {

View file

@ -17,26 +17,22 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package backends package backends
import ( import (
"errors"
"fmt" "fmt"
"reflect" "reflect"
"sort"
"strings" "strings"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/backends/deezer" "go.uploadedlobster.com/scotty/internal/backends/deezer"
"go.uploadedlobster.com/scotty/internal/backends/deezerhistory"
"go.uploadedlobster.com/scotty/internal/backends/dump" "go.uploadedlobster.com/scotty/internal/backends/dump"
"go.uploadedlobster.com/scotty/internal/backends/funkwhale" "go.uploadedlobster.com/scotty/internal/backends/funkwhale"
"go.uploadedlobster.com/scotty/internal/backends/jspf" "go.uploadedlobster.com/scotty/internal/backends/jspf"
"go.uploadedlobster.com/scotty/internal/backends/lastfm" "go.uploadedlobster.com/scotty/internal/backends/lastfm"
"go.uploadedlobster.com/scotty/internal/backends/lbarchive"
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz" "go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
"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/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
) )
@ -46,29 +42,11 @@ type BackendInfo struct {
ImportCapabilities []Capability ImportCapabilities []Capability
} }
func (b BackendInfo) String() string {
return b.Name
}
type BackendList []BackendInfo
func (l BackendList) Len() int {
return len(l)
}
func (l BackendList) Less(i, j int) bool {
return l[i].Name < l[j].Name
}
func (l BackendList) Swap(i, j int) {
l[i], l[j] = l[j], l[i]
}
type Capability = string type Capability = string
func ResolveBackend[T interface{}](config config.ServiceConfig) (T, error) { func ResolveBackend[T interface{}](config *viper.Viper) (T, error) {
backendName, backend, err := resolveBackend(config)
var result T var result T
backend, err := backendWithConfig(config)
if err != nil { if err != nil {
return result, err return result, err
} }
@ -76,22 +54,15 @@ func ResolveBackend[T interface{}](config config.ServiceConfig) (T, error) {
if implements { if implements {
result = backend.(T) result = backend.(T)
} else { } else {
err = fmt.Errorf(i18n.Tr("backend %s does not implement %s", config.Backend, interfaceName)) err = errors.New(
fmt.Sprintf("Backend %s does not implement %s", backendName, interfaceName))
} }
return result, err return result, err
} }
func BackendByName(backendName string) (models.Backend, error) { func GetBackends() []BackendInfo {
backendType := knownBackends[backendName] backends := make([]BackendInfo, 0)
if backendType == nil {
return nil, fmt.Errorf(i18n.Tr("unknown backend \"%s\"", backendName))
}
return backendType(), nil
}
func GetBackends() BackendList {
backends := make(BackendList, 0)
for name, backendFunc := range knownBackends { for name, backendFunc := range knownBackends {
backend := backendFunc() backend := backendFunc()
info := BackendInfo{ info := BackendInfo{
@ -102,36 +73,29 @@ func GetBackends() BackendList {
backends = append(backends, info) backends = append(backends, info)
} }
sort.Sort(backends)
return backends return backends
} }
var knownBackends = map[string]func() models.Backend{ var knownBackends = map[string]func() models.Backend{
"deezer": func() models.Backend { return &deezer.DeezerApiBackend{} }, "deezer": func() models.Backend { return &deezer.DeezerApiBackend{} },
"deezer-history": func() models.Backend { return &deezerhistory.DeezerHistoryBackend{} },
"dump": func() models.Backend { return &dump.DumpBackend{} }, "dump": func() models.Backend { return &dump.DumpBackend{} },
"funkwhale": func() models.Backend { return &funkwhale.FunkwhaleApiBackend{} }, "funkwhale": func() models.Backend { return &funkwhale.FunkwhaleApiBackend{} },
"jspf": func() models.Backend { return &jspf.JSPFBackend{} }, "jspf": func() models.Backend { return &jspf.JSPFBackend{} },
"lastfm": func() models.Backend { return &lastfm.LastfmApiBackend{} }, "lastfm": func() models.Backend { return &lastfm.LastfmApiBackend{} },
"listenbrainz": func() models.Backend { return &listenbrainz.ListenBrainzApiBackend{} }, "listenbrainz": func() models.Backend { return &listenbrainz.ListenBrainzApiBackend{} },
"listenbrainz-archive": func() models.Backend { return &lbarchive.ListenBrainzArchiveBackend{} },
"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{} },
} }
func backendWithConfig(config config.ServiceConfig) (models.Backend, error) { func resolveBackend(config *viper.Viper) (string, models.Backend, error) {
backend, err := BackendByName(config.Backend) backendName := config.GetString("backend")
if err != nil { backendType := knownBackends[backendName]
return nil, err if backendType == nil {
return backendName, nil, fmt.Errorf("Unknown backend %s", backendName)
} }
err = backend.InitConfig(&config) return backendName, backendType().FromConfig(config), nil
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) {

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -18,52 +18,45 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package backends_test package backends_test
import ( import (
"reflect"
"testing" "testing"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"go.uploadedlobster.com/scotty/internal/backends" "go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/backends/deezer" "go.uploadedlobster.com/scotty/internal/backends/deezer"
"go.uploadedlobster.com/scotty/internal/backends/deezerhistory"
"go.uploadedlobster.com/scotty/internal/backends/dump" "go.uploadedlobster.com/scotty/internal/backends/dump"
"go.uploadedlobster.com/scotty/internal/backends/funkwhale" "go.uploadedlobster.com/scotty/internal/backends/funkwhale"
"go.uploadedlobster.com/scotty/internal/backends/jspf" "go.uploadedlobster.com/scotty/internal/backends/jspf"
"go.uploadedlobster.com/scotty/internal/backends/lastfm" "go.uploadedlobster.com/scotty/internal/backends/lastfm"
"go.uploadedlobster.com/scotty/internal/backends/lbarchive"
"go.uploadedlobster.com/scotty/internal/backends/listenbrainz" "go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
"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/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
) )
func TestResolveBackend(t *testing.T) { func TestResolveBackend(t *testing.T) {
c := viper.New() config := viper.New()
c.Set("backend", "dump") config.Set("backend", "dump")
service := config.NewServiceConfig("test", c) backend, err := backends.ResolveBackend[models.ListensImport](config)
backend, err := backends.ResolveBackend[models.ListensImport](service)
assert.NoError(t, err) assert.NoError(t, err)
assert.IsType(t, &dump.DumpBackend{}, backend) assert.IsType(t, &dump.DumpBackend{}, backend)
} }
func TestResolveBackendUnknown(t *testing.T) { func TestResolveBackendUnknown(t *testing.T) {
c := viper.New() config := viper.New()
c.Set("backend", "foo") config.Set("backend", "foo")
service := config.NewServiceConfig("test", c) _, err := backends.ResolveBackend[models.ListensImport](config)
_, 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) {
c := viper.New() config := viper.New()
c.Set("backend", "dump") config.Set("backend", "dump")
service := config.NewServiceConfig("test", c) _, err := backends.ResolveBackend[models.ListensExport](config)
_, 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) {
@ -79,7 +72,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 backend \"dump\"") t.Errorf("GetBackends() did not return expected bacend \"dump\"")
} }
func TestImplementsInterfaces(t *testing.T) { func TestImplementsInterfaces(t *testing.T) {
@ -87,8 +80,6 @@ func TestImplementsInterfaces(t *testing.T) {
expectInterface[models.LovesExport](t, &deezer.DeezerApiBackend{}) expectInterface[models.LovesExport](t, &deezer.DeezerApiBackend{})
// expectInterface[models.LovesImport](t, &deezer.DeezerApiBackend{}) // expectInterface[models.LovesImport](t, &deezer.DeezerApiBackend{})
expectInterface[models.ListensExport](t, &deezerhistory.DeezerHistoryBackend{})
expectInterface[models.ListensImport](t, &dump.DumpBackend{}) expectInterface[models.ListensImport](t, &dump.DumpBackend{})
expectInterface[models.LovesImport](t, &dump.DumpBackend{}) expectInterface[models.LovesImport](t, &dump.DumpBackend{})
@ -97,9 +88,9 @@ func TestImplementsInterfaces(t *testing.T) {
expectInterface[models.LovesExport](t, &funkwhale.FunkwhaleApiBackend{}) expectInterface[models.LovesExport](t, &funkwhale.FunkwhaleApiBackend{})
// expectInterface[models.LovesImport](t, &funkwhale.FunkwhaleApiBackend{}) // expectInterface[models.LovesImport](t, &funkwhale.FunkwhaleApiBackend{})
expectInterface[models.ListensExport](t, &jspf.JSPFBackend{}) // expectInterface[models.ListensExport](t, &jspf.JSPFBackend{})
expectInterface[models.ListensImport](t, &jspf.JSPFBackend{}) expectInterface[models.ListensImport](t, &jspf.JSPFBackend{})
expectInterface[models.LovesExport](t, &jspf.JSPFBackend{}) // expectInterface[models.LovesExport](t, &jspf.JSPFBackend{})
expectInterface[models.LovesImport](t, &jspf.JSPFBackend{}) expectInterface[models.LovesImport](t, &jspf.JSPFBackend{})
// expectInterface[models.ListensExport](t, &lastfm.LastfmApiBackend{}) // expectInterface[models.ListensExport](t, &lastfm.LastfmApiBackend{})
@ -107,11 +98,6 @@ func TestImplementsInterfaces(t *testing.T) {
expectInterface[models.LovesExport](t, &lastfm.LastfmApiBackend{}) expectInterface[models.LovesExport](t, &lastfm.LastfmApiBackend{})
expectInterface[models.LovesImport](t, &lastfm.LastfmApiBackend{}) expectInterface[models.LovesImport](t, &lastfm.LastfmApiBackend{})
expectInterface[models.ListensExport](t, &lbarchive.ListenBrainzArchiveBackend{})
// expectInterface[models.ListensImport](t, &lbarchive.ListenBrainzArchiveBackend{})
expectInterface[models.LovesExport](t, &lbarchive.ListenBrainzArchiveBackend{})
// expectInterface[models.LovesImport](t, &lbarchive.ListenBrainzArchiveBackend{})
expectInterface[models.ListensExport](t, &listenbrainz.ListenBrainzApiBackend{}) expectInterface[models.ListensExport](t, &listenbrainz.ListenBrainzApiBackend{})
expectInterface[models.ListensImport](t, &listenbrainz.ListenBrainzApiBackend{}) expectInterface[models.ListensImport](t, &listenbrainz.ListenBrainzApiBackend{})
expectInterface[models.LovesExport](t, &listenbrainz.ListenBrainzApiBackend{}) expectInterface[models.LovesExport](t, &listenbrainz.ListenBrainzApiBackend{})
@ -124,8 +110,6 @@ func TestImplementsInterfaces(t *testing.T) {
expectInterface[models.LovesExport](t, &spotify.SpotifyApiBackend{}) expectInterface[models.LovesExport](t, &spotify.SpotifyApiBackend{})
// expectInterface[models.LovesImport](t, &spotify.SpotifyApiBackend{}) // expectInterface[models.LovesImport](t, &spotify.SpotifyApiBackend{})
expectInterface[models.ListensExport](t, &spotifyhistory.SpotifyHistoryBackend{})
expectInterface[models.ListensExport](t, &scrobblerlog.ScrobblerLogBackend{}) expectInterface[models.ListensExport](t, &scrobblerlog.ScrobblerLogBackend{})
expectInterface[models.ListensImport](t, &scrobblerlog.ScrobblerLogBackend{}) expectInterface[models.ListensImport](t, &scrobblerlog.ScrobblerLogBackend{})
@ -136,6 +120,6 @@ func TestImplementsInterfaces(t *testing.T) {
func expectInterface[T interface{}](t *testing.T, backend models.Backend) { func expectInterface[T interface{}](t *testing.T, backend models.Backend) {
ok, name := backends.ImplementsInterface[T](&backend) ok, name := backends.ImplementsInterface[T](&backend)
if !ok { if !ok {
t.Errorf("%v expected to implement %v", backend.Name(), name) t.Errorf("%v expected to implement %v", reflect.TypeOf(backend).Name(), name)
} }
} }

View file

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

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -23,7 +23,6 @@ THE SOFTWARE.
package deezer package deezer
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
@ -37,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
} }
@ -48,19 +47,19 @@ 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,
} }
} }
func (c Client) UserHistory(ctx context.Context, offset int, limit int) (result HistoryResult, err error) { func (c Client) UserHistory(offset int, limit int) (result HistoryResult, err error) {
const path = "/user/me/history" const path = "/user/me/history"
return listRequest[HistoryResult](ctx, c, path, offset, limit) return listRequest[HistoryResult](c, path, offset, limit)
} }
func (c Client) UserTracks(ctx context.Context, offset int, limit int) (TracksResult, error) { func (c Client) UserTracks(offset int, limit int) (TracksResult, error) {
const path = "/user/me/tracks" const path = "/user/me/tracks"
return listRequest[TracksResult](ctx, c, path, offset, limit) return listRequest[TracksResult](c, path, offset, limit)
} }
func (c Client) setToken(req *resty.Request) error { func (c Client) setToken(req *resty.Request) error {
@ -73,21 +72,17 @@ func (c Client) setToken(req *resty.Request) error {
return nil return nil
} }
func listRequest[T Result](ctx context.Context, 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().
SetContext(ctx).
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)
err = c.setToken(request) c.setToken(request)
if err != nil {
return
}
response, err := request.Get(path) response, err := request.Get(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
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)

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -23,7 +23,6 @@ THE SOFTWARE.
package deezer_test package deezer_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
@ -45,12 +44,11 @@ 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")
ctx := context.Background() result, err := client.UserHistory(0, 2)
result, err := client.UserHistory(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -67,12 +65,11 @@ 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")
ctx := context.Background() result, err := client.UserTracks(0, 2)
result, err := client.UserTracks(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -84,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))

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 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
@ -16,56 +16,40 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package deezer package deezer
import ( import (
"context"
"fmt" "fmt"
"math" "math"
"net/url" "net/url"
"sort" "sort"
"time" "time"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/auth" "go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
type DeezerApiBackend struct { type DeezerApiBackend struct {
client Client client Client
clientID string clientId string
clientSecret string clientSecret string
} }
func (b *DeezerApiBackend) Name() string { return "deezer" } func (b *DeezerApiBackend) Name() string { return "deezer" }
func (b *DeezerApiBackend) Close() {} func (b *DeezerApiBackend) FromConfig(config *viper.Viper) models.Backend {
b.clientId = config.GetString("client-id")
func (b *DeezerApiBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "client-id",
Label: i18n.Tr("Client ID"),
Type: models.String,
}, {
Name: "client-secret",
Label: i18n.Tr("Client secret"),
Type: models.Secret,
}}
}
func (b *DeezerApiBackend) InitConfig(config *config.ServiceConfig) error {
b.clientID = config.GetString("client-id")
b.clientSecret = config.GetString("client-secret") b.clientSecret = config.GetString("client-secret")
return nil return b
} }
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",
@ -80,42 +64,35 @@ func (b *DeezerApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
return nil return nil
} }
func (b *DeezerApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *DeezerApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
// Choose a high offset, we attempt to search the loves backwards starting // Choose a high offset, we attempt to search the loves backwards starting
// at the oldest one. // at the oldest one.
offset := math.MaxInt32 offset := math.MaxInt32
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
startTime := time.Now() defer close(results)
minTime := oldestTimestamp
totalDuration := startTime.Sub(oldestTimestamp) p := models.Progress{Total: int64(perPage)}
var totalCount int
p := models.TransferProgress{
Export: &models.Progress{
Total: int64(totalDuration.Seconds()),
},
}
out: out:
for { for {
result, err := b.client.UserHistory(ctx, offset, perPage) result, err := b.client.UserHistory(offset, perPage)
if err != nil { if err != nil {
p.Export.Abort() progress <- p.Complete()
progress <- p
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
// No result, break immediately
if result.Total == 0 {
break out
}
// The offset was higher then the actual number of tracks. Adjust the offset // The offset was higher then the actual number of tracks. Adjust the offset
// and continue. // and continue.
if offset >= result.Total { if offset >= result.Total {
offset = max(result.Total-perPage, 0) p.Total = int64(result.Total)
totalCount = result.Total
offset = result.Total - perPage
if offset < 0 {
offset = 0
}
continue continue
} }
@ -127,23 +104,18 @@ out:
listens := make(models.ListensList, 0, perPage) listens := make(models.ListensList, 0, perPage)
for _, track := range result.Tracks { for _, track := range result.Tracks {
listen := track.AsListen() listen := track.AsListen()
if listen.ListenedAt.After(oldestTimestamp) { if listen.ListenedAt.Unix() > oldestTimestamp.Unix() {
listens = append(listens, listen) listens = append(listens, listen)
} else { } else {
totalCount -= 1
break break
} }
} }
sort.Sort(listens) sort.Sort(listens)
if len(listens) > 0 { results <- models.ListensResult{Listens: listens, Total: totalCount}
minTime = listens[0].ListenedAt p.Elapsed += int64(count)
}
remainingTime := startTime.Sub(minTime)
p.Export.TotalItems += len(listens)
p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
progress <- p progress <- p
results <- models.ListensResult{Items: listens, OldestTimestamp: minTime}
if offset <= 0 { if offset <= 0 {
// This was the last request, no further results // This was the last request, no further results
@ -156,30 +128,25 @@ out:
} }
} }
results <- models.ListensResult{OldestTimestamp: minTime} progress <- p.Complete()
p.Export.Complete()
progress <- p
} }
func (b *DeezerApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *DeezerApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
// Choose a high offset, we attempt to search the loves backwards starting // Choose a high offset, we attempt to search the loves backwards starting
// at the oldest one. // at the oldest one.
offset := math.MaxInt32 offset := math.MaxInt32
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
p := models.TransferProgress{ defer close(results)
Export: &models.Progress{
Total: int64(perPage), p := models.Progress{Total: int64(perPage)}
},
}
var totalCount int var totalCount int
out: out:
for { for {
result, err := b.client.UserTracks(ctx, offset, perPage) result, err := b.client.UserTracks(offset, perPage)
if err != nil { if err != nil {
p.Export.Abort() progress <- p.Complete()
progress <- p
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }
@ -187,9 +154,12 @@ out:
// The offset was higher then the actual number of tracks. Adjust the offset // The offset was higher then the actual number of tracks. Adjust the offset
// and continue. // and continue.
if offset >= result.Total { if offset >= result.Total {
p.Total = int64(result.Total)
totalCount = result.Total totalCount = result.Total
p.Export.Total = int64(totalCount) offset = result.Total - perPage
offset = max(result.Total-perPage, 0) if offset < 0 {
offset = 0
}
continue continue
} }
@ -201,18 +171,17 @@ out:
loves := make(models.LovesList, 0, perPage) loves := make(models.LovesList, 0, perPage)
for _, track := range result.Tracks { for _, track := range result.Tracks {
love := track.AsLove() love := track.AsLove()
if love.Created.After(oldestTimestamp) { if love.Created.Unix() > oldestTimestamp.Unix() {
loves = append(loves, love) loves = append(loves, love)
} else { } else {
totalCount -= 1 totalCount -= 1
break
} }
} }
sort.Sort(loves) sort.Sort(loves)
results <- models.LovesResult{Items: loves, Total: totalCount} results <- models.LovesResult{Loves: loves, Total: totalCount}
p.Export.TotalItems = totalCount p.Elapsed += int64(count)
p.Export.Total = int64(totalCount)
p.Export.Elapsed += int64(count)
progress <- p progress <- p
if offset <= 0 { if offset <= 0 {
@ -226,8 +195,7 @@ out:
} }
} }
p.Export.Complete() progress <- p.Complete()
progress <- p
} }
func (t Listen) AsListen() models.Listen { func (t Listen) AsListen() models.Listen {
@ -253,7 +221,7 @@ func (t Track) AsTrack() 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},
Duration: time.Duration(t.Duration) * time.Second, Duration: time.Duration(t.Duration * int(time.Second)),
AdditionalInfo: map[string]any{}, AdditionalInfo: map[string]any{},
} }
@ -261,8 +229,8 @@ func (t Track) AsTrack() models.Track {
info["music_service"] = "deezer.com" info["music_service"] = "deezer.com"
info["origin_url"] = t.Link info["origin_url"] = t.Link
info["deezer_id"] = t.Link info["deezer_id"] = t.Link
info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/album/%v", t.Album.ID) info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/track/%v", t.Album.Id)
info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/artist/%v", t.Artist.ID) info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/track/%v", t.Artist.Id)
return track return track
} }

View file

@ -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"
@ -25,29 +25,21 @@ 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/deezer" "go.uploadedlobster.com/scotty/internal/backends/deezer"
"go.uploadedlobster.com/scotty/internal/config"
) )
var ( func TestFromConfig(t *testing.T) {
//go:embed testdata/listen.json config := viper.New()
testListen []byte config.Set("client-id", "someclientid")
//go:embed testdata/track.json config.Set("client-secret", "someclientsecret")
testTrack []byte backend := (&deezer.DeezerApiBackend{}).FromConfig(config)
) assert.IsType(t, &deezer.DeezerApiBackend{}, backend)
func TestInitConfig(t *testing.T) {
c := viper.New()
c.Set("client-id", "someclientid")
c.Set("client-secret", "someclientsecret")
service := config.NewServiceConfig("test", c)
backend := deezer.DeezerApiBackend{}
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(testListen, &track) err = json.Unmarshal(data, &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)
@ -58,13 +50,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(testTrack, &track) err = json.Unmarshal(data, &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)

View file

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

View file

@ -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,16 +25,11 @@ 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(testUserTracks, &result) err = json.Unmarshal(data, &result)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -50,8 +45,10 @@ 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(testUserHistory, &result) err = json.Unmarshal(data, &result)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)

View file

@ -1,208 +0,0 @@
/*
Copyright © 2025 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 deezerhistory
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/xuri/excelize/v2"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
)
const (
sheetListeningHistory = "10_listeningHistory"
sheetFavoriteSongs = "8_favoriteSong"
)
type DeezerHistoryBackend struct {
filePath string
}
func (b *DeezerHistoryBackend) Name() string { return "deezer-history" }
func (b *DeezerHistoryBackend) Close() {}
func (b *DeezerHistoryBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "file-path",
Label: i18n.Tr("File path"),
Type: models.String,
Default: "",
}}
}
func (b *DeezerHistoryBackend) InitConfig(config *config.ServiceConfig) error {
b.filePath = config.GetString("file-path")
return nil
}
func (b *DeezerHistoryBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
p := models.TransferProgress{
Export: &models.Progress{},
}
rows, err := ReadXLSXSheet(b.filePath, sheetListeningHistory)
if err != nil {
p.Export.Abort()
progress <- p
results <- models.ListensResult{Error: err}
return
}
count := len(rows) - 1 // Exclude the header row
p.Export.TotalItems = count
p.Export.Total = int64(count)
listens := make(models.ListensList, 0, count)
for i, row := range models.IterExportProgress(rows, &p, progress) {
// Skip header row
if i == 0 {
continue
}
l, err := RowAsListen(row)
if err != nil {
p.Export.Abort()
progress <- p
results <- models.ListensResult{Error: err}
return
}
listens = append(listens, *l)
}
sort.Sort(listens)
results <- models.ListensResult{Items: listens}
p.Export.Complete()
progress <- p
}
func (b *DeezerHistoryBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
p := models.TransferProgress{
Export: &models.Progress{},
}
rows, err := ReadXLSXSheet(b.filePath, sheetFavoriteSongs)
if err != nil {
p.Export.Abort()
progress <- p
results <- models.LovesResult{Error: err}
return
}
count := len(rows) - 1 // Exclude the header row
p.Export.TotalItems = count
p.Export.Total = int64(count)
love := make(models.LovesList, 0, count)
for i, row := range models.IterExportProgress(rows, &p, progress) {
// Skip header row
if i == 0 {
continue
}
l, err := RowAsLove(row)
if err != nil {
p.Export.Abort()
progress <- p
results <- models.LovesResult{Error: err}
return
}
love = append(love, *l)
}
sort.Sort(love)
results <- models.LovesResult{Items: love}
p.Export.Complete()
progress <- p
}
func ReadXLSXSheet(path string, sheet string) ([][]string, error) {
exc, err := excelize.OpenFile(path)
if err != nil {
return nil, err
}
// Get all the rows in the Sheet1.
return exc.GetRows(sheet)
}
func RowAsListen(row []string) (*models.Listen, error) {
if len(row) < 9 {
err := fmt.Errorf("Invalid row, expected 9 columns, got %d", len(row))
return nil, err
}
listenedAt, err := time.Parse(time.DateTime, row[8])
if err != nil {
return nil, err
}
listen := models.Listen{
ListenedAt: listenedAt,
Track: models.Track{
TrackName: row[0],
ArtistNames: []string{row[1]},
ReleaseName: row[3],
ISRC: mbtypes.ISRC(row[2]),
AdditionalInfo: map[string]any{
"music_service": "deezer.com",
},
},
}
if duration, err := strconv.Atoi(row[5]); err == nil {
listen.PlaybackDuration = time.Duration(duration) * time.Second
}
return &listen, nil
}
func RowAsLove(row []string) (*models.Love, error) {
if len(row) < 5 {
err := fmt.Errorf("Invalid row, expected 5 columns, got %d", len(row))
return nil, err
}
url := row[4]
if !strings.HasPrefix(url, "http://") || !strings.HasPrefix(url, "https") {
url = "https://" + url
}
love := models.Love{
Track: models.Track{
TrackName: row[0],
ArtistNames: []string{row[1]},
ReleaseName: row[2],
ISRC: mbtypes.ISRC(row[3]),
AdditionalInfo: map[string]any{
"music_service": "deezer.com",
"origin_url": url,
"deezer_id": url,
},
},
}
return &love, nil
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -17,119 +17,40 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package dump package dump
import ( import (
"bytes" "github.com/spf13/viper"
"context"
"fmt"
"io"
"os"
"strings"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
) )
type DumpBackend struct { type DumpBackend struct{}
buffer io.ReadWriter
print bool // Whether to print the output to stdout
}
func (b *DumpBackend) Name() string { return "dump" } func (b *DumpBackend) Name() string { return "dump" }
func (b *DumpBackend) Close() {} func (b *DumpBackend) FromConfig(config *viper.Viper) models.Backend {
return b
func (b *DumpBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "file-path",
Label: i18n.Tr("File path"),
Type: models.String,
}, {
Name: "append",
Label: i18n.Tr("Append to file"),
Type: models.Bool,
Default: "true",
}}
}
func (b *DumpBackend) InitConfig(config *config.ServiceConfig) error {
filePath := config.GetString("file-path")
append := config.GetBool("append", true)
if strings.TrimSpace(filePath) != "" {
mode := os.O_WRONLY | os.O_CREATE
if !append {
mode |= os.O_TRUNC // Truncate the file if not appending
}
f, err := os.OpenFile(filePath, mode, 0644)
if err != nil {
return err
}
b.buffer = f
b.print = false // If a file path is specified, we don't print to stdout
} else {
// If no file path is specified, use a bytes.Buffer for in-memory dumping
b.buffer = new(bytes.Buffer)
b.print = true // Print to stdout
}
return nil
} }
func (b *DumpBackend) StartImport() error { return nil } func (b *DumpBackend) StartImport() error { return nil }
func (b *DumpBackend) FinishImport() error { return nil }
func (b *DumpBackend) FinishImport(result *models.ImportResult) error { func (b *DumpBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
if b.print { for _, listen := range export.Listens {
out := new(strings.Builder)
_, err := io.Copy(out, b.buffer)
if err != nil {
return err
}
if result != nil {
result.Log(models.Output, out.String())
}
}
// Close the io writer if it is closable
if closer, ok := b.buffer.(io.Closer); ok {
if err := closer.Close(); err != nil {
return fmt.Errorf("failed to close output file: %w", err)
}
}
return nil
}
func (b *DumpBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
for _, listen := range export.Items {
if err := ctx.Err(); err != nil {
return importResult, err
}
importResult.UpdateTimestamp(listen.ListenedAt) importResult.UpdateTimestamp(listen.ListenedAt)
importResult.ImportCount += 1 importResult.ImportCount += 1
_, err := fmt.Fprintf(b.buffer, "🎶 %v: \"%v\" by %v (%v)\n", progress <- models.Progress{}.FromImportResult(importResult)
listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMBID) // fmt.Printf("🎶 %v: \"%v\" by %v (%v)\n",
if err != nil { // listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMbid)
return importResult, err
}
progress <- models.TransferProgress{}.FromImportResult(importResult, false)
} }
return importResult, nil return importResult, nil
} }
func (b *DumpBackend) ImportLoves(ctx context.Context, export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *DumpBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
for _, love := range export.Items { for _, love := range export.Loves {
if err := ctx.Err(); err != nil {
return importResult, err
}
importResult.UpdateTimestamp(love.Created) importResult.UpdateTimestamp(love.Created)
importResult.ImportCount += 1 importResult.ImportCount += 1
_, err := fmt.Fprintf(b.buffer, "❤️ %v: \"%v\" by %v (%v)\n", progress <- models.Progress{}.FromImportResult(importResult)
love.Created, love.TrackName, love.ArtistName(), love.RecordingMBID) // fmt.Printf("❤️ %v: \"%v\" by %v (%v)\n",
if err != nil { // love.Created, love.TrackName, love.ArtistName(), love.RecordingMbid)
return importResult, err
}
progress <- models.TransferProgress{}.FromImportResult(importResult, false)
} }
return importResult, nil return importResult, nil

View file

@ -1,59 +0,0 @@
/*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
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 backends
import (
"context"
"sync"
"time"
"go.uploadedlobster.com/scotty/internal/models"
)
type ExportProcessor[T models.ListensResult | models.LovesResult] interface {
ExportBackend() models.Backend
Process(ctx context.Context, wg *sync.WaitGroup, oldestTimestamp time.Time, results chan T, progress chan models.TransferProgress)
}
type ListensExportProcessor struct {
Backend models.ListensExport
}
func (p ListensExportProcessor) ExportBackend() models.Backend {
return p.Backend
}
func (p ListensExportProcessor) Process(ctx context.Context, wg *sync.WaitGroup, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
wg.Add(1)
defer wg.Done()
defer close(results)
p.Backend.ExportListens(ctx, oldestTimestamp, results, progress)
}
type LovesExportProcessor struct {
Backend models.LovesExport
}
func (p LovesExportProcessor) ExportBackend() models.Backend {
return p.Backend
}
func (p LovesExportProcessor) Process(ctx context.Context, wg *sync.WaitGroup, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) {
wg.Add(1)
defer wg.Done()
defer close(results)
p.Backend.ExportLoves(ctx, oldestTimestamp, results, progress)
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,25 +22,24 @@ THE SOFTWARE.
package funkwhale package funkwhale
import ( import (
"context"
"errors" "errors"
"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")
@ -50,44 +49,44 @@ 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(ctx context.Context, 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.buildListRequest(ctx, page, perPage). response, err := c.HttpClient.R().
SetQueryParam("username", user). SetQueryParams(map[string]string{
"username": user,
"page": strconv.Itoa(page),
"page_size": strconv.Itoa(perPage),
"ordering": "-creation_date",
}).
SetResult(&result). SetResult(&result).
Get(path) Get(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
err = errors.New(response.String()) err = errors.New(response.String())
return return
} }
return return
} }
func (c Client) GetFavoriteTracks(ctx context.Context, 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.buildListRequest(ctx, page, perPage). response, err := c.HttpClient.R().
SetResult(&result).
Get(path)
if !response.IsSuccess() {
err = errors.New(response.String())
return
}
return
}
func (c Client) buildListRequest(ctx context.Context, page int, perPage int) *resty.Request {
return c.HTTPClient.R().
SetContext(ctx).
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),
"ordering": "-creation_date", "ordering": "-creation_date",
}) }).
SetResult(&result).
Get(path)
if response.StatusCode() != 200 {
err = errors.New(response.String())
return
}
return
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package funkwhale_test package funkwhale_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
@ -33,25 +32,24 @@ 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")
ctx := context.Background() result, err := client.GetHistoryListenings("outsidecontext", 0, 2)
result, err := client.GetHistoryListenings(ctx, "outsidecontext", 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -69,14 +67,13 @@ 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")
ctx := context.Background() result, err := client.GetFavoriteTracks(0, 2)
result, err := client.GetFavoriteTracks(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -90,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))

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -17,13 +17,10 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package funkwhale package funkwhale
import ( import (
"context"
"sort" "sort"
"time" "time"
"go.uploadedlobster.com/mbtypes" "github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
) )
@ -36,53 +33,30 @@ type FunkwhaleApiBackend struct {
func (b *FunkwhaleApiBackend) Name() string { return "funkwhale" } func (b *FunkwhaleApiBackend) Name() string { return "funkwhale" }
func (b *FunkwhaleApiBackend) Close() {} func (b *FunkwhaleApiBackend) FromConfig(config *viper.Viper) models.Backend {
func (b *FunkwhaleApiBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "server-url",
Label: i18n.Tr("Server URL"),
Type: models.String,
}, {
Name: "username",
Label: i18n.Tr("User name"),
Type: models.String,
}, {
Name: "token",
Label: i18n.Tr("Access token"),
Type: models.Secret,
}}
}
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 nil return b
} }
func (b *FunkwhaleApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *FunkwhaleApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
page := 1 page := 1
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
// We need to gather the full list of listens in order to sort them // We need to gather the full list of listens in order to sort them
listens := make(models.ListensList, 0, 2*perPage) listens := make(models.ListensList, 0, 2*perPage)
p := models.TransferProgress{ p := models.Progress{Total: int64(perPage)}
Export: &models.Progress{
Total: int64(perPage),
},
}
out: out:
for { for {
result, err := b.client.GetHistoryListenings(ctx, b.username, page, perPage) result, err := b.client.GetHistoryListenings(b.username, page, perPage)
if err != nil { if err != nil {
p.Export.Abort()
progress <- p
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return
} }
count := len(result.Results) count := len(result.Results)
@ -92,8 +66,8 @@ out:
for _, fwListen := range result.Results { for _, fwListen := range result.Results {
listen := fwListen.AsListen() listen := fwListen.AsListen()
if listen.ListenedAt.After(oldestTimestamp) { if listen.ListenedAt.Unix() > oldestTimestamp.Unix() {
p.Export.Elapsed += 1 p.Elapsed += 1
listens = append(listens, listen) listens = append(listens, listen)
} else { } else {
break out break out
@ -102,42 +76,36 @@ out:
if result.Next == "" { if result.Next == "" {
// No further results // No further results
p.Export.Total = p.Export.Elapsed p.Total = p.Elapsed
p.Export.Total -= int64(perPage - count) p.Total -= int64(perPage - count)
break out break out
} }
p.Export.TotalItems = len(listens) p.Total += int64(perPage)
p.Export.Total += int64(perPage)
progress <- p progress <- p
page += 1 page += 1
} }
sort.Sort(listens) sort.Sort(listens)
p.Export.TotalItems = len(listens) progress <- p.Complete()
p.Export.Complete() results <- models.ListensResult{Listens: listens}
progress <- p
results <- models.ListensResult{Items: listens}
} }
func (b *FunkwhaleApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *FunkwhaleApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
page := 1 page := 1
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
// We need to gather the full list of listens in order to sort them // We need to gather the full list of listens in order to sort them
loves := make(models.LovesList, 0, 2*perPage) loves := make(models.LovesList, 0, 2*perPage)
p := models.TransferProgress{ p := models.Progress{Total: int64(perPage)}
Export: &models.Progress{
Total: int64(perPage),
},
}
out: out:
for { for {
result, err := b.client.GetFavoriteTracks(ctx, page, perPage) result, err := b.client.GetFavoriteTracks(page, perPage)
if err != nil { if err != nil {
p.Export.Abort() progress <- p.Complete()
progress <- p
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }
@ -149,8 +117,8 @@ out:
for _, favorite := range result.Results { for _, favorite := range result.Results {
love := favorite.AsLove() love := favorite.AsLove()
if love.Created.After(oldestTimestamp) { if love.Created.Unix() > oldestTimestamp.Unix() {
p.Export.Elapsed += 1 p.Elapsed += 1
loves = append(loves, love) loves = append(loves, love)
} else { } else {
break out break out
@ -162,17 +130,14 @@ out:
break out break out
} }
p.Export.TotalItems = len(loves) p.Total += int64(perPage)
p.Export.Total += int64(perPage)
progress <- p progress <- p
page += 1 page += 1
} }
sort.Sort(loves) sort.Sort(loves)
p.Export.TotalItems = len(loves) progress <- p.Complete()
p.Export.Complete() results <- models.LovesResult{Loves: loves}
progress <- p
results <- models.LovesResult{Items: loves}
} }
func (l Listening) AsListen() models.Listen { func (l Listening) AsListen() models.Listen {
@ -193,7 +158,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,
} }
@ -206,15 +171,16 @@ 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: t.RecordingMBID, RecordingMbid: recordingMbid,
ReleaseMBID: t.Album.ReleaseMBID, ReleaseMbid: models.MBID(t.Album.ReleaseMbid),
ArtistMBIDs: []mbtypes.MBID{t.Artist.ArtistMBID}, ArtistMbids: []models.MBID{models.MBID(t.Artist.ArtistMbid)},
Tags: t.Tags, Tags: t.Tags,
AdditionalInfo: map[string]any{ AdditionalInfo: map[string]any{
"media_player": FunkwhaleClientName, "media_player": FunkwhaleClientName,
@ -222,7 +188,7 @@ func (t Track) AsTrack() models.Track {
} }
if len(t.Uploads) > 0 { if len(t.Uploads) > 0 {
track.Duration = time.Duration(t.Uploads[0].Duration) * time.Second track.Duration = time.Duration(t.Uploads[0].Duration * int(time.Second))
} }
return track return track

View file

@ -24,16 +24,14 @@ 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/funkwhale" "go.uploadedlobster.com/scotty/internal/backends/funkwhale"
"go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/models"
) )
func TestInitConfig(t *testing.T) { func TestFromConfig(t *testing.T) {
c := viper.New() config := viper.New()
c.Set("token", "thetoken") config.Set("token", "thetoken")
service := config.NewServiceConfig("test", c) backend := (&funkwhale.FunkwhaleApiBackend{}).FromConfig(config)
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 +42,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 +73,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(fwListen.Track.RecordingMBID, listen.RecordingMBID) assert.Equal(models.MBID(fwListen.Track.RecordingMbid), listen.RecordingMbid)
assert.Equal(fwListen.Track.Album.ReleaseMBID, listen.ReleaseMBID) assert.Equal(models.MBID(fwListen.Track.Album.ReleaseMbid), listen.ReleaseMbid)
assert.Equal(fwListen.Track.Artist.ArtistMBID, listen.ArtistMBIDs[0]) assert.Equal(models.MBID(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 +87,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 +117,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(favorite.Track.RecordingMBID, love.RecordingMBID) assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.RecordingMbid)
assert.Equal(favorite.Track.RecordingMBID, love.Track.RecordingMBID) assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.Track.RecordingMbid)
assert.Equal(favorite.Track.Album.ReleaseMBID, love.ReleaseMBID) assert.Equal(models.MBID(favorite.Track.Album.ReleaseMbid), love.ReleaseMbid)
require.Len(t, love.Track.ArtistMBIDs, 1) require.Len(t, love.Track.ArtistMbids, 1)
assert.Equal(favorite.Track.Artist.ArtistMBID, love.ArtistMBIDs[0]) assert.Equal(models.MBID(favorite.Track.Artist.ArtistMbid), love.ArtistMbids[0])
assert.Equal(funkwhale.FunkwhaleClientName, love.AdditionalInfo["media_player"]) assert.Equal(funkwhale.FunkwhaleClientName, love.AdditionalInfo["media_player"])
} }

View file

@ -21,8 +21,6 @@ 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"`
@ -31,7 +29,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"`
@ -45,41 +43,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 mbtypes.MBID `json:"mbid"` RecordingMbid string `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 mbtypes.MBID `json:"mbid"` ArtistMbid string `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 mbtypes.MBID `json:"mbid"` ReleaseMbid string `json:"mbid"`
} }
type User struct { type User struct {
ID int `json:"int"` Id int `json:"int"`
UserName string `json:"username"` UserName string `json:"username"`
} }

View file

@ -1,141 +0,0 @@
/*
Copyright © 2023-2025 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 backends
import (
"context"
"sync"
"go.uploadedlobster.com/scotty/internal/models"
)
type ImportProcessor[T models.ListensResult | models.LovesResult] interface {
ImportBackend() models.ImportBackend
Process(ctx context.Context, wg *sync.WaitGroup, results chan T, out chan models.ImportResult, progress chan models.TransferProgress)
Import(ctx context.Context, export T, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error)
}
type ListensImportProcessor struct {
Backend models.ListensImport
}
func (p ListensImportProcessor) ImportBackend() models.ImportBackend {
return p.Backend
}
func (p ListensImportProcessor) Process(ctx context.Context, wg *sync.WaitGroup, results chan models.ListensResult, out chan models.ImportResult, progress chan models.TransferProgress) {
process(ctx, wg, p, results, out, progress)
}
func (p ListensImportProcessor) Import(ctx context.Context, export models.ListensResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
if export.Error != nil {
return result, export.Error
}
if export.Total > 0 {
result.TotalCount = export.Total
} else {
result.TotalCount += len(export.Items)
}
importResult, err := p.Backend.ImportListens(ctx, export, result, progress)
if err != nil {
return importResult, err
}
return importResult, nil
}
type LovesImportProcessor struct {
Backend models.LovesImport
}
func (p LovesImportProcessor) ImportBackend() models.ImportBackend {
return p.Backend
}
func (p LovesImportProcessor) Process(ctx context.Context, wg *sync.WaitGroup, results chan models.LovesResult, out chan models.ImportResult, progress chan models.TransferProgress) {
process(ctx, wg, p, results, out, progress)
}
func (p LovesImportProcessor) Import(ctx context.Context, export models.LovesResult, result models.ImportResult, out chan models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
if export.Error != nil {
return result, export.Error
}
if export.Total > 0 {
result.TotalCount = export.Total
} else {
result.TotalCount += len(export.Items)
}
importResult, err := p.Backend.ImportLoves(ctx, export, result, progress)
if err != nil {
return importResult, err
}
return importResult, nil
}
func process[R models.LovesResult | models.ListensResult, P ImportProcessor[R]](
ctx context.Context, wg *sync.WaitGroup,
processor P, results chan R,
out chan models.ImportResult,
progress chan models.TransferProgress,
) {
wg.Add(1)
defer wg.Done()
defer close(out)
result := models.ImportResult{}
p := models.TransferProgress{}
if err := processor.ImportBackend().StartImport(); err != nil {
out <- handleError(result, err, progress)
return
}
for exportResult := range results {
if err := ctx.Err(); err != nil {
processor.ImportBackend().FinishImport(&result)
out <- handleError(result, err, progress)
return
}
importResult, err := processor.Import(
ctx, exportResult, result.Copy(), out, progress)
result.Update(&importResult)
if err != nil {
processor.ImportBackend().FinishImport(&result)
out <- handleError(result, err, progress)
return
}
progress <- p.FromImportResult(result, false)
}
if err := processor.ImportBackend().FinishImport(&result); err != nil {
out <- handleError(result, err, progress)
return
}
progress <- p.FromImportResult(result, true)
out <- result
}
func handleError(result models.ImportResult, err error, progress chan models.TransferProgress) models.ImportResult {
result.Error = err
p := models.TransferProgress{}.FromImportResult(result, false)
p.Import.Abort()
progress <- p
return result
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -18,287 +18,121 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package jspf package jspf
import ( import (
"context"
"errors"
"os" "os"
"sort"
"strings"
"time" "time"
"go.uploadedlobster.com/mbtypes" "github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/pkg/jspf" "go.uploadedlobster.com/scotty/pkg/jspf"
) )
const (
artistMBIDPrefix = "https://musicbrainz.org/artist/"
recordingMBIDPrefix = "https://musicbrainz.org/recording/"
releaseMBIDPrefix = "https://musicbrainz.org/release/"
)
type JSPFBackend struct { type JSPFBackend struct {
filePath string filePath string
playlist jspf.Playlist title string
append bool creator string
identifier string
tracks []jspf.Track
} }
func (b *JSPFBackend) Name() string { return "jspf" } func (b *JSPFBackend) Name() string { return "jspf" }
func (b *JSPFBackend) Close() {} func (b *JSPFBackend) FromConfig(config *viper.Viper) models.Backend {
func (b *JSPFBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "file-path",
Label: i18n.Tr("File path"),
Type: models.String,
}, {
Name: "append",
Label: i18n.Tr("Append to file"),
Type: models.Bool,
Default: "true",
}, {
Name: "title",
Label: i18n.Tr("Playlist title"),
Type: models.String,
}, {
Name: "username",
Label: i18n.Tr("User name"),
Type: models.String,
}, {
Name: "identifier",
Label: i18n.Tr("Unique playlist identifier"),
Type: models.String,
}}
}
func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error {
b.filePath = config.GetString("file-path") b.filePath = config.GetString("file-path")
b.append = config.GetBool("append", true) b.title = config.GetString("title")
b.playlist = jspf.Playlist{ b.creator = config.GetString("username")
Title: config.GetString("title"), b.identifier = config.GetString("identifier")
Creator: config.GetString("username"), b.tracks = make([]jspf.Track, 0)
Identifier: config.GetString("identifier"), return b
Date: time.Now(),
Tracks: make([]jspf.Track, 0),
} }
b.addMusicBrainzPlaylistExtension() func (b *JSPFBackend) StartImport() error { return nil }
return nil func (b *JSPFBackend) FinishImport() error {
} err := b.writeJSPF(b.tracks)
return err
func (b *JSPFBackend) StartImport() error {
return b.readJSPF()
}
func (b *JSPFBackend) FinishImport(result *models.ImportResult) error {
return b.writeJSPF()
}
func (b *JSPFBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
err := b.readJSPF()
p := models.TransferProgress{
Export: &models.Progress{},
}
if err != nil {
p.Export.Abort()
progress <- p
results <- models.ListensResult{Error: err}
return
}
listens := make(models.ListensList, 0, len(b.playlist.Tracks))
p.Export.Total = int64(len(b.playlist.Tracks))
for _, track := range models.IterExportProgress(b.playlist.Tracks, &p, progress) {
listen, err := trackAsListen(track)
if err == nil && listen != nil && listen.ListenedAt.After(oldestTimestamp) {
listens = append(listens, *listen)
p.Export.TotalItems += 1
}
}
sort.Sort(listens)
results <- models.ListensResult{Items: listens}
}
func (b *JSPFBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
p := models.TransferProgress{}.FromImportResult(importResult, false)
for _, listen := range models.IterImportProgress(export.Items, &p, progress) {
if err := ctx.Err(); err != nil {
return importResult, err
} }
func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
for _, listen := range export.Listens {
track := listenAsTrack(listen) track := listenAsTrack(listen)
b.playlist.Tracks = append(b.playlist.Tracks, track) b.tracks = append(b.tracks, track)
importResult.ImportCount += 1 importResult.ImportCount += 1
importResult.UpdateTimestamp(listen.ListenedAt) importResult.UpdateTimestamp(listen.ListenedAt)
} }
progress <- models.Progress{}.FromImportResult(importResult)
return importResult, nil return importResult, nil
} }
func (b *JSPFBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
err := b.readJSPF() for _, love := range export.Loves {
p := models.TransferProgress{
Export: &models.Progress{},
}
if err != nil {
p.Export.Abort()
progress <- p
results <- models.LovesResult{Error: err}
return
}
loves := make(models.LovesList, 0, len(b.playlist.Tracks))
p.Export.Total = int64(len(b.playlist.Tracks))
for _, track := range models.IterExportProgress(b.playlist.Tracks, &p, progress) {
love, err := trackAsLove(track)
if err == nil && love != nil && love.Created.After(oldestTimestamp) {
loves = append(loves, *love)
p.Export.TotalItems += 1
}
}
sort.Sort(loves)
results <- models.LovesResult{Items: loves}
}
func (b *JSPFBackend) ImportLoves(ctx context.Context, export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
p := models.TransferProgress{}.FromImportResult(importResult, false)
for _, love := range models.IterImportProgress(export.Items, &p, progress) {
if err := ctx.Err(); err != nil {
return importResult, err
}
track := loveAsTrack(love) track := loveAsTrack(love)
b.playlist.Tracks = append(b.playlist.Tracks, track) b.tracks = append(b.tracks, track)
importResult.ImportCount += 1 importResult.ImportCount += 1
importResult.UpdateTimestamp(love.Created) importResult.UpdateTimestamp(love.Created)
} }
progress <- models.Progress{}.FromImportResult(importResult)
return importResult, nil return importResult, nil
} }
func listenAsTrack(l models.Listen) jspf.Track { func listenAsTrack(l models.Listen) jspf.Track {
l.FillAdditionalInfo() l.FillAdditionalInfo()
track := trackAsJSPFTrack(l.Track) track := trackAsTrack(l.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, recordingMBIDPrefix+string(l.RecordingMBID)) track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid))
} }
return track return track
} }
func trackAsListen(t jspf.Track) (*models.Listen, error) {
track, ext, err := jspfTrackAsTrack(t)
if err != nil {
return nil, err
}
listen := models.Listen{
ListenedAt: ext.AddedAt,
UserName: ext.AddedBy,
Track: *track,
}
return &listen, err
}
func loveAsTrack(l models.Love) jspf.Track { func loveAsTrack(l models.Love) jspf.Track {
l.FillAdditionalInfo() l.FillAdditionalInfo()
track := trackAsJSPFTrack(l.Track) track := trackAsTrack(l.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, recordingMBIDPrefix+string(recordingMBID)) track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMbid))
} }
return track return track
} }
func trackAsLove(t jspf.Track) (*models.Love, error) { func trackAsTrack(t models.Track) jspf.Track {
track, ext, err := jspfTrackAsTrack(t)
if err != nil {
return nil, err
}
love := models.Love{
Created: ext.AddedAt,
UserName: ext.AddedBy,
RecordingMBID: track.RecordingMBID,
Track: *track,
}
recordingMSID, ok := track.AdditionalInfo["recording_msid"].(string)
if ok {
love.RecordingMSID = mbtypes.MBID(recordingMSID)
}
return &love, err
}
func trackAsJSPFTrack(t models.Track) jspf.Track {
track := jspf.Track{ track := jspf.Track{
Title: t.TrackName, Title: t.TrackName,
Album: t.ReleaseName, Album: t.ReleaseName,
Creator: t.ArtistName(), Creator: t.ArtistName(),
TrackNum: t.TrackNumber, TrackNum: t.TrackNumber,
Duration: t.Duration.Milliseconds(), Extension: map[string]any{},
Extension: jspf.ExtensionMap{},
} }
return track return track
} }
func jspfTrackAsTrack(t jspf.Track) (*models.Track, *jspf.MusicBrainzTrackExtension, error) {
track := models.Track{
ArtistNames: []string{t.Creator},
ReleaseName: t.Album,
TrackName: t.Title,
TrackNumber: t.TrackNum,
Duration: time.Duration(t.Duration) * time.Millisecond,
}
for _, id := range t.Identifier {
if strings.HasPrefix(id, recordingMBIDPrefix) {
track.RecordingMBID = mbtypes.MBID(id[len(recordingMBIDPrefix):])
}
}
ext, err := readMusicBrainzExtension(t, &track)
if err != nil {
return nil, nil, err
}
return &track, ext, nil
}
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] = artistMBIDPrefix + string(mbid) extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid)
} }
if t.ReleaseMBID != "" { if t.ReleaseMbid != "" {
extension.ReleaseIdentifier = releaseMBIDPrefix + string(t.ReleaseMBID) extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMbid)
} }
// The tracknumber tag would be redundant // The tracknumber tag would be redundant
@ -307,58 +141,15 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension {
return extension return extension
} }
func readMusicBrainzExtension(jspfTrack jspf.Track, outputTrack *models.Track) (*jspf.MusicBrainzTrackExtension, error) { func (b JSPFBackend) writeJSPF(tracks []jspf.Track) error {
ext := jspf.MusicBrainzTrackExtension{}
err := jspfTrack.Extension.Get(jspf.MusicBrainzTrackExtensionID, &ext)
if err != nil {
return nil, errors.New("missing MusicBrainz track extension")
}
outputTrack.AdditionalInfo = ext.AdditionalMetadata
outputTrack.ReleaseMBID = mbtypes.MBID(ext.ReleaseIdentifier)
outputTrack.ArtistMBIDs = make([]mbtypes.MBID, len(ext.ArtistIdentifiers))
for i, mbid := range ext.ArtistIdentifiers {
if strings.HasPrefix(mbid, artistMBIDPrefix) {
outputTrack.ArtistMBIDs[i] = mbtypes.MBID(mbid[len(artistMBIDPrefix):])
}
}
return &ext, nil
}
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
b.addMusicBrainzPlaylistExtension()
}
}
return nil
}
func (b *JSPFBackend) writeJSPF() error {
playlist := jspf.JSPF{ playlist := jspf.JSPF{
Playlist: b.playlist, Playlist: jspf.Playlist{
Title: b.title,
Creator: b.creator,
Identifier: b.identifier,
Date: time.Now(),
Tracks: tracks,
},
} }
file, err := os.Create(b.filePath) file, err := os.Create(b.filePath)
@ -369,13 +160,3 @@ func (b *JSPFBackend) writeJSPF() error {
defer file.Close() defer file.Close()
return playlist.Write(file) return playlist.Write(file)
} }
func (b *JSPFBackend) addMusicBrainzPlaylistExtension() {
if b.playlist.Extension == nil {
b.playlist.Extension = make(jspf.ExtensionMap, 1)
}
extension := jspf.MusicBrainzPlaylistExtension{Public: true}
b.playlist.Extension.Get(jspf.MusicBrainzPlaylistExtensionID, &extension)
extension.LastModifiedAt = time.Now()
b.playlist.Extension[jspf.MusicBrainzPlaylistExtensionID] = extension
}

View file

@ -22,18 +22,15 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"go.uploadedlobster.com/scotty/internal/backends/jspf" "go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
"go.uploadedlobster.com/scotty/internal/config"
) )
func TestInitConfig(t *testing.T) { func TestFromConfig(t *testing.T) {
c := viper.New() config := viper.New()
c.Set("file-path", "/foo/bar.jspf") config.Set("file-path", "/foo/bar.jspf")
c.Set("title", "My Playlist") config.Set("title", "My Playlist")
c.Set("username", "outsidecontext") config.Set("username", "outsidecontext")
c.Set("identifier", "http://example.com/playlist1") config.Set("identifier", "http://example.com/playlist1")
service := config.NewServiceConfig("test", c) backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(config)
backend := jspf.JSPFBackend{} assert.IsType(t, &scrobblerlog.ScrobblerLogBackend{}, backend)
err := backend.InitConfig(&service)
assert.NoError(t, err)
} }

View file

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

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 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
@ -16,7 +16,7 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package lastfm package lastfm
import ( import (
"context" "errors"
"fmt" "fmt"
"net/url" "net/url"
"sort" "sort"
@ -24,10 +24,8 @@ import (
"time" "time"
"github.com/shkh/lastfm-go/lastfm" "github.com/shkh/lastfm-go/lastfm"
"go.uploadedlobster.com/mbtypes" "github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/auth" "go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@ -46,41 +44,21 @@ type LastfmApiBackend struct {
func (b *LastfmApiBackend) Name() string { return "lastfm" } func (b *LastfmApiBackend) Name() string { return "lastfm" }
func (b *LastfmApiBackend) Close() {} func (b *LastfmApiBackend) FromConfig(config *viper.Viper) models.Backend {
clientId := config.GetString("client-id")
func (b *LastfmApiBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "username",
Label: i18n.Tr("User name"),
Type: models.String,
}, {
Name: "client-id",
Label: i18n.Tr("Client ID"),
Type: models.String,
}, {
Name: "client-secret",
Label: i18n.Tr("Client secret"),
Type: models.Secret,
}}
}
func (b *LastfmApiBackend) InitConfig(config *config.ServiceConfig) error {
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 nil return b
} }
func (b *LastfmApiBackend) StartImport() error { return nil } func (b *LastfmApiBackend) StartImport() error { return nil }
func (b *LastfmApiBackend) FinishImport(result *models.ImportResult) error { func (b *LastfmApiBackend) FinishImport() error { return nil }
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,
} }
} }
@ -93,27 +71,18 @@ func (b *LastfmApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
return nil return nil
} }
func (b *LastfmApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *LastfmApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
page := MaxPage page := MaxPage
minTime := oldestTimestamp minTime := oldestTimestamp
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
// We need to gather the full list of listens in order to sort them // We need to gather the full list of listens in order to sort them
p := models.TransferProgress{ p := models.Progress{Total: int64(page)}
Export: &models.Progress{
Total: int64(page),
},
}
out: out:
for page > 0 { for page > 0 {
if err := ctx.Err(); err != nil {
results <- models.ListensResult{Error: err}
p.Export.Abort()
progress <- p
return
}
args := lastfm.P{ args := lastfm.P{
"user": b.username, "user": b.username,
"limit": MaxListensPerGet, "limit": MaxListensPerGet,
@ -124,8 +93,7 @@ out:
result, err := b.client.User.GetRecentTracks(args) result, err := b.client.User.GetRecentTracks(args)
if err != nil { if err != nil {
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
p.Export.Abort() progress <- p.Complete()
progress <- p
return return
} }
@ -144,12 +112,11 @@ out:
timestamp, err := strconv.ParseInt(scrobble.Date.Uts, 10, 64) timestamp, err := strconv.ParseInt(scrobble.Date.Uts, 10, 64)
if err != nil { if err != nil {
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
p.Export.Abort() progress <- p.Complete()
progress <- p
break out break out
} }
if timestamp > oldestTimestamp.Unix() { if timestamp > oldestTimestamp.Unix() {
p.Export.Elapsed += 1 p.Elapsed += 1
listen := models.Listen{ listen := models.Listen{
ListenedAt: time.Unix(timestamp, 0), ListenedAt: time.Unix(timestamp, 0),
UserName: b.username, UserName: b.username,
@ -157,16 +124,16 @@ out:
TrackName: scrobble.Name, TrackName: scrobble.Name,
ArtistNames: []string{}, ArtistNames: []string{},
ReleaseName: scrobble.Album.Name, ReleaseName: scrobble.Album.Name,
RecordingMBID: mbtypes.MBID(scrobble.Mbid), RecordingMbid: models.MBID(scrobble.Mbid),
ArtistMBIDs: []mbtypes.MBID{}, ArtistMbids: []models.MBID{},
ReleaseMBID: mbtypes.MBID(scrobble.Album.Mbid), ReleaseMbid: models.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 = []mbtypes.MBID{mbtypes.MBID(scrobble.Artist.Mbid)} listen.Track.ArtistMbids = []models.MBID{models.MBID(scrobble.Artist.Mbid)}
} }
listens = append(listens, listen) listens = append(listens, listen)
} else { } else {
@ -179,29 +146,23 @@ out:
page -= 1 page -= 1
results <- models.ListensResult{ results <- models.ListensResult{
Items: listens, Listens: listens,
Total: result.Total, Total: result.Total,
OldestTimestamp: minTime, OldestTimestamp: minTime,
} }
p.Export.Total = int64(result.TotalPages) p.Total = int64(result.TotalPages)
p.Export.Elapsed = int64(result.TotalPages - page) p.Elapsed = int64(result.TotalPages - page)
p.Export.TotalItems += len(listens)
progress <- p progress <- p
} }
results <- models.ListensResult{OldestTimestamp: minTime} results <- models.ListensResult{OldestTimestamp: minTime}
p.Export.Complete() progress <- p.Complete()
progress <- p
} }
func (b *LastfmApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
total := len(export.Items) total := len(export.Listens)
for i := 0; i < total; i += MaxListensPerSubmission { for i := 0; i < total; i += MaxListensPerSubmission {
if err := ctx.Err(); err != nil { listens := export.Listens[i:min(i+MaxListensPerSubmission, total)]
return importResult, err
}
listens := export.Items[i:min(i+MaxListensPerSubmission, total)]
count := len(listens) count := len(listens)
if count == 0 { if count == 0 {
break break
@ -226,8 +187,8 @@ func (b *LastfmApiBackend) ImportListens(ctx context.Context, export models.List
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)
@ -259,56 +220,47 @@ func (b *LastfmApiBackend) ImportListens(ctx context.Context, export models.List
for _, s := range result.Scrobbles { for _, s := range result.Scrobbles {
ignoreMsg := s.IgnoredMessage.Body ignoreMsg := s.IgnoredMessage.Body
if ignoreMsg != "" { if ignoreMsg != "" {
importResult.Log(models.Warning, ignoreMsg) importResult.ImportErrors = append(importResult.ImportErrors, ignoreMsg)
} }
} }
err := fmt.Errorf("last.fm import ignored %v scrobbles", count-accepted) errMsg := fmt.Sprintf("Last.fm import ignored %v scrobbles", count-accepted)
return importResult, err return importResult, errors.New(errMsg)
} }
importResult.UpdateTimestamp(listens[count-1].ListenedAt) importResult.UpdateTimestamp(listens[count-1].ListenedAt)
importResult.ImportCount += accepted importResult.ImportCount += accepted
progress <- models.TransferProgress{}.FromImportResult(importResult, false) progress <- models.Progress{}.FromImportResult(importResult)
} }
return importResult, nil return importResult, nil
} }
func (b *LastfmApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *LastfmApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
// Choose a high offset, we attempt to search the loves backwards starting // Choose a high offset, we attempt to search the loves backwards starting
// at the oldest one. // at the oldest one.
page := 1 page := 1
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
loves := make(models.LovesList, 0, 2*MaxItemsPerGet) loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
p := models.TransferProgress{ p := models.Progress{Total: int64(perPage)}
Export: &models.Progress{
Total: int64(perPage),
},
}
var totalCount int var totalCount int
out: out:
for { for {
if err := ctx.Err(); err != nil {
results <- models.LovesResult{Error: err}
p.Export.Abort()
progress <- p
return
}
result, err := b.client.User.GetLovedTracks(lastfm.P{ result, err := b.client.User.GetLovedTracks(lastfm.P{
"user": b.username, "user": b.username,
"limit": MaxItemsPerGet, "limit": MaxItemsPerGet,
"page": page, "page": page,
}) })
if err != nil { if err != nil {
p.Export.Abort() progress <- p.Complete()
progress <- p
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }
p.Total = int64(result.Total)
count := len(result.Tracks) count := len(result.Tracks)
if count == 0 { if count == 0 {
break out break out
@ -317,8 +269,7 @@ out:
for _, track := range result.Tracks { for _, track := range result.Tracks {
timestamp, err := strconv.ParseInt(track.Date.Uts, 10, 64) timestamp, err := strconv.ParseInt(track.Date.Uts, 10, 64)
if err != nil { if err != nil {
p.Export.Abort() progress <- p.Complete()
progress <- p
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }
@ -327,12 +278,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: mbtypes.MBID(track.Mbid), RecordingMbid: models.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: mbtypes.MBID(track.Mbid), RecordingMbid: models.MBID(track.Mbid),
ArtistMBIDs: []mbtypes.MBID{mbtypes.MBID(track.Artist.Mbid)}, ArtistMbids: []models.MBID{models.MBID(track.Artist.Mbid)},
AdditionalInfo: models.AdditionalInfo{ AdditionalInfo: models.AdditionalInfo{
"lastfm_url": track.Url, "lastfm_url": track.Url,
}, },
@ -344,26 +295,19 @@ out:
} }
} }
p.Export.Total += int64(perPage) p.Elapsed += int64(count)
p.Export.TotalItems = totalCount
p.Export.Elapsed += int64(count)
progress <- p progress <- p
page += 1 page += 1
} }
sort.Sort(loves) sort.Sort(loves)
p.Export.Complete() results <- models.LovesResult{Loves: loves, Total: totalCount}
progress <- p progress <- p.Complete()
results <- models.LovesResult{Items: loves, Total: totalCount}
}
func (b *LastfmApiBackend) ImportLoves(ctx context.Context, export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
for _, love := range export.Items {
if err := ctx.Err(); err != nil {
return importResult, err
} }
func (b *LastfmApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
for _, love := range export.Loves {
err := b.client.Track.Love(lastfm.P{ err := b.client.Track.Love(lastfm.P{
"track": love.TrackName, "track": love.TrackName,
"artist": love.ArtistName(), "artist": love.ArtistName(),
@ -375,10 +319,10 @@ func (b *LastfmApiBackend) ImportLoves(ctx context.Context, export models.LovesR
} 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.Log(models.Error, msg) importResult.ImportErrors = append(importResult.ImportErrors, msg)
} }
progress <- models.TransferProgress{}.FromImportResult(importResult, false) progress <- models.Progress{}.FromImportResult(importResult)
} }
return importResult, nil return importResult, nil

View file

@ -1,224 +0,0 @@
/*
Copyright © 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 lbarchive
import (
"context"
"time"
"go.uploadedlobster.com/musicbrainzws2"
lbapi "go.uploadedlobster.com/scotty/internal/backends/listenbrainz"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/listenbrainz"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/version"
)
const (
listensBatchSize = 2000
lovesBatchSize = listenbrainz.MaxItemsPerGet
)
type ListenBrainzArchiveBackend struct {
filePath string
lbClient listenbrainz.Client
mbClient *musicbrainzws2.Client
}
func (b *ListenBrainzArchiveBackend) Name() string { return "listenbrainz-archive" }
func (b *ListenBrainzArchiveBackend) Close() {
if b.mbClient != nil {
b.mbClient.Close()
}
}
func (b *ListenBrainzArchiveBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "archive-path",
Label: i18n.Tr("Archive path"),
Type: models.String,
}}
}
func (b *ListenBrainzArchiveBackend) InitConfig(config *config.ServiceConfig) error {
b.filePath = config.GetString("archive-path")
b.lbClient = listenbrainz.NewClient("", version.UserAgent())
b.mbClient = musicbrainzws2.NewClient(musicbrainzws2.AppInfo{
Name: version.AppName,
Version: version.AppVersion,
URL: version.AppURL,
})
return nil
}
func (b *ListenBrainzArchiveBackend) ExportListens(
ctx context.Context, oldestTimestamp time.Time,
results chan models.ListensResult, progress chan models.TransferProgress) {
startTime := time.Now()
minTime := oldestTimestamp
if minTime.Unix() < 1 {
minTime = time.Unix(1, 0)
}
totalDuration := startTime.Sub(oldestTimestamp)
p := models.TransferProgress{
Export: &models.Progress{
Total: int64(totalDuration.Seconds()),
},
}
archive, err := listenbrainz.OpenExportArchive(b.filePath)
if err != nil {
p.Export.Abort()
progress <- p
results <- models.ListensResult{Error: err}
return
}
defer archive.Close()
userInfo, err := archive.UserInfo()
if err != nil {
p.Export.Abort()
progress <- p
results <- models.ListensResult{Error: err}
return
}
listens := make(models.ListensList, 0, listensBatchSize)
for rawListen, err := range archive.IterListens(oldestTimestamp) {
if err != nil {
p.Export.Abort()
progress <- p
results <- models.ListensResult{Error: err}
return
}
listen := lbapi.AsListen(rawListen)
if listen.UserName == "" {
listen.UserName = userInfo.Name
}
listens = append(listens, listen)
// Update the progress
p.Export.TotalItems += 1
remainingTime := startTime.Sub(listen.ListenedAt)
p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
// Allow the importer to start processing the listens by
// sending them in batches.
if len(listens) >= listensBatchSize {
results <- models.ListensResult{Items: listens}
progress <- p
listens = listens[:0]
}
}
results <- models.ListensResult{Items: listens}
p.Export.Complete()
progress <- p
}
func (b *ListenBrainzArchiveBackend) ExportLoves(
ctx context.Context, oldestTimestamp time.Time,
results chan models.LovesResult, progress chan models.TransferProgress) {
startTime := time.Now()
minTime := oldestTimestamp
if minTime.Unix() < 1 {
minTime = time.Unix(1, 0)
}
totalDuration := startTime.Sub(oldestTimestamp)
p := models.TransferProgress{
Export: &models.Progress{
Total: int64(totalDuration.Seconds()),
},
}
archive, err := listenbrainz.OpenExportArchive(b.filePath)
if err != nil {
p.Export.Abort()
progress <- p
results <- models.LovesResult{Error: err}
return
}
defer archive.Close()
userInfo, err := archive.UserInfo()
if err != nil {
p.Export.Abort()
progress <- p
results <- models.LovesResult{Error: err}
return
}
batch := make([]listenbrainz.Feedback, 0, lovesBatchSize)
for feedback, err := range archive.IterFeedback(oldestTimestamp) {
if err != nil {
p.Export.Abort()
progress <- p
results <- models.LovesResult{Error: err}
return
}
if feedback.UserName == "" {
feedback.UserName = userInfo.Name
}
batch = append(batch, feedback)
// Update the progress
p.Export.TotalItems += 1
remainingTime := startTime.Sub(time.Unix(feedback.Created, 0))
p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
// Allow the importer to start processing the listens by
// sending them in batches.
if len(batch) >= lovesBatchSize {
// The dump does not contain track metadata. Extend it with additional
// lookups
loves, err := lbapi.ExtendTrackMetadata(ctx, &b.lbClient, b.mbClient, &batch)
if err != nil {
p.Export.Abort()
progress <- p
results <- models.LovesResult{Error: err}
return
}
results <- models.LovesResult{Items: loves}
progress <- p
batch = batch[:0]
}
}
loves, err := lbapi.ExtendTrackMetadata(ctx, &b.lbClient, b.mbClient, &batch)
if err != nil {
p.Export.Abort()
progress <- p
results <- models.LovesResult{Error: err}
return
}
results <- models.LovesResult{Items: loves}
p.Export.Complete()
progress <- p
}

View file

@ -1,40 +0,0 @@
/*
Copyright © 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 lbarchive_test
import (
"testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"go.uploadedlobster.com/scotty/internal/backends/lbarchive"
"go.uploadedlobster.com/scotty/internal/config"
)
func TestInitConfig(t *testing.T) {
c := viper.New()
c.Set("file-path", "/foo/lbarchive.zip")
service := config.NewServiceConfig("test", c)
backend := lbarchive.ListenBrainzArchiveBackend{}
err := backend.InitConfig(&service)
assert.NoError(t, err)
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,14 +22,13 @@ THE SOFTWARE.
package listenbrainz package listenbrainz
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
"time" "time"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
"go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/scotty/internal/ratelimit"
"go.uploadedlobster.com/scotty/pkg/ratelimit" "go.uploadedlobster.com/scotty/internal/version"
) )
const ( const (
@ -40,32 +39,31 @@ const (
) )
type Client struct { type Client struct {
HTTPClient *resty.Client HttpClient *resty.Client
MaxResults int MaxResults int
} }
func NewClient(token string, userAgent string) Client { func NewClient(token string) Client {
client := resty.New() client := resty.New()
client.SetBaseURL(listenBrainzBaseURL) client.SetBaseURL(listenBrainzBaseURL)
client.SetAuthScheme("Token") client.SetAuthScheme("Token")
client.SetAuthToken(token) client.SetAuthToken(token)
client.SetHeader("Accept", "application/json") client.SetHeader("Accept", "application/json")
client.SetHeader("User-Agent", userAgent) client.SetHeader("User-Agent", version.UserAgent())
// Handle rate limiting (see https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#rate-limiting) // Handle rate limiting (see https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#rate-limiting)
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,
} }
} }
func (c Client) GetListens(ctx context.Context, 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().
SetContext(ctx).
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),
@ -76,35 +74,33 @@ func (c Client) GetListens(ctx context.Context, user string, maxTime time.Time,
SetError(&errorResult). SetError(&errorResult).
Get(path) Get(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
err = errors.New(errorResult.Error) err = errors.New(errorResult.Error)
return return
} }
return return
} }
func (c Client) SubmitListens(ctx context.Context, 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().
SetContext(ctx).
SetBody(listens). SetBody(listens).
SetResult(&result). SetResult(&result).
SetError(&errorResult). SetError(&errorResult).
Post(path) Post(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
err = errors.New(errorResult.Error) err = errors.New(errorResult.Error)
return return
} }
return return
} }
func (c Client) GetFeedback(ctx context.Context, 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().
SetContext(ctx).
SetPathParam("username", user). SetPathParam("username", user).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"status": strconv.Itoa(status), "status": strconv.Itoa(status),
@ -116,35 +112,33 @@ func (c Client) GetFeedback(ctx context.Context, user string, status int, offset
SetError(&errorResult). SetError(&errorResult).
Get(path) Get(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
err = errors.New(errorResult.Error) err = errors.New(errorResult.Error)
return return
} }
return return
} }
func (c Client) SendFeedback(ctx context.Context, 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().
SetContext(ctx).
SetBody(feedback). SetBody(feedback).
SetResult(&result). SetResult(&result).
SetError(&errorResult). SetError(&errorResult).
Post(path) Post(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
err = errors.New(errorResult.Error) err = errors.New(errorResult.Error)
return return
} }
return return
} }
func (c Client) Lookup(ctx context.Context, 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().
SetContext(ctx).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"recording_name": recordingName, "recording_name": recordingName,
"artist_name": artistName, "artist_name": artistName,
@ -153,28 +147,7 @@ func (c Client) Lookup(ctx context.Context, recordingName string, artistName str
SetError(&errorResult). SetError(&errorResult).
Get(path) Get(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
err = errors.New(errorResult.Error)
return
}
return
}
func (c Client) MetadataRecordings(ctx context.Context, mbids []mbtypes.MBID) (result RecordingMetadataResult, err error) {
const path = "/metadata/recording/"
errorResult := ErrorResult{}
body := RecordingMetadataRequest{
RecordingMBIDs: mbids,
Includes: "artist release",
}
response, err := c.HTTPClient.R().
SetContext(ctx).
SetBody(body).
SetResult(&result).
SetError(&errorResult).
Post(path)
if !response.IsSuccess() {
err = errors.New(errorResult.Error) err = errors.New(errorResult.Error)
return return
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package listenbrainz_test package listenbrainz_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
"time" "time"
@ -30,42 +29,37 @@ 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/listenbrainz"
) )
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
token := "foobar123" token := "foobar123"
client := listenbrainz.NewClient(token, "test/1.0") 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)
} }
func TestGetListens(t *testing.T) { func TestGetListens(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
client := listenbrainz.NewClient("thetoken", "test/1.0") 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")
ctx := context.Background() result, err := client.GetListens("outsidecontext", time.Now(), time.Now().Add(-2*time.Hour))
result, err := client.GetListens(ctx, "outsidecontext",
time.Now(), time.Now().Add(-2*time.Hour))
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
assert.Equal(2, result.Payload.Count) assert.Equal(2, result.Payload.Count)
assert.Equal(int64(1699718723), result.Payload.LatestListenTimestamp)
assert.Equal(int64(1152911863), result.Payload.OldestListenTimestamp)
require.Len(t, result.Payload.Listens, 2) require.Len(t, result.Payload.Listens, 2)
assert.Equal("Shadowplay", result.Payload.Listens[0].TrackMetadata.TrackName) assert.Equal("Shadowplay", result.Payload.Listens[0].TrackMetadata.TrackName)
} }
func TestSubmitListens(t *testing.T) { func TestSubmitListens(t *testing.T) {
client := listenbrainz.NewClient("thetoken", "test/1.0") 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",
@ -95,8 +89,8 @@ func TestSubmitListens(t *testing.T) {
}, },
}, },
} }
ctx := context.Background() result, err := client.SubmitListens(listens)
result, err := client.SubmitListens(ctx, listens) require.NoError(t, err)
assert.Equal(t, "ok", result.Status) assert.Equal(t, "ok", result.Status)
} }
@ -104,14 +98,13 @@ func TestSubmitListens(t *testing.T) {
func TestGetFeedback(t *testing.T) { func TestGetFeedback(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
client := listenbrainz.NewClient("thetoken", "test/1.0") 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")
ctx := context.Background() result, err := client.GetFeedback("outsidecontext", 1, 3)
result, err := client.GetFeedback(ctx, "outsidecontext", 1, 0)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -119,12 +112,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(mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), result.Feedback[0].RecordingMBID) assert.Equal("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", result.Feedback[0].RecordingMbid)
} }
func TestSendFeedback(t *testing.T) { func TestSendFeedback(t *testing.T) {
client := listenbrainz.NewClient("thetoken", "test/1.0") 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",
@ -136,11 +129,10 @@ 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,
} }
ctx := context.Background() result, err := client.SendFeedback(feedback)
result, err := client.SendFeedback(ctx, feedback)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "ok", result.Status) assert.Equal(t, "ok", result.Status)
@ -149,22 +141,21 @@ func TestSendFeedback(t *testing.T) {
func TestLookup(t *testing.T) { func TestLookup(t *testing.T) {
defer httpmock.DeactivateAndReset() defer httpmock.DeactivateAndReset()
client := listenbrainz.NewClient("thetoken", "test/1.0") 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")
ctx := context.Background() result, err := client.Lookup("Paradise Lost", "Say Just Words")
result, err := client.Lookup(ctx, "Paradise Lost", "Say Just Words")
require.NoError(t, err) require.NoError(t, err)
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(mbtypes.MBID("569436a1-234a-44bc-a370-8f4d252bef21"), result.RecordingMBID) assert.Equal("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))

View file

@ -1,190 +0,0 @@
/*
Copyright © 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 listenbrainz
import (
"context"
"time"
"go.uploadedlobster.com/mbtypes"
"go.uploadedlobster.com/musicbrainzws2"
"go.uploadedlobster.com/scotty/internal/listenbrainz"
"go.uploadedlobster.com/scotty/internal/models"
)
func AsListen(lbListen listenbrainz.Listen) models.Listen {
listen := models.Listen{
ListenedAt: time.Unix(lbListen.ListenedAt, 0),
UserName: lbListen.UserName,
Track: AsTrack(lbListen.TrackMetadata),
}
return listen
}
func AsLove(f listenbrainz.Feedback) models.Love {
recordingMBID := f.RecordingMBID
track := f.TrackMetadata
if track == nil {
track = &listenbrainz.Track{}
}
love := models.Love{
UserName: f.UserName,
RecordingMBID: recordingMBID,
Created: time.Unix(f.Created, 0),
Track: AsTrack(*track),
}
if love.Track.RecordingMBID == "" {
love.Track.RecordingMBID = love.RecordingMBID
}
return love
}
func AsTrack(t listenbrainz.Track) models.Track {
track := models.Track{
TrackName: t.TrackName,
ReleaseName: t.ReleaseName,
ArtistNames: []string{t.ArtistName},
Duration: t.Duration(),
TrackNumber: t.TrackNumber(),
DiscNumber: t.DiscNumber(),
RecordingMBID: t.RecordingMBID(),
ReleaseMBID: t.ReleaseMBID(),
ReleaseGroupMBID: t.ReleaseGroupMBID(),
ISRC: t.ISRC(),
AdditionalInfo: t.AdditionalInfo,
}
if t.MBIDMapping != nil && len(track.ArtistMBIDs) == 0 {
for _, artistMBID := range t.MBIDMapping.ArtistMBIDs {
track.ArtistMBIDs = append(track.ArtistMBIDs, artistMBID)
}
}
return track
}
func LookupRecording(
ctx context.Context,
mb *musicbrainzws2.Client,
mbid mbtypes.MBID,
) (*listenbrainz.Track, error) {
filter := musicbrainzws2.IncludesFilter{
Includes: []string{"artist-credits"},
}
recording, err := mb.LookupRecording(ctx, 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 := listenbrainz.Track{
TrackName: recording.Title,
ArtistName: recording.ArtistCredit.String(),
MBIDMapping: &listenbrainz.MBIDMapping{
// In case of redirects this MBID differs from the looked up MBID
RecordingMBID: recording.ID,
ArtistMBIDs: artistMBIDs,
},
}
return &track, nil
}
func ExtendTrackMetadata(
ctx context.Context,
lb *listenbrainz.Client,
mb *musicbrainzws2.Client,
feedbacks *[]listenbrainz.Feedback,
) ([]models.Love, error) {
mbids := make([]mbtypes.MBID, 0, len(*feedbacks))
for _, feedback := range *feedbacks {
if feedback.TrackMetadata == nil && feedback.RecordingMBID != "" {
mbids = append(mbids, feedback.RecordingMBID)
}
}
result, err := lb.MetadataRecordings(ctx, mbids)
if err != nil {
return nil, err
}
loves := make([]models.Love, 0, len(*feedbacks))
for _, feedback := range *feedbacks {
if feedback.TrackMetadata == nil && feedback.RecordingMBID != "" {
metadata, ok := result[feedback.RecordingMBID]
if ok {
feedback.TrackMetadata = trackFromMetadataLookup(
feedback.RecordingMBID, metadata)
} else {
// MBID not in result. This is probably a MBID redirect, get
// data from MB instead (slower).
// If this also fails, just leave the metadata empty.
track, err := LookupRecording(ctx, mb, feedback.RecordingMBID)
if err == nil {
feedback.TrackMetadata = track
}
}
}
loves = append(loves, AsLove(feedback))
}
return loves, nil
}
func trackFromMetadataLookup(
recordingMBID mbtypes.MBID,
metadata listenbrainz.RecordingMetadata,
) *listenbrainz.Track {
artistMBIDs := make([]mbtypes.MBID, 0, len(metadata.Artist.Artists))
artists := make([]listenbrainz.Artist, 0, len(metadata.Artist.Artists))
for _, artist := range metadata.Artist.Artists {
artistMBIDs = append(artistMBIDs, artist.ArtistMBID)
artists = append(artists, listenbrainz.Artist{
ArtistCreditName: artist.Name,
ArtistMBID: artist.ArtistMBID,
JoinPhrase: artist.JoinPhrase,
})
}
return &listenbrainz.Track{
TrackName: metadata.Recording.Name,
ArtistName: metadata.Artist.Name,
ReleaseName: metadata.Release.Name,
AdditionalInfo: map[string]any{
"duration_ms": metadata.Recording.Length,
"release_group_mbid": metadata.Release.ReleaseGroupMBID,
},
MBIDMapping: &listenbrainz.MBIDMapping{
RecordingMBID: recordingMBID,
ReleaseMBID: metadata.Release.MBID,
ArtistMBIDs: artistMBIDs,
Artists: artists,
CAAID: metadata.Release.CAAID,
CAAReleaseMBID: metadata.Release.CAAReleaseMBID,
},
}
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -17,171 +17,103 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package listenbrainz package listenbrainz
import ( import (
"context"
"fmt" "fmt"
"sort" "sort"
"time" "time"
"go.uploadedlobster.com/mbtypes" "github.com/spf13/viper"
"go.uploadedlobster.com/musicbrainzws2"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/listenbrainz"
"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"
) )
const lovesBatchSize = listenbrainz.MaxItemsPerGet
type ListenBrainzApiBackend struct { type ListenBrainzApiBackend struct {
client listenbrainz.Client client Client
mbClient *musicbrainzws2.Client
username string username string
checkDuplicates bool existingMbids map[string]bool
existingMBIDs map[mbtypes.MBID]bool
}
func (b *ListenBrainzApiBackend) Close() {
if b.mbClient != nil {
b.mbClient.Close()
}
} }
func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" } func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" }
func (b *ListenBrainzApiBackend) Options() []models.BackendOption { func (b *ListenBrainzApiBackend) FromConfig(config *viper.Viper) models.Backend {
return []models.BackendOption{{ b.client = NewClient(config.GetString("token"))
Name: "username", b.client.MaxResults = MaxItemsPerGet
Label: i18n.Tr("User name"),
Type: models.String,
}, {
Name: "token",
Label: i18n.Tr("Access token"),
Type: models.Secret,
}, {
Name: "check-duplicate-listens",
Label: i18n.Tr("Check for duplicate listens on import (slower)"),
Type: models.Bool,
}}
}
func (b *ListenBrainzApiBackend) InitConfig(config *config.ServiceConfig) error {
b.client = listenbrainz.NewClient(config.GetString("token"), version.UserAgent())
b.mbClient = musicbrainzws2.NewClient(musicbrainzws2.AppInfo{
Name: version.AppName,
Version: version.AppVersion,
URL: version.AppURL,
})
b.client.MaxResults = listenbrainz.MaxItemsPerGet
b.username = config.GetString("username") b.username = config.GetString("username")
b.checkDuplicates = config.GetBool("check-duplicate-listens", false) return b
return nil
} }
func (b *ListenBrainzApiBackend) StartImport() error { return nil } func (b *ListenBrainzApiBackend) StartImport() error { return nil }
func (b *ListenBrainzApiBackend) FinishImport(result *models.ImportResult) error { func (b *ListenBrainzApiBackend) FinishImport() error { return nil }
return nil
}
func (b *ListenBrainzApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *ListenBrainzApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
startTime := time.Now() startTime := time.Now()
minTime := oldestTimestamp maxTime := startTime
if minTime.Unix() < 1 { minTime := time.Unix(0, 0)
minTime = time.Unix(1, 0)
}
totalDuration := startTime.Sub(oldestTimestamp) totalDuration := startTime.Sub(oldestTimestamp)
p := models.TransferProgress{
Export: &models.Progress{
Total: int64(totalDuration.Seconds()),
},
}
defer close(results)
// FIXME: Optimize by fetching the listens in reverse listen time order
listens := make(models.ListensList, 0, 2*MaxItemsPerGet)
p := models.Progress{Total: int64(totalDuration.Seconds())}
out:
for { for {
result, err := b.client.GetListens(ctx, b.username, time.Now(), minTime) result, err := b.client.GetListens(b.username, maxTime, minTime)
if err != nil { if err != nil {
p.Export.Abort() progress <- p.Complete()
progress <- p
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
count := len(result.Payload.Listens) count := len(result.Payload.Listens)
if count == 0 { if count == 0 {
if minTime.Unix() < result.Payload.OldestListenTimestamp {
minTime = time.Unix(result.Payload.OldestListenTimestamp, 0)
totalDuration = startTime.Sub(minTime)
p.Export.Total = int64(totalDuration.Seconds())
continue
} else {
break break
} }
}
// Set minTime to the newest returned listen // Set maxTime to the oldest returned listen
minTime = time.Unix(result.Payload.Listens[0].ListenedAt, 0) maxTime = time.Unix(result.Payload.Listens[count-1].ListenedAt, 0)
remainingTime := startTime.Sub(minTime) remainingTime := maxTime.Sub(oldestTimestamp)
listens := make(models.ListensList, 0, count)
for _, listen := range result.Payload.Listens { for _, listen := range result.Payload.Listens {
if listen.ListenedAt > oldestTimestamp.Unix() { if listen.ListenedAt > oldestTimestamp.Unix() {
listens = append(listens, AsListen(listen)) listens = append(listens, listen.AsListen())
} else { } else {
// result contains listens older then oldestTimestamp // result contains listens older then oldestTimestamp,
break // we can stop requesting more
p.Total = int64(startTime.Sub(time.Unix(listen.ListenedAt, 0)).Seconds())
break out
} }
} }
p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
progress <- p
}
sort.Sort(listens) sort.Sort(listens)
p.Export.TotalItems += len(listens) progress <- p.Complete()
p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) results <- models.ListensResult{Listens: listens, OldestTimestamp: oldestTimestamp}
progress <- p
results <- models.ListensResult{Items: listens, OldestTimestamp: minTime}
} }
results <- models.ListensResult{OldestTimestamp: minTime} func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
p.Export.Complete() total := len(export.Listens)
progress <- p for i := 0; i < total; i += MaxListensPerRequest {
} listens := export.Listens[i:min(i+MaxListensPerRequest, total)]
func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
total := len(export.Items)
p := models.TransferProgress{}.FromImportResult(importResult, false)
for i := 0; i < total; i += listenbrainz.MaxListensPerRequest {
listens := export.Items[i:min(i+listenbrainz.MaxListensPerRequest, total)]
count := len(listens) count := len(listens)
if count == 0 { if count == 0 {
break break
} }
submission := listenbrainz.ListenSubmission{ submission := ListenSubmission{
ListenType: listenbrainz.Import, ListenType: Import,
Payload: make([]listenbrainz.Listen, 0, count), Payload: make([]Listen, 0, count),
} }
for _, l := range listens { for _, l := range listens {
if b.checkDuplicates {
isDupe, err := b.checkDuplicateListen(ctx, l)
p.Import.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)
importResult.UpdateTimestamp(l.ListenedAt)
continue
}
}
l.FillAdditionalInfo() l.FillAdditionalInfo()
listen := listenbrainz.Listen{ listen := Listen{
ListenedAt: l.ListenedAt.Unix(), ListenedAt: l.ListenedAt.Unix(),
TrackMetadata: listenbrainz.Track{ TrackMetadata: Track{
TrackName: l.TrackName, TrackName: l.TrackName,
ReleaseName: l.ReleaseName, ReleaseName: l.ReleaseName,
ArtistName: l.ArtistName(), ArtistName: l.ArtistName(),
@ -190,63 +122,33 @@ func (b *ListenBrainzApiBackend) ImportListens(ctx context.Context, export model
} }
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(ctx, 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 <- p.FromImportResult(importResult, false) progress <- models.Progress{}.FromImportResult(importResult)
} }
return importResult, nil return importResult, nil
} }
func (b *ListenBrainzApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
exportChan := make(chan models.LovesResult)
p := models.TransferProgress{
Export: &models.Progress{},
}
go b.exportLoves(ctx, oldestTimestamp, exportChan)
for existingLoves := range exportChan {
if existingLoves.Error != nil {
p.Export.Abort()
progress <- p
results <- models.LovesResult{Error: existingLoves.Error}
return
}
p.Export.TotalItems = existingLoves.Total
p.Export.Total = int64(existingLoves.Total)
p.Export.Elapsed += int64(len(existingLoves.Items))
progress <- p
results <- existingLoves
}
p.Export.Complete()
progress <- p
}
func (b *ListenBrainzApiBackend) exportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult) {
offset := 0 offset := 0
defer close(results) defer close(results)
allLoves := make(models.LovesList, 0, 2*listenbrainz.MaxItemsPerGet) loves := make(models.LovesList, 0, 2*MaxItemsPerGet)
batch := make([]listenbrainz.Feedback, 0, lovesBatchSize) p := models.Progress{}
out: out:
for { for {
result, err := b.client.GetFeedback(ctx, 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
} }
@ -257,101 +159,66 @@ out:
} }
for _, feedback := range result.Feedback { for _, feedback := range result.Feedback {
if time.Unix(feedback.Created, 0).After(oldestTimestamp) { love := feedback.AsLove()
batch = append(batch, feedback) if love.Created.Unix() > oldestTimestamp.Unix() {
loves = append(loves, love)
p.Elapsed += 1
progress <- p
} else { } else {
break out break out
} }
if len(batch) >= lovesBatchSize {
// Missing track metadata indicates that the recording MBID is no
// longer available and might have been merged. Try fetching details
// from MusicBrainz.
lovesBatch, err := ExtendTrackMetadata(ctx, &b.client, b.mbClient, &batch)
if err != nil {
results <- models.LovesResult{Error: err}
return
} }
for _, l := range lovesBatch { p.Total = int64(result.TotalCount)
allLoves = append(allLoves, l) p.Elapsed += int64(count)
}
} offset += MaxItemsPerGet
} }
offset += listenbrainz.MaxItemsPerGet sort.Sort(loves)
progress <- p.Complete()
results <- models.LovesResult{Loves: loves}
} }
lovesBatch, err := ExtendTrackMetadata(ctx, &b.client, b.mbClient, &batch) func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
if err != nil { if len(b.existingMbids) == 0 {
results <- models.LovesResult{Error: err}
return
}
for _, l := range lovesBatch {
allLoves = append(allLoves, l)
}
sort.Sort(allLoves)
results <- models.LovesResult{
Total: len(allLoves),
Items: allLoves,
}
}
func (b *ListenBrainzApiBackend) ImportLoves(ctx context.Context, export models.LovesResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) {
if len(b.existingMBIDs) == 0 {
existingLovesChan := make(chan models.LovesResult) existingLovesChan := make(chan models.LovesResult)
go b.exportLoves(ctx, time.Unix(0, 0), existingLovesChan) go b.ExportLoves(time.Unix(0, 0), existingLovesChan, progress)
existingLoves := <-existingLovesChan
// TODO: Store MBIDs directly
b.existingMBIDs = make(map[mbtypes.MBID]bool, listenbrainz.MaxItemsPerGet)
for existingLoves := range existingLovesChan {
if existingLoves.Error != nil { if existingLoves.Error != nil {
return importResult, existingLoves.Error return importResult, existingLoves.Error
} }
for _, love := range existingLoves.Items { // TODO: Store MBIDs directly
b.existingMBIDs[love.RecordingMBID] = true b.existingMbids = make(map[string]bool, len(existingLoves.Loves))
// In case the loved MBID got merged the track MBID represents the for _, love := range existingLoves.Loves {
// actual recording MBID. b.existingMbids[string(love.RecordingMbid)] = true
if love.Track.RecordingMBID != "" &&
love.Track.RecordingMBID != love.RecordingMBID {
b.existingMBIDs[love.Track.RecordingMBID] = true
}
}
} }
} }
for _, love := range export.Items { for _, love := range export.Loves {
recordingMBID := love.RecordingMBID recordingMbid := string(love.RecordingMbid)
if recordingMBID == "" {
recordingMBID = love.Track.RecordingMBID
}
if recordingMBID == "" { if recordingMbid == "" {
lookup, err := b.client.Lookup(ctx, 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(ctx, listenbrainz.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
} }
} }
@ -361,43 +228,65 @@ func (b *ListenBrainzApiBackend) ImportLoves(ctx context.Context, export models.
} 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.Log(models.Error, msg) importResult.ImportErrors = append(importResult.ImportErrors, 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.TransferProgress{}.FromImportResult(importResult, false) progress <- models.Progress{}.FromImportResult(importResult)
} }
return importResult, nil return importResult, nil
} }
var defaultDuration = time.Duration(3 * time.Minute) func (lbListen Listen) AsListen() models.Listen {
listen := models.Listen{
const trackSimilarityThreshold = 0.9 ListenedAt: time.Unix(lbListen.ListenedAt, 0),
UserName: lbListen.UserName,
func (b *ListenBrainzApiBackend) checkDuplicateListen(ctx context.Context, listen models.Listen) (bool, error) { Track: lbListen.TrackMetadata.AsTrack(),
// Find listens
duration := listen.Duration
if duration == 0 {
duration = defaultDuration
} }
minTime := listen.ListenedAt.Add(-duration) return listen
maxTime := listen.ListenedAt.Add(duration)
candidates, err := b.client.GetListens(ctx, b.username, maxTime, minTime)
if err != nil {
return false, err
} }
for _, c := range candidates.Payload.Listens { func (f Feedback) AsLove() models.Love {
sim := similarity.CompareTracks(listen.Track, AsTrack(c.TrackMetadata)) recordingMbid := models.MBID(f.RecordingMbid)
if sim >= trackSimilarityThreshold { track := f.TrackMetadata
return true, nil if track == nil {
track = &Track{}
}
love := models.Love{
UserName: f.UserName,
RecordingMbid: recordingMbid,
Created: time.Unix(f.Created, 0),
Track: track.AsTrack(),
}
if love.Track.RecordingMbid == "" {
love.Track.RecordingMbid = love.RecordingMbid
}
return love
}
func (t Track) AsTrack() models.Track {
track := models.Track{
TrackName: t.TrackName,
ReleaseName: t.ReleaseName,
ArtistNames: []string{t.ArtistName},
Duration: t.Duration(),
TrackNumber: t.TrackNumber(),
DiscNumber: t.DiscNumber(),
RecordingMbid: models.MBID(t.RecordingMbid()),
ReleaseMbid: models.MBID(t.ReleaseMbid()),
ReleaseGroupMbid: models.MBID(t.ReleaseGroupMbid()),
ISRC: t.ISRC(),
AdditionalInfo: t.AdditionalInfo,
}
if t.MbidMapping != nil && len(track.ArtistMbids) == 0 {
for _, artistMbid := range t.MbidMapping.ArtistMbids {
track.ArtistMbids = append(track.ArtistMbids, models.MBID(artistMbid))
} }
} }
return false, nil return track
} }

View file

@ -23,19 +23,15 @@ 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"
lbapi "go.uploadedlobster.com/scotty/internal/backends/listenbrainz" "go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/listenbrainz"
) )
func TestInitConfig(t *testing.T) { func TestFromConfig(t *testing.T) {
c := viper.New() config := viper.New()
c.Set("token", "thetoken") config.Set("token", "thetoken")
service := config.NewServiceConfig("test", c) backend := (&listenbrainz.ListenBrainzApiBackend{}).FromConfig(config)
backend := lbapi.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) {
@ -58,7 +54,7 @@ func TestListenBrainzListenAsListen(t *testing.T) {
}, },
}, },
} }
listen := lbapi.AsListen(lbListen) listen := lbListen.AsListen()
assert.Equal(t, time.Unix(1699289873, 0), listen.ListenedAt) assert.Equal(t, time.Unix(1699289873, 0), listen.ListenedAt)
assert.Equal(t, lbListen.UserName, listen.UserName) assert.Equal(t, lbListen.UserName, listen.UserName)
assert.Equal(t, time.Duration(413787*time.Millisecond), listen.Duration) assert.Equal(t, time.Duration(413787*time.Millisecond), listen.Duration)
@ -67,58 +63,58 @@ 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, mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMBID) assert.Equal(t, models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMbid)
assert.Equal(t, mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMBID) assert.Equal(t, models.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMbid)
assert.Equal(t, mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMBID) assert.Equal(t, models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMbid)
assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC) assert.Equal(t, "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 := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12") recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
releaseMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68") releaseMbid := "d7f22677-9803-4d21-ba42-081b633a6f68"
artistMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68") artistMbid := "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: []mbtypes.MBID{artistMBID}, ArtistMbids: []string{artistMbid},
}, },
}, },
} }
love := lbapi.AsLove(feedback) 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(feedback.UserName, love.UserName) assert.Equal(feedback.UserName, love.UserName)
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(recordingMBID, love.RecordingMBID) assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
assert.Equal(recordingMBID, love.Track.RecordingMBID) assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
assert.Equal(releaseMBID, love.Track.ReleaseMBID) assert.Equal(models.MBID(releaseMbid), love.Track.ReleaseMbid)
require.Len(t, love.Track.ArtistMBIDs, 1) require.Len(t, love.Track.ArtistMbids, 1)
assert.Equal(artistMBID, love.Track.ArtistMBIDs[0]) assert.Equal(models.MBID(artistMbid), love.Track.ArtistMbids[0])
} }
func TestListenBrainzPartialFeedbackAsLove(t *testing.T) { func TestListenBrainzPartialFeedbackAsLove(t *testing.T) {
recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12") recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"
feedback := listenbrainz.Feedback{ feedback := listenbrainz.Feedback{
Created: 1699859066, Created: 1699859066,
RecordingMBID: recordingMBID, RecordingMbid: recordingMbid,
Score: 1, Score: 1,
} }
love := lbapi.AsLove(feedback) 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(recordingMBID, love.RecordingMBID) assert.Equal(models.MBID(recordingMbid), love.RecordingMbid)
assert.Equal(recordingMBID, love.Track.RecordingMBID) assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid)
assert.Empty(love.Track.TrackName) assert.Empty(love.Track.TrackName)
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -25,7 +25,6 @@ import (
"strconv" "strconv"
"time" "time"
"go.uploadedlobster.com/mbtypes"
"golang.org/x/exp/constraints" "golang.org/x/exp/constraints"
) )
@ -37,7 +36,6 @@ type GetListenPayload struct {
Count int `json:"count"` Count int `json:"count"`
UserName string `json:"user_id"` UserName string `json:"user_id"`
LatestListenTimestamp int64 `json:"latest_listen_ts"` LatestListenTimestamp int64 `json:"latest_listen_ts"`
OldestListenTimestamp int64 `json:"oldest_listen_ts"`
Listens []Listen `json:"listens"` Listens []Listen `json:"listens"`
} }
@ -55,9 +53,9 @@ type ListenSubmission struct {
} }
type Listen struct { type Listen struct {
InsertedAt float64 `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,24 +64,21 @@ type Track struct {
TrackName string `json:"track_name,omitempty"` TrackName string `json:"track_name,omitempty"`
ArtistName string `json:"artist_name,omitempty"` ArtistName string `json:"artist_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"` ReleaseName string `json:"release_name,omitempty"`
RecordingMSID string `json:"recording_msid,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 {
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids,omitempty"`
Artists []Artist `json:"artists,omitempty"`
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"`
RecordingName string `json:"recording_name,omitempty"` RecordingName string `json:"recording_name,omitempty"`
ReleaseMBID mbtypes.MBID `json:"release_mbid,omitempty"` RecordingMbid string `json:"recording_mbid,omitempty"`
CAAID int `json:"caa_id,omitempty"` ReleaseMbid string `json:"release_mbid,omitempty"`
CAAReleaseMBID mbtypes.MBID `json:"caa_release_mbid,omitempty"` ArtistMbids []string `json:"artist_mbids,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 mbtypes.MBID `json:"artist_mbid,omitempty"` ArtistMbid string `json:"artist_mbid,omitempty"`
JoinPhrase string `json:"join_phrase,omitempty"` JoinPhrase string `json:"join_phrase,omitempty"`
} }
@ -96,8 +91,8 @@ type GetFeedbackResult struct {
type Feedback struct { type Feedback struct {
Created int64 `json:"created,omitempty"` Created int64 `json:"created,omitempty"`
RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"` RecordingMbid string `json:"recording_mbid,omitempty"`
RecordingMSID mbtypes.MBID `json:"recording_msid,omitempty"` RecordingMsid string `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"`
@ -107,47 +102,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 mbtypes.MBID `json:"recording_mbid"` RecordingMbid string `json:"recording_mbid"`
ReleaseMBID mbtypes.MBID `json:"release_mbid"` ReleaseMbid string `json:"release_mbid"`
ArtistMBIDs []mbtypes.MBID `json:"artist_mbids"` ArtistMbids []string `json:"artist_mbids"`
}
type RecordingMetadataRequest struct {
RecordingMBIDs []mbtypes.MBID `json:"recording_mbids"`
Includes string `json:"inc,omitempty"`
}
// Result for a recording metadata lookup
type RecordingMetadataResult map[mbtypes.MBID]RecordingMetadata
type RecordingMetadata struct {
Artist struct {
Name string `json:"name"`
ArtistCreditID int `json:"artist_credit_id"`
Artists []struct {
Name string `json:"name"`
Area string `json:"area"`
ArtistMBID mbtypes.MBID `json:"artist_mbid"`
JoinPhrase string `json:"join_phrase"`
BeginYear int `json:"begin_year"`
Type string `json:"type"`
// todo rels
} `json:"artists"`
} `json:"artist"`
Recording struct {
Name string `json:"name"`
Length int `json:"length"`
// TODO rels
} `json:"recording"`
Release struct {
Name string `json:"name"`
AlbumArtistName string `json:"album_artist_name"`
Year int `json:"year"`
MBID mbtypes.MBID `json:"mbid"`
ReleaseGroupMBID mbtypes.MBID `json:"release_group_mbid"`
CAAID int `json:"caa_id"`
CAAReleaseMBID mbtypes.MBID `json:"caa_release_mbid"`
} `json:"release"`
} }
type StatusResult struct { type StatusResult struct {
@ -200,30 +157,30 @@ func (t Track) DiscNumber() int {
return 0 return 0
} }
func (t Track) ISRC() mbtypes.ISRC { func (t Track) ISRC() string {
return mbtypes.ISRC(tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc")) return tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc")
} }
func (t Track) RecordingMBID() mbtypes.MBID { func (t Track) RecordingMbid() string {
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid")) 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() mbtypes.MBID { func (t Track) ReleaseMbid() string {
mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid")) 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() mbtypes.MBID { func (t Track) ReleaseGroupMbid() string {
return mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid")) return 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 {

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -28,8 +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/listenbrainz"
) )
func TestTrackDurationMillisecondsInt(t *testing.T) { func TestTrackDurationMillisecondsInt(t *testing.T) {
@ -131,50 +130,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 := mbtypes.ISRC("TCAEJ1934417") expected := "TCAEJ1934417"
track := listenbrainz.Track{ track := listenbrainz.Track{
AdditionalInfo: map[string]any{ AdditionalInfo: map[string]any{
"isrc": string(expected), "isrc": expected,
}, },
} }
assert.Equal(t, expected, track.ISRC()) assert.Equal(t, expected, track.ISRC())
} }
func TestTrackRecordingMBID(t *testing.T) { func TestTrackRecordingMbid(t *testing.T) {
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b") expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
track := listenbrainz.Track{ track := listenbrainz.Track{
AdditionalInfo: map[string]any{ AdditionalInfo: map[string]any{
"recording_mbid": string(expected), "recording_mbid": expected,
}, },
} }
assert.Equal(t, expected, track.RecordingMBID()) assert.Equal(t, expected, track.RecordingMbid())
} }
func TestTrackReleaseMBID(t *testing.T) { func TestTrackReleaseMbid(t *testing.T) {
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b") expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
track := listenbrainz.Track{ track := listenbrainz.Track{
AdditionalInfo: map[string]any{ AdditionalInfo: map[string]any{
"release_mbid": string(expected), "release_mbid": expected,
}, },
} }
assert.Equal(t, expected, track.ReleaseMBID()) assert.Equal(t, expected, track.ReleaseMbid())
} }
func TestReleaseGroupMBID(t *testing.T) { func TestReleaseGroupMbid(t *testing.T) {
expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b") expected := "e02cc1c3-93fd-4e24-8b77-325060de920b"
track := listenbrainz.Track{ track := listenbrainz.Track{
AdditionalInfo: map[string]any{ AdditionalInfo: map[string]any{
"release_group_mbid": string(expected), "release_group_mbid": 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)

View file

@ -2,7 +2,6 @@
"payload": { "payload": {
"count": 2, "count": 2,
"latest_listen_ts": 1699718723, "latest_listen_ts": 1699718723,
"oldest_listen_ts": 1152911863,
"listens": [ "listens": [
{ {
"inserted_at": 1699719320, "inserted_at": 1699719320,

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package maloja package maloja
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
@ -33,26 +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(ctx context.Context, 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().
SetContext(ctx).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"page": strconv.Itoa(page), "page": strconv.Itoa(page),
"perpage": strconv.Itoa(perPage), "perpage": strconv.Itoa(perPage),
@ -60,23 +58,22 @@ func (c Client) GetScrobbles(ctx context.Context, page int, perPage int) (result
SetResult(&result). SetResult(&result).
Get(path) Get(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
err = errors.New(response.String()) err = errors.New(response.String())
return return
} }
return return
} }
func (c Client) NewScrobble(ctx context.Context, 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().
SetContext(ctx).
SetBody(scrobble). SetBody(scrobble).
SetResult(&result). SetResult(&result).
Post(path) Post(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
err = errors.New(response.String()) err = errors.New(response.String())
return return
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package maloja_test package maloja_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
@ -33,24 +32,23 @@ 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")
ctx := context.Background() result, err := client.GetScrobbles(0, 2)
result, err := client.GetScrobbles(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -62,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 {
@ -71,19 +69,18 @@ func TestNewScrobble(t *testing.T) {
url := server + "/apis/mlj_1/newscrobble" url := server + "/apis/mlj_1/newscrobble"
httpmock.RegisterResponder("POST", url, responder) httpmock.RegisterResponder("POST", url, responder)
ctx := context.Background()
scrobble := maloja.NewScrobble{ scrobble := maloja.NewScrobble{
Title: "Oweynagat", Title: "Oweynagat",
Artist: "Dool", Artist: "Dool",
Time: 1699574369, Time: 1699574369,
} }
result, err := client.NewScrobble(ctx, scrobble) result, err := client.NewScrobble(scrobble)
require.NoError(t, err) require.NoError(t, err)
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))

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -17,14 +17,12 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package maloja package maloja
import ( import (
"context"
"errors" "errors"
"sort" "sort"
"strings" "strings"
"time" "time"
"go.uploadedlobster.com/scotty/internal/config" "github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
) )
@ -35,57 +33,33 @@ type MalojaApiBackend struct {
func (b *MalojaApiBackend) Name() string { return "maloja" } func (b *MalojaApiBackend) Name() string { return "maloja" }
func (b *MalojaApiBackend) Close() {} func (b *MalojaApiBackend) FromConfig(config *viper.Viper) models.Backend {
func (b *MalojaApiBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "server-url",
Label: i18n.Tr("Server URL"),
Type: models.String,
}, {
Name: "token",
Label: i18n.Tr("Access token"),
Type: models.Secret,
}, {
Name: "nofix",
Label: i18n.Tr("Disable auto correction of submitted listens"),
Type: models.Bool,
Default: "false",
}}
}
func (b *MalojaApiBackend) InitConfig(config *config.ServiceConfig) error {
b.client = NewClient( b.client = NewClient(
config.GetString("server-url"), config.GetString("server-url"),
config.GetString("token"), config.GetString("token"),
) )
b.nofix = config.GetBool("nofix", false) b.nofix = config.GetBool("nofix")
return nil return b
} }
func (b *MalojaApiBackend) StartImport() error { return nil } func (b *MalojaApiBackend) StartImport() error { return nil }
func (b *MalojaApiBackend) FinishImport(result *models.ImportResult) error { func (b *MalojaApiBackend) FinishImport() error { return nil }
return nil
}
func (b *MalojaApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *MalojaApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
page := 0 page := 0
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
defer close(results)
// We need to gather the full list of listens in order to sort them // We need to gather the full list of listens in order to sort them
listens := make(models.ListensList, 0, 2*perPage) listens := make(models.ListensList, 0, 2*perPage)
p := models.TransferProgress{ p := models.Progress{Total: int64(perPage)}
Export: &models.Progress{
Total: int64(perPage),
},
}
out: out:
for { for {
result, err := b.client.GetScrobbles(ctx, page, perPage) result, err := b.client.GetScrobbles(page, perPage)
if err != nil { if err != nil {
p.Export.Abort() progress <- p.Complete()
progress <- p
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
@ -97,28 +71,25 @@ out:
for _, scrobble := range result.List { for _, scrobble := range result.List {
if scrobble.ListenedAt > oldestTimestamp.Unix() { if scrobble.ListenedAt > oldestTimestamp.Unix() {
p.Export.Elapsed += 1 p.Elapsed += 1
listens = append(listens, scrobble.AsListen()) listens = append(listens, scrobble.AsListen())
} else { } else {
break out break out
} }
} }
p.Export.TotalItems = len(listens) p.Total += int64(perPage)
p.Export.Total += int64(perPage)
progress <- p progress <- p
page += 1 page += 1
} }
sort.Sort(listens) sort.Sort(listens)
p.Export.Complete() progress <- p.Complete()
progress <- p results <- models.ListensResult{Listens: listens}
results <- models.ListensResult{Items: listens}
} }
func (b *MalojaApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
p := models.TransferProgress{}.FromImportResult(importResult, false) for _, listen := range export.Listens {
for _, listen := range export.Items {
scrobble := NewScrobble{ scrobble := NewScrobble{
Title: listen.TrackName, Title: listen.TrackName,
Artists: listen.ArtistNames, Artists: listen.ArtistNames,
@ -129,7 +100,7 @@ func (b *MalojaApiBackend) ImportListens(ctx context.Context, export models.List
Nofix: b.nofix, Nofix: b.nofix,
} }
resp, err := b.client.NewScrobble(ctx, scrobble) resp, err := b.client.NewScrobble(scrobble)
if err != nil { if err != nil {
return importResult, err return importResult, err
} else if resp.Status != "success" { } else if resp.Status != "success" {
@ -138,7 +109,7 @@ func (b *MalojaApiBackend) ImportListens(ctx context.Context, export models.List
importResult.UpdateTimestamp(listen.ListenedAt) importResult.UpdateTimestamp(listen.ListenedAt)
importResult.ImportCount += 1 importResult.ImportCount += 1
progress <- p.FromImportResult(importResult, false) progress <- models.Progress{}.FromImportResult(importResult)
} }
return importResult, nil return importResult, nil

View file

@ -23,16 +23,13 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"go.uploadedlobster.com/scotty/internal/backends/maloja" "go.uploadedlobster.com/scotty/internal/backends/maloja"
"go.uploadedlobster.com/scotty/internal/config"
) )
func TestInitConfig(t *testing.T) { func TestFromConfig(t *testing.T) {
c := viper.New() config := viper.New()
c.Set("token", "thetoken") config.Set("token", "thetoken")
service := config.NewServiceConfig("test", c) backend := (&maloja.MalojaApiBackend{}).FromConfig(config)
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) {

View file

@ -0,0 +1,110 @@
/*
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 backends
import "go.uploadedlobster.com/scotty/internal/models"
func ProcessListensImports(importer models.ListensImport, results chan models.ListensResult, out chan models.ImportResult, progress chan models.Progress) {
defer close(out)
defer close(progress)
result := models.ImportResult{}
err := importer.StartImport()
if err != nil {
handleError(result, err, out, progress)
return
}
for exportResult := range results {
if exportResult.Error != nil {
handleError(result, exportResult.Error, out, progress)
return
}
if exportResult.Total > 0 {
result.TotalCount = exportResult.Total
} else {
result.TotalCount += len(exportResult.Listens)
}
importResult, err := importer.ImportListens(exportResult, result, progress)
if err != nil {
handleError(importResult, err, out, progress)
return
}
result.Update(importResult)
progress <- models.Progress{}.FromImportResult(result)
}
err = importer.FinishImport()
if err != nil {
handleError(result, err, out, progress)
return
}
progress <- models.Progress{}.FromImportResult(result).Complete()
out <- result
}
func ProcessLovesImports(importer models.LovesImport, results chan models.LovesResult, out chan models.ImportResult, progress chan models.Progress) {
defer close(out)
defer close(progress)
result := models.ImportResult{}
err := importer.StartImport()
if err != nil {
handleError(result, err, out, progress)
return
}
for exportResult := range results {
if exportResult.Error != nil {
handleError(result, exportResult.Error, out, progress)
return
}
if exportResult.Total > 0 {
result.TotalCount = exportResult.Total
} else {
result.TotalCount += len(exportResult.Loves)
}
importResult, err := importer.ImportLoves(exportResult, result, progress)
if err != nil {
handleError(importResult, err, out, progress)
return
}
result.Update(importResult)
progress <- models.Progress{}.FromImportResult(result)
}
err = importer.FinishImport()
if err != nil {
handleError(result, err, out, progress)
return
}
progress <- models.Progress{}.FromImportResult(result).Complete()
out <- result
}
func handleError(result models.ImportResult, err error, out chan models.ImportResult, progress chan models.Progress) {
result.Error = err
progress <- models.Progress{}.FromImportResult(result).Complete()
out <- result
}

View file

@ -0,0 +1,212 @@
/*
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"
"errors"
"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, errors.New(fmt.Sprintf(
"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 = errors.New(fmt.Sprintf("Unexpected header (line %v)", i))
} else {
text := string(line)
if i == 0 && !strings.HasPrefix(text, "#AUDIOSCROBBLER/1") {
err = errors.New(fmt.Sprintf("Not a scrobbler log file"))
}
timezone, found := strings.CutPrefix(text, "#TZ/")
if strings.HasPrefix(text, "#TZ/") {
log.Timezone = timezone
}
client, found := strings.CutPrefix(text, "#CLIENT/")
if found {
log.Client = client
}
}
if err != nil {
return err
}
}
return nil
}
func WriteHeader(writer io.Writer, log *ScrobblerLog) error {
headers := []string{
"#AUDIOSCROBBLER/1.1\n",
"#TZ/" + log.Timezone + "\n",
"#CLIENT/" + log.Client + "\n",
}
for _, line := range headers {
_, err := writer.Write([]byte(line))
if err != nil {
return err
}
}
return nil
}
func rowToListen(row []string, client string) (models.Listen, error) {
var listen models.Listen
trackNumber, err := strconv.Atoi(row[3])
if err != nil {
return listen, err
}
duration, err := strconv.Atoi(row[4])
if err != nil {
return listen, err
}
timestamp, err := strconv.Atoi(row[6])
if err != nil {
return listen, err
}
listen = models.Listen{
Track: models.Track{
ArtistNames: []string{row[0]},
ReleaseName: row[1],
TrackName: row[2],
TrackNumber: trackNumber,
Duration: time.Duration(duration * int(time.Second)),
AdditionalInfo: models.AdditionalInfo{
"rockbox_rating": row[5],
"media_player": client,
},
},
ListenedAt: time.Unix(int64(timestamp), 0),
}
if len(row) > 7 {
listen.Track.RecordingMbid = models.MBID(row[7])
}
return listen, nil
}

View file

@ -0,0 +1,128 @@
/*
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_test
import (
"bufio"
"bytes"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
"go.uploadedlobster.com/scotty/internal/models"
)
var testScrobblerLog = `#AUDIOSCROBBLER/1.1
#TZ/UNKNOWN
#CLIENT/Rockbox sansaclipplus $Revision$
Özcan Deniz Ses ve Ayrilik Sevdanin rengi (sipacik) byMrTurkey 5 306 L 1260342084
Özcan Deniz Hediye 2@V@7 Bir Dudaktan 1 210 L 1260342633
KOMPROMAT Traum und Existenz Possession 1 220 L 1260357290 d66b1084-b2ae-4661-8382-5d0c1c484b6d
Kraftwerk Trans-Europe Express The Hall of Mirrors 2 474 S 1260358000 385ba9e9-626d-4750-a607-58e541dca78e
Teeth Agency You Don't Have To Live In Pain Wolfs Jam 2 107 L 1260359404 1262beaf-19f8-4534-b9ed-7eef9ca8e83f
`
func TestParser(t *testing.T) {
assert := assert.New(t)
data := bytes.NewBufferString(testScrobblerLog)
result, err := scrobblerlog.Parse(data, true)
require.NoError(t, err)
assert.Equal("UNKNOWN", result.Timezone)
assert.Equal("Rockbox sansaclipplus $Revision$", result.Client)
assert.Len(result.Listens, 5)
listen1 := result.Listens[0]
assert.Equal("Özcan Deniz", listen1.ArtistName())
assert.Equal("Ses ve Ayrilik", listen1.ReleaseName)
assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", listen1.TrackName)
assert.Equal(5, listen1.TrackNumber)
assert.Equal(time.Duration(306*time.Second), listen1.Duration)
assert.Equal("L", listen1.AdditionalInfo["rockbox_rating"])
assert.Equal(time.Unix(1260342084, 0), listen1.ListenedAt)
assert.Equal(models.MBID(""), listen1.RecordingMbid)
listen4 := result.Listens[3]
assert.Equal("S", listen4.AdditionalInfo["rockbox_rating"])
assert.Equal(models.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), listen4.RecordingMbid)
}
func TestParserExcludeSkipped(t *testing.T) {
assert := assert.New(t)
data := bytes.NewBufferString(testScrobblerLog)
result, err := scrobblerlog.Parse(data, false)
require.NoError(t, err)
assert.Len(result.Listens, 4)
listen4 := result.Listens[3]
assert.Equal("L", listen4.AdditionalInfo["rockbox_rating"])
assert.Equal(models.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), listen4.RecordingMbid)
}
func TestWrite(t *testing.T) {
assert := assert.New(t)
data := make([]byte, 0, 10)
buffer := bytes.NewBuffer(data)
log := scrobblerlog.ScrobblerLog{
Timezone: "Unknown",
Client: "Rockbox foo $Revision$",
Listens: []models.Listen{
{
ListenedAt: time.Unix(1699572072, 0),
Track: models.Track{
ArtistNames: []string{"Prinzhorn Dance School"},
ReleaseName: "Home Economics",
TrackName: "Reign",
TrackNumber: 1,
Duration: 271 * time.Second,
RecordingMbid: models.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"),
AdditionalInfo: models.AdditionalInfo{"rockbox_rating": "L"},
},
},
},
}
err := scrobblerlog.WriteHeader(buffer, &log)
require.NoError(t, err)
lastTimestamp, err := scrobblerlog.Write(buffer, log.Listens)
require.NoError(t, err)
result := string(buffer.Bytes())
lines := strings.Split(result, "\n")
assert.Equal(5, len(lines))
assert.Equal("#AUDIOSCROBBLER/1.1", lines[0])
assert.Equal("#TZ/Unknown", lines[1])
assert.Equal("#CLIENT/Rockbox foo $Revision$", lines[2])
assert.Equal(
"Prinzhorn Dance School\tHome Economics\tReign\t1\t271\tL\t1699572072\tb59cf4e7-caee-4019-a844-79d2c58d4dff",
lines[3])
assert.Equal("", lines[4])
assert.Equal(time.Unix(1699572072, 0), lastTimestamp)
}
func TestReadHeader(t *testing.T) {
data := bytes.NewBufferString(testScrobblerLog)
reader := bufio.NewReader(data)
log := scrobblerlog.ScrobblerLog{}
err := scrobblerlog.ReadHeader(reader, &log)
assert.NoError(t, err)
assert.Equal(t, log.Timezone, "UNKNOWN")
assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$")
assert.Empty(t, log.Listens)
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -17,72 +17,37 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package scrobblerlog package scrobblerlog
import ( import (
"context" "bufio"
"fmt"
"os" "os"
"sort" "sort"
"strings"
"time" "time"
"go.uploadedlobster.com/scotty/internal/config" "github.com/spf13/viper"
"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
ignoreSkipped bool includeSkipped bool
append bool append bool
file *os.File file *os.File
timezone *time.Location log ScrobblerLog
log scrobblerlog.ScrobblerLog
} }
func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" } func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" }
func (b *ScrobblerLogBackend) Close() {} func (b *ScrobblerLogBackend) FromConfig(config *viper.Viper) models.Backend {
func (b *ScrobblerLogBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "file-path",
Label: i18n.Tr("File path"),
Type: models.String,
}, {
Name: "ignore-skipped",
Label: i18n.Tr("Ignore skipped listens"),
Type: models.Bool,
Default: "true",
}, {
Name: "append",
Label: i18n.Tr("Append to file"),
Type: models.Bool,
Default: "true",
}, {
Name: "time-zone",
Label: i18n.Tr("Specify a time zone for the listen timestamps"),
Type: models.String,
}}
}
func (b *ScrobblerLogBackend) InitConfig(config *config.ServiceConfig) error {
b.filePath = config.GetString("file-path") b.filePath = config.GetString("file-path")
b.ignoreSkipped = config.GetBool("ignore-skipped", true) b.includeSkipped = config.GetBool("include-skipped")
b.append = config.GetBool("append", true) b.append = true
b.log = scrobblerlog.ScrobblerLog{ if config.IsSet("append") {
TZ: scrobblerlog.TimezoneUTC, b.append = config.GetBool("append")
}
b.log = ScrobblerLog{
Timezone: "UNKNOWN",
Client: "Rockbox unknown $Revision$", Client: "Rockbox unknown $Revision$",
} }
return b
if timezone := config.GetString("time-zone"); timezone != "" {
location, err := time.LoadLocation(timezone)
if err != nil {
return fmt.Errorf("Invalid time-zone %q: %w", timezone, err)
}
b.log.FallbackTimezone = location
}
return nil
} }
func (b *ScrobblerLogBackend) StartImport() error { func (b *ScrobblerLogBackend) StartImport() error {
@ -107,18 +72,19 @@ func (b *ScrobblerLogBackend) StartImport() error {
b.append = false b.append = false
} else { } else {
// Verify existing file is a scrobbler log // Verify existing file is a scrobbler log
if err = b.log.ReadHeader(file); err != nil { reader := bufio.NewReader(file)
err = ReadHeader(reader, &b.log)
if err != nil {
file.Close() file.Close()
return err return err
} }
if _, err = file.Seek(0, 2); err != nil { file.Seek(0, 2)
return err
}
} }
} }
if !b.append { if !b.append {
if err = b.log.WriteHeader(file); err != nil { err = WriteHeader(file, &b.log)
if err != nil {
file.Close() file.Close()
return err return err
} }
@ -128,98 +94,43 @@ func (b *ScrobblerLogBackend) StartImport() error {
return nil return nil
} }
func (b *ScrobblerLogBackend) FinishImport(result *models.ImportResult) error { func (b *ScrobblerLogBackend) FinishImport() error {
return b.file.Close() return b.file.Close()
} }
func (b *ScrobblerLogBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
defer close(results)
file, err := os.Open(b.filePath) file, err := os.Open(b.filePath)
p := models.TransferProgress{
Export: &models.Progress{},
}
if err != nil { if err != nil {
p.Export.Abort() progress <- models.Progress{}.Complete()
progress <- p
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
defer file.Close() defer file.Close()
err = b.log.Parse(file, b.ignoreSkipped) log, err := Parse(file, b.includeSkipped)
if err != nil { if err != nil {
p.Export.Abort() progress <- models.Progress{}.Complete()
progress <- p
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
listens := make(models.ListensList, 0, len(b.log.Records)) listens := log.Listens.NewerThan(oldestTimestamp)
client := strings.Split(b.log.Client, " ")[0]
p.Export.Total = int64(len(b.log.Records))
for _, record := range models.IterExportProgress(b.log.Records, &p, progress) {
listen := recordToListen(record, client)
if listen.ListenedAt.After(oldestTimestamp) {
listens = append(listens, recordToListen(record, client))
p.Export.TotalItems += 1
}
}
sort.Sort(listens) sort.Sort(listens)
results <- models.ListensResult{Items: listens} progress <- models.Progress{Elapsed: int64(len(listens))}.Complete()
results <- models.ListensResult{Listens: listens}
} }
func (b *ScrobblerLogBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) {
p := models.TransferProgress{}.FromImportResult(importResult, false) lastTimestamp, err := Write(b.file, export.Listens)
records := make([]scrobblerlog.Record, len(export.Items))
for i, listen := range models.IterImportProgress(export.Items, &p, progress) {
records[i] = listenToRecord(listen)
}
lastTimestamp, err := b.log.Append(b.file, records)
if err != nil { if err != nil {
return importResult, err return importResult, err
} }
importResult.UpdateTimestamp(lastTimestamp) importResult.UpdateTimestamp(lastTimestamp)
importResult.ImportCount += len(export.Items) importResult.ImportCount += len(export.Listens)
progress <- models.Progress{}.FromImportResult(importResult)
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,
}
}

View file

@ -22,24 +22,11 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"go.uploadedlobster.com/scotty/internal/backends/scrobblerlog" "go.uploadedlobster.com/scotty/internal/backends/scrobblerlog"
"go.uploadedlobster.com/scotty/internal/config"
) )
func TestInitConfig(t *testing.T) { func TestFromConfig(t *testing.T) {
c := viper.New() config := viper.New()
c.Set("token", "thetoken") config.Set("token", "thetoken")
service := config.NewServiceConfig("test", c) backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(config)
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"`)
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -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,22 +55,21 @@ func NewClient(token oauth2.TokenSource) Client {
ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After") ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After")
return Client{ return Client{
HTTPClient: client, HttpClient: client,
} }
} }
func (c Client) RecentlyPlayedAfter(ctx context.Context, after time.Time, limit int) (RecentlyPlayedResult, error) { func (c Client) RecentlyPlayedAfter(after time.Time, limit int) (RecentlyPlayedResult, error) {
return c.recentlyPlayed(ctx, &after, nil, limit) return c.recentlyPlayed(&after, nil, limit)
} }
func (c Client) RecentlyPlayedBefore(ctx context.Context, before time.Time, limit int) (RecentlyPlayedResult, error) { func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlayedResult, error) {
return c.recentlyPlayed(ctx, nil, &before, limit) return c.recentlyPlayed(nil, &before, limit)
} }
func (c Client) recentlyPlayed(ctx context.Context, 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().
SetContext(ctx).
SetQueryParam("limit", strconv.Itoa(limit)). SetQueryParam("limit", strconv.Itoa(limit)).
SetResult(&result) SetResult(&result)
if after != nil { if after != nil {
@ -80,16 +79,15 @@ func (c Client) recentlyPlayed(ctx context.Context, after *time.Time, before *ti
} }
response, err := request.Get(path) response, err := request.Get(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
err = errors.New(response.String()) err = errors.New(response.String())
} }
return return
} }
func (c Client) UserTracks(ctx context.Context, 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().
SetContext(ctx).
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"offset": strconv.Itoa(offset), "offset": strconv.Itoa(offset),
"limit": strconv.Itoa(limit), "limit": strconv.Itoa(limit),
@ -97,7 +95,7 @@ func (c Client) UserTracks(ctx context.Context, offset int, limit int) (result T
SetResult(&result). SetResult(&result).
Get(path) Get(path)
if !response.IsSuccess() { if response.StatusCode() != 200 {
err = errors.New(response.String()) err = errors.New(response.String())
} }
return return

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,7 +22,6 @@ THE SOFTWARE.
package spotify_test package spotify_test
import ( import (
"context"
"net/http" "net/http"
"testing" "testing"
"time" "time"
@ -44,12 +43,11 @@ 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")
ctx := context.Background() result, err := client.RecentlyPlayedAfter(time.Now(), 3)
result, err := client.RecentlyPlayedAfter(ctx, time.Now(), 3)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -65,12 +63,11 @@ 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")
ctx := context.Background() result, err := client.UserTracks(0, 2)
result, err := client.UserTracks(ctx, 0, 2)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)
@ -82,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))

View file

@ -22,8 +22,6 @@ 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"`
@ -58,7 +56,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"`
@ -69,14 +67,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"`
@ -85,32 +83,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 mbtypes.ISRC `json:"isrc"` ISRC string `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"`
} }

View file

@ -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,12 +32,11 @@ 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(testRecentlyPlayed, &result) err = json.Unmarshal(data, &result)
require.NoError(t, err) require.NoError(t, err)
assert := assert.New(t) assert := assert.New(t)

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -18,16 +18,14 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package spotify package spotify
import ( import (
"context"
"math" "math"
"net/url" "net/url"
"sort" "sort"
"strconv" "strconv"
"time" "time"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/auth" "go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/spotify" "golang.org/x/oauth2/spotify"
@ -35,35 +33,21 @@ import (
type SpotifyApiBackend struct { type SpotifyApiBackend struct {
client Client client Client
clientID string clientId string
clientSecret string clientSecret string
} }
func (b *SpotifyApiBackend) Name() string { return "spotify" } func (b *SpotifyApiBackend) Name() string { return "spotify" }
func (b *SpotifyApiBackend) Close() {} func (b *SpotifyApiBackend) FromConfig(config *viper.Viper) models.Backend {
b.clientId = config.GetString("client-id")
func (b *SpotifyApiBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "client-id",
Label: i18n.Tr("Client ID"),
Type: models.String,
}, {
Name: "client-secret",
Label: i18n.Tr("Client secret"),
Type: models.Secret,
}}
}
func (b *SpotifyApiBackend) InitConfig(config *config.ServiceConfig) error {
b.clientID = config.GetString("client-id")
b.clientSecret = config.GetString("client-secret") b.clientSecret = config.GetString("client-secret")
return nil return b
} }
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",
@ -71,16 +55,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",
@ -88,7 +72,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,
} }
} }
@ -98,22 +82,20 @@ func (b *SpotifyApiBackend) OAuth2Setup(token oauth2.TokenSource) error {
return nil return nil
} }
func (b *SpotifyApiBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { func (b *SpotifyApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) {
startTime := time.Now() startTime := time.Now()
minTime := oldestTimestamp minTime := oldestTimestamp
totalDuration := startTime.Sub(oldestTimestamp) totalDuration := startTime.Sub(oldestTimestamp)
p := models.TransferProgress{
Export: &models.Progress{ defer close(results)
Total: int64(totalDuration.Seconds()),
}, p := models.Progress{Total: int64(totalDuration.Seconds())}
}
for { for {
result, err := b.client.RecentlyPlayedAfter(ctx, minTime, MaxItemsPerGet) result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet)
if err != nil { if err != nil {
p.Export.Abort() progress <- p.Complete()
progress <- p
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} }
@ -125,8 +107,7 @@ func (b *SpotifyApiBackend) ExportListens(ctx context.Context, oldestTimestamp t
// Set minTime to the newest returned listen // Set minTime to the newest returned listen
after, err := strconv.ParseInt(result.Cursors.After, 10, 64) after, err := strconv.ParseInt(result.Cursors.After, 10, 64)
if err != nil { if err != nil {
p.Export.Abort() progress <- p.Complete()
progress <- p
results <- models.ListensResult{Error: err} results <- models.ListensResult{Error: err}
return return
} else if after <= minTime.Unix() { } else if after <= minTime.Unix() {
@ -141,50 +122,45 @@ func (b *SpotifyApiBackend) ExportListens(ctx context.Context, oldestTimestamp t
break break
} }
listens := make(models.ListensList, 0, count) listens := make(models.ListensList, 0, len(result.Items))
for _, listen := range result.Items { for _, listen := range result.Items {
l := listen.AsListen() l := listen.AsListen()
if l.ListenedAt.After(oldestTimestamp) { if l.ListenedAt.Unix() > oldestTimestamp.Unix() {
listens = append(listens, l) listens = append(listens, l)
} else { } else {
// result contains listens older then oldestTimestamp // result contains listens older then oldestTimestamp,
// we can stop requesting more
break break
} }
} }
sort.Sort(listens) sort.Sort(listens)
p.Export.TotalItems += len(listens) p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds())
progress <- p progress <- p
results <- models.ListensResult{Items: listens, OldestTimestamp: minTime} results <- models.ListensResult{Listens: listens, OldestTimestamp: minTime}
} }
results <- models.ListensResult{OldestTimestamp: minTime} results <- models.ListensResult{OldestTimestamp: minTime}
p.Export.Complete() progress <- p.Complete()
progress <- p
} }
func (b *SpotifyApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *SpotifyApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
// Choose a high offset, we attempt to search the loves backwards starting // Choose a high offset, we attempt to search the loves backwards starting
// at the oldest one. // at the oldest one.
offset := math.MaxInt32 offset := math.MaxInt32
perPage := MaxItemsPerGet perPage := MaxItemsPerGet
p := models.TransferProgress{ defer close(results)
Export: &models.Progress{
Total: int64(perPage), p := models.Progress{Total: int64(perPage)}
}, var totalCount int
}
totalCount := 0
exportCount := 0
out: out:
for { for {
result, err := b.client.UserTracks(ctx, offset, perPage) result, err := b.client.UserTracks(offset, perPage)
if err != nil { if err != nil {
p.Export.Abort() progress <- p.Complete()
progress <- p
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }
@ -192,9 +168,12 @@ out:
// The offset was higher then the actual number of tracks. Adjust the offset // The offset was higher then the actual number of tracks. Adjust the offset
// and continue. // and continue.
if offset >= result.Total { if offset >= result.Total {
p.Export.Total = int64(result.Total) p.Total = int64(result.Total)
totalCount = result.Total totalCount = result.Total
offset = max(result.Total-perPage, 0) offset = result.Total - perPage
if offset < 0 {
offset = 0
}
continue continue
} }
@ -206,17 +185,17 @@ out:
loves := make(models.LovesList, 0, perPage) loves := make(models.LovesList, 0, perPage)
for _, track := range result.Items { for _, track := range result.Items {
love := track.AsLove() love := track.AsLove()
if love.Created.After(oldestTimestamp) { if love.Created.Unix() > oldestTimestamp.Unix() {
loves = append(loves, love) loves = append(loves, love)
} else { } else {
continue totalCount -= 1
break
} }
} }
exportCount += len(loves)
sort.Sort(loves) sort.Sort(loves)
results <- models.LovesResult{Items: loves, Total: totalCount} results <- models.LovesResult{Loves: loves, Total: totalCount}
p.Export.Elapsed += int64(count) p.Elapsed += int64(count)
progress <- p progress <- p
if offset <= 0 { if offset <= 0 {
@ -230,9 +209,7 @@ out:
} }
} }
results <- models.LovesResult{Total: exportCount} progress <- p.Complete()
p.Export.Complete()
progress <- p
} }
func (l Listen) AsListen() models.Listen { func (l Listen) AsListen() models.Listen {
@ -260,10 +237,10 @@ func (t Track) AsTrack() models.Track {
TrackName: t.Name, TrackName: t.Name,
ReleaseName: t.Album.Name, ReleaseName: t.Album.Name,
ArtistNames: make([]string, 0, len(t.Artists)), ArtistNames: make([]string, 0, len(t.Artists)),
Duration: time.Duration(t.DurationMs) * 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{},
} }
@ -276,30 +253,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
} }

View file

@ -18,39 +18,30 @@ 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"
) )
var ( func TestFromConfig(t *testing.T) {
//go:embed testdata/listen.json config := viper.New()
testListen []byte config.Set("client-id", "someclientid")
//go:embed testdata/track.json config.Set("client-secret", "someclientsecret")
testTrack []byte backend := (&spotify.SpotifyApiBackend{}).FromConfig(config)
) assert.IsType(t, &spotify.SpotifyApiBackend{}, backend)
func TestInitConfig(t *testing.T) {
c := viper.New()
c.Set("client-id", "someclientid")
c.Set("client-secret", "someclientsecret")
service := config.NewServiceConfig("test", c)
backend := spotify.SpotifyApiBackend{}
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(testListen, &spListen) err = json.Unmarshal(data, &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")
@ -61,7 +52,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, mbtypes.ISRC("DES561620801"), listen.ISRC) assert.Equal(t, "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"])
@ -72,8 +63,10 @@ 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(testTrack, &track) err = json.Unmarshal(data, &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")

View file

@ -1,82 +0,0 @@
/*
Copyright © 2025 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 (
"errors"
"sort"
"go.uploadedlobster.com/scotty/pkg/archive"
)
var historyFileGlobs = []string{
"Spotify Extended Streaming History/Streaming_History_Audio_*.json",
"Streaming_History_Audio_*.json",
}
// Access a Spotify history archive.
// This can be either the ZIP file as provided by Spotify
// or a directory where this was extracted to.
type HistoryArchive struct {
backend archive.ArchiveReader
}
// Open a Spotify history archive from file path.
func OpenHistoryArchive(path string) (*HistoryArchive, error) {
backend, err := archive.OpenArchive(path)
if err != nil {
return nil, err
}
return &HistoryArchive{backend: backend}, nil
}
func (h *HistoryArchive) GetHistoryFiles() ([]archive.FileInfo, error) {
for _, glob := range historyFileGlobs {
files, err := h.backend.Glob(glob)
if err != nil {
return nil, err
}
if len(files) > 0 {
sort.Slice(files, func(i, j int) bool {
return files[i].Name < files[j].Name
})
return files, nil
}
}
// Found no files, fail
return nil, errors.New("found no history files in archive")
}
func readHistoryFile(f archive.OpenableFile) (StreamingHistory, error) {
file, err := f.Open()
if err != nil {
return nil, err
}
defer file.Close()
history := StreamingHistory{}
err = history.Read(file)
if err != nil {
return nil, err
}
return history, nil
}

View file

@ -1,110 +0,0 @@
/*
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) * 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)
}
}

View file

@ -1,130 +0,0 @@
/*
Copyright © 2023-2025 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 (
"context"
"sort"
"time"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
)
type SpotifyHistoryBackend struct {
archivePath string
ignoreIncognito bool
ignoreSkipped bool
skippedMinSeconds int
}
func (b *SpotifyHistoryBackend) Name() string { return "spotify-history" }
func (b *SpotifyHistoryBackend) Close() {}
func (b *SpotifyHistoryBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "archive-path",
Label: i18n.Tr("Archive path"),
Type: models.String,
Default: "./my_spotify_data_extended.zip",
MigrateFrom: "dir-path",
}, {
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.archivePath = config.GetString("archive-path")
// Backward compatibility
if b.archivePath == "" {
b.archivePath = 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(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) {
p := models.TransferProgress{
Export: &models.Progress{},
}
archive, err := OpenHistoryArchive(b.archivePath)
if err != nil {
p.Export.Abort()
progress <- p
results <- models.ListensResult{Error: err}
return
}
files, err := archive.GetHistoryFiles()
if err != nil {
p.Export.Abort()
progress <- p
results <- models.ListensResult{Error: err}
return
}
fileCount := int64(len(files))
p.Export.Total = fileCount
for i, f := range files {
if err := ctx.Err(); err != nil {
results <- models.ListensResult{Error: err}
p.Export.Abort()
progress <- p
return
}
history, err := readHistoryFile(f.File)
if err != nil {
results <- models.ListensResult{Error: err}
p.Export.Abort()
progress <- p
return
}
listens := history.AsListenList(ListenListOptions{
IgnoreIncognito: b.ignoreIncognito,
IgnoreSkipped: b.ignoreSkipped,
skippedMinSeconds: b.skippedMinSeconds,
})
sort.Sort(listens)
results <- models.ListensResult{Items: listens}
p.Export.Elapsed = int64(i)
p.Export.TotalItems += len(listens)
progress <- p
}
p.Export.Complete()
progress <- p
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -17,15 +17,12 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package subsonic package subsonic
import ( import (
"context"
"net/http" "net/http"
"sort" "sort"
"time" "time"
"github.com/supersonic-app/go-subsonic/subsonic" "github.com/delucks/go-subsonic"
"go.uploadedlobster.com/mbtypes" "github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/version" "go.uploadedlobster.com/scotty/internal/version"
) )
@ -37,25 +34,7 @@ type SubsonicApiBackend struct {
func (b *SubsonicApiBackend) Name() string { return "subsonic" } func (b *SubsonicApiBackend) Name() string { return "subsonic" }
func (b *SubsonicApiBackend) Close() {} func (b *SubsonicApiBackend) FromConfig(config *viper.Viper) models.Backend {
func (b *SubsonicApiBackend) Options() []models.BackendOption {
return []models.BackendOption{{
Name: "server-url",
Label: i18n.Tr("Server URL"),
Type: models.String,
}, {
Name: "username",
Label: i18n.Tr("User name"),
Type: models.String,
}, {
Name: "token",
Label: i18n.Tr("Access token"),
Type: models.Secret,
}}
}
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"),
@ -63,42 +42,35 @@ func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error {
ClientName: version.AppName, ClientName: version.AppName,
} }
b.password = config.GetString("token") b.password = config.GetString("token")
return nil return b
} }
func (b *SubsonicApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { func (b *SubsonicApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) {
defer close(results)
err := b.client.Authenticate(b.password) err := b.client.Authenticate(b.password)
p := models.TransferProgress{
Export: &models.Progress{},
}
if err != nil { if err != nil {
p.Export.Abort() progress <- models.Progress{}.Complete()
progress <- p
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }
starred, err := b.client.GetStarred2(map[string]string{}) starred, err := b.client.GetStarred2(map[string]string{})
if err != nil { if err != nil {
p.Export.Abort() progress <- models.Progress{}.Complete()
progress <- p
results <- models.LovesResult{Error: err} results <- models.LovesResult{Error: err}
return return
} }
loves := b.filterSongs(starred.Song, oldestTimestamp) progress <- models.Progress{Elapsed: int64(len(starred.Song))}.Complete()
p.Export.Total = int64(len(loves)) results <- models.LovesResult{Loves: b.filterSongs(starred.Song, oldestTimestamp)}
p.Export.Complete()
progress <- p
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 {
loves := make(models.LovesList, 0, len(songs)) loves := make(models.LovesList, len(songs))
for _, song := range songs { for i, song := range songs {
love := SongAsLove(*song, b.client.User) love := SongAsLove(*song, b.client.User)
if love.Created.After(oldestTimestamp) { if love.Created.Unix() > oldestTimestamp.Unix() {
loves = append(loves, love) loves[i] = love
} }
} }
@ -107,34 +79,19 @@ func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestam
} }
func SongAsLove(song subsonic.Child, username string) models.Love { func SongAsLove(song subsonic.Child, username string) models.Love {
recordingMBID := mbtypes.MBID(song.MusicBrainzID)
love := models.Love{ love := models.Love{
UserName: username, UserName: username,
Created: song.Starred, Created: song.Starred,
RecordingMBID: recordingMBID,
Track: models.Track{ Track: models.Track{
TrackName: song.Title, TrackName: song.Title,
ReleaseName: song.Album, ReleaseName: song.Album,
ArtistNames: []string{song.Artist}, ArtistNames: []string{song.Artist},
TrackNumber: song.Track, TrackNumber: song.Track,
DiscNumber: song.DiscNumber, DiscNumber: song.DiscNumber,
RecordingMBID: recordingMBID, Tags: []string{song.Genre},
Tags: []string{}, AdditionalInfo: map[string]any{},
AdditionalInfo: map[string]any{ Duration: time.Duration(song.Duration * int(time.Second)),
"subsonic_id": song.ID,
}, },
Duration: time.Duration(song.Duration) * 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

View file

@ -20,27 +20,23 @@ 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"
) )
func TestInitConfig(t *testing.T) { func TestFromConfig(t *testing.T) {
c := viper.New() config := viper.New()
c.Set("server-url", "https://subsonic.example.com") config.Set("server-url", "https://subsonic.example.com")
c.Set("token", "thetoken") config.Set("token", "thetoken")
service := config.NewServiceConfig("test", c) backend := (&subsonic.SubsonicApiBackend{}).FromConfig(config)
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",
@ -61,5 +57,4 @@ 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"])
} }

View file

@ -1,76 +0,0 @@
/*
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 cli
import (
"fmt"
"os"
"github.com/cli/browser"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/auth"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/storage"
"golang.org/x/oauth2"
)
func AuthenticationFlow(service config.ServiceConfig, backend auth.OAuth2Authenticator) {
redirectURL, err := backends.BuildRedirectURL(viper.GetViper(), backend.Name())
cobra.CheckErr(err)
// The backend must provide an authentication strategy
strategy := backend.OAuth2Strategy(redirectURL)
// use PKCE to protect against CSRF attacks
// https://www.ietf.org/archive/id/draft-ietf-oauth-security-topics-22.html#name-countermeasures-6
verifier := oauth2.GenerateVerifier()
state := auth.RandomState()
// Redirect user to consent page to ask for permission specified scopes.
authURL := strategy.AuthCodeURL(verifier, state)
// Start an HTTP server to listen for the response
responseChan := make(chan auth.CodeResponse)
auth.RunOauth2CallbackServer(*redirectURL, authURL.Param, responseChan)
// Open the URL
fmt.Println(i18n.Tr("Visit the URL for authorization: %v", authURL.URL))
err = browser.OpenURL(authURL.URL)
cobra.CheckErr(err)
// Retrieve the code from the authentication callback
code := <-responseChan
if code.State != authURL.State {
cobra.CompErrorln(i18n.Tr("Error: OAuth state mismatch"))
os.Exit(1)
}
// Exchange the code for the authentication token
tok, err := strategy.ExchangeToken(code, verifier)
cobra.CheckErr(err)
// Store the retrieved token in the database
db, err := storage.New(config.DatabasePath())
cobra.CheckErr(err)
err = db.SetOAuth2Token(service.Name, tok)
cobra.CheckErr(err)
fmt.Println(i18n.Tr("Access token received, you can use %v now.\n", service.Name))
}

View file

@ -1,152 +0,0 @@
/*
Copyright © 2023-2025 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 cli
import (
"context"
"sync"
"time"
"github.com/fatih/color"
"github.com/vbauerster/mpb/v8"
"github.com/vbauerster/mpb/v8/decor"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
)
type progressBarUpdater struct {
wg *sync.WaitGroup
progress *mpb.Progress
exportBar *mpb.Bar
importBar *mpb.Bar
updateChan chan models.TransferProgress
lastExportUpdate time.Time
totalItems int
importedItems int
}
func setupProgressBars(ctx context.Context, updateChan chan models.TransferProgress) progressBarUpdater {
wg := &sync.WaitGroup{}
p := mpb.NewWithContext(
ctx,
mpb.WithWaitGroup(wg),
mpb.WithOutput(color.Output),
// mpb.WithWidth(64),
mpb.WithAutoRefresh(),
)
u := progressBarUpdater{
wg: wg,
progress: p,
exportBar: initExportProgressBar(p, i18n.Tr("exporting")),
importBar: initImportProgressBar(p, i18n.Tr("importing")),
updateChan: updateChan,
}
go u.update()
return u
}
func (u *progressBarUpdater) close() {
close(u.updateChan)
u.progress.Wait()
}
func (u *progressBarUpdater) update() {
u.wg.Add(1)
defer u.wg.Done()
u.lastExportUpdate = time.Now()
for progress := range u.updateChan {
if progress.Export != nil {
u.updateExportProgress(progress.Export)
}
if progress.Import != nil {
if int64(u.totalItems) > progress.Import.Total {
progress.Import.Total = int64(u.totalItems)
}
u.updateImportProgress(progress.Import)
}
}
}
func (u *progressBarUpdater) updateExportProgress(progress *models.Progress) {
bar := u.exportBar
if progress.TotalItems != u.totalItems {
u.totalItems = progress.TotalItems
u.importBar.SetTotal(int64(u.totalItems), false)
}
if progress.Aborted {
bar.Abort(false)
return
}
oldIterTime := u.lastExportUpdate
u.lastExportUpdate = time.Now()
elapsedTime := u.lastExportUpdate.Sub(oldIterTime)
bar.EwmaSetCurrent(progress.Elapsed, elapsedTime)
bar.SetTotal(progress.Total, progress.Completed)
}
func (u *progressBarUpdater) updateImportProgress(progress *models.Progress) {
bar := u.importBar
if progress.Aborted {
bar.Abort(false)
return
}
bar.SetCurrent(progress.Elapsed)
bar.SetTotal(progress.Total, progress.Completed)
}
func initExportProgressBar(p *mpb.Progress, name string) *mpb.Bar {
return initProgressBar(p, name,
decor.EwmaETA(decor.ET_STYLE_GO, 0, decor.WC{C: decor.DSyncWidth}))
}
func initImportProgressBar(p *mpb.Progress, name string) *mpb.Bar {
return initProgressBar(p, name, decor.Counters(0, "%d / %d"))
}
func initProgressBar(p *mpb.Progress, name string, progressDecorator decor.Decorator) *mpb.Bar {
green := color.New(color.FgGreen).SprintFunc()
red := color.New(color.FgHiRed, color.Bold).SprintFunc()
return p.New(0,
mpb.BarStyle(),
mpb.PrependDecorators(
decor.Name(" "),
decor.OnComplete(
decor.Spinner(nil, decor.WC{W: 2, C: decor.DindentRight}),
green("✓ "),
),
decor.Name(name, decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.OnComplete(
decor.OnAbort(
progressDecorator,
red(i18n.Tr("aborted")),
),
i18n.Tr("done"),
),
decor.Name(" "),
),
)
}

View file

@ -1,108 +0,0 @@
/*
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 cli
import (
"fmt"
"strconv"
"github.com/manifoldco/promptui"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
)
func Prompt(opt models.BackendOption) (any, error) {
switch opt.Type {
case models.Bool:
return PromptBool(opt)
case models.Secret:
return PromptSecret(opt)
case models.String:
return PromptString(opt)
case models.Int:
return PromptInt(opt)
default:
return nil, fmt.Errorf("unknown prompt type %v", opt.Type)
}
}
func PromptString(opt models.BackendOption) (string, error) {
prompt := promptui.Prompt{
Label: opt.Label,
Validate: opt.Validate,
Default: opt.Default,
}
val, err := prompt.Run()
return val, err
}
func PromptSecret(opt models.BackendOption) (string, error) {
prompt := promptui.Prompt{
Label: opt.Label,
Validate: opt.Validate,
Default: opt.Default,
Mask: '*',
}
val, err := prompt.Run()
return val, err
}
func PromptBool(opt models.BackendOption) (bool, error) {
return PromptYesNo(opt.Label, opt.Default == "true")
}
func PromptYesNo(label string, defaultValue bool) (bool, error) {
yes := i18n.Tr("Yes")
no := i18n.Tr("No")
selected := 1
if defaultValue {
selected = 0
}
sel := promptui.Select{
Label: label,
Items: []string{yes, no},
CursorPos: selected,
}
_, val, err := sel.Run()
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)
}

View file

@ -1,103 +0,0 @@
/*
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 cli
import (
"errors"
"fmt"
"slices"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
)
func SelectService(cmd *cobra.Command) (config.ServiceConfig, error) {
// First try to load service from command line flag
service, err := GetServiceConfigFromFlag(cmd, "service")
if err == nil {
return service, nil
}
// Prompt the user to select a service
services := config.AllServicesAsList()
if len(services) == 0 {
err := errors.New(i18n.Tr("no existing service configurations"))
return config.ServiceConfig{}, err
}
sel := promptui.Select{
Label: i18n.Tr("Service"),
Items: services,
Size: 10,
}
i, _, err := sel.Run()
if err != nil {
return config.ServiceConfig{}, err
}
return services[i], nil
}
func SelectBackend(selected string) (string, error) {
backendList := backends.GetBackends()
i := slices.IndexFunc(backendList, func(b backends.BackendInfo) bool {
return b.Name == selected
})
sel := promptui.Select{
Label: i18n.Tr("Backend"),
Items: backendList,
CursorPos: i,
Size: 10,
}
_, backend, err := sel.Run()
return backend, err
}
func PromptExtraOptions(config config.ServiceConfig) (config.ServiceConfig, error) {
backend, err := backends.BackendByName(config.Backend)
if err != nil {
return config, err
}
opts := backend.Options()
if opts == nil {
return config, nil
}
values := make(map[string]any, len(opts))
for _, opt := range opts {
// Use current value as default
current, exists := config.ConfigValues[opt.Name]
if exists {
opt.Default = fmt.Sprintf("%v", current)
} else if opt.MigrateFrom != "" {
// If there is an old value to migrate from, try that
fallback, exists := config.ConfigValues[opt.MigrateFrom]
if exists {
opt.Default = fmt.Sprintf("%v", fallback)
}
}
val, err := Prompt(opt)
if err != nil {
return config, err
}
values[opt.Name] = val
}
config.ConfigValues = values
return config, nil
}

View file

@ -1,208 +0,0 @@
/*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com>
Scotty is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
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 cli
import (
"context"
"errors"
"fmt"
"strconv"
"sync"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/backends"
"go.uploadedlobster.com/scotty/internal/config"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/internal/storage"
)
func NewTransferCmd[
E models.Backend,
I models.ImportBackend,
R models.ListensResult | models.LovesResult,
](
cmd *cobra.Command,
db *storage.Database,
entity models.Entity,
source string,
target string,
) (TransferCmd[E, I, R], error) {
c := TransferCmd[E, I, R]{
cmd: cmd,
db: db,
entity: entity,
}
err := c.resolveBackends(source, target)
if err != nil {
return c, err
}
return c, nil
}
type TransferCmd[E models.Backend, I models.ImportBackend, R models.ListensResult | models.LovesResult] struct {
cmd *cobra.Command
db *storage.Database
entity models.Entity
sourceName string
targetName string
ExpBackend E
ImpBackend I
}
func (c *TransferCmd[E, I, R]) resolveBackends(source string, target string) error {
sourceConfig, err := config.GetService(source)
cobra.CheckErr(err)
targetConfig, err := config.GetService(target)
cobra.CheckErr(err)
// Initialize backends
expBackend, err := backends.ResolveBackend[E](sourceConfig)
if err != nil {
return err
}
impBackend, err := backends.ResolveBackend[I](targetConfig)
if err != nil {
return err
}
c.sourceName = sourceConfig.Name
c.targetName = targetConfig.Name
c.ExpBackend = expBackend
c.ImpBackend = impBackend
return nil
}
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))
// Authenticate backends, if needed
config := viper.GetViper()
_, err := backends.Authenticate(c.sourceName, c.ExpBackend, *c.db, config)
if err != nil {
return err
}
_, err = backends.Authenticate(c.targetName, c.ImpBackend, *c.db, config)
if err != nil {
return err
}
// Read timestamp
timestamp, err := c.timestamp()
if err != nil {
return err
}
printTimestamp("From timestamp: %v (%v)", timestamp)
// Use a context with cancel to abort the transfer
ctx, cancel := context.WithCancel(context.Background())
// Prepare progress bars
progressChan := make(chan models.TransferProgress)
progress := setupProgressBars(ctx, progressChan)
wg := &sync.WaitGroup{}
// Export from source
exportChan := make(chan R, 1000)
go exp.Process(ctx, wg, timestamp, exportChan, progressChan)
// Import into target
resultChan := make(chan models.ImportResult)
go imp.Process(ctx, wg, exportChan, resultChan, progressChan)
result := <-resultChan
// If the import has errored, the context can be cancelled immediately
if result.Error != nil {
cancel()
} else {
defer cancel()
}
// Wait for all goroutines to finish
wg.Wait()
progress.close()
// Update timestamp
err = c.updateTimestamp(&result, timestamp)
if err != nil {
return err
}
fmt.Println(i18n.Tr("Imported %v of %v %s into %v.",
result.ImportCount, result.TotalCount, c.entity, c.targetName))
if result.Error != nil {
printTimestamp("Import failed, last reported timestamp was %v (%s)", result.LastTimestamp)
return result.Error
}
// Print errors
if len(result.ImportLog) > 0 {
fmt.Println()
fmt.Println(i18n.Tr("Import log:"))
for _, entry := range result.ImportLog {
if entry.Type != models.Output {
fmt.Println(i18n.Tr("%v: %v", entry.Type, entry.Message))
} else {
fmt.Println(entry.Message)
}
}
}
return nil
}
func (c *TransferCmd[E, I, R]) timestamp() (time.Time, error) {
flagValue, err := c.cmd.Flags().GetString("timestamp")
if err != nil {
return time.Time{}, err
}
// No timestamp given, read from database
if flagValue == "" {
timestamp, err := c.db.GetImportTimestamp(c.sourceName, c.targetName, c.entity)
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 {
if oldTimestamp.After(result.LastTimestamp) {
result.LastTimestamp = oldTimestamp
}
printTimestamp("Latest timestamp: %v (%v)", result.LastTimestamp)
err := c.db.SetImportTimestamp(c.sourceName, c.targetName, c.entity, result.LastTimestamp)
return err
}
func printTimestamp(s string, t time.Time) {
fmt.Println(i18n.Tr(s, t, strconv.FormatInt(t.Unix(), 10)))
}

View file

@ -1,142 +0,0 @@
/*
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 config
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/i18n"
"go.uploadedlobster.com/scotty/internal/version"
)
const (
defaultDatabase = "scotty.sqlite3"
defaultOAuthHost = "127.0.0.1:2369"
fileMode = 0640
)
func DefaultConfigDir() string {
configDir, err := os.UserConfigDir()
cobra.CheckErr(err)
return filepath.Join(configDir, version.AppName)
}
// initConfig reads in config file and ENV variables if set.
func InitConfig(cfgFile string) error {
configDir := DefaultConfigDir()
if cfgFile != "" {
// Use given config file
viper.SetConfigFile(cfgFile)
} else {
viper.AddConfigPath(configDir)
viper.SetConfigType("toml")
viper.SetConfigName(version.AppName)
viper.SetConfigPermissions(fileMode)
}
setDefaults()
// Create global config if it does not exist
if viper.ConfigFileUsed() == "" && cfgFile == "" {
if err := os.MkdirAll(configDir, 0750); err == nil {
// This call is expected to return an error if the file already exists
viper.SafeWriteConfig() //nolint:errcheck
}
}
// read in environment variables that match
viper.AutomaticEnv()
// If a config file is found, read it in.
return viper.ReadInConfig()
}
// Write the configuration except for removedKeys
func WriteConfig(removedKeys ...string) error {
file := viper.ConfigFileUsed()
if len(file) == 0 {
return errors.New(i18n.Tr("no configuration file defined, cannot write config"))
}
configMap := viper.AllSettings()
for _, key := range removedKeys {
c := configMap
var ok bool
subKeys := strings.Split(key, ".")
keyLen := len(subKeys)
// Deep search the key in the config and delete the deepest key, if it exists
for i, s := range subKeys {
if i == keyLen-1 {
// This is the final key, delete it from the map
delete(c, s)
} else {
// Use the child for next iteration if it is a map
c, ok = c[s].(map[string]any)
if !ok {
// Child is not a map, can't search deeper
break
}
}
}
}
content, err := toml.Marshal(configMap)
if err != nil {
return err
}
return os.WriteFile(file, content, fileMode)
}
func DatabasePath() string {
path := viper.GetString("database")
if filepath.IsAbs(path) {
return path
}
return filepath.Join(getConfigDir(), path)
}
func ValidateKey(key string) error {
found, err := regexp.MatchString("^[A-Za-z0-9_-]+$", key)
if err != nil {
return err
} else if found {
return nil
} else {
return fmt.Errorf(i18n.Tr("key must only consist of A-Za-z0-9_-"))
}
}
func setDefaults() {
viper.SetDefault("database", defaultDatabase)
viper.SetDefault("oauth-host", defaultOAuthHost)
// Always configure the dump backend as a default service
viper.SetDefault("service.dump.backend", "dump")
}
func getConfigDir() string {
return filepath.Dir(viper.ConfigFileUsed())
}

View file

@ -1,32 +0,0 @@
/*
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 config_test
import (
"testing"
"github.com/stretchr/testify/assert"
"go.uploadedlobster.com/scotty/internal/config"
)
func TestTimestampUpdate(t *testing.T) {
assert := assert.New(t)
assert.Nil(config.ValidateKey("foo"))
assert.Nil(config.ValidateKey("foo123"))
assert.Nil(config.ValidateKey("foo_bar-123"))
assert.NotNil(config.ValidateKey("foo/bar"))
assert.NotNil(config.ValidateKey("bär"))
}

View file

@ -1,140 +0,0 @@
/*
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 config
import (
"fmt"
"sort"
"github.com/spf13/cast"
"github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/i18n"
)
type ServiceConfig struct {
Name string
Backend string
ConfigValues map[string]any
}
func NewServiceConfig(name string, config *viper.Viper) ServiceConfig {
service := ServiceConfig{
Name: name,
Backend: config.GetString("backend"),
ConfigValues: make(map[string]any),
}
for key, val := range config.AllSettings() {
if key != "backend" {
service.ConfigValues[key] = val
}
}
return service
}
func (c ServiceConfig) String() string {
return c.Name
}
func (c *ServiceConfig) GetString(key string) string {
return cast.ToString(c.ConfigValues[key])
}
func (c *ServiceConfig) GetBool(key string, defaultValue bool) bool {
if c.IsSet(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 {
_, ok := c.ConfigValues[key]
return ok
}
func (c *ServiceConfig) Save() error {
key := "service." + c.Name
viper.Set(key+".backend", c.Backend)
for k, v := range c.ConfigValues {
viper.Set(key+"."+k, v)
}
return WriteConfig()
}
// Deletes the service configuration from the config file
func (c *ServiceConfig) Delete() error {
key := "service." + c.Name
return WriteConfig(key)
}
type ServiceList []ServiceConfig
func (l ServiceList) Len() int {
return len(l)
}
func (l ServiceList) Less(i, j int) bool {
return l[i].Name < l[j].Name
}
func (l ServiceList) Swap(i, j int) {
l[i], l[j] = l[j], l[i]
}
func AllServices() map[string]ServiceConfig {
services := make(map[string]ServiceConfig)
config := viper.Sub("service")
if config != nil {
for k := range config.AllSettings() {
s := config.Sub(k)
if s != nil {
services[k] = NewServiceConfig(k, s)
}
}
}
return services
}
func AllServicesAsList() ServiceList {
services := AllServices()
list := make(ServiceList, 0, len(services))
for _, s := range services {
list = append(list, s)
}
sort.Sort(list)
return list
}
func GetService(name string) (ServiceConfig, error) {
key := "service." + name
config := viper.Sub(key)
if config != nil {
service := NewServiceConfig(name, config)
return service, nil
}
return ServiceConfig{}, fmt.Errorf(i18n.Tr("no service configuration \"%v\"", name))
}

View file

@ -1,38 +0,0 @@
/*
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 i18n
import (
"github.com/Xuanwo/go-locale"
_ "go.uploadedlobster.com/scotty/internal/translations"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
var localizer Localizer
func init() {
tag, err := locale.Detect()
if err != nil {
tag = language.English
}
localizer = New(tag)
}
func Tr(key message.Reference, args ...interface{}) string {
return localizer.Translate(key, args...)
}

View file

@ -1,37 +0,0 @@
/*
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 i18n
import (
"golang.org/x/text/language"
"golang.org/x/text/message"
)
type Localizer struct {
printer *message.Printer
}
// Create a new Localizer for a language tag
func New(lang language.Tag) Localizer {
return Localizer{
printer: message.NewPrinter(lang),
}
}
// Return the translated string, with variables replaced.
func (l Localizer) Translate(key message.Reference, args ...interface{}) string {
return l.printer.Sprintf(key, args...)
}

View file

@ -1,215 +0,0 @@
/*
Copyright © 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 listenbrainz
import (
"encoding/json"
"errors"
"io"
"iter"
"regexp"
"sort"
"strconv"
"time"
"go.uploadedlobster.com/scotty/internal/models"
"go.uploadedlobster.com/scotty/pkg/archive"
)
// Represents a ListenBrainz export archive.
//
// The export contains the user's listen history, favorite tracks and
// user information.
type ExportArchive struct {
backend archive.ArchiveReader
}
// Open a ListenBrainz archive from file path.
func OpenExportArchive(path string) (*ExportArchive, error) {
backend, err := archive.OpenArchive(path)
if err != nil {
return nil, err
}
return &ExportArchive{backend: backend}, nil
}
// Close the archive and release any resources.
func (a *ExportArchive) Close() error {
if a.backend == nil {
return nil
}
return a.backend.Close()
}
// Read the user information from the archive.
func (a *ExportArchive) UserInfo() (UserInfo, error) {
f, err := a.backend.Open("user.json")
if err != nil {
return UserInfo{}, err
}
defer f.Close()
userInfo := UserInfo{}
bytes, err := io.ReadAll(f)
if err != nil {
return userInfo, err
}
json.Unmarshal(bytes, &userInfo)
return userInfo, nil
}
func (a *ExportArchive) ListListenExports() ([]ListenExportFileInfo, error) {
re := regexp.MustCompile(`^listens/(\d{4})/(\d{1,2})\.jsonl$`)
result := make([]ListenExportFileInfo, 0)
files, err := a.backend.Glob("listens/*/*.jsonl")
if err != nil {
return nil, err
}
for _, file := range files {
match := re.FindStringSubmatch(file.Name)
if match == nil {
continue
}
year := match[1]
month := match[2]
times, err := getMonthTimeRange(year, month)
if err != nil {
return nil, err
}
info := ListenExportFileInfo{
Name: file.Name,
TimeRange: *times,
f: file.File,
}
result = append(result, info)
}
return result, nil
}
// Yields all listens from the archive that are newer than the given timestamp.
// The listens are yielded in ascending order of their listened_at timestamp.
func (a *ExportArchive) IterListens(minTimestamp time.Time) iter.Seq2[Listen, error] {
return func(yield func(Listen, error) bool) {
files, err := a.ListListenExports()
if err != nil {
yield(Listen{}, err)
return
}
sort.Slice(files, func(i, j int) bool {
return files[i].TimeRange.Start.Before(files[j].TimeRange.Start)
})
for _, file := range files {
if file.TimeRange.End.Before(minTimestamp) {
continue
}
f := models.JSONLFile[Listen]{File: file.f}
for l, err := range f.IterItems() {
if err != nil {
yield(Listen{}, err)
return
}
if !time.Unix(l.ListenedAt, 0).After(minTimestamp) {
continue
}
if !yield(l, nil) {
break
}
}
}
}
}
// Yields all feedbacks from the archive that are newer than the given timestamp.
// The feedbacks are yielded in ascending order of their Created timestamp.
func (a *ExportArchive) IterFeedback(minTimestamp time.Time) iter.Seq2[Feedback, error] {
return func(yield func(Feedback, error) bool) {
files, err := a.backend.Glob("feedback.jsonl")
if err != nil {
yield(Feedback{}, err)
return
} else if len(files) == 0 {
yield(Feedback{}, errors.New("no feedback.jsonl file found in archive"))
return
}
j := models.JSONLFile[Feedback]{File: files[0].File}
for l, err := range j.IterItems() {
if err != nil {
yield(Feedback{}, err)
return
}
if !time.Unix(l.Created, 0).After(minTimestamp) {
continue
}
if !yield(l, nil) {
break
}
}
}
}
type UserInfo struct {
ID string `json:"user_id"`
Name string `json:"username"`
}
type timeRange struct {
Start time.Time
End time.Time
}
type ListenExportFileInfo struct {
Name string
TimeRange timeRange
f archive.OpenableFile
}
func getMonthTimeRange(year string, month string) (*timeRange, error) {
yearInt, err := strconv.Atoi(year)
if err != nil {
return nil, err
}
monthInt, err := strconv.Atoi(month)
if err != nil {
return nil, err
}
r := &timeRange{}
r.Start = time.Date(yearInt, time.Month(monthInt), 1, 0, 0, 0, 0, time.UTC)
// Get the end of the month
nextMonth := monthInt + 1
r.End = time.Date(
yearInt, time.Month(nextMonth), 1, 0, 0, 0, 0, time.UTC).Add(-time.Second)
return r, nil
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
This file is part of Scotty. This file is part of Scotty.
@ -17,11 +17,12 @@ Scotty. If not, see <https://www.gnu.org/licenses/>.
package models package models
import ( import (
"context" "net/url"
"time" "time"
// "go.uploadedlobster.com/scotty/internal/auth" "github.com/spf13/viper"
"go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/auth"
"golang.org/x/oauth2"
) )
// A listen service backend. // A listen service backend.
@ -31,13 +32,7 @@ type Backend interface {
Name() string Name() string
// Initialize the backend from a config. // Initialize the backend from a config.
InitConfig(config *config.ServiceConfig) error FromConfig(config *viper.Viper) Backend
// Return configuration options
Options() []BackendOption
// Free all resources of the backend
Close()
} }
type ImportBackend interface { type ImportBackend interface {
@ -49,7 +44,7 @@ type ImportBackend interface {
// The implementation can perform all steps here to finalize the // The implementation can perform all steps here to finalize the
// export/import and free used resources. // export/import and free used resources.
FinishImport(result *ImportResult) error FinishImport() error
} }
// Must be implemented by services supporting the export of listens. // Must be implemented by services supporting the export of listens.
@ -59,7 +54,7 @@ type ListensExport interface {
// Returns a list of all listens newer then oldestTimestamp. // Returns a list of all listens newer then oldestTimestamp.
// The returned list of listens is supposed to be ordered by the // The returned list of listens is supposed to be ordered by the
// Listen.ListenedAt timestamp, with the oldest entry first. // Listen.ListenedAt timestamp, with the oldest entry first.
ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan ListensResult, progress chan TransferProgress) ExportListens(oldestTimestamp time.Time, results chan ListensResult, progress chan Progress)
} }
// Must be implemented by services supporting the import of listens. // Must be implemented by services supporting the import of listens.
@ -67,7 +62,7 @@ type ListensImport interface {
ImportBackend ImportBackend
// Imports the given list of listens. // Imports the given list of listens.
ImportListens(ctx context.Context, export ListensResult, importResult ImportResult, progress chan TransferProgress) (ImportResult, error) ImportListens(export ListensResult, importResult ImportResult, progress chan Progress) (ImportResult, error)
} }
// Must be implemented by services supporting the export of loves. // Must be implemented by services supporting the export of loves.
@ -77,7 +72,7 @@ type LovesExport interface {
// Returns a list of all loves newer then oldestTimestamp. // Returns a list of all loves newer then oldestTimestamp.
// The returned list of listens is supposed to be ordered by the // The returned list of listens is supposed to be ordered by the
// Love.Created timestamp, with the oldest entry first. // Love.Created timestamp, with the oldest entry first.
ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan LovesResult, progress chan TransferProgress) ExportLoves(oldestTimestamp time.Time, results chan LovesResult, progress chan Progress)
} }
// Must be implemented by services supporting the import of loves. // Must be implemented by services supporting the import of loves.
@ -85,5 +80,16 @@ type LovesImport interface {
ImportBackend ImportBackend
// Imports the given list of loves. // Imports the given list of loves.
ImportLoves(ctx context.Context, export LovesResult, importResult ImportResult, progress chan TransferProgress) (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
} }

View file

@ -1,65 +0,0 @@
/*
Copyright © 2025 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 models
import (
"errors"
"iter"
"github.com/simonfrey/jsonl"
"go.uploadedlobster.com/scotty/pkg/archive"
)
type JSONLFile[T any] struct {
File archive.OpenableFile
}
func (f *JSONLFile[T]) openReader() (*jsonl.Reader, error) {
if f.File == nil {
return nil, errors.New("file not set")
}
fio, err := f.File.Open()
if err != nil {
return nil, err
}
reader := jsonl.NewReader(fio)
return &reader, nil
}
func (f *JSONLFile[T]) IterItems() iter.Seq2[T, error] {
return func(yield func(T, error) bool) {
reader, err := f.openReader()
if err != nil {
var listen T
yield(listen, err)
return
}
defer reader.Close()
for {
var listen T
err := reader.ReadSingleLine(&listen)
if err != nil {
break
}
if !yield(listen, nil) {
break
}
}
}
}

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -22,19 +22,11 @@ THE SOFTWARE.
package models package models
import ( import (
"iter"
"strings" "strings"
"time" "time"
"go.uploadedlobster.com/mbtypes"
) )
type Entity string type MBID string
const (
Listens Entity = "listens"
Loves Entity = "loves"
)
type AdditionalInfo map[string]any type AdditionalInfo map[string]any
@ -45,12 +37,12 @@ type Track struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
Duration time.Duration Duration time.Duration
ISRC mbtypes.ISRC ISRC string
RecordingMBID mbtypes.MBID RecordingMbid MBID
ReleaseMBID mbtypes.MBID ReleaseMbid MBID
ReleaseGroupMBID mbtypes.MBID ReleaseGroupMbid MBID
ArtistMBIDs []mbtypes.MBID ArtistMbids []MBID
WorkMBIDs []mbtypes.MBID WorkMbids []MBID
Tags []string Tags []string
AdditionalInfo AdditionalInfo AdditionalInfo AdditionalInfo
} }
@ -64,20 +56,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
@ -112,8 +104,8 @@ type Love struct {
Track Track
Created time.Time Created time.Time
UserName string UserName string
RecordingMBID mbtypes.MBID RecordingMbid MBID
RecordingMSID mbtypes.MBID RecordingMsid MBID
} }
type ListensList []Listen type ListensList []Listen
@ -122,7 +114,7 @@ type ListensList []Listen
func (l ListensList) NewerThan(t time.Time) ListensList { func (l ListensList) NewerThan(t time.Time) ListensList {
result := make(ListensList, 0, len(l)) result := make(ListensList, 0, len(l))
for _, item := range l { for _, item := range l {
if item.ListenedAt.After(t) { if item.ListenedAt.Unix() > t.Unix() {
result = append(result, item) result = append(result, item)
} }
} }
@ -134,7 +126,7 @@ func (l ListensList) Len() int {
} }
func (l ListensList) Less(i, j int) bool { func (l ListensList) Less(i, j int) bool {
return l[j].ListenedAt.After(l[i].ListenedAt) return l[i].ListenedAt.Unix() < l[j].ListenedAt.Unix()
} }
func (l ListensList) Swap(i, j int) { func (l ListensList) Swap(i, j int) {
@ -148,43 +140,31 @@ func (l LovesList) Len() int {
} }
func (l LovesList) Less(i, j int) bool { func (l LovesList) Less(i, j int) bool {
return l[j].Created.After(l[i].Created) return l[i].Created.Unix() < l[j].Created.Unix()
} }
func (l LovesList) Swap(i, j int) { func (l LovesList) Swap(i, j int) {
l[i], l[j] = l[j], l[i] l[i], l[j] = l[j], l[i]
} }
type ExportResult[T LovesList | ListensList] struct { type ListensResult struct {
Items T
Total int Total int
Listens ListensList
OldestTimestamp time.Time OldestTimestamp time.Time
Error error Error error
} }
type ListensResult ExportResult[ListensList] type LovesResult struct {
Total int
type LovesResult ExportResult[LovesList] Loves LovesList
Error error
type LogEntryType string
const (
Output LogEntryType = ""
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
ImportLog []LogEntry ImportErrors []string
// Error is only set if an unrecoverable import error occurred // Error is only set if an unrecoverable import error occurred
Error error Error error
@ -192,54 +172,22 @@ type ImportResult struct {
// Sets LastTimestamp to newTime, if newTime is newer than LastTimestamp // Sets LastTimestamp to newTime, if newTime is newer than LastTimestamp
func (i *ImportResult) UpdateTimestamp(newTime time.Time) { func (i *ImportResult) UpdateTimestamp(newTime time.Time) {
if newTime.After(i.LastTimestamp) { if newTime.Unix() > i.LastTimestamp.Unix() {
i.LastTimestamp = newTime i.LastTimestamp = newTime
} }
} }
func (i *ImportResult) Update(from *ImportResult) { func (i *ImportResult) Update(from ImportResult) {
if i != from {
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.ImportLog = append(i.ImportLog, from.ImportLog...) i.ImportErrors = append(i.ImportErrors, from.ImportErrors...)
}
}
func (i *ImportResult) Copy() ImportResult {
return ImportResult{
TotalCount: i.TotalCount,
ImportCount: i.ImportCount,
LastTimestamp: i.LastTimestamp,
}
}
func (i *ImportResult) Log(t LogEntryType, msg string) {
i.ImportLog = append(i.ImportLog, LogEntry{
Type: t,
Message: msg,
})
}
type TransferProgress struct {
Export *Progress
Import *Progress
}
func (p TransferProgress) FromImportResult(result ImportResult, completed bool) TransferProgress {
importProgress := Progress{
Completed: completed,
}.FromImportResult(result)
p.Import = &importProgress
return p
} }
type Progress struct { type Progress struct {
TotalItems int
Total int64 Total int64
Elapsed int64 Elapsed int64
Completed bool Completed bool
Aborted bool
} }
func (p Progress) FromImportResult(result ImportResult) Progress { func (p Progress) FromImportResult(result ImportResult) Progress {
@ -248,48 +196,8 @@ func (p Progress) FromImportResult(result ImportResult) Progress {
return p return p
} }
func (p *Progress) Complete() { func (p Progress) Complete() Progress {
p.Elapsed = p.Total p.Total = p.Elapsed
p.Completed = true p.Completed = true
} return p
func (p *Progress) Abort() {
p.Aborted = true
}
func IterExportProgress[T any](
items []T, t *TransferProgress, c chan TransferProgress,
) iter.Seq2[int, T] {
return iterProgress(items, t, t.Export, c, true)
}
func IterImportProgress[T any](
items []T, t *TransferProgress, c chan TransferProgress,
) iter.Seq2[int, T] {
return iterProgress(items, t, t.Import, c, false)
}
func iterProgress[T any](
items []T, t *TransferProgress,
p *Progress, c chan TransferProgress,
autocomplete bool,
) iter.Seq2[int, T] {
// Report progress in 1% steps
steps := max(len(items)/100, 1)
return func(yield func(int, T) bool) {
for i, item := range items {
if !yield(i, item) {
return
}
p.Elapsed++
if i%steps == 0 {
c <- *t
}
}
if autocomplete {
p.Complete()
c <- *t
}
}
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright © 2023-2025 Philipp Wolfer <phw@uploadedlobster.com> Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -28,7 +28,6 @@ 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"
) )
@ -45,25 +44,25 @@ func TestTrackArtistName(t *testing.T) {
func TestTrackFillAdditionalInfo(t *testing.T) { func TestTrackFillAdditionalInfo(t *testing.T) {
track := models.Track{ track := models.Track{
RecordingMBID: mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), RecordingMbid: models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"),
ReleaseGroupMBID: mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), ReleaseGroupMbid: models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"),
ReleaseMBID: mbtypes.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"), ReleaseMbid: models.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"),
ArtistMBIDs: []mbtypes.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"}, ArtistMbids: []models.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"},
WorkMBIDs: []mbtypes.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"}, WorkMbids: []models.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: mbtypes.ISRC("DES561620801"), 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"])
@ -118,63 +117,23 @@ 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(),
ImportLog: []models.LogEntry{logEntry1}, ImportErrors: []string{"foo"},
} }
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),
ImportLog: []models.LogEntry{logEntry2}, ImportErrors: []string{"bar"},
} }
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, []models.LogEntry{logEntry1, logEntry2}, result.ImportLog) assert.Equal(t, []string{"foo", "bar"}, result.ImportErrors)
}
func TestImportResultCopy(t *testing.T) {
logEntry := models.LogEntry{
Type: models.Warning,
Message: "foo",
}
result := models.ImportResult{
TotalCount: 100,
ImportCount: 20,
LastTimestamp: time.Now(),
ImportLog: []models.LogEntry{logEntry},
}
copy := result.Copy()
assert.Equal(t, result.TotalCount, copy.TotalCount)
assert.Equal(t, result.ImportCount, copy.ImportCount)
assert.Equal(t, result.LastTimestamp, copy.LastTimestamp)
assert.Empty(t, copy.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) {

View file

@ -1,34 +0,0 @@
/*
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 models
type OptionType string
const (
Bool OptionType = "bool"
Secret OptionType = "secret"
String OptionType = "string"
Int OptionType = "int"
)
type BackendOption struct {
Name string
Label string
Type OptionType
Default string
Validate func(string) error
MigrateFrom string
}

Some files were not shown because too many files have changed in this diff Show more