diff --git a/.build.yml b/.build.yml index a5d2238..f21034b 100644 --- a/.build.yml +++ b/.build.yml @@ -3,41 +3,22 @@ packages: - go - goreleaser-bin - hut - - weblate-wlc -secrets: - - 0e2ad815-6c46-4cea-878e-70fc33f71e77 oauth: pages.sr.ht/PAGES:RW tasks: - - weblate-update: | - cd scotty - wlc --format text pull scotty - test: | cd scotty go build -v . go test -v ./... - build: | 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 - fi - cd dist/ - tar cvf artifacts.tar scotty-*.{gz,zip} scotty_*_checksums.txt + goreleaser release --snapshot --clean - publish-redirect: | # Update redirect on https://go.uploadedlobster.com/scotty ./scotty/pages/publish.sh - # Skip releasing if this is not a tagged release - - only-tags: | - cd scotty - GIT_REF=$(git describe --always) - [[ "$GIT_REF" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || complete-build - - announce-release: | - # Announce new release to Go Module Index - cd scotty - VERSION=$(git describe --exact-match) - curl "https://proxy.golang.org/go.uploadedlobster.com/scotty/@v/${VERSION}.info" artifacts: - - scotty/dist/artifacts.tar + - 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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1a1e0ba..69adc4c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,7 +6,7 @@ # yaml-language-server: $schema=https://goreleaser.com/static/schema.json # vim: set ts=2 sw=2 tw=0 fo=cnqoj -version: 2 +version: 1 before: hooks: @@ -21,8 +21,6 @@ builds: - windows - darwin ignore: - - goos: linux - goarch: "386" - goos: windows goarch: "386" @@ -30,28 +28,22 @@ universal_binaries: - replace: true archives: - - formats: ['tar.gz'] + - format: tar.gz # this name template makes the OS and Arch compatible with the results of `uname`. name_template: >- - {{ .ProjectName }}-{{ .Version }}_ - {{- if eq .Os "darwin" }}macos - {{- else }}{{ .Os }}{{ end }}_ + {{ .ProjectName }}_ + {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} - wrap_in_directory: true # use zip for windows archives format_overrides: - goos: windows - formats: ['zip'] + format: zip files: - COPYING - README.md - - config.example.toml - -release: - disable: true # changelog: # sort: asc diff --git a/.weblate b/.weblate deleted file mode 100644 index 9c9511e..0000000 --- a/.weblate +++ /dev/null @@ -1,3 +0,0 @@ -[weblate] -url = https://translate.uploadedlobster.com/api/ -translation = scotty/app diff --git a/CHANGES.md b/CHANGES.md index 228b101..c763e1d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,113 +1,5 @@ # 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 - lastfm: support for scrobble and love export/import - jspf: consider loved track MBID diff --git a/README.md b/README.md index 3c004c0..771a66c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ Scotty transfers your listens/scrobbles and favorite tracks between various musi - Submit listens from ListenBrainz to Maloja or Last.fm - Transfer loved tracks from Funkwhale to ListenBrainz - 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 - 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 -To use Scotty you need to configure at least two services (e.g. ListenBrainz, Last.fm, Funkwhale or Spotify). - -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). +Scotty requires the configuration of the services in a configuration file in TOML format. See [scotty.example.toml](./scotty.example.toml) for details. ## Usage -Run `scotty --help` for general 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" -``` +Run `scotty --help` for command line help. -### Supported backends +## Supported backends + The following table lists the available backends and the currently supported features. -Backend | Listens Export | Listens Import | Loves Export | Loves Import ----------------------|----------------|----------------|--------------|------------- -deezer | ✓ | ⨯ | ✓ | - -deezer-history | ✓ | ⨯ | ✓ | ⨯ -funkwhale | ✓ | ⨯ | ✓ | - -jspf | ✓ | ✓ | ✓ | ✓ -lastfm | ✓ | ✓ | ✓ | ✓ -listenbrainz | ✓ | ✓ | ✓ | ✓ -listenbrainz-archive | ✓ | - | ✓ | - -maloja | ✓ | ✓ | ⨯ | ⨯ -scrobbler-log | ✓ | ✓ | ⨯ | ⨯ -spotify | ✓ | ⨯ | ✓ | - -spotify-history | ✓ | ⨯ | ⨯ | ⨯ -subsonic | ⨯ | ⨯ | ✓ | - +Backend | Listens Export | Listens Import | Loves Export | Loves Import +---------------|----------------|----------------|--------------|------------- +deezer | ✓ | ⨯ | ✓ | - +dump | ⨯ | ✓ | ⨯ | ✓ +funkwhale | ✓ | ⨯ | ✓ | - +jspf | - | ✓ | - | ✓ +lastfm | ✓ | ✓ | ✓ | ✓ +listenbrainz | ✓ | ✓ | ✓ | ✓ +maloja | ✓ | ✓ | ⨯ | ⨯ +scrobbler-log | ✓ | ✓ | ⨯ | ⨯ +spotify | ✓ | ⨯ | ✓ | - +subsonic | ⨯ | ⨯ | ✓ | - **✓** 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 -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. -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 -Scotty © 2023-2025 Philipp Wolfer +Scotty © 2023 Philipp Wolfer 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. 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/). diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 0000000..67b7411 --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,93 @@ +/* +Copyright © 2023 Philipp Wolfer + +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 . +*/ +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") +} diff --git a/cmd/backends.go b/cmd/backends.go index bf5c7d3..7d6383e 100644 --- a/cmd/backends.go +++ b/cmd/backends.go @@ -22,9 +22,9 @@ import ( "github.com/spf13/cobra" "go.uploadedlobster.com/scotty/internal/backends" - "go.uploadedlobster.com/scotty/internal/i18n" ) +// backendsCmd represents the backends command var backendsCmd = &cobra.Command{ Use: "backends", Short: "List available backends", @@ -33,8 +33,8 @@ var backendsCmd = &cobra.Command{ backends := backends.GetBackends() for _, info := range backends { fmt.Printf("%s:\n", info.Name) - fmt.Println(i18n.Tr("\texport: %s", strings.Join(info.ExportCapabilities, ", "))) - fmt.Println(i18n.Tr("\timport: %s\n", strings.Join(info.ImportCapabilities, ", "))) + fmt.Printf("\texport: %s\n", strings.Join(info.ExportCapabilities, ", ")) + fmt.Printf("\timport: %s\n\n", strings.Join(info.ImportCapabilities, ", ")) } }, } diff --git a/cmd/beam.go b/cmd/beam.go index 83d51db..63b5975 100644 --- a/cmd/beam.go +++ b/cmd/beam.go @@ -20,14 +20,14 @@ import ( "github.com/spf13/cobra" ) +// beamCmd represents the beam command var beamCmd = &cobra.Command{ Use: "beam", Short: "Transfer data between two 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 data. See "scotty backends" for a list of backends and their supported -features.`, +the data.`, // Run: func(cmd *cobra.Command, args []string) { }, } @@ -38,7 +38,11 @@ func init() { // Cobra supports Persistent Flags which will work for this command // 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 // is called directly, e.g.: diff --git a/cmd/beam_listens.go b/cmd/beam_listens.go deleted file mode 100644 index fe567f0..0000000 --- a/cmd/beam_listens.go +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ -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") -} diff --git a/cmd/beam_loves.go b/cmd/beam_loves.go deleted file mode 100644 index 5f75d70..0000000 --- a/cmd/beam_loves.go +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ -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") -} diff --git a/internal/cli/common.go b/cmd/common.go similarity index 52% rename from internal/cli/common.go rename to cmd/common.go index fb6fb02..05059b4 100644 --- a/internal/cli/common.go +++ b/cmd/common.go @@ -1,6 +1,8 @@ /* Copyright © 2023 Philipp Wolfer +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. @@ -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 Scotty. If not, see . */ - -package cli +package cmd import ( + "fmt" + "github.com/spf13/cobra" - "go.uploadedlobster.com/scotty/internal/config" + "github.com/spf13/viper" ) -func GetServiceConfigFromFlag(cmd *cobra.Command, flagName string) (config.ServiceConfig, error) { - name := cmd.Flag(flagName).Value.String() - return config.GetService(name) +func getConfigFromFlag(cmd *cobra.Command, flagName string) (string, *viper.Viper) { + configName := cmd.Flag(flagName).Value.String() + 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 } diff --git a/cmd/listens.go b/cmd/listens.go new file mode 100644 index 0000000..c18bace --- /dev/null +++ b/cmd/listens.go @@ -0,0 +1,119 @@ +/* +Copyright © 2023 Philipp Wolfer + +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 . +*/ +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") +} diff --git a/cmd/loves.go b/cmd/loves.go new file mode 100644 index 0000000..941290f --- /dev/null +++ b/cmd/loves.go @@ -0,0 +1,120 @@ +/* +Copyright © 2023 Philipp Wolfer + +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 . +*/ +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") +} diff --git a/cmd/progress.go b/cmd/progress.go new file mode 100644 index 0000000..b328f29 --- /dev/null +++ b/cmd/progress.go @@ -0,0 +1,79 @@ +/* +Copyright © 2023 Philipp Wolfer + +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 . +*/ + +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) + } +} diff --git a/cmd/root.go b/cmd/root.go index 20f01ee..10dc528 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,10 +19,10 @@ package cmd import ( "fmt" "os" + "path" "github.com/spf13/cobra" - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/i18n" + "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/version" ) @@ -32,8 +32,8 @@ var cfgFile string var rootCmd = &cobra.Command{ Use: version.AppName, Short: "Beam data between music listening services", - Long: `Scotty transfers listens and loves between different listening and streaming -services. Run "scotty backends" for a list of supported service backends.`, + Long: `Scotty transfers your listens/scrobbles between ListenBrainz and +various other listening and streaming services.`, Version: version.AppVersion, // Uncomment the following line if your bare application // has an action associated with it: @@ -56,19 +56,36 @@ func init() { // Cobra supports persistent flags, which, if defined here, // will be global for your application. - configDir := config.DefaultConfigDir() + configDir := defaultConfigDir() 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 // when this action is called directly. // 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. 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 err := config.InitConfig(cfgFile); err != nil { - fmt.Fprintln(os.Stderr, i18n.Tr("Failed reading config: %v", err)) + if err := viper.ReadInConfig(); err == nil { + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) } } diff --git a/cmd/service.go b/cmd/service.go deleted file mode 100644 index 7fba51e..0000000 --- a/cmd/service.go +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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") -} diff --git a/cmd/service_add.go b/cmd/service_add.go deleted file mode 100644 index 3360faa..0000000 --- a/cmd/service_add.go +++ /dev/null @@ -1,117 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 -} diff --git a/cmd/service_auth.go b/cmd/service_auth.go deleted file mode 100644 index 0a075c5..0000000 --- a/cmd/service_auth.go +++ /dev/null @@ -1,45 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ -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") -} diff --git a/cmd/service_delete.go b/cmd/service_delete.go deleted file mode 100644 index 3a2738d..0000000 --- a/cmd/service_delete.go +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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") -} diff --git a/cmd/service_edit.go b/cmd/service_edit.go deleted file mode 100644 index abd790b..0000000 --- a/cmd/service_edit.go +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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") -} diff --git a/cmd/service_list.go b/cmd/service_list.go deleted file mode 100644 index f51ff00..0000000 --- a/cmd/service_list.go +++ /dev/null @@ -1,63 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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") -} diff --git a/config.example.toml b/config.example.toml deleted file mode 100644 index 91d5318..0000000 --- a/config.example.toml +++ /dev/null @@ -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 diff --git a/go.mod b/go.mod index aec95c7..0d85553 100644 --- a/go.mod +++ b/go.mod @@ -1,83 +1,67 @@ module go.uploadedlobster.com/scotty -go 1.23.0 - -toolchain go1.24.2 +go 1.21.1 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/fatih/color v1.18.0 - github.com/glebarez/sqlite v1.11.0 - github.com/go-resty/resty/v2 v2.16.5 + github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5 + github.com/fatih/color v1.16.0 + github.com/glebarez/sqlite v1.10.0 + github.com/go-resty/resty/v2 v2.10.0 github.com/jarcoal/httpmock v1.3.1 - github.com/manifoldco/promptui v0.9.0 - github.com/pelletier/go-toml/v2 v2.2.4 - github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 - github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740 - github.com/spf13/cast v1.9.2 - github.com/spf13/cobra v1.9.1 - github.com/spf13/viper v1.20.1 - github.com/stretchr/testify v1.10.0 - github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d - github.com/vbauerster/mpb/v8 v8.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 + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.17.0 + github.com/stretchr/testify v1.8.4 + github.com/vbauerster/mpb/v8 v8.6.2 + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 + golang.org/x/oauth2 v0.14.0 + gorm.io/datatypes v1.2.0 + gorm.io/gorm v1.25.5 ) require ( - filippo.io/edwards25519 v1.1.0 // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect - github.com/chzyer/readline v1.5.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/glebarez/go-sqlite v1.22.0 // indirect - github.com/go-sql-driver/mysql v1.9.2 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/golang/protobuf v1.5.3 // 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/jinzhu/inflection v1.0.0 // 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-runewidth v0.0.16 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/mattn/go-runewidth v0.0.15 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/richardlehane/mscfb v1.0.4 // indirect - github.com/richardlehane/msoleps v1.0.4 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.9.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/sagikazarmark/locafero v0.3.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.14.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/afero v1.10.0 // 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/tiendc/go-deepcopy v1.6.1 // indirect - github.com/xuri/efp v0.0.1 // indirect - github.com/xuri/nfp v0.0.1 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/image v0.28.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.41.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 + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/net v0.18.0 // indirect + golang.org/x/sys v0.14.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/mysql v1.6.0 // indirect - modernc.org/libc v1.65.10 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.38.0 // indirect + gorm.io/driver/mysql v1.4.7 // indirect + modernc.org/libc v1.34.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.27.0 // indirect ) - -tool golang.org/x/text/cmd/gotext diff --git a/go.sum b/go.sum index 2483a83..51d651e 100644 --- a/go.sum +++ b/go.sum @@ -1,247 +1,633 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +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/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/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= -github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= -github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= -github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= -github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 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 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 v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= -github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= -github.com/cpuguy83/go-md2man/v2 v2.0.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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= -github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5 h1:RuuxidatioSKGOiBzL1mTY4X22DQD8weEbS3iRLHnAg= +github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5/go.mod h1:vnbEuj6Z20PLcHB4rrLQAOXGMjtULfMGhRVSFPcSdUo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= -github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= -github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= -github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= -github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= -github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= -github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= -github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc= +github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo= +github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= -github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= -github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA= +github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 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/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/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= -github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= -github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= -github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= -github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= +github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +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/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/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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +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/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= +github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ= +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/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/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= -github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= -github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= -github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= -github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY= +github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= +github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.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/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d h1:70+Nn7yh+cfeKqqXVTdpneFqXuvrBLyP7U6GVUsjTU4= -github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d/go.mod h1:D+OWPXeD9owcdcoXATv5YPBGWxxVvn5k98rt5B4wMc4= -github.com/tiendc/go-deepcopy v1.6.1 h1:uVRTItFeNHkMcLueHS7OCsxgxT9P8MzGB/taUa2Y4Tk= -github.com/tiendc/go-deepcopy v1.6.1/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I= -github.com/vbauerster/mpb/v8 v8.10.2 h1:2uBykSHAYHekE11YvJhKxYmLATKHAGorZwFlyNw4hHM= -github.com/vbauerster/mpb/v8 v8.10.2/go.mod h1:+Ja4P92E3/CorSZgfDtK46D7AVbDqmBQRTmyTqPElo0= -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/vbauerster/mpb/v8 v8.6.2 h1:9EhnJGQRtvgDVCychJgR96EDCOqgg2NsMuk5JUcX4DA= +github.com/vbauerster/mpb/v8 v8.6.2/go.mod h1:oVJ7T+dib99kZ/VBjoBaC8aPXiSAihnzuKmotuihyFo= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s= -go.uploadedlobster.com/mbtypes v0.4.0/go.mod h1:Bu1K1Hl77QTAE2Z7QKiW/JAp9KqYWQebkRRfG02dlZM= -go.uploadedlobster.com/musicbrainzws2 v0.16.0 h1:Boux1cZg5S559G/pbQC35BoF+1H7I56oxhBwg8Nzhs0= -go.uploadedlobster.com/musicbrainzws2 v0.16.0/go.mod h1:T6sYE7ZHRH3mJWT3g9jdSUPKJLZubnBjKyjMPNdkgao= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +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-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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= -golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk= -golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= -golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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-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-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.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.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= +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-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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -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-20180830151530-49385e6e1522/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-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-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.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.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.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.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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +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-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-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.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-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 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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/datatypes v1.2.5 h1:9UogU3jkydFVW1bIVVeoYsTpLRgwDVW3rHfJG6/Ek9I= -gorm.io/datatypes v1.2.5/go.mod h1:I5FUdlKpLb5PMqeMQhm30CQ6jXP8Rj89xkTeCSAaAD4= -gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= -gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco= +gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04= +gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y= +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/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= -gorm.io/driver/sqlserver v1.5.4 h1:xA+Y1KDNspv79q43bPyjDMUgHoYHLhXYmdFcYPobg8g= -gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH1Zh/g= -gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= -gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= -modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= -modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc= -modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= -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= +gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0= +gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig= +gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +modernc.org/libc v1.34.3 h1:ag+3JIGF0o009YKhKjkqAG3N36X6ctUv2V85hGM45WA= +modernc.org/libc v1.34.3/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8= +modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/auth/auth.go b/internal/auth/auth.go deleted file mode 100644 index 84c85b6..0000000 --- a/internal/auth/auth.go +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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 -} diff --git a/internal/auth/callback.go b/internal/auth/callback.go index e49bfc7..0ad9c9d 100644 --- a/internal/auth/callback.go +++ b/internal/auth/callback.go @@ -17,30 +17,20 @@ package auth import ( "fmt" - "log" "net/http" "net/url" - - "go.uploadedlobster.com/scotty/internal/i18n" ) func RunOauth2CallbackServer(redirectURL url.URL, param string, responseChan chan CodeResponse) { http.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get(param) 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{ Code: code, State: state, } }) - go runServer(redirectURL.Host) -} - -func runServer(addr string) { - err := http.ListenAndServe(addr, nil) - if err != nil { - log.Fatal(err) - } + go http.ListenAndServe(redirectURL.Host, nil) } diff --git a/internal/auth/strategy.go b/internal/auth/strategy.go index 7e3c265..3d03fa4 100644 --- a/internal/auth/strategy.go +++ b/internal/auth/strategy.go @@ -24,14 +24,14 @@ import ( type OAuth2Strategy interface { Config() oauth2.Config - AuthCodeURL(verifier string, state string) AuthURL + AuthCodeURL(verifier string, state string) AuthUrl ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error) } -type AuthURL struct { +type AuthUrl struct { // The URL the user must visit to approve access - URL string + Url string // Random state string passed on to the callback. // Leave empty if the service does not support state. State string @@ -56,10 +56,10 @@ func (s StandardStrategy) Config() oauth2.Config { return s.conf } -func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthURL { +func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthUrl { url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) - return AuthURL{ - URL: url, + return AuthUrl{ + Url: url, State: state, Param: "code", } diff --git a/internal/auth/util.go b/internal/auth/util.go deleted file mode 100644 index 76b55b0..0000000 --- a/internal/auth/util.go +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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) -} diff --git a/internal/backends/auth.go b/internal/backends/auth.go index c17e9ba..d27efd6 100644 --- a/internal/backends/auth.go +++ b/internal/backends/auth.go @@ -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) { - authenticator, needAuth := backend.(auth.OAuth2Authenticator) + authenticator, needAuth := backend.(models.OAuth2Authenticator) if needAuth { redirectURL, err := BuildRedirectURL(config, backend.Name()) if err != nil { diff --git a/internal/backends/backends.go b/internal/backends/backends.go index 97a78c2..029cca7 100644 --- a/internal/backends/backends.go +++ b/internal/backends/backends.go @@ -17,26 +17,22 @@ Scotty. If not, see . package backends import ( + "errors" "fmt" "reflect" - "sort" "strings" + "github.com/spf13/viper" "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/funkwhale" "go.uploadedlobster.com/scotty/internal/backends/jspf" "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/maloja" "go.uploadedlobster.com/scotty/internal/backends/scrobblerlog" "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/config" - "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" ) @@ -46,29 +42,11 @@ type BackendInfo struct { 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 -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 - backend, err := backendWithConfig(config) if err != nil { return result, err } @@ -76,22 +54,15 @@ func ResolveBackend[T interface{}](config config.ServiceConfig) (T, error) { if implements { result = backend.(T) } 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 } -func BackendByName(backendName string) (models.Backend, error) { - backendType := knownBackends[backendName] - if backendType == nil { - return nil, fmt.Errorf(i18n.Tr("unknown backend \"%s\"", backendName)) - } - return backendType(), nil -} - -func GetBackends() BackendList { - backends := make(BackendList, 0) +func GetBackends() []BackendInfo { + backends := make([]BackendInfo, 0) for name, backendFunc := range knownBackends { backend := backendFunc() info := BackendInfo{ @@ -102,36 +73,29 @@ func GetBackends() BackendList { backends = append(backends, info) } - sort.Sort(backends) return backends } var knownBackends = map[string]func() models.Backend{ - "deezer": func() models.Backend { return &deezer.DeezerApiBackend{} }, - "deezer-history": func() models.Backend { return &deezerhistory.DeezerHistoryBackend{} }, - "dump": func() models.Backend { return &dump.DumpBackend{} }, - "funkwhale": func() models.Backend { return &funkwhale.FunkwhaleApiBackend{} }, - "jspf": func() models.Backend { return &jspf.JSPFBackend{} }, - "lastfm": func() models.Backend { return &lastfm.LastfmApiBackend{} }, - "listenbrainz": func() models.Backend { return &listenbrainz.ListenBrainzApiBackend{} }, - "listenbrainz-archive": func() models.Backend { return &lbarchive.ListenBrainzArchiveBackend{} }, - "maloja": func() models.Backend { return &maloja.MalojaApiBackend{} }, - "scrobbler-log": func() models.Backend { return &scrobblerlog.ScrobblerLogBackend{} }, - "spotify": func() models.Backend { return &spotify.SpotifyApiBackend{} }, - "spotify-history": func() models.Backend { return &spotifyhistory.SpotifyHistoryBackend{} }, - "subsonic": func() models.Backend { return &subsonic.SubsonicApiBackend{} }, + "deezer": func() models.Backend { return &deezer.DeezerApiBackend{} }, + "dump": func() models.Backend { return &dump.DumpBackend{} }, + "funkwhale": func() models.Backend { return &funkwhale.FunkwhaleApiBackend{} }, + "jspf": func() models.Backend { return &jspf.JSPFBackend{} }, + "lastfm": func() models.Backend { return &lastfm.LastfmApiBackend{} }, + "listenbrainz": func() models.Backend { return &listenbrainz.ListenBrainzApiBackend{} }, + "maloja": func() models.Backend { return &maloja.MalojaApiBackend{} }, + "scrobbler-log": func() models.Backend { return &scrobblerlog.ScrobblerLogBackend{} }, + "spotify": func() models.Backend { return &spotify.SpotifyApiBackend{} }, + "subsonic": func() models.Backend { return &subsonic.SubsonicApiBackend{} }, } -func backendWithConfig(config config.ServiceConfig) (models.Backend, error) { - backend, err := BackendByName(config.Backend) - if err != nil { - return nil, err +func resolveBackend(config *viper.Viper) (string, models.Backend, error) { + backendName := config.GetString("backend") + backendType := knownBackends[backendName] + if backendType == nil { + return backendName, nil, fmt.Errorf("Unknown backend %s", backendName) } - err = backend.InitConfig(&config) - if err != nil { - return nil, err - } - return backend, nil + return backendName, backendType().FromConfig(config), nil } func ImplementsInterface[T interface{}](backend *models.Backend) (bool, string) { diff --git a/internal/backends/backends_test.go b/internal/backends/backends_test.go index b30eb95..1af09a4 100644 --- a/internal/backends/backends_test.go +++ b/internal/backends/backends_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer This file is part of Scotty. @@ -18,52 +18,45 @@ Scotty. If not, see . package backends_test import ( + "reflect" "testing" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "go.uploadedlobster.com/scotty/internal/backends" "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/funkwhale" "go.uploadedlobster.com/scotty/internal/backends/jspf" "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/maloja" "go.uploadedlobster.com/scotty/internal/backends/scrobblerlog" "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/config" - "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" ) func TestResolveBackend(t *testing.T) { - c := viper.New() - c.Set("backend", "dump") - service := config.NewServiceConfig("test", c) - backend, err := backends.ResolveBackend[models.ListensImport](service) + config := viper.New() + config.Set("backend", "dump") + backend, err := backends.ResolveBackend[models.ListensImport](config) assert.NoError(t, err) assert.IsType(t, &dump.DumpBackend{}, backend) } func TestResolveBackendUnknown(t *testing.T) { - c := viper.New() - c.Set("backend", "foo") - service := config.NewServiceConfig("test", c) - _, err := backends.ResolveBackend[models.ListensImport](service) - assert.EqualError(t, err, i18n.Tr("unknown backend \"%s\"", "foo")) + config := viper.New() + config.Set("backend", "foo") + _, err := backends.ResolveBackend[models.ListensImport](config) + assert.EqualError(t, err, "Unknown backend foo") } func TestResolveBackendInvalidInterface(t *testing.T) { - c := viper.New() - c.Set("backend", "dump") - service := config.NewServiceConfig("test", c) - _, err := backends.ResolveBackend[models.ListensExport](service) - assert.EqualError(t, err, i18n.Tr("backend %s does not implement %s", "dump", "ListensExport")) + config := viper.New() + config.Set("backend", "dump") + _, err := backends.ResolveBackend[models.ListensExport](config) + assert.EqualError(t, err, "Backend dump does not implement ListensExport") } func TestGetBackends(t *testing.T) { @@ -79,7 +72,7 @@ func TestGetBackends(t *testing.T) { } // 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) { @@ -87,8 +80,6 @@ func TestImplementsInterfaces(t *testing.T) { expectInterface[models.LovesExport](t, &deezer.DeezerApiBackend{}) // expectInterface[models.LovesImport](t, &deezer.DeezerApiBackend{}) - expectInterface[models.ListensExport](t, &deezerhistory.DeezerHistoryBackend{}) - expectInterface[models.ListensImport](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.LovesImport](t, &funkwhale.FunkwhaleApiBackend{}) - expectInterface[models.ListensExport](t, &jspf.JSPFBackend{}) + // expectInterface[models.ListensExport](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.ListensExport](t, &lastfm.LastfmApiBackend{}) @@ -107,11 +98,6 @@ func TestImplementsInterfaces(t *testing.T) { expectInterface[models.LovesExport](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.ListensImport](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.LovesImport](t, &spotify.SpotifyApiBackend{}) - expectInterface[models.ListensExport](t, &spotifyhistory.SpotifyHistoryBackend{}) - expectInterface[models.ListensExport](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) { ok, name := backends.ImplementsInterface[T](&backend) if !ok { - t.Errorf("%v expected to implement %v", backend.Name(), name) + t.Errorf("%v expected to implement %v", reflect.TypeOf(backend).Name(), name) } } diff --git a/internal/backends/deezer/auth.go b/internal/backends/deezer/auth.go index 0304dec..aa30b04 100644 --- a/internal/backends/deezer/auth.go +++ b/internal/backends/deezer/auth.go @@ -33,10 +33,10 @@ func (s deezerStrategy) Config() oauth2.Config { return s.conf } -func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL { +func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl { url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) - return auth.AuthURL{ - URL: url, + return auth.AuthUrl{ + Url: url, State: state, Param: "code", } diff --git a/internal/backends/deezer/client.go b/internal/backends/deezer/client.go index 3ab2b6c..0d9cbb0 100644 --- a/internal/backends/deezer/client.go +++ b/internal/backends/deezer/client.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -23,7 +23,6 @@ THE SOFTWARE. package deezer import ( - "context" "errors" "strconv" @@ -37,7 +36,7 @@ const MaxItemsPerGet = 1000 const DefaultRateLimitWaitSeconds = 5 type Client struct { - HTTPClient *resty.Client + HttpClient *resty.Client token oauth2.TokenSource } @@ -48,19 +47,19 @@ func NewClient(token oauth2.TokenSource) Client { client.SetHeader("User-Agent", version.UserAgent()) client.SetRetryCount(5) return Client{ - HTTPClient: client, + HttpClient: client, 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" - 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" - return listRequest[TracksResult](ctx, c, path, offset, limit) + return listRequest[TracksResult](c, path, offset, limit) } func (c Client) setToken(req *resty.Request) error { @@ -73,21 +72,17 @@ func (c Client) setToken(req *resty.Request) error { return nil } -func listRequest[T Result](ctx context.Context, c Client, path string, offset int, limit int) (result T, err error) { - request := c.HTTPClient.R(). - SetContext(ctx). +func listRequest[T Result](c Client, path string, offset int, limit int) (result T, err error) { + request := c.HttpClient.R(). SetQueryParams(map[string]string{ "index": strconv.Itoa(offset), "limit": strconv.Itoa(limit), }). SetResult(&result) - err = c.setToken(request) - if err != nil { - return - } + c.setToken(request) response, err := request.Get(path) - if !response.IsSuccess() { + if response.StatusCode() != 200 { err = errors.New(response.String()) } else if result.Error() != nil { err = errors.New(result.Error().Message) diff --git a/internal/backends/deezer/client_test.go b/internal/backends/deezer/client_test.go index 8b61804..f8240f8 100644 --- a/internal/backends/deezer/client_test.go +++ b/internal/backends/deezer/client_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -23,7 +23,6 @@ THE SOFTWARE. package deezer_test import ( - "context" "net/http" "testing" @@ -45,12 +44,11 @@ func TestGetUserHistory(t *testing.T) { token := oauth2.StaticTokenSource(&oauth2.Token{}) client := deezer.NewClient(token) - setupHTTPMock(t, client.HTTPClient.GetClient(), + setupHttpMock(t, client.HttpClient.GetClient(), "https://api.deezer.com/user/me/history", "testdata/user-history.json") - ctx := context.Background() - result, err := client.UserHistory(ctx, 0, 2) + result, err := client.UserHistory(0, 2) require.NoError(t, err) assert := assert.New(t) @@ -67,12 +65,11 @@ func TestGetUserTracks(t *testing.T) { token := oauth2.StaticTokenSource(&oauth2.Token{}) client := deezer.NewClient(token) - setupHTTPMock(t, client.HTTPClient.GetClient(), + setupHttpMock(t, client.HttpClient.GetClient(), "https://api.deezer.com/user/me/tracks", "testdata/user-tracks.json") - ctx := context.Background() - result, err := client.UserTracks(ctx, 0, 2) + result, err := client.UserTracks(0, 2) require.NoError(t, err) assert := assert.New(t) @@ -84,7 +81,7 @@ func TestGetUserTracks(t *testing.T) { assert.Equal("Outland", track1.Track.Album.Title) } -func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) { +func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { httpmock.ActivateNonDefault(client) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) diff --git a/internal/backends/deezer/deezer.go b/internal/backends/deezer/deezer.go index f70a2c9..c35b8ae 100644 --- a/internal/backends/deezer/deezer.go +++ b/internal/backends/deezer/deezer.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer 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 @@ -16,56 +16,40 @@ Scotty. If not, see . package deezer import ( - "context" "fmt" "math" "net/url" "sort" "time" + "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/auth" - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" "golang.org/x/oauth2" ) type DeezerApiBackend struct { client Client - clientID string + clientId string clientSecret string } func (b *DeezerApiBackend) Name() string { return "deezer" } -func (b *DeezerApiBackend) Close() {} - -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") +func (b *DeezerApiBackend) FromConfig(config *viper.Viper) models.Backend { + b.clientId = config.GetString("client-id") 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{ - ClientID: b.clientID, + ClientID: b.clientId, ClientSecret: b.clientSecret, Scopes: []string{ "offline_access,basic_access,listening_history", }, - RedirectURL: redirectURL.String(), + RedirectURL: redirectUrl.String(), Endpoint: oauth2.Endpoint{ AuthURL: "https://connect.deezer.com/oauth/auth.php", TokenURL: "https://connect.deezer.com/oauth/access_token.php", @@ -80,42 +64,35 @@ func (b *DeezerApiBackend) OAuth2Setup(token oauth2.TokenSource) error { 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 // at the oldest one. offset := math.MaxInt32 perPage := MaxItemsPerGet - startTime := time.Now() - minTime := oldestTimestamp + defer close(results) - totalDuration := startTime.Sub(oldestTimestamp) - - p := models.TransferProgress{ - Export: &models.Progress{ - Total: int64(totalDuration.Seconds()), - }, - } + p := models.Progress{Total: int64(perPage)} + var totalCount int out: for { - result, err := b.client.UserHistory(ctx, offset, perPage) + result, err := b.client.UserHistory(offset, perPage) if err != nil { - p.Export.Abort() - progress <- p + progress <- p.Complete() results <- models.ListensResult{Error: err} return } - // No result, break immediately - if result.Total == 0 { - break out - } - // The offset was higher then the actual number of tracks. Adjust the offset // and continue. 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 } @@ -127,23 +104,18 @@ out: listens := make(models.ListensList, 0, perPage) for _, track := range result.Tracks { listen := track.AsListen() - if listen.ListenedAt.After(oldestTimestamp) { + if listen.ListenedAt.Unix() > oldestTimestamp.Unix() { listens = append(listens, listen) } else { + totalCount -= 1 break } } sort.Sort(listens) - if len(listens) > 0 { - minTime = listens[0].ListenedAt - } - - remainingTime := startTime.Sub(minTime) - p.Export.TotalItems += len(listens) - p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) + results <- models.ListensResult{Listens: listens, Total: totalCount} + p.Elapsed += int64(count) progress <- p - results <- models.ListensResult{Items: listens, OldestTimestamp: minTime} if offset <= 0 { // This was the last request, no further results @@ -156,30 +128,25 @@ out: } } - results <- models.ListensResult{OldestTimestamp: minTime} - p.Export.Complete() - progress <- p + progress <- p.Complete() } -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 // at the oldest one. offset := math.MaxInt32 perPage := MaxItemsPerGet - p := models.TransferProgress{ - Export: &models.Progress{ - Total: int64(perPage), - }, - } + defer close(results) + + p := models.Progress{Total: int64(perPage)} var totalCount int out: for { - result, err := b.client.UserTracks(ctx, offset, perPage) + result, err := b.client.UserTracks(offset, perPage) if err != nil { - p.Export.Abort() - progress <- p + progress <- p.Complete() results <- models.LovesResult{Error: err} return } @@ -187,9 +154,12 @@ out: // The offset was higher then the actual number of tracks. Adjust the offset // and continue. if offset >= result.Total { + p.Total = int64(result.Total) totalCount = result.Total - p.Export.Total = int64(totalCount) - offset = max(result.Total-perPage, 0) + offset = result.Total - perPage + if offset < 0 { + offset = 0 + } continue } @@ -201,18 +171,17 @@ out: loves := make(models.LovesList, 0, perPage) for _, track := range result.Tracks { love := track.AsLove() - if love.Created.After(oldestTimestamp) { + if love.Created.Unix() > oldestTimestamp.Unix() { loves = append(loves, love) } else { totalCount -= 1 + break } } sort.Sort(loves) - results <- models.LovesResult{Items: loves, Total: totalCount} - p.Export.TotalItems = totalCount - p.Export.Total = int64(totalCount) - p.Export.Elapsed += int64(count) + results <- models.LovesResult{Loves: loves, Total: totalCount} + p.Elapsed += int64(count) progress <- p if offset <= 0 { @@ -226,8 +195,7 @@ out: } } - p.Export.Complete() - progress <- p + progress <- p.Complete() } func (t Listen) AsListen() models.Listen { @@ -253,7 +221,7 @@ func (t Track) AsTrack() models.Track { TrackName: t.Title, ReleaseName: t.Album.Title, ArtistNames: []string{t.Artist.Name}, - Duration: time.Duration(t.Duration) * time.Second, + Duration: time.Duration(t.Duration * int(time.Second)), AdditionalInfo: map[string]any{}, } @@ -261,8 +229,8 @@ func (t Track) AsTrack() models.Track { info["music_service"] = "deezer.com" info["origin_url"] = t.Link info["deezer_id"] = t.Link - info["deezer_album_id"] = fmt.Sprintf("https://www.deezer.com/album/%v", t.Album.ID) - info["deezer_artist_id"] = fmt.Sprintf("https://www.deezer.com/artist/%v", t.Artist.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/track/%v", t.Artist.Id) return track } diff --git a/internal/backends/deezer/deezer_test.go b/internal/backends/deezer/deezer_test.go index 19776f4..c1bc23d 100644 --- a/internal/backends/deezer/deezer_test.go +++ b/internal/backends/deezer/deezer_test.go @@ -16,8 +16,8 @@ Scotty. If not, see . package deezer_test import ( - _ "embed" "encoding/json" + "os" "testing" "time" @@ -25,29 +25,21 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uploadedlobster.com/scotty/internal/backends/deezer" - "go.uploadedlobster.com/scotty/internal/config" ) -var ( - //go:embed testdata/listen.json - testListen []byte - //go:embed testdata/track.json - testTrack []byte -) - -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 TestFromConfig(t *testing.T) { + config := viper.New() + config.Set("client-id", "someclientid") + config.Set("client-secret", "someclientsecret") + backend := (&deezer.DeezerApiBackend{}).FromConfig(config) + assert.IsType(t, &deezer.DeezerApiBackend{}, backend) } func TestListenAsListen(t *testing.T) { + data, err := os.ReadFile("testdata/listen.json") + require.NoError(t, err) track := deezer.Listen{} - err := json.Unmarshal(testListen, &track) + err = json.Unmarshal(data, &track) require.NoError(t, err) listen := track.AsListen() 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, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["origin_url"]) assert.Equal(t, "https://www.deezer.com/track/14631511", listen.AdditionalInfo["deezer_id"]) - assert.Equal(t, "https://www.deezer.com/album/1346960", listen.AdditionalInfo["deezer_album_id"]) - assert.Equal(t, "https://www.deezer.com/artist/92", listen.AdditionalInfo["deezer_artist_id"]) } func TestLovedTrackAsLove(t *testing.T) { + data, err := os.ReadFile("testdata/track.json") + require.NoError(t, err) track := deezer.LovedTrack{} - err := json.Unmarshal(testTrack, &track) + err = json.Unmarshal(data, &track) require.NoError(t, err) love := track.AsLove() assert.Equal(t, time.Unix(1700743848, 0), love.Created) diff --git a/internal/backends/deezer/models.go b/internal/backends/deezer/models.go index 85b569c..712cd73 100644 --- a/internal/backends/deezer/models.go +++ b/internal/backends/deezer/models.go @@ -51,7 +51,7 @@ type HistoryResult struct { } type Track struct { - ID int `json:"id"` + Id int `json:"id"` Type string `json:"type"` Link string `json:"link"` Title string `json:"title"` @@ -75,7 +75,7 @@ type LovedTrack struct { } type Album struct { - ID int `json:"id"` + Id int `json:"id"` Type string `json:"type"` Link string `json:"link"` Title string `json:"title"` @@ -83,7 +83,7 @@ type Album struct { } type Artist struct { - ID int `json:"id"` + Id int `json:"id"` Type string `json:"type"` Link string `json:"link"` Name string `json:"name"` diff --git a/internal/backends/deezer/models_test.go b/internal/backends/deezer/models_test.go index 0fc6ab4..6a38b5c 100644 --- a/internal/backends/deezer/models_test.go +++ b/internal/backends/deezer/models_test.go @@ -16,8 +16,8 @@ Scotty. If not, see . package deezer_test import ( - _ "embed" "encoding/json" + "os" "testing" "github.com/stretchr/testify/assert" @@ -25,16 +25,11 @@ import ( "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) { + data, err := os.ReadFile("testdata/user-tracks.json") + require.NoError(t, err) result := deezer.TracksResult{} - err := json.Unmarshal(testUserTracks, &result) + err = json.Unmarshal(data, &result) require.NoError(t, err) assert := assert.New(t) @@ -50,8 +45,10 @@ func TestUserTracksResult(t *testing.T) { } func TestUserHistoryResult(t *testing.T) { + data, err := os.ReadFile("testdata/user-history.json") + require.NoError(t, err) result := deezer.HistoryResult{} - err := json.Unmarshal(testUserHistory, &result) + err = json.Unmarshal(data, &result) require.NoError(t, err) assert := assert.New(t) diff --git a/internal/backends/deezerhistory/deezerhistory.go b/internal/backends/deezerhistory/deezerhistory.go deleted file mode 100644 index 9c74368..0000000 --- a/internal/backends/deezerhistory/deezerhistory.go +++ /dev/null @@ -1,208 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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 . -*/ - -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 -} diff --git a/internal/backends/dump/dump.go b/internal/backends/dump/dump.go index b342ba5..a5c3751 100644 --- a/internal/backends/dump/dump.go +++ b/internal/backends/dump/dump.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer This file is part of Scotty. @@ -17,119 +17,40 @@ Scotty. If not, see . package dump import ( - "bytes" - "context" - "fmt" - "io" - "os" - "strings" - - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/i18n" + "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/models" ) -type DumpBackend struct { - buffer io.ReadWriter - print bool // Whether to print the output to stdout -} +type DumpBackend struct{} func (b *DumpBackend) Name() string { return "dump" } -func (b *DumpBackend) Close() {} - -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) FromConfig(config *viper.Viper) models.Backend { + return b } -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) FinishImport(result *models.ImportResult) error { - if b.print { - 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 - } +func (b *DumpBackend) StartImport() error { return nil } +func (b *DumpBackend) FinishImport() error { return nil } +func (b *DumpBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { + for _, listen := range export.Listens { importResult.UpdateTimestamp(listen.ListenedAt) importResult.ImportCount += 1 - _, err := fmt.Fprintf(b.buffer, "🎶 %v: \"%v\" by %v (%v)\n", - listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMBID) - if err != nil { - return importResult, err - } - progress <- models.TransferProgress{}.FromImportResult(importResult, false) + progress <- models.Progress{}.FromImportResult(importResult) + // fmt.Printf("🎶 %v: \"%v\" by %v (%v)\n", + // listen.ListenedAt, listen.TrackName, listen.ArtistName(), listen.RecordingMbid) } return importResult, nil } -func (b *DumpBackend) 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 *DumpBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { + for _, love := range export.Loves { importResult.UpdateTimestamp(love.Created) importResult.ImportCount += 1 - _, err := fmt.Fprintf(b.buffer, "❤️ %v: \"%v\" by %v (%v)\n", - love.Created, love.TrackName, love.ArtistName(), love.RecordingMBID) - if err != nil { - return importResult, err - } - progress <- models.TransferProgress{}.FromImportResult(importResult, false) + progress <- models.Progress{}.FromImportResult(importResult) + // fmt.Printf("❤️ %v: \"%v\" by %v (%v)\n", + // love.Created, love.TrackName, love.ArtistName(), love.RecordingMbid) } return importResult, nil diff --git a/internal/backends/export.go b/internal/backends/export.go deleted file mode 100644 index 29ae595..0000000 --- a/internal/backends/export.go +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright © 2023-2025 Philipp Wolfer - -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 . -*/ - -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) -} diff --git a/internal/backends/funkwhale/client.go b/internal/backends/funkwhale/client.go index 3471612..b757d6f 100644 --- a/internal/backends/funkwhale/client.go +++ b/internal/backends/funkwhale/client.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,25 +22,24 @@ THE SOFTWARE. package funkwhale import ( - "context" "errors" "strconv" "github.com/go-resty/resty/v2" + "go.uploadedlobster.com/scotty/internal/ratelimit" "go.uploadedlobster.com/scotty/internal/version" - "go.uploadedlobster.com/scotty/pkg/ratelimit" ) const MaxItemsPerGet = 50 type Client struct { - HTTPClient *resty.Client + HttpClient *resty.Client token string } -func NewClient(serverURL string, token string) Client { +func NewClient(serverUrl string, token string) Client { client := resty.New() - client.SetBaseURL(serverURL) + client.SetBaseURL(serverUrl) client.SetAuthScheme("Bearer") client.SetAuthToken(token) client.SetHeader("Accept", "application/json") @@ -50,44 +49,44 @@ func NewClient(serverURL string, token string) Client { ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After") return Client{ - HTTPClient: client, + HttpClient: client, 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" - response, err := c.buildListRequest(ctx, page, perPage). - SetQueryParam("username", user). + response, err := c.HttpClient.R(). + SetQueryParams(map[string]string{ + "username": user, + "page": strconv.Itoa(page), + "page_size": strconv.Itoa(perPage), + "ordering": "-creation_date", + }). SetResult(&result). Get(path) - if !response.IsSuccess() { + if response.StatusCode() != 200 { err = errors.New(response.String()) 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" - response, err := c.buildListRequest(ctx, page, perPage). - 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). + response, err := c.HttpClient.R(). SetQueryParams(map[string]string{ "page": strconv.Itoa(page), "page_size": strconv.Itoa(perPage), "ordering": "-creation_date", - }) + }). + SetResult(&result). + Get(path) + + if response.StatusCode() != 200 { + err = errors.New(response.String()) + return + } + return } diff --git a/internal/backends/funkwhale/client_test.go b/internal/backends/funkwhale/client_test.go index d6b04e0..89325cd 100644 --- a/internal/backends/funkwhale/client_test.go +++ b/internal/backends/funkwhale/client_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,6 @@ THE SOFTWARE. package funkwhale_test import ( - "context" "net/http" "testing" @@ -33,25 +32,24 @@ import ( ) func TestNewClient(t *testing.T) { - serverURL := "https://funkwhale.example.com" + serverUrl := "https://funkwhale.example.com" token := "foobar123" - client := funkwhale.NewClient(serverURL, token) - assert.Equal(t, serverURL, client.HTTPClient.BaseURL) - assert.Equal(t, token, client.HTTPClient.Token) + client := funkwhale.NewClient(serverUrl, token) + assert.Equal(t, serverUrl, client.HttpClient.BaseURL) + assert.Equal(t, token, client.HttpClient.Token) } func TestGetHistoryListenings(t *testing.T) { defer httpmock.DeactivateAndReset() - serverURL := "https://funkwhale.example.com" + serverUrl := "https://funkwhale.example.com" token := "thetoken" - client := funkwhale.NewClient(serverURL, token) - setupHTTPMock(t, client.HTTPClient.GetClient(), + client := funkwhale.NewClient(serverUrl, token) + setupHttpMock(t, client.HttpClient.GetClient(), "https://funkwhale.example.com/api/v1/history/listenings", "testdata/listenings.json") - ctx := context.Background() - result, err := client.GetHistoryListenings(ctx, "outsidecontext", 0, 2) + result, err := client.GetHistoryListenings("outsidecontext", 0, 2) require.NoError(t, err) assert := assert.New(t) @@ -69,14 +67,13 @@ func TestGetFavoriteTracks(t *testing.T) { defer httpmock.DeactivateAndReset() token := "thetoken" - serverURL := "https://funkwhale.example.com" - client := funkwhale.NewClient(serverURL, token) - setupHTTPMock(t, client.HTTPClient.GetClient(), + serverUrl := "https://funkwhale.example.com" + client := funkwhale.NewClient(serverUrl, token) + setupHttpMock(t, client.HttpClient.GetClient(), "https://funkwhale.example.com/api/v1/favorites/tracks", "testdata/favorite-tracks.json") - ctx := context.Background() - result, err := client.GetFavoriteTracks(ctx, 0, 2) + result, err := client.GetFavoriteTracks(0, 2) require.NoError(t, err) assert := assert.New(t) @@ -90,7 +87,7 @@ func TestGetFavoriteTracks(t *testing.T) { assert.Equal("phw", fav1.User.UserName) } -func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) { +func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { httpmock.ActivateNonDefault(client) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) diff --git a/internal/backends/funkwhale/funkwhale.go b/internal/backends/funkwhale/funkwhale.go index 8039ec2..617b90a 100644 --- a/internal/backends/funkwhale/funkwhale.go +++ b/internal/backends/funkwhale/funkwhale.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer This file is part of Scotty. @@ -17,13 +17,10 @@ Scotty. If not, see . package funkwhale import ( - "context" "sort" "time" - "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/i18n" + "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/models" ) @@ -36,53 +33,30 @@ type FunkwhaleApiBackend struct { func (b *FunkwhaleApiBackend) Name() string { return "funkwhale" } -func (b *FunkwhaleApiBackend) Close() {} - -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 { +func (b *FunkwhaleApiBackend) FromConfig(config *viper.Viper) models.Backend { b.client = NewClient( config.GetString("server-url"), config.GetString("token"), ) 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 perPage := MaxItemsPerGet + defer close(results) + // We need to gather the full list of listens in order to sort them listens := make(models.ListensList, 0, 2*perPage) - p := models.TransferProgress{ - Export: &models.Progress{ - Total: int64(perPage), - }, - } + p := models.Progress{Total: int64(perPage)} out: for { - result, err := b.client.GetHistoryListenings(ctx, b.username, page, perPage) + result, err := b.client.GetHistoryListenings(b.username, page, perPage) if err != nil { - p.Export.Abort() - progress <- p results <- models.ListensResult{Error: err} - return } count := len(result.Results) @@ -92,8 +66,8 @@ out: for _, fwListen := range result.Results { listen := fwListen.AsListen() - if listen.ListenedAt.After(oldestTimestamp) { - p.Export.Elapsed += 1 + if listen.ListenedAt.Unix() > oldestTimestamp.Unix() { + p.Elapsed += 1 listens = append(listens, listen) } else { break out @@ -102,42 +76,36 @@ out: if result.Next == "" { // No further results - p.Export.Total = p.Export.Elapsed - p.Export.Total -= int64(perPage - count) + p.Total = p.Elapsed + p.Total -= int64(perPage - count) break out } - p.Export.TotalItems = len(listens) - p.Export.Total += int64(perPage) + p.Total += int64(perPage) progress <- p page += 1 } sort.Sort(listens) - p.Export.TotalItems = len(listens) - p.Export.Complete() - progress <- p - results <- models.ListensResult{Items: listens} + progress <- p.Complete() + results <- models.ListensResult{Listens: 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 perPage := MaxItemsPerGet + defer close(results) + // We need to gather the full list of listens in order to sort them loves := make(models.LovesList, 0, 2*perPage) - p := models.TransferProgress{ - Export: &models.Progress{ - Total: int64(perPage), - }, - } + p := models.Progress{Total: int64(perPage)} out: for { - result, err := b.client.GetFavoriteTracks(ctx, page, perPage) + result, err := b.client.GetFavoriteTracks(page, perPage) if err != nil { - p.Export.Abort() - progress <- p + progress <- p.Complete() results <- models.LovesResult{Error: err} return } @@ -149,8 +117,8 @@ out: for _, favorite := range result.Results { love := favorite.AsLove() - if love.Created.After(oldestTimestamp) { - p.Export.Elapsed += 1 + if love.Created.Unix() > oldestTimestamp.Unix() { + p.Elapsed += 1 loves = append(loves, love) } else { break out @@ -162,17 +130,14 @@ out: break out } - p.Export.TotalItems = len(loves) - p.Export.Total += int64(perPage) + p.Total += int64(perPage) progress <- p page += 1 } sort.Sort(loves) - p.Export.TotalItems = len(loves) - p.Export.Complete() - progress <- p - results <- models.LovesResult{Items: loves} + progress <- p.Complete() + results <- models.LovesResult{Loves: loves} } func (l Listening) AsListen() models.Listen { @@ -193,7 +158,7 @@ func (f FavoriteTrack) AsLove() models.Love { track := f.Track.AsTrack() love := models.Love{ UserName: f.User.UserName, - RecordingMBID: track.RecordingMBID, + RecordingMbid: track.RecordingMbid, Track: track, } @@ -206,15 +171,16 @@ func (f FavoriteTrack) AsLove() models.Love { } func (t Track) AsTrack() models.Track { + recordingMbid := models.MBID(t.RecordingMbid) track := models.Track{ TrackName: t.Title, ReleaseName: t.Album.Title, ArtistNames: []string{t.Artist.Name}, TrackNumber: t.Position, DiscNumber: t.DiscNumber, - RecordingMBID: t.RecordingMBID, - ReleaseMBID: t.Album.ReleaseMBID, - ArtistMBIDs: []mbtypes.MBID{t.Artist.ArtistMBID}, + RecordingMbid: recordingMbid, + ReleaseMbid: models.MBID(t.Album.ReleaseMbid), + ArtistMbids: []models.MBID{models.MBID(t.Artist.ArtistMbid)}, Tags: t.Tags, AdditionalInfo: map[string]any{ "media_player": FunkwhaleClientName, @@ -222,7 +188,7 @@ func (t Track) AsTrack() models.Track { } 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 diff --git a/internal/backends/funkwhale/funkwhale_test.go b/internal/backends/funkwhale/funkwhale_test.go index 93ab97b..1047671 100644 --- a/internal/backends/funkwhale/funkwhale_test.go +++ b/internal/backends/funkwhale/funkwhale_test.go @@ -24,16 +24,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uploadedlobster.com/scotty/internal/backends/funkwhale" - "go.uploadedlobster.com/scotty/internal/config" + "go.uploadedlobster.com/scotty/internal/models" ) -func TestInitConfig(t *testing.T) { - c := viper.New() - c.Set("token", "thetoken") - service := config.NewServiceConfig("test", c) - backend := funkwhale.FunkwhaleApiBackend{} - err := backend.InitConfig(&service) - assert.NoError(t, err) +func TestFromConfig(t *testing.T) { + config := viper.New() + config.Set("token", "thetoken") + backend := (&funkwhale.FunkwhaleApiBackend{}).FromConfig(config) + assert.IsType(t, &funkwhale.FunkwhaleApiBackend{}, backend) } func TestFunkwhaleListeningAsListen(t *testing.T) { @@ -44,17 +42,17 @@ func TestFunkwhaleListeningAsListen(t *testing.T) { }, Track: funkwhale.Track{ Title: "Oweynagat", - RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", + RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", Position: 5, DiscNumber: 1, Tags: []string{"foo", "bar"}, Artist: funkwhale.Artist{ Name: "Dool", - ArtistMBID: "24412926-c7bd-48e8-afad-8a285b42e131", + ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131", }, Album: funkwhale.Album{ Title: "Here Now, There Then", - ReleaseMBID: "d7f22677-9803-4d21-ba42-081b633a6f68", + ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68", }, Uploads: []funkwhale.Upload{ { @@ -75,9 +73,9 @@ func TestFunkwhaleListeningAsListen(t *testing.T) { assert.Equal(fwListen.Track.DiscNumber, listen.Track.DiscNumber) assert.Equal(fwListen.Track.Tags, listen.Track.Tags) // assert.Equal(backends.FunkwhaleClientName, listen.AdditionalInfo["disc_number"]) - assert.Equal(fwListen.Track.RecordingMBID, listen.RecordingMBID) - assert.Equal(fwListen.Track.Album.ReleaseMBID, listen.ReleaseMBID) - assert.Equal(fwListen.Track.Artist.ArtistMBID, listen.ArtistMBIDs[0]) + assert.Equal(models.MBID(fwListen.Track.RecordingMbid), listen.RecordingMbid) + assert.Equal(models.MBID(fwListen.Track.Album.ReleaseMbid), listen.ReleaseMbid) + assert.Equal(models.MBID(fwListen.Track.Artist.ArtistMbid), listen.ArtistMbids[0]) assert.Equal(funkwhale.FunkwhaleClientName, listen.AdditionalInfo["media_player"]) } @@ -89,17 +87,17 @@ func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) { }, Track: funkwhale.Track{ Title: "Oweynagat", - RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", + RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", Position: 5, DiscNumber: 1, Tags: []string{"foo", "bar"}, Artist: funkwhale.Artist{ Name: "Dool", - ArtistMBID: "24412926-c7bd-48e8-afad-8a285b42e131", + ArtistMbid: "24412926-c7bd-48e8-afad-8a285b42e131", }, Album: funkwhale.Album{ Title: "Here Now, There Then", - ReleaseMBID: "d7f22677-9803-4d21-ba42-081b633a6f68", + ReleaseMbid: "d7f22677-9803-4d21-ba42-081b633a6f68", }, Uploads: []funkwhale.Upload{ { @@ -119,10 +117,10 @@ func TestFunkwhaleFavoriteTrackAsLove(t *testing.T) { assert.Equal(favorite.Track.Position, love.Track.TrackNumber) assert.Equal(favorite.Track.DiscNumber, love.Track.DiscNumber) assert.Equal(favorite.Track.Tags, love.Track.Tags) - assert.Equal(favorite.Track.RecordingMBID, love.RecordingMBID) - assert.Equal(favorite.Track.RecordingMBID, love.Track.RecordingMBID) - assert.Equal(favorite.Track.Album.ReleaseMBID, love.ReleaseMBID) - require.Len(t, love.Track.ArtistMBIDs, 1) - assert.Equal(favorite.Track.Artist.ArtistMBID, love.ArtistMBIDs[0]) + assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.RecordingMbid) + assert.Equal(models.MBID(favorite.Track.RecordingMbid), love.Track.RecordingMbid) + assert.Equal(models.MBID(favorite.Track.Album.ReleaseMbid), love.ReleaseMbid) + require.Len(t, love.Track.ArtistMbids, 1) + assert.Equal(models.MBID(favorite.Track.Artist.ArtistMbid), love.ArtistMbids[0]) assert.Equal(funkwhale.FunkwhaleClientName, love.AdditionalInfo["media_player"]) } diff --git a/internal/backends/funkwhale/models.go b/internal/backends/funkwhale/models.go index faaae12..6e0349e 100644 --- a/internal/backends/funkwhale/models.go +++ b/internal/backends/funkwhale/models.go @@ -21,8 +21,6 @@ THE SOFTWARE. */ package funkwhale -import "go.uploadedlobster.com/mbtypes" - type ListeningsResult struct { Count int `json:"count"` Previous string `json:"previous"` @@ -31,7 +29,7 @@ type ListeningsResult struct { } type Listening struct { - ID int `json:"int"` + Id int `json:"int"` User User `json:"user"` Track Track `json:"track"` CreationDate string `json:"creation_date"` @@ -45,41 +43,41 @@ type FavoriteTracksResult struct { } type FavoriteTrack struct { - ID int `json:"int"` + Id int `json:"int"` User User `json:"user"` Track Track `json:"track"` CreationDate string `json:"creation_date"` } type Track struct { - ID int `json:"int"` - Artist Artist `json:"artist"` - Album Album `json:"album"` - Title string `json:"title"` - Position int `json:"position"` - DiscNumber int `json:"disc_number"` - RecordingMBID mbtypes.MBID `json:"mbid"` - Tags []string `json:"tags"` - Uploads []Upload `json:"uploads"` + Id int `json:"int"` + Artist Artist `json:"artist"` + Album Album `json:"album"` + Title string `json:"title"` + Position int `json:"position"` + DiscNumber int `json:"disc_number"` + RecordingMbid string `json:"mbid"` + Tags []string `json:"tags"` + Uploads []Upload `json:"uploads"` } type Artist struct { - ID int `json:"int"` - Name string `json:"name"` - ArtistMBID mbtypes.MBID `json:"mbid"` + Id int `json:"int"` + Name string `json:"name"` + ArtistMbid string `json:"mbid"` } type Album struct { - ID int `json:"int"` - Title string `json:"title"` - AlbumArtist Artist `json:"artist"` - ReleaseDate string `json:"release_date"` - TrackCount int `json:"track_count"` - ReleaseMBID mbtypes.MBID `json:"mbid"` + Id int `json:"int"` + Title string `json:"title"` + AlbumArtist Artist `json:"artist"` + ReleaseDate string `json:"release_date"` + TrackCount int `json:"track_count"` + ReleaseMbid string `json:"mbid"` } type User struct { - ID int `json:"int"` + Id int `json:"int"` UserName string `json:"username"` } diff --git a/internal/backends/import.go b/internal/backends/import.go deleted file mode 100644 index ae6da92..0000000 --- a/internal/backends/import.go +++ /dev/null @@ -1,141 +0,0 @@ -/* -Copyright © 2023-2025 Philipp Wolfer - -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 . -*/ - -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 -} diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index cdbb23e..26bc57f 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer This file is part of Scotty. @@ -18,287 +18,121 @@ Scotty. If not, see . package jspf import ( - "context" - "errors" "os" - "sort" - "strings" "time" - "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/i18n" + "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/models" "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 { - filePath string - playlist jspf.Playlist - append bool + filePath string + title string + creator string + identifier string + tracks []jspf.Track } func (b *JSPFBackend) Name() string { return "jspf" } -func (b *JSPFBackend) Close() {} - -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 { +func (b *JSPFBackend) FromConfig(config *viper.Viper) models.Backend { b.filePath = config.GetString("file-path") - b.append = config.GetBool("append", true) - b.playlist = jspf.Playlist{ - Title: config.GetString("title"), - Creator: config.GetString("username"), - Identifier: config.GetString("identifier"), - Date: time.Now(), - Tracks: make([]jspf.Track, 0), - } - - b.addMusicBrainzPlaylistExtension() - return nil + b.title = config.GetString("title") + b.creator = config.GetString("username") + b.identifier = config.GetString("identifier") + b.tracks = make([]jspf.Track, 0) + return b } -func (b *JSPFBackend) StartImport() error { - return b.readJSPF() +func (b *JSPFBackend) StartImport() error { return nil } +func (b *JSPFBackend) FinishImport() error { + err := b.writeJSPF(b.tracks) + return err } -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) - b.playlist.Tracks = append(b.playlist.Tracks, track) + b.tracks = append(b.tracks, track) importResult.ImportCount += 1 importResult.UpdateTimestamp(listen.ListenedAt) } + progress <- models.Progress{}.FromImportResult(importResult) return importResult, nil } -func (b *JSPFBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { - err := b.readJSPF() - 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 - } - +func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { + for _, love := range export.Loves { track := loveAsTrack(love) - b.playlist.Tracks = append(b.playlist.Tracks, track) + b.tracks = append(b.tracks, track) importResult.ImportCount += 1 importResult.UpdateTimestamp(love.Created) } + progress <- models.Progress{}.FromImportResult(importResult) return importResult, nil } func listenAsTrack(l models.Listen) jspf.Track { l.FillAdditionalInfo() - track := trackAsJSPFTrack(l.Track) + track := trackAsTrack(l.Track) extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.ListenedAt extension.AddedBy = l.UserName - track.Extension[jspf.MusicBrainzTrackExtensionID] = extension + track.Extension[jspf.MusicBrainzTrackExtensionId] = extension - if l.RecordingMBID != "" { - track.Identifier = append(track.Identifier, recordingMBIDPrefix+string(l.RecordingMBID)) + if l.RecordingMbid != "" { + track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid)) } 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 { l.FillAdditionalInfo() - track := trackAsJSPFTrack(l.Track) + track := trackAsTrack(l.Track) extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.Created extension.AddedBy = l.UserName - track.Extension[jspf.MusicBrainzTrackExtensionID] = extension + track.Extension[jspf.MusicBrainzTrackExtensionId] = extension - recordingMBID := l.Track.RecordingMBID - if l.RecordingMBID != "" { - recordingMBID = l.RecordingMBID + recordingMbid := l.Track.RecordingMbid + if l.RecordingMbid != "" { + recordingMbid = l.RecordingMbid } - if recordingMBID != "" { - track.Identifier = append(track.Identifier, recordingMBIDPrefix+string(recordingMBID)) + if recordingMbid != "" { + track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMbid)) } return track } -func trackAsLove(t jspf.Track) (*models.Love, error) { - 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 { +func trackAsTrack(t models.Track) jspf.Track { track := jspf.Track{ Title: t.TrackName, Album: t.ReleaseName, Creator: t.ArtistName(), TrackNum: t.TrackNumber, - Duration: t.Duration.Milliseconds(), - Extension: jspf.ExtensionMap{}, + Extension: map[string]any{}, } 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 { extension := jspf.MusicBrainzTrackExtension{ AdditionalMetadata: t.AdditionalInfo, - ArtistIdentifiers: make([]string, len(t.ArtistMBIDs)), + ArtistIdentifiers: make([]string, len(t.ArtistMbids)), } - for i, mbid := range t.ArtistMBIDs { - extension.ArtistIdentifiers[i] = artistMBIDPrefix + string(mbid) + for i, mbid := range t.ArtistMbids { + extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid) } - if t.ReleaseMBID != "" { - extension.ReleaseIdentifier = releaseMBIDPrefix + string(t.ReleaseMBID) + if t.ReleaseMbid != "" { + extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMbid) } // The tracknumber tag would be redundant @@ -307,58 +141,15 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { return extension } -func readMusicBrainzExtension(jspfTrack jspf.Track, outputTrack *models.Track) (*jspf.MusicBrainzTrackExtension, 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 { +func (b JSPFBackend) writeJSPF(tracks []jspf.Track) error { 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) @@ -369,13 +160,3 @@ func (b *JSPFBackend) writeJSPF() error { defer file.Close() 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 -} diff --git a/internal/backends/jspf/jspf_test.go b/internal/backends/jspf/jspf_test.go index bf4f99d..08c5b2e 100644 --- a/internal/backends/jspf/jspf_test.go +++ b/internal/backends/jspf/jspf_test.go @@ -22,18 +22,15 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" - "go.uploadedlobster.com/scotty/internal/backends/jspf" - "go.uploadedlobster.com/scotty/internal/config" + "go.uploadedlobster.com/scotty/internal/backends/scrobblerlog" ) -func TestInitConfig(t *testing.T) { - c := viper.New() - c.Set("file-path", "/foo/bar.jspf") - c.Set("title", "My Playlist") - c.Set("username", "outsidecontext") - c.Set("identifier", "http://example.com/playlist1") - service := config.NewServiceConfig("test", c) - backend := jspf.JSPFBackend{} - err := backend.InitConfig(&service) - assert.NoError(t, err) +func TestFromConfig(t *testing.T) { + config := viper.New() + config.Set("file-path", "/foo/bar.jspf") + config.Set("title", "My Playlist") + config.Set("username", "outsidecontext") + config.Set("identifier", "http://example.com/playlist1") + backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(config) + assert.IsType(t, &scrobblerlog.ScrobblerLogBackend{}, backend) } diff --git a/internal/backends/lastfm/auth.go b/internal/backends/lastfm/auth.go index 9d43e0c..bbc65bc 100644 --- a/internal/backends/lastfm/auth.go +++ b/internal/backends/lastfm/auth.go @@ -25,21 +25,21 @@ import ( type lastfmStrategy struct { client *lastfm.Api - redirectURL *url.URL + redirectUrl *url.URL } func (s lastfmStrategy) Config() oauth2.Config { return oauth2.Config{} } -func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthURL { +func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl { // Last.fm does not use OAuth2, but the provided authorization flow with // callback URL is close enough we can shoehorn it into the existing // authentication strategy. // TODO: Investigate and use callback-less flow with api.GetAuthTokenUrl(token) - url := s.client.GetAuthRequestUrl(s.redirectURL.String()) - return auth.AuthURL{ - URL: url, + url := s.client.GetAuthRequestUrl(s.redirectUrl.String()) + return auth.AuthUrl{ + Url: url, State: "", // last.fm does not use state Param: "token", } diff --git a/internal/backends/lastfm/lastfm.go b/internal/backends/lastfm/lastfm.go index ebe226a..e79c82c 100644 --- a/internal/backends/lastfm/lastfm.go +++ b/internal/backends/lastfm/lastfm.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer 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 @@ -16,7 +16,7 @@ Scotty. If not, see . package lastfm import ( - "context" + "errors" "fmt" "net/url" "sort" @@ -24,10 +24,8 @@ import ( "time" "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/config" - "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" "golang.org/x/oauth2" ) @@ -46,41 +44,21 @@ type LastfmApiBackend struct { func (b *LastfmApiBackend) Name() string { return "lastfm" } -func (b *LastfmApiBackend) Close() {} - -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") +func (b *LastfmApiBackend) FromConfig(config *viper.Viper) models.Backend { + clientId := config.GetString("client-id") clientSecret := config.GetString("client-secret") - b.client = lastfm.New(clientID, clientSecret) + b.client = lastfm.New(clientId, clientSecret) b.username = config.GetString("username") - return nil + return b } -func (b *LastfmApiBackend) StartImport() error { return nil } -func (b *LastfmApiBackend) FinishImport(result *models.ImportResult) error { - return nil -} +func (b *LastfmApiBackend) StartImport() error { return nil } +func (b *LastfmApiBackend) FinishImport() error { return nil } -func (b *LastfmApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Strategy { +func (b *LastfmApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { return lastfmStrategy{ client: b.client, - redirectURL: redirectURL, + redirectUrl: redirectUrl, } } @@ -93,27 +71,18 @@ func (b *LastfmApiBackend) OAuth2Setup(token oauth2.TokenSource) error { 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 minTime := oldestTimestamp perPage := MaxItemsPerGet + defer close(results) + // We need to gather the full list of listens in order to sort them - p := models.TransferProgress{ - Export: &models.Progress{ - Total: int64(page), - }, - } + p := models.Progress{Total: int64(page)} out: for page > 0 { - if err := ctx.Err(); err != nil { - results <- models.ListensResult{Error: err} - p.Export.Abort() - progress <- p - return - } - args := lastfm.P{ "user": b.username, "limit": MaxListensPerGet, @@ -124,8 +93,7 @@ out: result, err := b.client.User.GetRecentTracks(args) if err != nil { results <- models.ListensResult{Error: err} - p.Export.Abort() - progress <- p + progress <- p.Complete() return } @@ -144,12 +112,11 @@ out: timestamp, err := strconv.ParseInt(scrobble.Date.Uts, 10, 64) if err != nil { results <- models.ListensResult{Error: err} - p.Export.Abort() - progress <- p + progress <- p.Complete() break out } if timestamp > oldestTimestamp.Unix() { - p.Export.Elapsed += 1 + p.Elapsed += 1 listen := models.Listen{ ListenedAt: time.Unix(timestamp, 0), UserName: b.username, @@ -157,16 +124,16 @@ out: TrackName: scrobble.Name, ArtistNames: []string{}, ReleaseName: scrobble.Album.Name, - RecordingMBID: mbtypes.MBID(scrobble.Mbid), - ArtistMBIDs: []mbtypes.MBID{}, - ReleaseMBID: mbtypes.MBID(scrobble.Album.Mbid), + RecordingMbid: models.MBID(scrobble.Mbid), + ArtistMbids: []models.MBID{}, + ReleaseMbid: models.MBID(scrobble.Album.Mbid), }, } if scrobble.Artist.Name != "" { listen.Track.ArtistNames = []string{scrobble.Artist.Name} } 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) } else { @@ -179,29 +146,23 @@ out: page -= 1 results <- models.ListensResult{ - Items: listens, + Listens: listens, Total: result.Total, OldestTimestamp: minTime, } - p.Export.Total = int64(result.TotalPages) - p.Export.Elapsed = int64(result.TotalPages - page) - p.Export.TotalItems += len(listens) + p.Total = int64(result.TotalPages) + p.Elapsed = int64(result.TotalPages - page) progress <- p } results <- models.ListensResult{OldestTimestamp: minTime} - p.Export.Complete() - progress <- p + progress <- p.Complete() } -func (b *LastfmApiBackend) ImportListens(ctx context.Context, export models.ListensResult, importResult models.ImportResult, progress chan models.TransferProgress) (models.ImportResult, error) { - total := len(export.Items) +func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { + total := len(export.Listens) for i := 0; i < total; i += MaxListensPerSubmission { - if err := ctx.Err(); err != nil { - return importResult, err - } - - listens := export.Items[i:min(i+MaxListensPerSubmission, total)] + listens := export.Listens[i:min(i+MaxListensPerSubmission, total)] count := len(listens) if count == 0 { break @@ -226,8 +187,8 @@ func (b *LastfmApiBackend) ImportListens(ctx context.Context, export models.List if l.TrackNumber > 0 { trackNumbers = append(trackNumbers, strconv.Itoa(l.TrackNumber)) } - if l.RecordingMBID != "" { - mbids = append(mbids, string(l.RecordingMBID)) + if l.RecordingMbid != "" { + mbids = append(mbids, string(l.RecordingMbid)) } // if l.ReleaseArtist != "" { // albumArtists = append(albums, l.ReleaseArtist) @@ -259,56 +220,47 @@ func (b *LastfmApiBackend) ImportListens(ctx context.Context, export models.List for _, s := range result.Scrobbles { ignoreMsg := s.IgnoredMessage.Body if ignoreMsg != "" { - importResult.Log(models.Warning, ignoreMsg) + importResult.ImportErrors = append(importResult.ImportErrors, ignoreMsg) } } - err := fmt.Errorf("last.fm import ignored %v scrobbles", count-accepted) - return importResult, err + errMsg := fmt.Sprintf("Last.fm import ignored %v scrobbles", count-accepted) + return importResult, errors.New(errMsg) } importResult.UpdateTimestamp(listens[count-1].ListenedAt) importResult.ImportCount += accepted - progress <- models.TransferProgress{}.FromImportResult(importResult, false) + progress <- models.Progress{}.FromImportResult(importResult) } 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 // at the oldest one. page := 1 perPage := MaxItemsPerGet + defer close(results) + loves := make(models.LovesList, 0, 2*MaxItemsPerGet) - p := models.TransferProgress{ - Export: &models.Progress{ - Total: int64(perPage), - }, - } + p := models.Progress{Total: int64(perPage)} var totalCount int out: 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{ "user": b.username, "limit": MaxItemsPerGet, "page": page, }) if err != nil { - p.Export.Abort() - progress <- p + progress <- p.Complete() results <- models.LovesResult{Error: err} return } + p.Total = int64(result.Total) count := len(result.Tracks) if count == 0 { break out @@ -317,8 +269,7 @@ out: for _, track := range result.Tracks { timestamp, err := strconv.ParseInt(track.Date.Uts, 10, 64) if err != nil { - p.Export.Abort() - progress <- p + progress <- p.Complete() results <- models.LovesResult{Error: err} return } @@ -327,12 +278,12 @@ out: love := models.Love{ Created: time.Unix(timestamp, 0), UserName: result.User, - RecordingMBID: mbtypes.MBID(track.Mbid), + RecordingMbid: models.MBID(track.Mbid), Track: models.Track{ TrackName: track.Name, ArtistNames: []string{track.Artist.Name}, - RecordingMBID: mbtypes.MBID(track.Mbid), - ArtistMBIDs: []mbtypes.MBID{mbtypes.MBID(track.Artist.Mbid)}, + RecordingMbid: models.MBID(track.Mbid), + ArtistMbids: []models.MBID{models.MBID(track.Artist.Mbid)}, AdditionalInfo: models.AdditionalInfo{ "lastfm_url": track.Url, }, @@ -344,26 +295,19 @@ out: } } - p.Export.Total += int64(perPage) - p.Export.TotalItems = totalCount - p.Export.Elapsed += int64(count) + p.Elapsed += int64(count) progress <- p page += 1 } sort.Sort(loves) - p.Export.Complete() - progress <- p - results <- models.LovesResult{Items: loves, Total: totalCount} + results <- models.LovesResult{Loves: loves, Total: totalCount} + progress <- p.Complete() } -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{ "track": love.TrackName, "artist": love.ArtistName(), @@ -375,10 +319,10 @@ func (b *LastfmApiBackend) ImportLoves(ctx context.Context, export models.LovesR } else { msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v", 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 diff --git a/internal/backends/lbarchive/lbarchive.go b/internal/backends/lbarchive/lbarchive.go deleted file mode 100644 index ce23795..0000000 --- a/internal/backends/lbarchive/lbarchive.go +++ /dev/null @@ -1,224 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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 -} diff --git a/internal/backends/lbarchive/lbarchive_test.go b/internal/backends/lbarchive/lbarchive_test.go deleted file mode 100644 index b7e164a..0000000 --- a/internal/backends/lbarchive/lbarchive_test.go +++ /dev/null @@ -1,40 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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) -} diff --git a/internal/listenbrainz/client.go b/internal/backends/listenbrainz/client.go similarity index 64% rename from internal/listenbrainz/client.go rename to internal/backends/listenbrainz/client.go index 270bf4b..aa30b78 100644 --- a/internal/listenbrainz/client.go +++ b/internal/backends/listenbrainz/client.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,14 +22,13 @@ THE SOFTWARE. package listenbrainz import ( - "context" "errors" "strconv" "time" "github.com/go-resty/resty/v2" - "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/scotty/pkg/ratelimit" + "go.uploadedlobster.com/scotty/internal/ratelimit" + "go.uploadedlobster.com/scotty/internal/version" ) const ( @@ -40,32 +39,31 @@ const ( ) type Client struct { - HTTPClient *resty.Client + HttpClient *resty.Client MaxResults int } -func NewClient(token string, userAgent string) Client { +func NewClient(token string) Client { client := resty.New() client.SetBaseURL(listenBrainzBaseURL) client.SetAuthScheme("Token") client.SetAuthToken(token) 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) ratelimit.EnableHTTPHeaderRateLimit(client, "X-RateLimit-Reset-In") return Client{ - HTTPClient: client, + HttpClient: client, 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" errorResult := ErrorResult{} - response, err := c.HTTPClient.R(). - SetContext(ctx). + response, err := c.HttpClient.R(). SetPathParam("username", user). SetQueryParams(map[string]string{ "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). Get(path) - if !response.IsSuccess() { + if response.StatusCode() != 200 { err = errors.New(errorResult.Error) 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" errorResult := ErrorResult{} - response, err := c.HTTPClient.R(). - SetContext(ctx). + response, err := c.HttpClient.R(). SetBody(listens). SetResult(&result). SetError(&errorResult). Post(path) - if !response.IsSuccess() { + if response.StatusCode() != 200 { err = errors.New(errorResult.Error) 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" errorResult := ErrorResult{} - response, err := c.HTTPClient.R(). - SetContext(ctx). + response, err := c.HttpClient.R(). SetPathParam("username", user). SetQueryParams(map[string]string{ "status": strconv.Itoa(status), @@ -116,35 +112,33 @@ func (c Client) GetFeedback(ctx context.Context, user string, status int, offset SetError(&errorResult). Get(path) - if !response.IsSuccess() { + if response.StatusCode() != 200 { err = errors.New(errorResult.Error) 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" errorResult := ErrorResult{} - response, err := c.HTTPClient.R(). - SetContext(ctx). + response, err := c.HttpClient.R(). SetBody(feedback). SetResult(&result). SetError(&errorResult). Post(path) - if !response.IsSuccess() { + if response.StatusCode() != 200 { err = errors.New(errorResult.Error) 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" errorResult := ErrorResult{} - response, err := c.HTTPClient.R(). - SetContext(ctx). + response, err := c.HttpClient.R(). SetQueryParams(map[string]string{ "recording_name": recordingName, "artist_name": artistName, @@ -153,28 +147,7 @@ func (c Client) Lookup(ctx context.Context, recordingName string, artistName str SetError(&errorResult). Get(path) - if !response.IsSuccess() { - 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() { + if response.StatusCode() != 200 { err = errors.New(errorResult.Error) return } diff --git a/internal/listenbrainz/client_test.go b/internal/backends/listenbrainz/client_test.go similarity index 68% rename from internal/listenbrainz/client_test.go rename to internal/backends/listenbrainz/client_test.go index 9baf293..faabbe1 100644 --- a/internal/listenbrainz/client_test.go +++ b/internal/backends/listenbrainz/client_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,6 @@ THE SOFTWARE. package listenbrainz_test import ( - "context" "net/http" "testing" "time" @@ -30,42 +29,37 @@ import ( "github.com/jarcoal/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/scotty/internal/listenbrainz" + "go.uploadedlobster.com/scotty/internal/backends/listenbrainz" ) func TestNewClient(t *testing.T) { token := "foobar123" - client := listenbrainz.NewClient(token, "test/1.0") - assert.Equal(t, token, client.HTTPClient.Token) + client := listenbrainz.NewClient(token) + assert.Equal(t, token, client.HttpClient.Token) assert.Equal(t, listenbrainz.DefaultItemsPerGet, client.MaxResults) } func TestGetListens(t *testing.T) { defer httpmock.DeactivateAndReset() - client := listenbrainz.NewClient("thetoken", "test/1.0") + client := listenbrainz.NewClient("thetoken") client.MaxResults = 2 - setupHTTPMock(t, client.HTTPClient.GetClient(), + setupHttpMock(t, client.HttpClient.GetClient(), "https://api.listenbrainz.org/1/user/outsidecontext/listens", "testdata/listens.json") - ctx := context.Background() - result, err := client.GetListens(ctx, "outsidecontext", - time.Now(), time.Now().Add(-2*time.Hour)) + result, err := client.GetListens("outsidecontext", time.Now(), time.Now().Add(-2*time.Hour)) require.NoError(t, err) assert := assert.New(t) 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) assert.Equal("Shadowplay", result.Payload.Listens[0].TrackMetadata.TrackName) } func TestSubmitListens(t *testing.T) { - client := listenbrainz.NewClient("thetoken", "test/1.0") - httpmock.ActivateNonDefault(client.HTTPClient.GetClient()) + client := listenbrainz.NewClient("thetoken") + httpmock.ActivateNonDefault(client.HttpClient.GetClient()) responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{ Status: "ok", @@ -95,8 +89,8 @@ func TestSubmitListens(t *testing.T) { }, }, } - ctx := context.Background() - result, err := client.SubmitListens(ctx, listens) + result, err := client.SubmitListens(listens) + require.NoError(t, err) assert.Equal(t, "ok", result.Status) } @@ -104,14 +98,13 @@ func TestSubmitListens(t *testing.T) { func TestGetFeedback(t *testing.T) { defer httpmock.DeactivateAndReset() - client := listenbrainz.NewClient("thetoken", "test/1.0") + client := listenbrainz.NewClient("thetoken") client.MaxResults = 2 - setupHTTPMock(t, client.HTTPClient.GetClient(), + setupHttpMock(t, client.HttpClient.GetClient(), "https://api.listenbrainz.org/1/feedback/user/outsidecontext/get-feedback", "testdata/feedback.json") - ctx := context.Background() - result, err := client.GetFeedback(ctx, "outsidecontext", 1, 0) + result, err := client.GetFeedback("outsidecontext", 1, 3) require.NoError(t, err) assert := assert.New(t) @@ -119,12 +112,12 @@ func TestGetFeedback(t *testing.T) { assert.Equal(302, result.TotalCount) assert.Equal(3, result.Offset) 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) { - client := listenbrainz.NewClient("thetoken", "test/1.0") - httpmock.ActivateNonDefault(client.HTTPClient.GetClient()) + client := listenbrainz.NewClient("thetoken") + httpmock.ActivateNonDefault(client.HttpClient.GetClient()) responder, err := httpmock.NewJsonResponder(200, listenbrainz.StatusResult{ Status: "ok", @@ -136,11 +129,10 @@ func TestSendFeedback(t *testing.T) { httpmock.RegisterResponder("POST", url, responder) feedback := listenbrainz.Feedback{ - RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", + RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", Score: 1, } - ctx := context.Background() - result, err := client.SendFeedback(ctx, feedback) + result, err := client.SendFeedback(feedback) require.NoError(t, err) assert.Equal(t, "ok", result.Status) @@ -149,22 +141,21 @@ func TestSendFeedback(t *testing.T) { func TestLookup(t *testing.T) { defer httpmock.DeactivateAndReset() - client := listenbrainz.NewClient("thetoken", "test/1.0") - setupHTTPMock(t, client.HTTPClient.GetClient(), + client := listenbrainz.NewClient("thetoken") + setupHttpMock(t, client.HttpClient.GetClient(), "https://api.listenbrainz.org/1/metadata/lookup", "testdata/lookup.json") - ctx := context.Background() - result, err := client.Lookup(ctx, "Paradise Lost", "Say Just Words") + result, err := client.Lookup("Paradise Lost", "Say Just Words") require.NoError(t, err) assert := assert.New(t) assert.Equal("Say Just Words", result.RecordingName) 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) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) diff --git a/internal/backends/listenbrainz/helper.go b/internal/backends/listenbrainz/helper.go deleted file mode 100644 index d6572d0..0000000 --- a/internal/backends/listenbrainz/helper.go +++ /dev/null @@ -1,190 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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, - }, - } -} diff --git a/internal/backends/listenbrainz/listenbrainz.go b/internal/backends/listenbrainz/listenbrainz.go index b809b47..cafcc0f 100644 --- a/internal/backends/listenbrainz/listenbrainz.go +++ b/internal/backends/listenbrainz/listenbrainz.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer This file is part of Scotty. @@ -17,171 +17,103 @@ Scotty. If not, see . package listenbrainz import ( - "context" "fmt" "sort" "time" - "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/musicbrainzws2" - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/i18n" - "go.uploadedlobster.com/scotty/internal/listenbrainz" + "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/models" - "go.uploadedlobster.com/scotty/internal/similarity" "go.uploadedlobster.com/scotty/internal/version" ) -const lovesBatchSize = listenbrainz.MaxItemsPerGet - type ListenBrainzApiBackend struct { - client listenbrainz.Client - mbClient *musicbrainzws2.Client - username string - checkDuplicates bool - existingMBIDs map[mbtypes.MBID]bool -} - -func (b *ListenBrainzApiBackend) Close() { - if b.mbClient != nil { - b.mbClient.Close() - } + client Client + username string + existingMbids map[string]bool } func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" } -func (b *ListenBrainzApiBackend) Options() []models.BackendOption { - return []models.BackendOption{{ - Name: "username", - 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 +func (b *ListenBrainzApiBackend) FromConfig(config *viper.Viper) models.Backend { + b.client = NewClient(config.GetString("token")) + b.client.MaxResults = MaxItemsPerGet b.username = config.GetString("username") - b.checkDuplicates = config.GetBool("check-duplicate-listens", false) - return nil + return b } -func (b *ListenBrainzApiBackend) StartImport() error { return nil } -func (b *ListenBrainzApiBackend) FinishImport(result *models.ImportResult) error { - return nil -} +func (b *ListenBrainzApiBackend) StartImport() error { return nil } +func (b *ListenBrainzApiBackend) FinishImport() error { 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() - minTime := oldestTimestamp - if minTime.Unix() < 1 { - minTime = time.Unix(1, 0) - } + maxTime := startTime + minTime := time.Unix(0, 0) 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 { - result, err := b.client.GetListens(ctx, b.username, time.Now(), minTime) + result, err := b.client.GetListens(b.username, maxTime, minTime) if err != nil { - p.Export.Abort() - progress <- p + progress <- p.Complete() results <- models.ListensResult{Error: err} return } count := len(result.Payload.Listens) 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 - minTime = time.Unix(result.Payload.Listens[0].ListenedAt, 0) - remainingTime := startTime.Sub(minTime) - - listens := make(models.ListensList, 0, count) + // Set maxTime to the oldest returned listen + maxTime = time.Unix(result.Payload.Listens[count-1].ListenedAt, 0) + remainingTime := maxTime.Sub(oldestTimestamp) for _, listen := range result.Payload.Listens { if listen.ListenedAt > oldestTimestamp.Unix() { - listens = append(listens, AsListen(listen)) + listens = append(listens, listen.AsListen()) } else { - // result contains listens older then oldestTimestamp - break + // result contains listens older then oldestTimestamp, + // we can stop requesting more + p.Total = int64(startTime.Sub(time.Unix(listen.ListenedAt, 0)).Seconds()) + break out } } - sort.Sort(listens) - p.Export.TotalItems += len(listens) - p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) + p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) progress <- p - results <- models.ListensResult{Items: listens, OldestTimestamp: minTime} } - results <- models.ListensResult{OldestTimestamp: minTime} - p.Export.Complete() - progress <- p + sort.Sort(listens) + progress <- p.Complete() + results <- models.ListensResult{Listens: listens, OldestTimestamp: oldestTimestamp} } -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)] +func (b *ListenBrainzApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { + total := len(export.Listens) + for i := 0; i < total; i += MaxListensPerRequest { + listens := export.Listens[i:min(i+MaxListensPerRequest, total)] count := len(listens) if count == 0 { break } - submission := listenbrainz.ListenSubmission{ - ListenType: listenbrainz.Import, - Payload: make([]listenbrainz.Listen, 0, count), + submission := ListenSubmission{ + ListenType: Import, + Payload: make([]Listen, 0, count), } 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() - listen := listenbrainz.Listen{ + listen := Listen{ ListenedAt: l.ListenedAt.Unix(), - TrackMetadata: listenbrainz.Track{ + TrackMetadata: Track{ TrackName: l.TrackName, ReleaseName: l.ReleaseName, 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"] = version.AppVersion - submission.Payload = append(submission.Payload, listen) } - if len(submission.Payload) > 0 { - _, err := b.client.SubmitListens(ctx, submission) - if err != nil { - return importResult, err - } + _, err := b.client.SubmitListens(submission) + if err != nil { + return importResult, err } - if count > 0 { - importResult.UpdateTimestamp(listens[count-1].ListenedAt) - } + importResult.UpdateTimestamp(listens[count-1].ListenedAt) importResult.ImportCount += count - progress <- p.FromImportResult(importResult, false) + progress <- models.Progress{}.FromImportResult(importResult) } return importResult, nil } -func (b *ListenBrainzApiBackend) ExportLoves(ctx context.Context, oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.TransferProgress) { - 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) { +func (b *ListenBrainzApiBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { offset := 0 defer close(results) - allLoves := make(models.LovesList, 0, 2*listenbrainz.MaxItemsPerGet) - batch := make([]listenbrainz.Feedback, 0, lovesBatchSize) + loves := make(models.LovesList, 0, 2*MaxItemsPerGet) + p := models.Progress{} out: for { - result, err := b.client.GetFeedback(ctx, b.username, 1, offset) + result, err := b.client.GetFeedback(b.username, 1, offset) if err != nil { + progress <- p.Complete() results <- models.LovesResult{Error: err} return } @@ -257,101 +159,66 @@ out: } for _, feedback := range result.Feedback { - if time.Unix(feedback.Created, 0).After(oldestTimestamp) { - batch = append(batch, feedback) + love := feedback.AsLove() + if love.Created.Unix() > oldestTimestamp.Unix() { + loves = append(loves, love) + p.Elapsed += 1 + progress <- p } else { 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 { - allLoves = append(allLoves, l) - } - } } - offset += listenbrainz.MaxItemsPerGet + p.Total = int64(result.TotalCount) + p.Elapsed += int64(count) + + offset += MaxItemsPerGet } - lovesBatch, err := ExtendTrackMetadata(ctx, &b.client, b.mbClient, &batch) - if err != nil { - 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, - } + sort.Sort(loves) + progress <- p.Complete() + results <- models.LovesResult{Loves: loves} } -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 { +func (b *ListenBrainzApiBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { + if len(b.existingMbids) == 0 { 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 + if existingLoves.Error != nil { + return importResult, existingLoves.Error + } // TODO: Store MBIDs directly - b.existingMBIDs = make(map[mbtypes.MBID]bool, listenbrainz.MaxItemsPerGet) - - for existingLoves := range existingLovesChan { - if existingLoves.Error != nil { - return importResult, existingLoves.Error - } - - for _, love := range existingLoves.Items { - b.existingMBIDs[love.RecordingMBID] = true - // In case the loved MBID got merged the track MBID represents the - // actual recording MBID. - if love.Track.RecordingMBID != "" && - love.Track.RecordingMBID != love.RecordingMBID { - b.existingMBIDs[love.Track.RecordingMBID] = true - } - } + b.existingMbids = make(map[string]bool, len(existingLoves.Loves)) + for _, love := range existingLoves.Loves { + b.existingMbids[string(love.RecordingMbid)] = true } } - for _, love := range export.Items { - recordingMBID := love.RecordingMBID - if recordingMBID == "" { - recordingMBID = love.Track.RecordingMBID - } + for _, love := range export.Loves { + recordingMbid := string(love.RecordingMbid) - if recordingMBID == "" { - lookup, err := b.client.Lookup(ctx, love.TrackName, love.ArtistName()) + if recordingMbid == "" { + lookup, err := b.client.Lookup(love.TrackName, love.ArtistName()) if err == nil { - recordingMBID = lookup.RecordingMBID + recordingMbid = lookup.RecordingMbid } } - if recordingMBID != "" { + if recordingMbid != "" { ok := false errMsg := "" - if b.existingMBIDs[recordingMBID] { + if b.existingMbids[recordingMbid] { ok = true } else { - resp, err := b.client.SendFeedback(ctx, listenbrainz.Feedback{ - RecordingMBID: recordingMBID, + resp, err := b.client.SendFeedback(Feedback{ + RecordingMbid: recordingMbid, Score: 1, }) ok = err == nil && resp.Status == "ok" if err != nil { errMsg = err.Error() - } else { - b.existingMBIDs[recordingMBID] = true } } @@ -361,43 +228,65 @@ func (b *ListenBrainzApiBackend) ImportLoves(ctx context.Context, export models. } else { msg := fmt.Sprintf("Failed import of \"%s\" by %s: %v", 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 } -var defaultDuration = time.Duration(3 * time.Minute) - -const trackSimilarityThreshold = 0.9 - -func (b *ListenBrainzApiBackend) checkDuplicateListen(ctx context.Context, listen models.Listen) (bool, error) { - // Find listens - duration := listen.Duration - if duration == 0 { - duration = defaultDuration +func (lbListen Listen) AsListen() models.Listen { + listen := models.Listen{ + ListenedAt: time.Unix(lbListen.ListenedAt, 0), + UserName: lbListen.UserName, + Track: lbListen.TrackMetadata.AsTrack(), } - minTime := listen.ListenedAt.Add(-duration) - maxTime := listen.ListenedAt.Add(duration) - candidates, err := b.client.GetListens(ctx, b.username, maxTime, minTime) - if err != nil { - return false, err + return listen +} + +func (f Feedback) AsLove() models.Love { + recordingMbid := models.MBID(f.RecordingMbid) + track := f.TrackMetadata + if track == nil { + track = &Track{} + } + love := models.Love{ + UserName: f.UserName, + RecordingMbid: recordingMbid, + Created: time.Unix(f.Created, 0), + Track: track.AsTrack(), } - for _, c := range candidates.Payload.Listens { - sim := similarity.CompareTracks(listen.Track, AsTrack(c.TrackMetadata)) - if sim >= trackSimilarityThreshold { - return true, nil + 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 } diff --git a/internal/backends/listenbrainz/listenbrainz_test.go b/internal/backends/listenbrainz/listenbrainz_test.go index f7151e5..03592a3 100644 --- a/internal/backends/listenbrainz/listenbrainz_test.go +++ b/internal/backends/listenbrainz/listenbrainz_test.go @@ -23,19 +23,15 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uploadedlobster.com/mbtypes" - lbapi "go.uploadedlobster.com/scotty/internal/backends/listenbrainz" - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/listenbrainz" + "go.uploadedlobster.com/scotty/internal/backends/listenbrainz" + "go.uploadedlobster.com/scotty/internal/models" ) -func TestInitConfig(t *testing.T) { - c := viper.New() - c.Set("token", "thetoken") - service := config.NewServiceConfig("test", c) - backend := lbapi.ListenBrainzApiBackend{} - err := backend.InitConfig(&service) - assert.NoError(t, err) +func TestFromConfig(t *testing.T) { + config := viper.New() + config.Set("token", "thetoken") + backend := (&listenbrainz.ListenBrainzApiBackend{}).FromConfig(config) + assert.IsType(t, &listenbrainz.ListenBrainzApiBackend{}, backend) } 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, lbListen.UserName, listen.UserName) 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, 5, listen.TrackNumber) assert.Equal(t, 1, listen.DiscNumber) - assert.Equal(t, mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMBID) - assert.Equal(t, mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMBID) - assert.Equal(t, mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMBID) - assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC) + assert.Equal(t, models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), listen.RecordingMbid) + assert.Equal(t, models.MBID("d7f22677-9803-4d21-ba42-081b633a6f68"), listen.ReleaseMbid) + assert.Equal(t, models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), listen.ReleaseGroupMbid) + assert.Equal(t, "DES561620801", listen.ISRC) assert.Equal(t, lbListen.TrackMetadata.AdditionalInfo["foo"], listen.AdditionalInfo["foo"]) } func TestListenBrainzFeedbackAsLove(t *testing.T) { - recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12") - releaseMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68") - artistMBID := mbtypes.MBID("d7f22677-9803-4d21-ba42-081b633a6f68") + recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12" + releaseMbid := "d7f22677-9803-4d21-ba42-081b633a6f68" + artistMbid := "d7f22677-9803-4d21-ba42-081b633a6f68" feedback := listenbrainz.Feedback{ Created: 1699859066, - RecordingMBID: recordingMBID, + RecordingMbid: recordingMbid, Score: 1, UserName: "ousidecontext", TrackMetadata: &listenbrainz.Track{ TrackName: "Oweynagat", ArtistName: "Dool", ReleaseName: "Here Now, There Then", - MBIDMapping: &listenbrainz.MBIDMapping{ - RecordingMBID: recordingMBID, - ReleaseMBID: releaseMBID, - ArtistMBIDs: []mbtypes.MBID{artistMBID}, + MbidMapping: &listenbrainz.MbidMapping{ + RecordingMbid: recordingMbid, + ReleaseMbid: releaseMbid, + ArtistMbids: []string{artistMbid}, }, }, } - love := lbapi.AsLove(feedback) + love := feedback.AsLove() assert := assert.New(t) assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix()) assert.Equal(feedback.UserName, love.UserName) assert.Equal(feedback.TrackMetadata.TrackName, love.TrackName) assert.Equal(feedback.TrackMetadata.ReleaseName, love.ReleaseName) assert.Equal([]string{feedback.TrackMetadata.ArtistName}, love.ArtistNames) - assert.Equal(recordingMBID, love.RecordingMBID) - assert.Equal(recordingMBID, love.Track.RecordingMBID) - assert.Equal(releaseMBID, love.Track.ReleaseMBID) - require.Len(t, love.Track.ArtistMBIDs, 1) - assert.Equal(artistMBID, love.Track.ArtistMBIDs[0]) + assert.Equal(models.MBID(recordingMbid), love.RecordingMbid) + assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid) + assert.Equal(models.MBID(releaseMbid), love.Track.ReleaseMbid) + require.Len(t, love.Track.ArtistMbids, 1) + assert.Equal(models.MBID(artistMbid), love.Track.ArtistMbids[0]) } func TestListenBrainzPartialFeedbackAsLove(t *testing.T) { - recordingMBID := mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12") + recordingMbid := "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12" feedback := listenbrainz.Feedback{ Created: 1699859066, - RecordingMBID: recordingMBID, + RecordingMbid: recordingMbid, Score: 1, } - love := lbapi.AsLove(feedback) + love := feedback.AsLove() assert := assert.New(t) assert.Equal(time.Unix(1699859066, 0).Unix(), love.Created.Unix()) - assert.Equal(recordingMBID, love.RecordingMBID) - assert.Equal(recordingMBID, love.Track.RecordingMBID) + assert.Equal(models.MBID(recordingMbid), love.RecordingMbid) + assert.Equal(models.MBID(recordingMbid), love.Track.RecordingMbid) assert.Empty(love.Track.TrackName) } diff --git a/internal/listenbrainz/models.go b/internal/backends/listenbrainz/models.go similarity index 53% rename from internal/listenbrainz/models.go rename to internal/backends/listenbrainz/models.go index 5e0d0e1..a7cd32b 100644 --- a/internal/listenbrainz/models.go +++ b/internal/backends/listenbrainz/models.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -25,7 +25,6 @@ import ( "strconv" "time" - "go.uploadedlobster.com/mbtypes" "golang.org/x/exp/constraints" ) @@ -37,7 +36,6 @@ type GetListenPayload struct { Count int `json:"count"` UserName string `json:"user_id"` LatestListenTimestamp int64 `json:"latest_listen_ts"` - OldestListenTimestamp int64 `json:"oldest_listen_ts"` Listens []Listen `json:"listens"` } @@ -55,36 +53,33 @@ type ListenSubmission struct { } type Listen struct { - InsertedAt float64 `json:"inserted_at,omitempty"` - ListenedAt int64 `json:"listened_at"` - RecordingMSID string `json:"recording_msid,omitempty"` - UserName string `json:"user_name,omitempty"` - TrackMetadata Track `json:"track_metadata"` + InsertedAt int64 `json:"inserted_at,omitempty"` + ListenedAt int64 `json:"listened_at"` + RecordingMsid string `json:"recording_msid,omitempty"` + UserName string `json:"user_name,omitempty"` + TrackMetadata Track `json:"track_metadata"` } type Track struct { TrackName string `json:"track_name,omitempty"` ArtistName string `json:"artist_name,omitempty"` ReleaseName string `json:"release_name,omitempty"` - RecordingMSID string `json:"recording_msid,omitempty"` AdditionalInfo map[string]any `json:"additional_info,omitempty"` - MBIDMapping *MBIDMapping `json:"mbid_mapping,omitempty"` + MbidMapping *MbidMapping `json:"mbid_mapping,omitempty"` } -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"` - ReleaseMBID mbtypes.MBID `json:"release_mbid,omitempty"` - CAAID int `json:"caa_id,omitempty"` - CAAReleaseMBID mbtypes.MBID `json:"caa_release_mbid,omitempty"` +type MbidMapping struct { + RecordingName string `json:"recording_name,omitempty"` + RecordingMbid string `json:"recording_mbid,omitempty"` + ReleaseMbid string `json:"release_mbid,omitempty"` + ArtistMbids []string `json:"artist_mbids,omitempty"` + Artists []Artist `json:"artists,omitempty"` } type Artist struct { - ArtistCreditName string `json:"artist_credit_name,omitempty"` - ArtistMBID mbtypes.MBID `json:"artist_mbid,omitempty"` - JoinPhrase string `json:"join_phrase,omitempty"` + ArtistCreditName string `json:"artist_credit_name,omitempty"` + ArtistMbid string `json:"artist_mbid,omitempty"` + JoinPhrase string `json:"join_phrase,omitempty"` } type GetFeedbackResult struct { @@ -95,59 +90,21 @@ type GetFeedbackResult struct { } type Feedback struct { - Created int64 `json:"created,omitempty"` - RecordingMBID mbtypes.MBID `json:"recording_mbid,omitempty"` - RecordingMSID mbtypes.MBID `json:"recording_msid,omitempty"` - Score int `json:"score,omitempty"` - TrackMetadata *Track `json:"track_metadata,omitempty"` - UserName string `json:"user_id,omitempty"` + Created int64 `json:"created,omitempty"` + RecordingMbid string `json:"recording_mbid,omitempty"` + RecordingMsid string `json:"recording_msid,omitempty"` + Score int `json:"score,omitempty"` + TrackMetadata *Track `json:"track_metadata,omitempty"` + UserName string `json:"user_id,omitempty"` } type LookupResult struct { - ArtistCreditName string `json:"artist_credit_name"` - ReleaseName string `json:"release_name"` - RecordingName string `json:"recording_name"` - RecordingMBID mbtypes.MBID `json:"recording_mbid"` - ReleaseMBID mbtypes.MBID `json:"release_mbid"` - ArtistMBIDs []mbtypes.MBID `json:"artist_mbids"` -} - -type 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"` + ArtistCreditName string `json:"artist_credit_name"` + ReleaseName string `json:"release_name"` + RecordingName string `json:"recording_name"` + RecordingMbid string `json:"recording_mbid"` + ReleaseMbid string `json:"release_mbid"` + ArtistMbids []string `json:"artist_mbids"` } type StatusResult struct { @@ -200,30 +157,30 @@ func (t Track) DiscNumber() int { return 0 } -func (t Track) ISRC() mbtypes.ISRC { - return mbtypes.ISRC(tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc")) +func (t Track) ISRC() string { + return tryGetValueOrEmpty[string](t.AdditionalInfo, "isrc") } -func (t Track) RecordingMBID() mbtypes.MBID { - mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid")) - if mbid == "" && t.MBIDMapping != nil { - return t.MBIDMapping.RecordingMBID +func (t Track) RecordingMbid() string { + mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "recording_mbid") + if mbid == "" && t.MbidMapping != nil { + return t.MbidMapping.RecordingMbid } else { return mbid } } -func (t Track) ReleaseMBID() mbtypes.MBID { - mbid := mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid")) - if mbid == "" && t.MBIDMapping != nil { - return t.MBIDMapping.ReleaseMBID +func (t Track) ReleaseMbid() string { + mbid := tryGetValueOrEmpty[string](t.AdditionalInfo, "release_mbid") + if mbid == "" && t.MbidMapping != nil { + return t.MbidMapping.ReleaseMbid } else { return mbid } } -func (t Track) ReleaseGroupMBID() mbtypes.MBID { - return mbtypes.MBID(tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid")) +func (t Track) ReleaseGroupMbid() string { + return tryGetValueOrEmpty[string](t.AdditionalInfo, "release_group_mbid") } func tryGetValueOrEmpty[T any](dict map[string]any, key string) T { diff --git a/internal/listenbrainz/models_test.go b/internal/backends/listenbrainz/models_test.go similarity index 82% rename from internal/listenbrainz/models_test.go rename to internal/backends/listenbrainz/models_test.go index 404b87b..845690d 100644 --- a/internal/listenbrainz/models_test.go +++ b/internal/backends/listenbrainz/models_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy 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/require" - "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/scotty/internal/listenbrainz" + "go.uploadedlobster.com/scotty/internal/backends/listenbrainz" ) func TestTrackDurationMillisecondsInt(t *testing.T) { @@ -131,50 +130,50 @@ func TestTrackTrackNumberString(t *testing.T) { assert.Equal(t, 12, track.TrackNumber()) } -func TestTrackISRC(t *testing.T) { - expected := mbtypes.ISRC("TCAEJ1934417") +func TestTrackIsrc(t *testing.T) { + expected := "TCAEJ1934417" track := listenbrainz.Track{ AdditionalInfo: map[string]any{ - "isrc": string(expected), + "isrc": expected, }, } assert.Equal(t, expected, track.ISRC()) } -func TestTrackRecordingMBID(t *testing.T) { - expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b") +func TestTrackRecordingMbid(t *testing.T) { + expected := "e02cc1c3-93fd-4e24-8b77-325060de920b" track := listenbrainz.Track{ 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) { - expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b") +func TestTrackReleaseMbid(t *testing.T) { + expected := "e02cc1c3-93fd-4e24-8b77-325060de920b" track := listenbrainz.Track{ 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) { - expected := mbtypes.MBID("e02cc1c3-93fd-4e24-8b77-325060de920b") +func TestReleaseGroupMbid(t *testing.T) { + expected := "e02cc1c3-93fd-4e24-8b77-325060de920b" track := listenbrainz.Track{ 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) { feedback := listenbrainz.Feedback{ Created: 1699859066, - RecordingMBID: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", + RecordingMbid: "c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12", } b, err := json.Marshal(feedback) require.NoError(t, err) diff --git a/internal/listenbrainz/testdata/feedback.json b/internal/backends/listenbrainz/testdata/feedback.json similarity index 100% rename from internal/listenbrainz/testdata/feedback.json rename to internal/backends/listenbrainz/testdata/feedback.json diff --git a/internal/listenbrainz/testdata/listen.json b/internal/backends/listenbrainz/testdata/listen.json similarity index 100% rename from internal/listenbrainz/testdata/listen.json rename to internal/backends/listenbrainz/testdata/listen.json diff --git a/internal/listenbrainz/testdata/listens.json b/internal/backends/listenbrainz/testdata/listens.json similarity index 99% rename from internal/listenbrainz/testdata/listens.json rename to internal/backends/listenbrainz/testdata/listens.json index 41119ba..5d799b8 100644 --- a/internal/listenbrainz/testdata/listens.json +++ b/internal/backends/listenbrainz/testdata/listens.json @@ -2,7 +2,6 @@ "payload": { "count": 2, "latest_listen_ts": 1699718723, - "oldest_listen_ts": 1152911863, "listens": [ { "inserted_at": 1699719320, diff --git a/internal/listenbrainz/testdata/lookup.json b/internal/backends/listenbrainz/testdata/lookup.json similarity index 100% rename from internal/listenbrainz/testdata/lookup.json rename to internal/backends/listenbrainz/testdata/lookup.json diff --git a/internal/backends/maloja/client.go b/internal/backends/maloja/client.go index b80cb56..3b79110 100644 --- a/internal/backends/maloja/client.go +++ b/internal/backends/maloja/client.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,6 @@ THE SOFTWARE. package maloja import ( - "context" "errors" "strconv" @@ -33,26 +32,25 @@ import ( const MaxItemsPerGet = 1000 type Client struct { - HTTPClient *resty.Client + HttpClient *resty.Client token string } -func NewClient(serverURL string, token string) Client { +func NewClient(serverUrl string, token string) Client { client := resty.New() - client.SetBaseURL(serverURL) + client.SetBaseURL(serverUrl) client.SetHeader("Accept", "application/json") client.SetHeader("User-Agent", version.UserAgent()) client.SetRetryCount(5) return Client{ - HTTPClient: client, + HttpClient: client, token: token, } } -func (c Client) GetScrobbles(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" - response, err := c.HTTPClient.R(). - SetContext(ctx). + response, err := c.HttpClient.R(). SetQueryParams(map[string]string{ "page": strconv.Itoa(page), "perpage": strconv.Itoa(perPage), @@ -60,23 +58,22 @@ func (c Client) GetScrobbles(ctx context.Context, page int, perPage int) (result SetResult(&result). Get(path) - if !response.IsSuccess() { + if response.StatusCode() != 200 { err = errors.New(response.String()) 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" scrobble.Key = c.token - response, err := c.HTTPClient.R(). - SetContext(ctx). + response, err := c.HttpClient.R(). SetBody(scrobble). SetResult(&result). Post(path) - if !response.IsSuccess() { + if response.StatusCode() != 200 { err = errors.New(response.String()) return } diff --git a/internal/backends/maloja/client_test.go b/internal/backends/maloja/client_test.go index 415f911..6a07adb 100644 --- a/internal/backends/maloja/client_test.go +++ b/internal/backends/maloja/client_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,6 @@ THE SOFTWARE. package maloja_test import ( - "context" "net/http" "testing" @@ -33,24 +32,23 @@ import ( ) func TestNewClient(t *testing.T) { - serverURL := "https://maloja.example.com" + serverUrl := "https://maloja.example.com" token := "foobar123" - client := maloja.NewClient(serverURL, token) - assert.Equal(t, serverURL, client.HTTPClient.BaseURL) + client := maloja.NewClient(serverUrl, token) + assert.Equal(t, serverUrl, client.HttpClient.BaseURL) } func TestGetScrobbles(t *testing.T) { defer httpmock.DeactivateAndReset() - serverURL := "https://maloja.example.com" + serverUrl := "https://maloja.example.com" token := "thetoken" - client := maloja.NewClient(serverURL, token) - setupHTTPMock(t, client.HTTPClient.GetClient(), + client := maloja.NewClient(serverUrl, token) + setupHttpMock(t, client.HttpClient.GetClient(), "https://maloja.example.com/apis/mlj_1/scrobbles", "testdata/scrobbles.json") - ctx := context.Background() - result, err := client.GetScrobbles(ctx, 0, 2) + result, err := client.GetScrobbles(0, 2) require.NoError(t, err) assert := assert.New(t) @@ -62,7 +60,7 @@ func TestGetScrobbles(t *testing.T) { func TestNewScrobble(t *testing.T) { server := "https://maloja.example.com" client := maloja.NewClient(server, "thetoken") - httpmock.ActivateNonDefault(client.HTTPClient.GetClient()) + httpmock.ActivateNonDefault(client.HttpClient.GetClient()) responder, err := httpmock.NewJsonResponder(200, httpmock.File("testdata/newscrobble-result.json")) if err != nil { @@ -71,19 +69,18 @@ func TestNewScrobble(t *testing.T) { url := server + "/apis/mlj_1/newscrobble" httpmock.RegisterResponder("POST", url, responder) - ctx := context.Background() scrobble := maloja.NewScrobble{ Title: "Oweynagat", Artist: "Dool", Time: 1699574369, } - result, err := client.NewScrobble(ctx, scrobble) + result, err := client.NewScrobble(scrobble) require.NoError(t, err) assert.Equal(t, "success", result.Status) } -func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) { +func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { httpmock.ActivateNonDefault(client) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) diff --git a/internal/backends/maloja/maloja.go b/internal/backends/maloja/maloja.go index e5537df..58a70b7 100644 --- a/internal/backends/maloja/maloja.go +++ b/internal/backends/maloja/maloja.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer This file is part of Scotty. @@ -17,14 +17,12 @@ Scotty. If not, see . package maloja import ( - "context" "errors" "sort" "strings" "time" - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/i18n" + "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/models" ) @@ -35,57 +33,33 @@ type MalojaApiBackend struct { func (b *MalojaApiBackend) Name() string { return "maloja" } -func (b *MalojaApiBackend) Close() {} - -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 { +func (b *MalojaApiBackend) FromConfig(config *viper.Viper) models.Backend { b.client = NewClient( config.GetString("server-url"), config.GetString("token"), ) - b.nofix = config.GetBool("nofix", false) - return nil + b.nofix = config.GetBool("nofix") + return b } -func (b *MalojaApiBackend) StartImport() error { return nil } -func (b *MalojaApiBackend) FinishImport(result *models.ImportResult) error { - return nil -} +func (b *MalojaApiBackend) StartImport() error { return nil } +func (b *MalojaApiBackend) FinishImport() error { 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 perPage := MaxItemsPerGet + defer close(results) + // We need to gather the full list of listens in order to sort them listens := make(models.ListensList, 0, 2*perPage) - p := models.TransferProgress{ - Export: &models.Progress{ - Total: int64(perPage), - }, - } + p := models.Progress{Total: int64(perPage)} out: for { - result, err := b.client.GetScrobbles(ctx, page, perPage) + result, err := b.client.GetScrobbles(page, perPage) if err != nil { - p.Export.Abort() - progress <- p + progress <- p.Complete() results <- models.ListensResult{Error: err} return } @@ -97,28 +71,25 @@ out: for _, scrobble := range result.List { if scrobble.ListenedAt > oldestTimestamp.Unix() { - p.Export.Elapsed += 1 + p.Elapsed += 1 listens = append(listens, scrobble.AsListen()) } else { break out } } - p.Export.TotalItems = len(listens) - p.Export.Total += int64(perPage) + p.Total += int64(perPage) progress <- p page += 1 } sort.Sort(listens) - p.Export.Complete() - progress <- p - results <- models.ListensResult{Items: listens} + progress <- p.Complete() + results <- models.ListensResult{Listens: listens} } -func (b *MalojaApiBackend) 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 export.Items { +func (b *MalojaApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { + for _, listen := range export.Listens { scrobble := NewScrobble{ Title: listen.TrackName, Artists: listen.ArtistNames, @@ -129,7 +100,7 @@ func (b *MalojaApiBackend) ImportListens(ctx context.Context, export models.List Nofix: b.nofix, } - resp, err := b.client.NewScrobble(ctx, scrobble) + resp, err := b.client.NewScrobble(scrobble) if err != nil { return importResult, err } else if resp.Status != "success" { @@ -138,7 +109,7 @@ func (b *MalojaApiBackend) ImportListens(ctx context.Context, export models.List importResult.UpdateTimestamp(listen.ListenedAt) importResult.ImportCount += 1 - progress <- p.FromImportResult(importResult, false) + progress <- models.Progress{}.FromImportResult(importResult) } return importResult, nil diff --git a/internal/backends/maloja/maloja_test.go b/internal/backends/maloja/maloja_test.go index 4a1f318..bb0dc16 100644 --- a/internal/backends/maloja/maloja_test.go +++ b/internal/backends/maloja/maloja_test.go @@ -23,16 +23,13 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" "go.uploadedlobster.com/scotty/internal/backends/maloja" - "go.uploadedlobster.com/scotty/internal/config" ) -func TestInitConfig(t *testing.T) { - c := viper.New() - c.Set("token", "thetoken") - service := config.NewServiceConfig("test", c) - backend := maloja.MalojaApiBackend{} - err := backend.InitConfig(&service) - assert.NoError(t, err) +func TestFromConfig(t *testing.T) { + config := viper.New() + config.Set("token", "thetoken") + backend := (&maloja.MalojaApiBackend{}).FromConfig(config) + assert.IsType(t, &maloja.MalojaApiBackend{}, backend) } func TestScrobbleAsListen(t *testing.T) { diff --git a/internal/backends/process.go b/internal/backends/process.go new file mode 100644 index 0000000..1143cc0 --- /dev/null +++ b/internal/backends/process.go @@ -0,0 +1,110 @@ +/* +Copyright © 2023 Philipp Wolfer + +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 . +*/ + +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 +} diff --git a/internal/backends/scrobblerlog/parser.go b/internal/backends/scrobblerlog/parser.go new file mode 100644 index 0000000..8687c6a --- /dev/null +++ b/internal/backends/scrobblerlog/parser.go @@ -0,0 +1,212 @@ +/* +Copyright © 2023 Philipp Wolfer + +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 +} diff --git a/internal/backends/scrobblerlog/parser_test.go b/internal/backends/scrobblerlog/parser_test.go new file mode 100644 index 0000000..76b6c87 --- /dev/null +++ b/internal/backends/scrobblerlog/parser_test.go @@ -0,0 +1,128 @@ +/* +Copyright © 2023 Philipp Wolfer + +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) +} diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 3a91e92..1c4f652 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer This file is part of Scotty. @@ -17,72 +17,37 @@ Scotty. If not, see . package scrobblerlog import ( - "context" - "fmt" + "bufio" "os" "sort" - "strings" "time" - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/i18n" + "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/models" - "go.uploadedlobster.com/scotty/pkg/scrobblerlog" ) type ScrobblerLogBackend struct { - filePath string - ignoreSkipped bool - append bool - file *os.File - timezone *time.Location - log scrobblerlog.ScrobblerLog + filePath string + includeSkipped bool + append bool + file *os.File + log ScrobblerLog } func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" } -func (b *ScrobblerLogBackend) Close() {} - -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 { +func (b *ScrobblerLogBackend) FromConfig(config *viper.Viper) models.Backend { b.filePath = config.GetString("file-path") - b.ignoreSkipped = config.GetBool("ignore-skipped", true) - b.append = config.GetBool("append", true) - b.log = scrobblerlog.ScrobblerLog{ - TZ: scrobblerlog.TimezoneUTC, - Client: "Rockbox unknown $Revision$", + b.includeSkipped = config.GetBool("include-skipped") + b.append = true + if config.IsSet("append") { + b.append = config.GetBool("append") } - - 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 + b.log = ScrobblerLog{ + Timezone: "UNKNOWN", + Client: "Rockbox unknown $Revision$", } - - return nil + return b } func (b *ScrobblerLogBackend) StartImport() error { @@ -107,18 +72,19 @@ func (b *ScrobblerLogBackend) StartImport() error { b.append = false } else { // 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() return err } - if _, err = file.Seek(0, 2); err != nil { - return err - } + file.Seek(0, 2) } } if !b.append { - if err = b.log.WriteHeader(file); err != nil { + err = WriteHeader(file, &b.log) + if err != nil { file.Close() return err } @@ -128,98 +94,43 @@ func (b *ScrobblerLogBackend) StartImport() error { return nil } -func (b *ScrobblerLogBackend) FinishImport(result *models.ImportResult) error { +func (b *ScrobblerLogBackend) FinishImport() error { 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) - p := models.TransferProgress{ - Export: &models.Progress{}, - } if err != nil { - p.Export.Abort() - progress <- p + progress <- models.Progress{}.Complete() results <- models.ListensResult{Error: err} return } defer file.Close() - err = b.log.Parse(file, b.ignoreSkipped) + log, err := Parse(file, b.includeSkipped) if err != nil { - p.Export.Abort() - progress <- p + progress <- models.Progress{}.Complete() results <- models.ListensResult{Error: err} return } - listens := make(models.ListensList, 0, len(b.log.Records)) - 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 - } - } - + listens := log.Listens.NewerThan(oldestTimestamp) 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) { - p := models.TransferProgress{}.FromImportResult(importResult, false) - 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) +func (b *ScrobblerLogBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { + lastTimestamp, err := Write(b.file, export.Listens) if err != nil { return importResult, err } importResult.UpdateTimestamp(lastTimestamp) - importResult.ImportCount += len(export.Items) + importResult.ImportCount += len(export.Listens) + progress <- models.Progress{}.FromImportResult(importResult) + 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, - } -} diff --git a/internal/backends/scrobblerlog/scrobblerlog_test.go b/internal/backends/scrobblerlog/scrobblerlog_test.go index 962aebf..4e6c600 100644 --- a/internal/backends/scrobblerlog/scrobblerlog_test.go +++ b/internal/backends/scrobblerlog/scrobblerlog_test.go @@ -22,24 +22,11 @@ import ( "github.com/spf13/viper" "github.com/stretchr/testify/assert" "go.uploadedlobster.com/scotty/internal/backends/scrobblerlog" - "go.uploadedlobster.com/scotty/internal/config" ) -func TestInitConfig(t *testing.T) { - c := viper.New() - c.Set("token", "thetoken") - service := config.NewServiceConfig("test", c) - backend := scrobblerlog.ScrobblerLogBackend{} - err := backend.InitConfig(&service) - assert.NoError(t, err) -} - -func TestInitConfigInvalidTimezone(t *testing.T) { - c := viper.New() - configuredTimezone := "Invalid/Timezone" - c.Set("time-zone", configuredTimezone) - service := config.NewServiceConfig("test", c) - backend := scrobblerlog.ScrobblerLogBackend{} - err := backend.InitConfig(&service) - assert.ErrorContains(t, err, `Invalid time-zone "Invalid/Timezone"`) +func TestFromConfig(t *testing.T) { + config := viper.New() + config.Set("token", "thetoken") + backend := (&scrobblerlog.ScrobblerLogBackend{}).FromConfig(config) + assert.IsType(t, &scrobblerlog.ScrobblerLogBackend{}, backend) } diff --git a/internal/backends/spotify/client.go b/internal/backends/spotify/client.go index 94d50ac..08c00f3 100644 --- a/internal/backends/spotify/client.go +++ b/internal/backends/spotify/client.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -29,8 +29,8 @@ import ( "time" "github.com/go-resty/resty/v2" + "go.uploadedlobster.com/scotty/internal/ratelimit" "go.uploadedlobster.com/scotty/internal/version" - "go.uploadedlobster.com/scotty/pkg/ratelimit" "golang.org/x/oauth2" ) @@ -40,7 +40,7 @@ const ( ) type Client struct { - HTTPClient *resty.Client + HttpClient *resty.Client } func NewClient(token oauth2.TokenSource) Client { @@ -55,22 +55,21 @@ func NewClient(token oauth2.TokenSource) Client { ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After") return Client{ - HTTPClient: client, + HttpClient: client, } } -func (c Client) RecentlyPlayedAfter(ctx context.Context, after time.Time, limit int) (RecentlyPlayedResult, error) { - return c.recentlyPlayed(ctx, &after, nil, limit) +func (c Client) RecentlyPlayedAfter(after time.Time, limit int) (RecentlyPlayedResult, error) { + return c.recentlyPlayed(&after, nil, limit) } -func (c Client) RecentlyPlayedBefore(ctx context.Context, before time.Time, limit int) (RecentlyPlayedResult, error) { - return c.recentlyPlayed(ctx, nil, &before, limit) +func (c Client) RecentlyPlayedBefore(before time.Time, limit int) (RecentlyPlayedResult, error) { + 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" - request := c.HTTPClient.R(). - SetContext(ctx). + request := c.HttpClient.R(). SetQueryParam("limit", strconv.Itoa(limit)). SetResult(&result) if after != nil { @@ -80,16 +79,15 @@ func (c Client) recentlyPlayed(ctx context.Context, after *time.Time, before *ti } response, err := request.Get(path) - if !response.IsSuccess() { + if response.StatusCode() != 200 { err = errors.New(response.String()) } 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" - response, err := c.HTTPClient.R(). - SetContext(ctx). + response, err := c.HttpClient.R(). SetQueryParams(map[string]string{ "offset": strconv.Itoa(offset), "limit": strconv.Itoa(limit), @@ -97,7 +95,7 @@ func (c Client) UserTracks(ctx context.Context, offset int, limit int) (result T SetResult(&result). Get(path) - if !response.IsSuccess() { + if response.StatusCode() != 200 { err = errors.New(response.String()) } return diff --git a/internal/backends/spotify/client_test.go b/internal/backends/spotify/client_test.go index 8135e1d..7d738bf 100644 --- a/internal/backends/spotify/client_test.go +++ b/internal/backends/spotify/client_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,6 @@ THE SOFTWARE. package spotify_test import ( - "context" "net/http" "testing" "time" @@ -44,12 +43,11 @@ func TestRecentlyPlayedAfter(t *testing.T) { defer httpmock.DeactivateAndReset() client := spotify.NewClient(nil) - setupHTTPMock(t, client.HTTPClient.GetClient(), + setupHttpMock(t, client.HttpClient.GetClient(), "https://api.spotify.com/v1/me/player/recently-played", "testdata/recently-played.json") - ctx := context.Background() - result, err := client.RecentlyPlayedAfter(ctx, time.Now(), 3) + result, err := client.RecentlyPlayedAfter(time.Now(), 3) require.NoError(t, err) assert := assert.New(t) @@ -65,12 +63,11 @@ func TestGetUserTracks(t *testing.T) { defer httpmock.DeactivateAndReset() client := spotify.NewClient(nil) - setupHTTPMock(t, client.HTTPClient.GetClient(), + setupHttpMock(t, client.HttpClient.GetClient(), "https://api.spotify.com/v1/me/tracks", "testdata/user-tracks.json") - ctx := context.Background() - result, err := client.UserTracks(ctx, 0, 2) + result, err := client.UserTracks(0, 2) require.NoError(t, err) assert := assert.New(t) @@ -82,7 +79,7 @@ func TestGetUserTracks(t *testing.T) { assert.Equal("Zeal & Ardor", track1.Track.Album.Name) } -func setupHTTPMock(t *testing.T, client *http.Client, url string, testDataPath string) { +func setupHttpMock(t *testing.T, client *http.Client, url string, testDataPath string) { httpmock.ActivateNonDefault(client) responder, err := httpmock.NewJsonResponder(200, httpmock.File(testDataPath)) diff --git a/internal/backends/spotify/models.go b/internal/backends/spotify/models.go index e279e15..e80eccc 100644 --- a/internal/backends/spotify/models.go +++ b/internal/backends/spotify/models.go @@ -22,8 +22,6 @@ THE SOFTWARE. package spotify -import "go.uploadedlobster.com/mbtypes" - type TracksResult struct { Href string `json:"href"` Limit int `json:"limit"` @@ -58,7 +56,7 @@ type Listen struct { } type Track struct { - ID string `json:"id"` + Id string `json:"id"` Name string `json:"name"` Href string `json:"href"` Uri string `json:"uri"` @@ -69,14 +67,14 @@ type Track struct { Explicit bool `json:"explicit"` IsLocal bool `json:"is_local"` Popularity int `json:"popularity"` - ExternalIDs ExternalIDs `json:"external_ids"` - ExternalURLs ExternalURLs `json:"external_urls"` + ExternalIds ExternalIds `json:"external_ids"` + ExternalUrls ExternalUrls `json:"external_urls"` Album Album `json:"album"` Artists []Artist `json:"artists"` } type Album struct { - ID string `json:"id"` + Id string `json:"id"` Name string `json:"name"` Href string `json:"href"` Uri string `json:"uri"` @@ -85,32 +83,32 @@ type Album struct { ReleaseDate string `json:"release_date"` ReleaseDatePrecision string `json:"release_date_precision"` AlbumType string `json:"album_type"` - ExternalURLs ExternalURLs `json:"external_urls"` + ExternalUrls ExternalUrls `json:"external_urls"` Artists []Artist `json:"artists"` Images []Image `json:"images"` } type Artist struct { - ID string `json:"id"` + Id string `json:"id"` Name string `json:"name"` Href string `json:"href"` Uri string `json:"uri"` Type string `json:"type"` - ExternalURLs ExternalURLs `json:"external_urls"` + ExternalUrls ExternalUrls `json:"external_urls"` } -type ExternalIDs struct { - ISRC mbtypes.ISRC `json:"isrc"` - EAN string `json:"ean"` - UPC string `json:"upc"` +type ExternalIds struct { + ISRC string `json:"isrc"` + EAN string `json:"ean"` + UPC string `json:"upc"` } -type ExternalURLs struct { +type ExternalUrls struct { Spotify string `json:"spotify"` } type Image struct { - URL string `json:"url"` + Url string `json:"url"` Height int `json:"height"` Width int `json:"width"` } diff --git a/internal/backends/spotify/models_test.go b/internal/backends/spotify/models_test.go index 9d38b45..fcb4b6b 100644 --- a/internal/backends/spotify/models_test.go +++ b/internal/backends/spotify/models_test.go @@ -23,8 +23,8 @@ THE SOFTWARE. package spotify_test import ( - _ "embed" "encoding/json" + "os" "testing" "github.com/stretchr/testify/assert" @@ -32,12 +32,11 @@ import ( "go.uploadedlobster.com/scotty/internal/backends/spotify" ) -//go:embed testdata/recently-played.json -var testRecentlyPlayed []byte - func TestRecentlyPlayedResult(t *testing.T) { + data, err := os.ReadFile("testdata/recently-played.json") + require.NoError(t, err) result := spotify.RecentlyPlayedResult{} - err := json.Unmarshal(testRecentlyPlayed, &result) + err = json.Unmarshal(data, &result) require.NoError(t, err) assert := assert.New(t) diff --git a/internal/backends/spotify/spotify.go b/internal/backends/spotify/spotify.go index 85b40dd..44db61c 100644 --- a/internal/backends/spotify/spotify.go +++ b/internal/backends/spotify/spotify.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer This file is part of Scotty. @@ -18,16 +18,14 @@ Scotty. If not, see . package spotify import ( - "context" "math" "net/url" "sort" "strconv" "time" + "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/auth" - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" "golang.org/x/oauth2" "golang.org/x/oauth2/spotify" @@ -35,35 +33,21 @@ import ( type SpotifyApiBackend struct { client Client - clientID string + clientId string clientSecret string } func (b *SpotifyApiBackend) Name() string { return "spotify" } -func (b *SpotifyApiBackend) Close() {} - -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") +func (b *SpotifyApiBackend) FromConfig(config *viper.Viper) models.Backend { + b.clientId = config.GetString("client-id") 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{ - ClientID: b.clientID, + ClientID: b.clientId, ClientSecret: b.clientSecret, Scopes: []string{ "user-read-currently-playing", @@ -71,16 +55,16 @@ func (b *SpotifyApiBackend) OAuth2Strategy(redirectURL *url.URL) auth.OAuth2Stra "user-library-read", "user-library-modify", }, - RedirectURL: redirectURL.String(), + RedirectURL: redirectUrl.String(), Endpoint: spotify.Endpoint, } return auth.NewStandardStrategy(conf) } -func (b *SpotifyApiBackend) OAuth2Config(redirectURL *url.URL) oauth2.Config { +func (b *SpotifyApiBackend) OAuth2Config(redirectUrl *url.URL) oauth2.Config { return oauth2.Config{ - ClientID: b.clientID, + ClientID: b.clientId, ClientSecret: b.clientSecret, Scopes: []string{ "user-read-currently-playing", @@ -88,7 +72,7 @@ func (b *SpotifyApiBackend) OAuth2Config(redirectURL *url.URL) oauth2.Config { "user-library-read", "user-library-modify", }, - RedirectURL: redirectURL.String(), + RedirectURL: redirectUrl.String(), Endpoint: spotify.Endpoint, } } @@ -98,22 +82,20 @@ func (b *SpotifyApiBackend) OAuth2Setup(token oauth2.TokenSource) error { 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() minTime := oldestTimestamp totalDuration := startTime.Sub(oldestTimestamp) - p := models.TransferProgress{ - Export: &models.Progress{ - Total: int64(totalDuration.Seconds()), - }, - } + + defer close(results) + + p := models.Progress{Total: int64(totalDuration.Seconds())} for { - result, err := b.client.RecentlyPlayedAfter(ctx, minTime, MaxItemsPerGet) + result, err := b.client.RecentlyPlayedAfter(minTime, MaxItemsPerGet) if err != nil { - p.Export.Abort() - progress <- p + progress <- p.Complete() results <- models.ListensResult{Error: err} return } @@ -125,8 +107,7 @@ func (b *SpotifyApiBackend) ExportListens(ctx context.Context, oldestTimestamp t // Set minTime to the newest returned listen after, err := strconv.ParseInt(result.Cursors.After, 10, 64) if err != nil { - p.Export.Abort() - progress <- p + progress <- p.Complete() results <- models.ListensResult{Error: err} return } else if after <= minTime.Unix() { @@ -141,50 +122,45 @@ func (b *SpotifyApiBackend) ExportListens(ctx context.Context, oldestTimestamp t break } - listens := make(models.ListensList, 0, count) + listens := make(models.ListensList, 0, len(result.Items)) for _, listen := range result.Items { l := listen.AsListen() - if l.ListenedAt.After(oldestTimestamp) { + if l.ListenedAt.Unix() > oldestTimestamp.Unix() { listens = append(listens, l) } else { - // result contains listens older then oldestTimestamp + // result contains listens older then oldestTimestamp, + // we can stop requesting more break } } sort.Sort(listens) - p.Export.TotalItems += len(listens) - p.Export.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) + p.Elapsed = int64(totalDuration.Seconds() - remainingTime.Seconds()) progress <- p - results <- models.ListensResult{Items: listens, OldestTimestamp: minTime} + results <- models.ListensResult{Listens: listens, OldestTimestamp: minTime} } results <- models.ListensResult{OldestTimestamp: minTime} - p.Export.Complete() - progress <- p + progress <- p.Complete() } -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 // at the oldest one. offset := math.MaxInt32 perPage := MaxItemsPerGet - p := models.TransferProgress{ - Export: &models.Progress{ - Total: int64(perPage), - }, - } - totalCount := 0 - exportCount := 0 + defer close(results) + + p := models.Progress{Total: int64(perPage)} + var totalCount int out: for { - result, err := b.client.UserTracks(ctx, offset, perPage) + result, err := b.client.UserTracks(offset, perPage) if err != nil { - p.Export.Abort() - progress <- p + progress <- p.Complete() results <- models.LovesResult{Error: err} return } @@ -192,9 +168,12 @@ out: // The offset was higher then the actual number of tracks. Adjust the offset // and continue. if offset >= result.Total { - p.Export.Total = int64(result.Total) + p.Total = int64(result.Total) totalCount = result.Total - offset = max(result.Total-perPage, 0) + offset = result.Total - perPage + if offset < 0 { + offset = 0 + } continue } @@ -206,17 +185,17 @@ out: loves := make(models.LovesList, 0, perPage) for _, track := range result.Items { love := track.AsLove() - if love.Created.After(oldestTimestamp) { + if love.Created.Unix() > oldestTimestamp.Unix() { loves = append(loves, love) } else { - continue + totalCount -= 1 + break } } - exportCount += len(loves) sort.Sort(loves) - results <- models.LovesResult{Items: loves, Total: totalCount} - p.Export.Elapsed += int64(count) + results <- models.LovesResult{Loves: loves, Total: totalCount} + p.Elapsed += int64(count) progress <- p if offset <= 0 { @@ -230,9 +209,7 @@ out: } } - results <- models.LovesResult{Total: exportCount} - p.Export.Complete() - progress <- p + progress <- p.Complete() } func (l Listen) AsListen() models.Listen { @@ -260,10 +237,10 @@ func (t Track) AsTrack() models.Track { TrackName: t.Name, ReleaseName: t.Album.Name, 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, DiscNumber: t.DiscNumber, - ISRC: t.ExternalIDs.ISRC, + ISRC: t.ExternalIds.ISRC, AdditionalInfo: map[string]any{}, } @@ -276,30 +253,30 @@ func (t Track) AsTrack() models.Track { info["music_service"] = "spotify.com" } - if t.ExternalURLs.Spotify != "" { - info["origin_url"] = t.ExternalURLs.Spotify - info["spotify_id"] = t.ExternalURLs.Spotify + if t.ExternalUrls.Spotify != "" { + info["origin_url"] = t.ExternalUrls.Spotify + info["spotify_id"] = t.ExternalUrls.Spotify } - if t.Album.ExternalURLs.Spotify != "" { - info["spotify_album_id"] = t.Album.ExternalURLs.Spotify + if t.Album.ExternalUrls.Spotify != "" { + info["spotify_album_id"] = t.Album.ExternalUrls.Spotify } if len(t.Artists) > 0 { - info["spotify_artist_ids"] = extractArtistIDs(t.Artists) + info["spotify_artist_ids"] = extractArtistIds(t.Artists) } if len(t.Album.Artists) > 0 { - info["spotify_album_artist_ids"] = extractArtistIDs(t.Album.Artists) + info["spotify_album_artist_ids"] = extractArtistIds(t.Album.Artists) } return track } -func extractArtistIDs(artists []Artist) []string { - artistIDs := make([]string, len(artists)) +func extractArtistIds(artists []Artist) []string { + artistIds := make([]string, len(artists)) for i, artist := range artists { - artistIDs[i] = artist.ExternalURLs.Spotify + artistIds[i] = artist.ExternalUrls.Spotify } - return artistIDs + return artistIds } diff --git a/internal/backends/spotify/spotify_test.go b/internal/backends/spotify/spotify_test.go index 8949128..5f1d544 100644 --- a/internal/backends/spotify/spotify_test.go +++ b/internal/backends/spotify/spotify_test.go @@ -18,39 +18,30 @@ Scotty. If not, see . package spotify_test import ( - _ "embed" "encoding/json" + "os" "testing" "time" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/scotty/internal/backends/spotify" - "go.uploadedlobster.com/scotty/internal/config" ) -var ( - //go:embed testdata/listen.json - testListen []byte - //go:embed testdata/track.json - testTrack []byte -) - -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 TestFromConfig(t *testing.T) { + config := viper.New() + config.Set("client-id", "someclientid") + config.Set("client-secret", "someclientsecret") + backend := (&spotify.SpotifyApiBackend{}).FromConfig(config) + assert.IsType(t, &spotify.SpotifyApiBackend{}, backend) } func TestSpotifyListenAsListen(t *testing.T) { + data, err := os.ReadFile("testdata/listen.json") + require.NoError(t, err) spListen := spotify.Listen{} - err := json.Unmarshal(testListen, &spListen) + err = json.Unmarshal(data, &spListen) require.NoError(t, err) listen := spListen.AsListen() 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, 5, listen.TrackNumber) assert.Equal(t, 1, listen.DiscNumber) - assert.Equal(t, mbtypes.ISRC("DES561620801"), listen.ISRC) + assert.Equal(t, "DES561620801", listen.ISRC) info := listen.AdditionalInfo assert.Equal(t, "spotify.com", info["music_service"]) assert.Equal(t, "https://open.spotify.com/track/2JKUgGuXK3dEvyuIJ4Yj2V", info["origin_url"]) @@ -72,8 +63,10 @@ func TestSpotifyListenAsListen(t *testing.T) { } func TestSavedTrackAsLove(t *testing.T) { + data, err := os.ReadFile("testdata/track.json") + require.NoError(t, err) track := spotify.SavedTrack{} - err := json.Unmarshal(testTrack, &track) + err = json.Unmarshal(data, &track) require.NoError(t, err) love := track.AsLove() created, _ := time.Parse(time.RFC3339, "2022-02-13T21:46:08Z") diff --git a/internal/backends/spotifyhistory/archive.go b/internal/backends/spotifyhistory/archive.go deleted file mode 100644 index 2f9a2ec..0000000 --- a/internal/backends/spotifyhistory/archive.go +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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 . -*/ - -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 -} diff --git a/internal/backends/spotifyhistory/models.go b/internal/backends/spotifyhistory/models.go deleted file mode 100644 index 3efaa38..0000000 --- a/internal/backends/spotifyhistory/models.go +++ /dev/null @@ -1,110 +0,0 @@ -/* -Copyright © 2024 Philipp Wolfer - -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 . -*/ - -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) - } -} diff --git a/internal/backends/spotifyhistory/spotifyhistory.go b/internal/backends/spotifyhistory/spotifyhistory.go deleted file mode 100644 index 985469f..0000000 --- a/internal/backends/spotifyhistory/spotifyhistory.go +++ /dev/null @@ -1,130 +0,0 @@ -/* -Copyright © 2023-2025 Philipp Wolfer - -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 . -*/ - -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 -} diff --git a/internal/backends/subsonic/subsonic.go b/internal/backends/subsonic/subsonic.go index 1ffa510..732b9ab 100644 --- a/internal/backends/subsonic/subsonic.go +++ b/internal/backends/subsonic/subsonic.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer This file is part of Scotty. @@ -17,15 +17,12 @@ Scotty. If not, see . package subsonic import ( - "context" "net/http" "sort" "time" - "github.com/supersonic-app/go-subsonic/subsonic" - "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/scotty/internal/config" - "go.uploadedlobster.com/scotty/internal/i18n" + "github.com/delucks/go-subsonic" + "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/version" ) @@ -37,25 +34,7 @@ type SubsonicApiBackend struct { func (b *SubsonicApiBackend) Name() string { return "subsonic" } -func (b *SubsonicApiBackend) Close() {} - -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 { +func (b *SubsonicApiBackend) FromConfig(config *viper.Viper) models.Backend { b.client = subsonic.Client{ Client: &http.Client{}, BaseUrl: config.GetString("server-url"), @@ -63,42 +42,35 @@ func (b *SubsonicApiBackend) InitConfig(config *config.ServiceConfig) error { ClientName: version.AppName, } 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) - p := models.TransferProgress{ - Export: &models.Progress{}, - } if err != nil { - p.Export.Abort() - progress <- p + progress <- models.Progress{}.Complete() results <- models.LovesResult{Error: err} return } starred, err := b.client.GetStarred2(map[string]string{}) if err != nil { - p.Export.Abort() - progress <- p + progress <- models.Progress{}.Complete() results <- models.LovesResult{Error: err} return } - loves := b.filterSongs(starred.Song, oldestTimestamp) - p.Export.Total = int64(len(loves)) - p.Export.Complete() - progress <- p - results <- models.LovesResult{Items: loves} + progress <- models.Progress{Elapsed: int64(len(starred.Song))}.Complete() + results <- models.LovesResult{Loves: b.filterSongs(starred.Song, oldestTimestamp)} } func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestamp time.Time) models.LovesList { - loves := make(models.LovesList, 0, len(songs)) - for _, song := range songs { + loves := make(models.LovesList, len(songs)) + for i, song := range songs { love := SongAsLove(*song, b.client.User) - if love.Created.After(oldestTimestamp) { - loves = append(loves, love) + if love.Created.Unix() > oldestTimestamp.Unix() { + loves[i] = love } } @@ -107,35 +79,20 @@ func (b *SubsonicApiBackend) filterSongs(songs []*subsonic.Child, oldestTimestam } func SongAsLove(song subsonic.Child, username string) models.Love { - recordingMBID := mbtypes.MBID(song.MusicBrainzID) love := models.Love{ - UserName: username, - Created: song.Starred, - RecordingMBID: recordingMBID, + UserName: username, + Created: song.Starred, Track: models.Track{ - TrackName: song.Title, - ReleaseName: song.Album, - ArtistNames: []string{song.Artist}, - TrackNumber: song.Track, - DiscNumber: song.DiscNumber, - RecordingMBID: recordingMBID, - Tags: []string{}, - AdditionalInfo: map[string]any{ - "subsonic_id": song.ID, - }, - Duration: time.Duration(song.Duration) * time.Second, + TrackName: song.Title, + ReleaseName: song.Album, + ArtistNames: []string{song.Artist}, + TrackNumber: song.Track, + DiscNumber: song.DiscNumber, + Tags: []string{song.Genre}, + AdditionalInfo: map[string]any{}, + Duration: time.Duration(song.Duration * int(time.Second)), }, } - if len(song.Genres) > 0 { - genres := make([]string, 0, len(song.Genres)) - for _, genre := range song.Genres { - genres = append(genres, genre.Name) - } - love.Track.Tags = genres - } else if song.Genre != "" { - love.Track.Tags = []string{song.Genre} - } - return love } diff --git a/internal/backends/subsonic/subsonic_test.go b/internal/backends/subsonic/subsonic_test.go index 638c116..d87c173 100644 --- a/internal/backends/subsonic/subsonic_test.go +++ b/internal/backends/subsonic/subsonic_test.go @@ -20,27 +20,23 @@ import ( "testing" "time" + go_subsonic "github.com/delucks/go-subsonic" "github.com/spf13/viper" "github.com/stretchr/testify/assert" - go_subsonic "github.com/supersonic-app/go-subsonic/subsonic" "go.uploadedlobster.com/scotty/internal/backends/subsonic" - "go.uploadedlobster.com/scotty/internal/config" ) -func TestInitConfig(t *testing.T) { - c := viper.New() - c.Set("server-url", "https://subsonic.example.com") - c.Set("token", "thetoken") - service := config.NewServiceConfig("test", c) - backend := subsonic.SubsonicApiBackend{} - err := backend.InitConfig(&service) - assert.NoError(t, err) +func TestFromConfig(t *testing.T) { + config := viper.New() + config.Set("server-url", "https://subsonic.example.com") + config.Set("token", "thetoken") + backend := (&subsonic.SubsonicApiBackend{}).FromConfig(config) + assert.IsType(t, &subsonic.SubsonicApiBackend{}, backend) } func TestSongToLove(t *testing.T) { user := "outsidecontext" song := go_subsonic.Child{ - ID: "foo123", Starred: time.Unix(1699574369, 0), Title: "Oweynagat", Album: "Here Now, There Then", @@ -61,5 +57,4 @@ func TestSongToLove(t *testing.T) { assert.Equal(song.Track, love.Track.TrackNumber) assert.Equal(song.DiscNumber, love.Track.DiscNumber) assert.Equal([]string{song.Genre}, love.Track.Tags) - assert.Equal(song.ID, love.AdditionalInfo["subsonic_id"]) } diff --git a/internal/cli/auth.go b/internal/cli/auth.go deleted file mode 100644 index ff14573..0000000 --- a/internal/cli/auth.go +++ /dev/null @@ -1,76 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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)) -} diff --git a/internal/cli/progress.go b/internal/cli/progress.go deleted file mode 100644 index d17594c..0000000 --- a/internal/cli/progress.go +++ /dev/null @@ -1,152 +0,0 @@ -/* -Copyright © 2023-2025 Philipp Wolfer - -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 . -*/ - -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(" "), - ), - ) -} diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go deleted file mode 100644 index f2d045b..0000000 --- a/internal/cli/prompt.go +++ /dev/null @@ -1,108 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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) -} diff --git a/internal/cli/services.go b/internal/cli/services.go deleted file mode 100644 index 65e4337..0000000 --- a/internal/cli/services.go +++ /dev/null @@ -1,103 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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 -} diff --git a/internal/cli/transfer.go b/internal/cli/transfer.go deleted file mode 100644 index 7c5ecc0..0000000 --- a/internal/cli/transfer.go +++ /dev/null @@ -1,208 +0,0 @@ -/* -Copyright © 2023-2025 Philipp Wolfer - -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 . -*/ - -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))) -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 94da799..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,142 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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()) -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index 4616857..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,32 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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")) -} diff --git a/internal/config/services.go b/internal/config/services.go deleted file mode 100644 index c04ee12..0000000 --- a/internal/config/services.go +++ /dev/null @@ -1,140 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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)) -} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go deleted file mode 100644 index a910ca0..0000000 --- a/internal/i18n/i18n.go +++ /dev/null @@ -1,38 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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...) -} diff --git a/internal/i18n/localizer.go b/internal/i18n/localizer.go deleted file mode 100644 index 7e839a2..0000000 --- a/internal/i18n/localizer.go +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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...) -} diff --git a/internal/listenbrainz/archive.go b/internal/listenbrainz/archive.go deleted file mode 100644 index b263ca9..0000000 --- a/internal/listenbrainz/archive.go +++ /dev/null @@ -1,215 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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 -} diff --git a/internal/models/interfaces.go b/internal/models/interfaces.go index 0f287bf..9078eba 100644 --- a/internal/models/interfaces.go +++ b/internal/models/interfaces.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer This file is part of Scotty. @@ -17,11 +17,12 @@ Scotty. If not, see . package models import ( - "context" + "net/url" "time" - // "go.uploadedlobster.com/scotty/internal/auth" - "go.uploadedlobster.com/scotty/internal/config" + "github.com/spf13/viper" + "go.uploadedlobster.com/scotty/internal/auth" + "golang.org/x/oauth2" ) // A listen service backend. @@ -31,13 +32,7 @@ type Backend interface { Name() string // Initialize the backend from a config. - InitConfig(config *config.ServiceConfig) error - - // Return configuration options - Options() []BackendOption - - // Free all resources of the backend - Close() + FromConfig(config *viper.Viper) Backend } type ImportBackend interface { @@ -49,7 +44,7 @@ type ImportBackend interface { // The implementation can perform all steps here to finalize the // export/import and free used resources. - FinishImport(result *ImportResult) error + FinishImport() error } // 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. // The returned list of listens is supposed to be ordered by the // 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. @@ -67,7 +62,7 @@ type ListensImport interface { ImportBackend // 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. @@ -77,7 +72,7 @@ type LovesExport interface { // Returns a list of all loves newer then oldestTimestamp. // The returned list of listens is supposed to be ordered by the // 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. @@ -85,5 +80,16 @@ type LovesImport interface { ImportBackend // 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 } diff --git a/internal/models/jsonl.go b/internal/models/jsonl.go deleted file mode 100644 index 2bb1ea1..0000000 --- a/internal/models/jsonl.go +++ /dev/null @@ -1,65 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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 . -*/ - -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 - } - } - } -} diff --git a/internal/models/models.go b/internal/models/models.go index 69280b3..2175ea1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,19 +22,11 @@ THE SOFTWARE. package models import ( - "iter" "strings" "time" - - "go.uploadedlobster.com/mbtypes" ) -type Entity string - -const ( - Listens Entity = "listens" - Loves Entity = "loves" -) +type MBID string type AdditionalInfo map[string]any @@ -45,12 +37,12 @@ type Track struct { TrackNumber int DiscNumber int Duration time.Duration - ISRC mbtypes.ISRC - RecordingMBID mbtypes.MBID - ReleaseMBID mbtypes.MBID - ReleaseGroupMBID mbtypes.MBID - ArtistMBIDs []mbtypes.MBID - WorkMBIDs []mbtypes.MBID + ISRC string + RecordingMbid MBID + ReleaseMbid MBID + ReleaseGroupMbid MBID + ArtistMbids []MBID + WorkMbids []MBID Tags []string AdditionalInfo AdditionalInfo } @@ -64,20 +56,20 @@ func (t *Track) FillAdditionalInfo() { if t.AdditionalInfo == nil { t.AdditionalInfo = make(AdditionalInfo, 5) } - if t.RecordingMBID != "" { - t.AdditionalInfo["recording_mbid"] = t.RecordingMBID + if t.RecordingMbid != "" { + t.AdditionalInfo["recording_mbid"] = t.RecordingMbid } - if t.ReleaseGroupMBID != "" { - t.AdditionalInfo["release_group_mbid"] = t.ReleaseGroupMBID + if t.ReleaseGroupMbid != "" { + t.AdditionalInfo["release_group_mbid"] = t.ReleaseGroupMbid } - if t.ReleaseMBID != "" { - t.AdditionalInfo["release_mbid"] = t.ReleaseMBID + if t.ReleaseMbid != "" { + t.AdditionalInfo["release_mbid"] = t.ReleaseMbid } - if len(t.ArtistMBIDs) > 0 { - t.AdditionalInfo["artist_mbids"] = t.ArtistMBIDs + if len(t.ArtistMbids) > 0 { + t.AdditionalInfo["artist_mbids"] = t.ArtistMbids } - if len(t.WorkMBIDs) > 0 { - t.AdditionalInfo["work_mbids"] = t.WorkMBIDs + if len(t.WorkMbids) > 0 { + t.AdditionalInfo["work_mbids"] = t.WorkMbids } if t.ISRC != "" { t.AdditionalInfo["isrc"] = t.ISRC @@ -112,8 +104,8 @@ type Love struct { Track Created time.Time UserName string - RecordingMBID mbtypes.MBID - RecordingMSID mbtypes.MBID + RecordingMbid MBID + RecordingMsid MBID } type ListensList []Listen @@ -122,7 +114,7 @@ type ListensList []Listen func (l ListensList) NewerThan(t time.Time) ListensList { result := make(ListensList, 0, len(l)) for _, item := range l { - if item.ListenedAt.After(t) { + if item.ListenedAt.Unix() > t.Unix() { result = append(result, item) } } @@ -134,7 +126,7 @@ func (l ListensList) Len() int { } 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) { @@ -148,43 +140,31 @@ func (l LovesList) Len() int { } 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) { l[i], l[j] = l[j], l[i] } -type ExportResult[T LovesList | ListensList] struct { - Items T +type ListensResult struct { Total int + Listens ListensList OldestTimestamp time.Time Error error } -type ListensResult ExportResult[ListensList] - -type LovesResult ExportResult[LovesList] - -type LogEntryType string - -const ( - Output LogEntryType = "" - Info LogEntryType = "Info" - Warning LogEntryType = "Warning" - Error LogEntryType = "Error" -) - -type LogEntry struct { - Type LogEntryType - Message string +type LovesResult struct { + Total int + Loves LovesList + Error error } type ImportResult struct { TotalCount int ImportCount int LastTimestamp time.Time - ImportLog []LogEntry + ImportErrors []string // Error is only set if an unrecoverable import error occurred Error error @@ -192,54 +172,22 @@ type ImportResult struct { // Sets LastTimestamp to newTime, if newTime is newer than LastTimestamp func (i *ImportResult) UpdateTimestamp(newTime time.Time) { - if newTime.After(i.LastTimestamp) { + if newTime.Unix() > i.LastTimestamp.Unix() { i.LastTimestamp = newTime } } -func (i *ImportResult) Update(from *ImportResult) { - if i != from { - i.TotalCount = from.TotalCount - i.ImportCount = from.ImportCount - i.UpdateTimestamp(from.LastTimestamp) - i.ImportLog = append(i.ImportLog, from.ImportLog...) - } -} - -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 +func (i *ImportResult) Update(from ImportResult) { + i.TotalCount = from.TotalCount + i.ImportCount = from.ImportCount + i.UpdateTimestamp(from.LastTimestamp) + i.ImportErrors = append(i.ImportErrors, from.ImportErrors...) } type Progress struct { - TotalItems int - Total int64 - Elapsed int64 - Completed bool - Aborted bool + Total int64 + Elapsed int64 + Completed bool } func (p Progress) FromImportResult(result ImportResult) Progress { @@ -248,48 +196,8 @@ func (p Progress) FromImportResult(result ImportResult) Progress { return p } -func (p *Progress) Complete() { - p.Elapsed = p.Total +func (p Progress) Complete() Progress { + p.Total = p.Elapsed p.Completed = true -} - -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 - } - } + return p } diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 47ef86f..59cc4dd 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy 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/require" - "go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/scotty/internal/models" ) @@ -45,25 +44,25 @@ func TestTrackArtistName(t *testing.T) { func TestTrackFillAdditionalInfo(t *testing.T) { track := models.Track{ - RecordingMBID: mbtypes.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), - ReleaseGroupMBID: mbtypes.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), - ReleaseMBID: mbtypes.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"), - ArtistMBIDs: []mbtypes.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"}, - WorkMBIDs: []mbtypes.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"}, + RecordingMbid: models.MBID("c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"), + ReleaseGroupMbid: models.MBID("80aca1ee-aa51-41be-9f75-024710d92ff4"), + ReleaseMbid: models.MBID("aa1ea1ac-7ec4-4542-a494-105afbfe547d"), + ArtistMbids: []models.MBID{"24412926-c7bd-48e8-afad-8a285b42e131"}, + WorkMbids: []models.MBID{"c0a1fc94-5f04-4a5f-bc09-e5de0c49cd12"}, TrackNumber: 5, DiscNumber: 1, Duration: time.Duration(413787 * time.Millisecond), - ISRC: mbtypes.ISRC("DES561620801"), + ISRC: "DES561620801", Tags: []string{"rock", "psychedelic rock"}, } track.FillAdditionalInfo() i := track.AdditionalInfo assert := assert.New(t) - assert.Equal(track.RecordingMBID, i["recording_mbid"]) - assert.Equal(track.ReleaseGroupMBID, i["release_group_mbid"]) - assert.Equal(track.ReleaseMBID, i["release_mbid"]) - assert.Equal(track.ArtistMBIDs, i["artist_mbids"]) - assert.Equal(track.WorkMBIDs, i["work_mbids"]) + assert.Equal(track.RecordingMbid, i["recording_mbid"]) + assert.Equal(track.ReleaseGroupMbid, i["release_group_mbid"]) + assert.Equal(track.ReleaseMbid, i["release_mbid"]) + assert.Equal(track.ArtistMbids, i["artist_mbids"]) + assert.Equal(track.WorkMbids, i["work_mbids"]) assert.Equal(track.TrackNumber, i["tracknumber"]) assert.Equal(track.DiscNumber, i["discnumber"]) assert.Equal(track.Duration.Milliseconds(), i["duration_ms"]) @@ -118,63 +117,23 @@ func TestLovesListSort(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{ TotalCount: 100, ImportCount: 20, LastTimestamp: time.Now(), - ImportLog: []models.LogEntry{logEntry1}, + ImportErrors: []string{"foo"}, } newResult := models.ImportResult{ TotalCount: 120, ImportCount: 50, 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, 50, result.ImportCount) assert.Equal(t, newResult.LastTimestamp, result.LastTimestamp) - assert.Equal(t, []models.LogEntry{logEntry1, logEntry2}, result.ImportLog) -} - -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) + assert.Equal(t, []string{"foo", "bar"}, result.ImportErrors) } func TestImportResultUpdateTimestamp(t *testing.T) { diff --git a/internal/models/options.go b/internal/models/options.go deleted file mode 100644 index 0e09dd7..0000000 --- a/internal/models/options.go +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -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 -} diff --git a/pkg/ratelimit/httpheader.go b/internal/ratelimit/httpheader.go similarity index 58% rename from pkg/ratelimit/httpheader.go rename to internal/ratelimit/httpheader.go index 617c3b8..91a05b5 100644 --- a/pkg/ratelimit/httpheader.go +++ b/internal/ratelimit/httpheader.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer 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 @@ -13,7 +13,6 @@ You should have received a copy of the GNU General Public License along with Scotty. If not, see . */ -// Helper functions to set up rate limiting with resty. package ratelimit import ( @@ -25,20 +24,11 @@ import ( ) const ( - RetryCount = 5 - DefaultRateLimitWait = 5 * time.Second - MaxWaitTime = 60 * time.Second + RetryCount = 5 + DefaultRateLimitWaitSeconds = 5 + MaxWaitTimeSeconds = 60 ) -// Implements rate HTTP header based limiting for resty. -// -// This works with servers that return the status code 429 (Too Many Requests) -// and an HTTP header indicating the time in seconds until rate limit resets. -// Common headers used are "X-RateLimit-Reset-In" or "Retry-After". -// -// Usage: -// -// ratelimit.EnableHTTPHeaderRateLimit(client, "Retry-After") func EnableHTTPHeaderRateLimit(client *resty.Client, resetInHeader string) { client.SetRetryCount(RetryCount) client.AddRetryCondition( @@ -47,15 +37,16 @@ func EnableHTTPHeaderRateLimit(client *resty.Client, resetInHeader string) { return code == http.StatusTooManyRequests || code >= http.StatusInternalServerError }, ) - client.SetRetryMaxWaitTime(MaxWaitTime) + client.SetRetryMaxWaitTime(time.Duration(MaxWaitTimeSeconds * time.Second)) client.SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) { - retryAfter := DefaultRateLimitWait + var err error + var retryAfter int = DefaultRateLimitWaitSeconds if resp.StatusCode() == http.StatusTooManyRequests { - retryAfterHeader, err := strconv.Atoi(resp.Header().Get(resetInHeader)) - if err == nil { - retryAfter = time.Duration(retryAfterHeader) * time.Second + retryAfter, err = strconv.Atoi(resp.Header().Get(resetInHeader)) + if err != nil { + retryAfter = DefaultRateLimitWaitSeconds } } - return retryAfter, nil + return time.Duration(retryAfter * int(time.Second)), err }) } diff --git a/internal/similarity/similarity.go b/internal/similarity/similarity.go deleted file mode 100644 index 3fb27c4..0000000 --- a/internal/similarity/similarity.go +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright © 2024 Philipp Wolfer - -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 . -*/ - -package similarity - -import ( - "regexp" - "strings" - - "github.com/agnivade/levenshtein" - "go.uploadedlobster.com/scotty/internal/models" - "go.uploadedlobster.com/scotty/internal/util" - "golang.org/x/text/unicode/norm" -) - -// Returns the Levensthein distance between s1 and s2 relative to the length of -// the longer string. -// Unicode normalization on the strings is performed. -func Similarity(s1 string, s2 string) float64 { - s1 = norm.NFKC.String(s1) - s2 = norm.NFKC.String(s2) - l1 := len([]rune(s1)) - l2 := len([]rune(s2)) - maxLen := max(l1, l2) - // Empty strings always compare full equal - if maxLen == 0 { - return 1.0 - } - dist := levenshtein.ComputeDistance(s1, s2) - // fmt.Printf("%v (%v) ~ %v (%v) = %v\n", s1, l1, s2, l2, dist) - return 1.0 - (float64(dist) / float64(maxLen)) -} - -var reMultiSpace = regexp.MustCompile(`\s+`) -var reIgnoredPatterns = []*regexp.Regexp{ - regexp.MustCompile(`\s+\([^)]+\)$`), - regexp.MustCompile(`\s+- (\d{4} )?remaster(ed)?$`), -} - -// Normalizes a track or release title. -func NormalizeTitle(s string) string { - s = strings.TrimSpace(s) - s = strings.ToLower(s) - s = reMultiSpace.ReplaceAllString(s, " ") - for _, re := range reIgnoredPatterns { - s = re.ReplaceAllString(s, "") - } - return s -} - -// Compare two tracks for similarity. -func CompareTracks(t1 models.Track, t2 models.Track) float64 { - // Identical recording MBID always compares 100% - if t1.RecordingMBID == t2.RecordingMBID && t1.RecordingMBID != "" { - return 1.0 - } - - // Compare track name and artist - sims := []float64{ - Similarity(NormalizeTitle(t1.TrackName), NormalizeTitle(t2.TrackName)), - Similarity(NormalizeTitle(t1.ArtistName()), NormalizeTitle(t2.ArtistName())), - } - - // Compare release names only if they are set for both tracks - if t1.ReleaseName != "" && t2.ReleaseName != "" { - sims = append(sims, Similarity(NormalizeTitle(t1.ReleaseName), NormalizeTitle(t2.ReleaseName))) - } - - return util.Average(sims...) -} diff --git a/internal/similarity/similarity_test.go b/internal/similarity/similarity_test.go deleted file mode 100644 index c43e1d7..0000000 --- a/internal/similarity/similarity_test.go +++ /dev/null @@ -1,87 +0,0 @@ -/* -Copyright © 2024 Philipp Wolfer - -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 . -*/ - -package similarity_test - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "go.uploadedlobster.com/mbtypes" - "go.uploadedlobster.com/scotty/internal/models" - "go.uploadedlobster.com/scotty/internal/similarity" -) - -func ExampleSimilarity() { - s := similarity.Similarity("bar1", "bär1") - fmt.Println(s) - // Output: 0.75 -} - -func TestSimilarity(t *testing.T) { - assert := assert.New(t) - assert.Equal(1.0, similarity.Similarity("", "")) - assert.Equal(0.0, similarity.Similarity("foo", "")) - assert.Equal(0.0, similarity.Similarity("foo", "bar")) - assert.Equal(0.5, similarity.Similarity("foobar", "bar")) - assert.Equal(1.0, similarity.Similarity("foo", "foo")) - assert.Equal(0.6, similarity.Similarity("Forever After", "Forever Failure")) -} - -func ExampleNormalizeTitle() { - s := similarity.NormalizeTitle(" Forever \tFailure (video edit) ") - fmt.Println(s) - // Output: forever failure -} - -func TestNormalizeTitle(t *testing.T) { - assert := assert.New(t) - assert.Equal("forever failure", similarity.NormalizeTitle("Forever Failure")) - assert.Equal("foo", similarity.NormalizeTitle(" \tfoo\t \t")) - assert.Equal("wasted years", similarity.NormalizeTitle("Wasted Years - 2015 Remaster")) - assert.Equal("london calling", similarity.NormalizeTitle("London Calling - Remastered")) - assert.Equal("london calling", similarity.NormalizeTitle("London Calling (Remastered)")) -} - -func ExampleCompareTracks() { - t1 := models.Track{ - ArtistNames: []string{"Paradise Lost"}, - TrackName: "Forever After", - } - t2 := models.Track{ - ArtistNames: []string{"Paradise Lost"}, - TrackName: "Forever Failure (radio edit)", - ReleaseName: "Draconian Times", - } - sim := similarity.CompareTracks(t1, t2) - fmt.Println(sim) - // Output: 0.8333333333333334 -} - -func TestCompareTracksSameMBID(t *testing.T) { - t1 := models.Track{ - ArtistNames: []string{"Paradise Lost"}, - TrackName: "Forever After", - RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"), - } - t2 := models.Track{ - ArtistNames: []string{"Paradise Lost"}, - TrackName: "Forever Failure (radio edit)", - ReleaseName: "Draconian Times", - RecordingMBID: mbtypes.MBID("2886d15c-09b0-43c6-af56-932f70dde164"), - } - assert.Equal(t, 1.0, similarity.CompareTracks(t1, t2)) -} diff --git a/internal/storage/database.go b/internal/storage/database.go index d31a176..1548feb 100644 --- a/internal/storage/database.go +++ b/internal/storage/database.go @@ -24,7 +24,6 @@ import ( "time" "github.com/glebarez/sqlite" - "go.uploadedlobster.com/scotty/internal/models" "golang.org/x/oauth2" "gorm.io/datatypes" "gorm.io/gorm" @@ -55,7 +54,7 @@ func New(dsn string) (db Database, err error) { return } -func (db Database) GetImportTimestamp(source string, target string, entity models.Entity) (time.Time, error) { +func (db Database) GetImportTimestamp(source string, target string, entity string) (time.Time, error) { result := ImportTimestamp{ SourceService: source, TargetService: target, @@ -65,7 +64,7 @@ func (db Database) GetImportTimestamp(source string, target string, entity model return result.Timestamp, err } -func (db Database) SetImportTimestamp(source string, target string, entity models.Entity, timestamp time.Time) error { +func (db Database) SetImportTimestamp(source string, target string, entity string, timestamp time.Time) error { entry := ImportTimestamp{ SourceService: source, TargetService: target, diff --git a/internal/storage/database_test.go b/internal/storage/database_test.go index 606d334..cd95149 100644 --- a/internal/storage/database_test.go +++ b/internal/storage/database_test.go @@ -23,7 +23,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/internal/storage" "golang.org/x/oauth2" ) @@ -34,7 +33,7 @@ func TestTimestampUpdate(t *testing.T) { source := "maloja" target := "funkwhale" - entity := models.Loves + entity := "loves" timestamp, err := db.GetImportTimestamp(source, target, entity) require.NoError(t, err) assert.Equal(t, time.Time{}, timestamp) diff --git a/internal/storage/models.go b/internal/storage/models.go index 4b40e85..9df85db 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -20,14 +20,13 @@ package storage import ( "time" - "go.uploadedlobster.com/scotty/internal/models" "gorm.io/datatypes" ) type ImportTimestamp struct { - SourceService string `gorm:"primaryKey"` - TargetService string `gorm:"primaryKey"` - Entity models.Entity `gorm:"primaryKey"` + SourceService string `gorm:"primaryKey"` + TargetService string `gorm:"primaryKey"` + Entity string `gorm:"primaryKey"` CreatedAt time.Time UpdatedAt time.Time Timestamp time.Time `gorm:"default:'1970-01-01T00:00:00'"` diff --git a/internal/translations/COPYING b/internal/translations/COPYING deleted file mode 100644 index 0e259d4..0000000 --- a/internal/translations/COPYING +++ /dev/null @@ -1,121 +0,0 @@ -Creative Commons Legal Code - -CC0 1.0 Universal - - CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE - LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN - ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS - INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES - REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS - PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM - THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED - HEREUNDER. - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator -and subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for -the purpose of contributing to a commons of creative, cultural and -scientific works ("Commons") that the public can reliably and without fear -of later claims of infringement build upon, modify, incorporate in other -works, reuse and redistribute as freely as possible in any form whatsoever -and for any purposes, including without limitation commercial purposes. -These owners may contribute to the Commons to promote the ideal of a free -culture and the further production of creative, cultural and scientific -works, or to gain reputation or greater distribution for their Work in -part through the use and efforts of others. - -For these and/or other purposes and motivations, and without any -expectation of additional consideration or compensation, the person -associating CC0 with a Work (the "Affirmer"), to the extent that he or she -is an owner of Copyright and Related Rights in the Work, voluntarily -elects to apply CC0 to the Work and publicly distribute the Work under its -terms, with knowledge of his or her Copyright and Related Rights in the -Work and the meaning and intended legal effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not -limited to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, - communicate, and translate a Work; - ii. moral rights retained by the original author(s) and/or performer(s); -iii. publicity and privacy rights pertaining to a person's image or - likeness depicted in a Work; - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - v. rights protecting the extraction, dissemination, use and reuse of data - in a Work; - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation - thereof, including any amended or successor version of such - directive); and -vii. other similar, equivalent or corresponding rights throughout the - world based on applicable law or treaty, and any national - implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention -of, applicable law, Affirmer hereby overtly, fully, permanently, -irrevocably and unconditionally waives, abandons, and surrenders all of -Affirmer's Copyright and Related Rights and associated claims and causes -of action, whether now known or unknown (including existing as well as -future claims and causes of action), in the Work (i) in all territories -worldwide, (ii) for the maximum duration provided by applicable law or -treaty (including future time extensions), (iii) in any current or future -medium and for any number of copies, and (iv) for any purpose whatsoever, -including without limitation commercial, advertising or promotional -purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each -member of the public at large and to the detriment of Affirmer's heirs and -successors, fully intending that such Waiver shall not be subject to -revocation, rescission, cancellation, termination, or any other legal or -equitable action to disrupt the quiet enjoyment of the Work by the public -as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason -be judged legally invalid or ineffective under applicable law, then the -Waiver shall be preserved to the maximum extent permitted taking into -account Affirmer's express Statement of Purpose. In addition, to the -extent the Waiver is so judged Affirmer hereby grants to each affected -person a royalty-free, non transferable, non sublicensable, non exclusive, -irrevocable and unconditional license to exercise Affirmer's Copyright and -Related Rights in the Work (i) in all territories worldwide, (ii) for the -maximum duration provided by applicable law or treaty (including future -time extensions), (iii) in any current or future medium and for any number -of copies, and (iv) for any purpose whatsoever, including without -limitation commercial, advertising or promotional purposes (the -"License"). The License shall be deemed effective as of the date CC0 was -applied by Affirmer to the Work. Should any part of the License for any -reason be judged legally invalid or ineffective under applicable law, such -partial invalidity or ineffectiveness shall not invalidate the remainder -of the License, and in such case Affirmer hereby affirms that he or she -will not (i) exercise any of his or her remaining Copyright and Related -Rights in the Work or (ii) assert any associated claims and causes of -action with respect to the Work, in either case contrary to Affirmer's -express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - b. Affirmer offers the Work as-is and makes no representations or - warranties of any kind concerning the Work, express, implied, - statutory or otherwise, including without limitation warranties of - title, merchantability, fitness for a particular purpose, non - infringement, or the absence of latent or other defects, accuracy, or - the present or absence of errors, whether or not discoverable, all to - the greatest extent permissible under applicable law. - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without - limitation any person's Copyright and Related Rights in the Work. - Further, Affirmer disclaims responsibility for obtaining any necessary - consents, permissions or other rights required for any use of the - Work. - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to - this CC0 or use of the Work. diff --git a/internal/translations/README.md b/internal/translations/README.md deleted file mode 100644 index 77cd8fe..0000000 --- a/internal/translations/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Scotty translation - -This package provides the translation files for [Scotty](https://sr.ht/~phw/scotty/). - - -## Contribute - -You can help translate this project into your language with [Weblate](https://translate.uploadedlobster.com/projects/scotty/). If there are missing or wrong translations in your language you can directly improve the translations there. - -If your language is not yet available on Weblate please request the language to be added. Use the mailing list [~phw/musicbrainz@lists.sr.ht](https://lists.sr.ht/~phw/musicbrainz) or [Start new translation](https://translate.uploadedlobster.com/new-lang/scotty/app/) in Weblate to do so. - -It is also possible to directly edit the `messages.gotext.json` file located in the `locales/[language-code]` directories. If you do so please send the modified file or a git patch to the mailing list. *Please do not use the `out.gotext.json` files.* - - -## Maintenance - -After strings have been added or modified in the code or after changes have been made to the `messages.gotext.json` files the new strings need to be extracted and changed translations need to be merged. - -This requires the gotext tool to be installed: - - go install golang.org/x/text/cmd/gotext@latest - -The following command will extract all strings and merge the translations: - - go generate ./internal/translations/translations.go - - -## License - -All Scotty user interface strings and their translations included in this package are published under the conditions of [CC0 1.0 Universal (CC0 1.0)](https://creativecommons.org/publicdomain/zero/1.0/). diff --git a/internal/translations/catalog.go b/internal/translations/catalog.go deleted file mode 100644 index f0aaaae..0000000 --- a/internal/translations/catalog.go +++ /dev/null @@ -1,195 +0,0 @@ -// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT. - -package translations - -import ( - "golang.org/x/text/language" - "golang.org/x/text/message" - "golang.org/x/text/message/catalog" -) - -type dictionary struct { - index []uint32 - data string -} - -func (d *dictionary) Lookup(key string) (data string, ok bool) { - p, ok := messageKeyToIndex[key] - if !ok { - return "", false - } - start, end := d.index[p], d.index[p+1] - if start == end { - return "", false - } - return d.data[start:end], true -} - -func init() { - dict := map[string]catalog.Dictionary{ - "de": &dictionary{index: deIndex, data: deData}, - "en": &dictionary{index: enIndex, data: enData}, - } - fallback := language.MustParse("en") - cat, err := catalog.NewFromMap(dict, catalog.Fallback(fallback)) - if err != nil { - panic(err) - } - message.DefaultCatalog = cat -} - -var messageKeyToIndex = map[string]int{ - "\tbackend: %v": 11, - "\texport: %s": 0, - "\timport: %s\n": 1, - "%v: %v": 49, - "Aborted": 8, - "Access token": 19, - "Access token received, you can use %v now.\n": 34, - "Append to file": 21, - "Backend": 43, - "Check for duplicate listens on import (slower)": 24, - "Client ID": 15, - "Client secret": 16, - "Delete the service configuration \"%v\"?": 7, - "Directory path": 29, - "Disable auto correction of submitted listens": 26, - "Error: OAuth state mismatch": 33, - "Failed reading config: %v": 2, - "File path": 20, - "From timestamp: %v (%v)": 45, - "Ignore listens in incognito mode": 30, - "Ignore skipped listens": 27, - "Ignored duplicate listen %v: \"%v\" by %v (%v)": 25, - "Import failed, last reported timestamp was %v (%s)": 47, - "Import log:": 48, - "Imported %v of %v %s into %v.": 46, - "Latest timestamp: %v (%v)": 51, - "Minimum playback duration for skipped tracks (seconds)": 31, - "No": 40, - "Playlist title": 22, - "Saved service %v using backend %v": 5, - "Server URL": 17, - "Service": 42, - "Service \"%v\" deleted\n": 9, - "Service name": 3, - "Specify a time zone for the listen timestamps": 28, - "The backend %v requires authentication. Authenticate now?": 6, - "Token received, you can close this window now.": 12, - "Transferring %s from %s to %s…": 44, - "Unique playlist identifier": 23, - "Updated service %v using backend %v\n": 10, - "User name": 18, - "Visit the URL for authorization: %v": 32, - "Yes": 39, - "a service with this name already exists": 4, - "aborted": 37, - "backend %s does not implement %s": 13, - "done": 38, - "exporting": 35, - "importing": 36, - "invalid timestamp string \"%v\"": 50, - "key must only consist of A-Za-z0-9_-": 53, - "no configuration file defined, cannot write config": 52, - "no existing service configurations": 41, - "no service configuration \"%v\"": 54, - "unknown backend \"%s\"": 14, -} - -var deIndex = []uint32{ // 56 elements - // Entry 0 - 1F - 0x00000000, 0x00000013, 0x00000027, 0x00000052, - 0x0000005e, 0x0000008d, 0x000000bd, 0x00000104, - 0x00000133, 0x0000013f, 0x00000162, 0x00000198, - 0x000001ac, 0x000001e7, 0x00000213, 0x00000233, - 0x0000023d, 0x0000024b, 0x00000256, 0x00000263, - 0x00000271, 0x0000027b, 0x0000028e, 0x000002a1, - 0x000002b8, 0x000002ed, 0x00000328, 0x0000035c, - 0x0000037e, 0x000003a4, 0x000003b4, 0x000003da, - // Entry 20 - 3F - 0x00000418, 0x00000443, 0x0000046d, 0x000004ad, - 0x000004b8, 0x000004c3, 0x000004cf, 0x000004d6, - 0x000004d9, 0x000004de, 0x00000507, 0x0000050f, - 0x00000517, 0x00000540, 0x0000055e, 0x00000589, - 0x000005c6, 0x000005d1, 0x000005de, 0x00000602, - 0x00000625, 0x00000676, 0x000006ad, 0x000006d4, -} // Size: 248 bytes - -const deData string = "" + // Size: 1748 bytes - "\x04\x01\x09\x00\x0e\x02Export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02Import:" + - " %[1]s\x02Fehler beim Lesen der Konfiguration: %[1]v\x02Servicename\x02e" + - "in Service mit diesem Namen existiert bereits\x02Service %[1]v mit dem B" + - "ackend %[2]v gespeichert\x02Das Backend %[1]v erfordert Authentifizierun" + - "g. Jetzt authentifizieren?\x02Die Servicekonfiguration „%[1]v“ löschen?" + - "\x02Abgebrochen\x04\x00\x01\x0a\x1e\x02Service „%[1]v“ gelöscht\x04\x00" + - "\x01\x0a1\x02Service %[1]v mit dem Backend %[2]v aktualisiert\x04\x01" + - "\x09\x00\x0f\x02Backend: %[1]v\x02Token erhalten, das Fenster kann jetzt" + - " geschlossen werden.\x02das Backend %[1]s implementiert %[2]s nicht\x02u" + - "nbekanntes Backend „%[1]s“\x02Client-ID\x02Client-Secret\x02Server-URL" + - "\x02Benutzername\x02Zugriffstoken\x02Dateipfad\x02An Datei anhängen\x02T" + - "itel der Playlist\x02Eindeutige Playlist-ID\x02Beim Import auf Listen-Du" + - "plikate prüfen (langsamer)\x02Listen-Duplikat ignoriert %[1]v: \x22%[2]v" + - "\x22 von %[3]v (%[4]v)\x02Autokorrektur für übermittelte Titel deaktivie" + - "ren\x02Übersprungene Listens ignorieren\x02Zeitzone für den Abspiel-Zeit" + - "stempel\x02Verzeichnispfad\x02Listens im Inkognito-Modus ignorieren\x02M" + - "inimale Wiedergabedauer für übersprungene Titel (Sekunden)\x02Zur Anmeld" + - "ung folgende URL aufrufen: %[1]v\x02Fehler: OAuth-State stimmt nicht übe" + - "rein\x04\x00\x01\x0a;\x02Zugriffstoken erhalten, %[1]v kann jetzt verwen" + - "det werden.\x02exportiere\x02importiere\x02abgebrochen\x02fertig\x02Ja" + - "\x02Nein\x02keine bestehenden Servicekonfigurationen\x02Service\x02Backe" + - "nd\x02Übertrage %[1]s von %[2]s nach %[3]s…\x02Ab Zeitstempel: %[1]v (%[" + - "2]v)\x02%[1]v von %[2]v %[3]s in %[4]v importiert.\x02Import fehlgeschla" + - "gen, letzter Zeitstempel war %[1]v (%[2]s)\x02Importlog:\x02%[1]v: %[2]v" + - "\x02ungültiger Zeitstempel „%[1]v“\x02Letzter Zeitstempel: %[1]v (%[2]v)" + - "\x02keine Konfigurationsdatei definiert, Konfiguration kann nicht geschr" + - "ieben werden\x02Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten" + - "\x02keine Servicekonfiguration „%[1]v“" - -var enIndex = []uint32{ // 56 elements - // Entry 0 - 1F - 0x00000000, 0x00000013, 0x00000027, 0x00000044, - 0x00000051, 0x00000079, 0x000000a1, 0x000000de, - 0x00000108, 0x00000110, 0x0000012d, 0x0000015c, - 0x00000170, 0x0000019f, 0x000001c6, 0x000001de, - 0x000001e8, 0x000001f6, 0x00000201, 0x0000020b, - 0x00000218, 0x00000222, 0x00000231, 0x00000240, - 0x0000025b, 0x0000028a, 0x000002c3, 0x000002f0, - 0x00000307, 0x00000335, 0x00000344, 0x00000365, - // Entry 20 - 3F - 0x0000039c, 0x000003c3, 0x000003df, 0x00000412, - 0x0000041c, 0x00000426, 0x0000042e, 0x00000433, - 0x00000437, 0x0000043a, 0x0000045d, 0x00000465, - 0x0000046d, 0x00000497, 0x000004b5, 0x000004df, - 0x00000518, 0x00000524, 0x00000531, 0x00000552, - 0x00000572, 0x000005a5, 0x000005ca, 0x000005eb, -} // Size: 248 bytes - -const enData string = "" + // Size: 1515 bytes - "\x04\x01\x09\x00\x0e\x02export: %[1]s\x04\x01\x09\x01\x0a\x0e\x02import:" + - " %[1]s\x02Failed reading config: %[1]v\x02Service name\x02a service with" + - " this name already exists\x02Saved service %[1]v using backend %[2]v\x02" + - "The backend %[1]v requires authentication. Authenticate now?\x02Delete t" + - "he service configuration \x22%[1]v\x22?\x02Aborted\x04\x00\x01\x0a\x18" + - "\x02Service \x22%[1]v\x22 deleted\x04\x00\x01\x0a*\x02Updated service %[" + - "1]v using backend %[2]v\x04\x01\x09\x00\x0f\x02backend: %[1]v\x02Token r" + - "eceived, you can close this window now.\x02backend %[1]s does not implem" + - "ent %[2]s\x02unknown backend \x22%[1]s\x22\x02Client ID\x02Client secret" + - "\x02Server URL\x02User name\x02Access token\x02File path\x02Append to fi" + - "le\x02Playlist title\x02Unique playlist identifier\x02Check for duplicat" + - "e listens on import (slower)\x02Ignored duplicate listen %[1]v: \x22%[2]" + - "v\x22 by %[3]v (%[4]v)\x02Disable auto correction of submitted listens" + - "\x02Ignore skipped listens\x02Specify a time zone for the listen timesta" + - "mps\x02Directory path\x02Ignore listens in incognito mode\x02Minimum pla" + - "yback duration for skipped tracks (seconds)\x02Visit the URL for authori" + - "zation: %[1]v\x02Error: OAuth state mismatch\x04\x00\x01\x0a.\x02Access " + - "token received, you can use %[1]v now.\x02exporting\x02importing\x02abor" + - "ted\x02done\x02Yes\x02No\x02no existing service configurations\x02Servic" + - "e\x02Backend\x02Transferring %[1]s from %[2]s to %[3]s…\x02From timestam" + - "p: %[1]v (%[2]v)\x02Imported %[1]v of %[2]v %[3]s into %[4]v.\x02Import " + - "failed, last reported timestamp was %[1]v (%[2]s)\x02Import log:\x02%[1]" + - "v: %[2]v\x02invalid timestamp string \x22%[1]v\x22\x02Latest timestamp: " + - "%[1]v (%[2]v)\x02no configuration file defined, cannot write config\x02k" + - "ey must only consist of A-Za-z0-9_-\x02no service configuration \x22%[1]" + - "v\x22" - - // Total table size 3759 bytes (3KiB); checksum: 7B4CF967 diff --git a/internal/translations/locales/de/messages.gotext.json b/internal/translations/locales/de/messages.gotext.json deleted file mode 100644 index b44b7af..0000000 --- a/internal/translations/locales/de/messages.gotext.json +++ /dev/null @@ -1,617 +0,0 @@ -{ - "language": "de", - "messages": [ - { - "id": "export: {ExportCapabilities__}", - "message": "export: {ExportCapabilities__}", - "translation": "Export: {ExportCapabilities__}", - "placeholders": [ - { - "id": "ExportCapabilities__", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "strings.Join(info.ExportCapabilities, \", \")" - } - ] - }, - { - "id": "import: {ImportCapabilities__}", - "message": "import: {ImportCapabilities__}", - "translation": "Import: {ImportCapabilities__}", - "placeholders": [ - { - "id": "ImportCapabilities__", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "strings.Join(info.ImportCapabilities, \", \")" - } - ] - }, - { - "id": "Failed reading config: {Err}", - "message": "Failed reading config: {Err}", - "translation": "Fehler beim Lesen der Konfiguration: {Err}", - "placeholders": [ - { - "id": "Err", - "string": "%[1]v", - "type": "error", - "underlyingType": "interface{Error() string}", - "argNum": 1, - "expr": "err" - } - ] - }, - { - "id": "Service name", - "message": "Service name", - "translation": "Servicename" - }, - { - "id": "a service with this name already exists", - "message": "a service with this name already exists", - "translation": "ein Service mit diesem Namen existiert bereits" - }, - { - "id": "Saved service {Name} using backend {Backend}", - "message": "Saved service {Name} using backend {Backend}", - "translation": "Service {Name} mit dem Backend {Backend} gespeichert", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - }, - { - "id": "Backend", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "service.Backend" - } - ] - }, - { - "id": "The backend {Backend} requires authentication. Authenticate now?", - "message": "The backend {Backend} requires authentication. Authenticate now?", - "translation": "Das Backend {Backend} erfordert Authentifizierung. Jetzt authentifizieren?", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Backend" - } - ] - }, - { - "id": "Delete the service configuration \"{Service}\"?", - "message": "Delete the service configuration \"{Service}\"?", - "translation": "Die Servicekonfiguration „{Service}“ löschen?", - "placeholders": [ - { - "id": "Service", - "string": "%[1]v", - "type": "go.uploadedlobster.com/scotty/internal/config.ServiceConfig", - "underlyingType": "struct{Name string; Backend string; ConfigValues map[string]any}", - "argNum": 1, - "expr": "service" - } - ] - }, - { - "id": "Aborted", - "message": "Aborted", - "translation": "Abgebrochen" - }, - { - "id": "Service \"{Name}\" deleted", - "message": "Service \"{Name}\" deleted", - "translation": "Service „{Name}“ gelöscht", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - } - ] - }, - { - "id": "Updated service {Name} using backend {Backend}", - "message": "Updated service {Name} using backend {Backend}", - "translation": "Service {Name} mit dem Backend {Backend} aktualisiert", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - }, - { - "id": "Backend", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "service.Backend" - } - ] - }, - { - "id": "backend: {Backend}", - "message": "backend: {Backend}", - "translation": "Backend: {Backend}", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "s.Backend" - } - ] - }, - { - "id": "Token received, you can close this window now.", - "message": "Token received, you can close this window now.", - "translation": "Token erhalten, das Fenster kann jetzt geschlossen werden." - }, - { - "id": "backend {Backend} does not implement {InterfaceName}", - "message": "backend {Backend} does not implement {InterfaceName}", - "translation": "das Backend {Backend} implementiert {InterfaceName} nicht", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "config.Backend" - }, - { - "id": "InterfaceName", - "string": "%[2]s", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "interfaceName" - } - ] - }, - { - "id": "unknown backend \"{BackendName}\"", - "message": "unknown backend \"{BackendName}\"", - "translation": "unbekanntes Backend „{BackendName}“", - "placeholders": [ - { - "id": "BackendName", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "backendName" - } - ] - }, - { - "id": "Client ID", - "message": "Client ID", - "translation": "Client-ID" - }, - { - "id": "Client secret", - "message": "Client secret", - "translation": "Client-Secret" - }, - { - "id": "Server URL", - "message": "Server URL", - "translation": "Server-URL" - }, - { - "id": "User name", - "message": "User name", - "translation": "Benutzername" - }, - { - "id": "Access token", - "message": "Access token", - "translation": "Zugriffstoken" - }, - { - "id": "File path", - "message": "File path", - "translation": "Dateipfad" - }, - { - "id": "Append to file", - "message": "Append to file", - "translation": "An Datei anhängen" - }, - { - "id": "Playlist title", - "message": "Playlist title", - "translation": "Titel der Playlist" - }, - { - "id": "Unique playlist identifier", - "message": "Unique playlist identifier", - "translation": "Eindeutige Playlist-ID" - }, - { - "id": "Check for duplicate listens on import (slower)", - "message": "Check for duplicate listens on import (slower)", - "translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)" - }, - { - "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", - "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", - "translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})", - "placeholders": [ - { - "id": "ListenedAt", - "string": "%[1]v", - "type": "time.Time", - "underlyingType": "struct{wall uint64; ext int64; loc *time.Location}", - "argNum": 1, - "expr": "l.ListenedAt" - }, - { - "id": "TrackName", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "l.TrackName" - }, - { - "id": "ArtistName", - "string": "%[3]v", - "type": "string", - "underlyingType": "string", - "argNum": 3, - "expr": "l.ArtistName()" - }, - { - "id": "RecordingMBID", - "string": "%[4]v", - "type": "go.uploadedlobster.com/mbtypes.MBID", - "underlyingType": "string", - "argNum": 4, - "expr": "l.RecordingMBID" - } - ] - }, - { - "id": "Disable auto correction of submitted listens", - "message": "Disable auto correction of submitted listens", - "translation": "Autokorrektur für übermittelte Titel deaktivieren" - }, - { - "id": "Ignore skipped listens", - "message": "Ignore skipped listens", - "translation": "Übersprungene Listens ignorieren" - }, - { - "id": "Specify a time zone for the listen timestamps", - "message": "Specify a time zone for the listen timestamps", - "translation": "Zeitzone für den Abspiel-Zeitstempel" - }, - { - "id": "Directory path", - "message": "Directory path", - "translation": "Verzeichnispfad" - }, - { - "id": "Ignore listens in incognito mode", - "message": "Ignore listens in incognito mode", - "translation": "Listens im Inkognito-Modus ignorieren" - }, - { - "id": "Minimum playback duration for skipped tracks (seconds)", - "message": "Minimum playback duration for skipped tracks (seconds)", - "translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)" - }, - { - "id": "Visit the URL for authorization: {URL}", - "message": "Visit the URL for authorization: {URL}", - "translation": "Zur Anmeldung folgende URL aufrufen: {URL}", - "placeholders": [ - { - "id": "URL", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "authURL.URL" - } - ] - }, - { - "id": "Error: OAuth state mismatch", - "message": "Error: OAuth state mismatch", - "translation": "Fehler: OAuth-State stimmt nicht überein" - }, - { - "id": "Access token received, you can use {Name} now.", - "message": "Access token received, you can use {Name} now.", - "translation": "Zugriffstoken erhalten, {Name} kann jetzt verwendet werden.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - } - ] - }, - { - "id": "exporting", - "message": "exporting", - "translatorComment": "Copied from source.", - "translation": "exportiere" - }, - { - "id": "importing", - "message": "importing", - "translatorComment": "Copied from source.", - "translation": "importiere" - }, - { - "id": "aborted", - "message": "aborted", - "translation": "abgebrochen" - }, - { - "id": "done", - "message": "done", - "translatorComment": "Copied from source.", - "translation": "fertig" - }, - { - "id": "Yes", - "message": "Yes", - "translation": "Ja" - }, - { - "id": "No", - "message": "No", - "translation": "Nein" - }, - { - "id": "no existing service configurations", - "message": "no existing service configurations", - "translation": "keine bestehenden Servicekonfigurationen" - }, - { - "id": "Service", - "message": "Service", - "translation": "Service" - }, - { - "id": "Backend", - "message": "Backend", - "translation": "Backend" - }, - { - "id": "Transferring {Entity} from {SourceName} to {TargetName}…", - "message": "Transferring {Entity} from {SourceName} to {TargetName}…", - "translation": "Übertrage {Entity} von {SourceName} nach {TargetName}…", - "placeholders": [ - { - "id": "Entity", - "string": "%[1]s", - "type": "go.uploadedlobster.com/scotty/internal/models.Entity", - "underlyingType": "string", - "argNum": 1, - "expr": "c.entity" - }, - { - "id": "SourceName", - "string": "%[2]s", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "c.sourceName" - }, - { - "id": "TargetName", - "string": "%[3]s", - "type": "string", - "underlyingType": "string", - "argNum": 3, - "expr": "c.targetName" - } - ] - }, - { - "id": "From timestamp: {Arg_1} ({Arg_2})", - "message": "From timestamp: {Arg_1} ({Arg_2})", - "translation": "Ab Zeitstempel: {Arg_1} ({Arg_2})", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 2 - } - ] - }, - { - "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", - "message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", - "translation": "{ImportCount} von {TotalCount} {Entity} in {TargetName} importiert.", - "placeholders": [ - { - "id": "ImportCount", - "string": "%[1]v", - "type": "int", - "underlyingType": "int", - "argNum": 1, - "expr": "result.ImportCount" - }, - { - "id": "TotalCount", - "string": "%[2]v", - "type": "int", - "underlyingType": "int", - "argNum": 2, - "expr": "result.TotalCount" - }, - { - "id": "Entity", - "string": "%[3]s", - "type": "go.uploadedlobster.com/scotty/internal/models.Entity", - "underlyingType": "string", - "argNum": 3, - "expr": "c.entity" - }, - { - "id": "TargetName", - "string": "%[4]v", - "type": "string", - "underlyingType": "string", - "argNum": 4, - "expr": "c.targetName" - } - ] - }, - { - "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]s", - "type": "", - "underlyingType": "string", - "argNum": 2 - } - ] - }, - { - "id": "Import log:", - "message": "Import log:", - "translation": "Importlog:" - }, - { - "id": "{Type}: {Message}", - "message": "{Type}: {Message}", - "translation": "{Type}: {Message}", - "placeholders": [ - { - "id": "Type", - "string": "%[1]v", - "type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType", - "underlyingType": "string", - "argNum": 1, - "expr": "entry.Type" - }, - { - "id": "Message", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "entry.Message" - } - ] - }, - { - "id": "invalid timestamp string \"{FlagValue}\"", - "message": "invalid timestamp string \"{FlagValue}\"", - "translation": "ungültiger Zeitstempel „{FlagValue}“", - "placeholders": [ - { - "id": "FlagValue", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "flagValue" - } - ] - }, - { - "id": "Latest timestamp: {Arg_1} ({Arg_2})", - "message": "Latest timestamp: {Arg_1} ({Arg_2})", - "translation": "Letzter Zeitstempel: {Arg_1} ({Arg_2})", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 2 - } - ] - }, - { - "id": "no configuration file defined, cannot write config", - "message": "no configuration file defined, cannot write config", - "translation": "keine Konfigurationsdatei definiert, Konfiguration kann nicht geschrieben werden" - }, - { - "id": "key must only consist of A-Za-z0-9_-", - "message": "key must only consist of A-Za-z0-9_-", - "translation": "Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten" - }, - { - "id": "no service configuration \"{Name}\"", - "message": "no service configuration \"{Name}\"", - "translation": "keine Servicekonfiguration „{Name}“", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "name" - } - ] - } - ] -} diff --git a/internal/translations/locales/de/out.gotext.json b/internal/translations/locales/de/out.gotext.json deleted file mode 100644 index 863d9c8..0000000 --- a/internal/translations/locales/de/out.gotext.json +++ /dev/null @@ -1,617 +0,0 @@ -{ - "language": "de", - "messages": [ - { - "id": "export: {ExportCapabilities__}", - "message": "export: {ExportCapabilities__}", - "translation": "Export: {ExportCapabilities__}", - "placeholders": [ - { - "id": "ExportCapabilities__", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "strings.Join(info.ExportCapabilities, \", \")" - } - ] - }, - { - "id": "import: {ImportCapabilities__}", - "message": "import: {ImportCapabilities__}", - "translation": "Import: {ImportCapabilities__}", - "placeholders": [ - { - "id": "ImportCapabilities__", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "strings.Join(info.ImportCapabilities, \", \")" - } - ] - }, - { - "id": "Failed reading config: {Err}", - "message": "Failed reading config: {Err}", - "translation": "Fehler beim Lesen der Konfiguration: {Err}", - "placeholders": [ - { - "id": "Err", - "string": "%[1]v", - "type": "error", - "underlyingType": "interface{Error() string}", - "argNum": 1, - "expr": "err" - } - ] - }, - { - "id": "Service name", - "message": "Service name", - "translation": "Servicename" - }, - { - "id": "a service with this name already exists", - "message": "a service with this name already exists", - "translation": "ein Service mit diesem Namen existiert bereits" - }, - { - "id": "Saved service {Name} using backend {Backend}", - "message": "Saved service {Name} using backend {Backend}", - "translation": "Service {Name} mit dem Backend {Backend} gespeichert", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - }, - { - "id": "Backend", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "service.Backend" - } - ] - }, - { - "id": "The backend {Backend} requires authentication. Authenticate now?", - "message": "The backend {Backend} requires authentication. Authenticate now?", - "translation": "Das Backend {Backend} erfordert Authentifizierung. Jetzt authentifizieren?", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Backend" - } - ] - }, - { - "id": "Delete the service configuration \"{Service}\"?", - "message": "Delete the service configuration \"{Service}\"?", - "translation": "Die Servicekonfiguration „{Service}“ löschen?", - "placeholders": [ - { - "id": "Service", - "string": "%[1]v", - "type": "go.uploadedlobster.com/scotty/internal/config.ServiceConfig", - "underlyingType": "struct{Name string; Backend string; ConfigValues map[string]any}", - "argNum": 1, - "expr": "service" - } - ] - }, - { - "id": "Aborted", - "message": "Aborted", - "translation": "Abgebrochen" - }, - { - "id": "Service \"{Name}\" deleted", - "message": "Service \"{Name}\" deleted", - "translation": "Service „{Name}“ gelöscht", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - } - ] - }, - { - "id": "Updated service {Name} using backend {Backend}", - "message": "Updated service {Name} using backend {Backend}", - "translation": "Service {Name} mit dem Backend {Backend} aktualisiert", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - }, - { - "id": "Backend", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "service.Backend" - } - ] - }, - { - "id": "backend: {Backend}", - "message": "backend: {Backend}", - "translation": "Backend: {Backend}", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "s.Backend" - } - ] - }, - { - "id": "Token received, you can close this window now.", - "message": "Token received, you can close this window now.", - "translation": "Token erhalten, das Fenster kann jetzt geschlossen werden." - }, - { - "id": "backend {Backend} does not implement {InterfaceName}", - "message": "backend {Backend} does not implement {InterfaceName}", - "translation": "das Backend {Backend} implementiert {InterfaceName} nicht", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "config.Backend" - }, - { - "id": "InterfaceName", - "string": "%[2]s", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "interfaceName" - } - ] - }, - { - "id": "unknown backend \"{BackendName}\"", - "message": "unknown backend \"{BackendName}\"", - "translation": "unbekanntes Backend „{BackendName}“", - "placeholders": [ - { - "id": "BackendName", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "backendName" - } - ] - }, - { - "id": "Client ID", - "message": "Client ID", - "translation": "Client-ID" - }, - { - "id": "Client secret", - "message": "Client secret", - "translation": "Client-Secret" - }, - { - "id": "Server URL", - "message": "Server URL", - "translation": "Server-URL" - }, - { - "id": "User name", - "message": "User name", - "translation": "Benutzername" - }, - { - "id": "Access token", - "message": "Access token", - "translation": "Zugriffstoken" - }, - { - "id": "File path", - "message": "File path", - "translation": "Dateipfad" - }, - { - "id": "Append to file", - "message": "Append to file", - "translation": "An Datei anhängen" - }, - { - "id": "Playlist title", - "message": "Playlist title", - "translation": "Titel der Playlist" - }, - { - "id": "Unique playlist identifier", - "message": "Unique playlist identifier", - "translation": "Eindeutige Playlist-ID" - }, - { - "id": "Check for duplicate listens on import (slower)", - "message": "Check for duplicate listens on import (slower)", - "translation": "Beim Import auf Listen-Duplikate prüfen (langsamer)" - }, - { - "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", - "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", - "translation": "Listen-Duplikat ignoriert {ListenedAt}: \"{TrackName}\" von {ArtistName} ({RecordingMBID})", - "placeholders": [ - { - "id": "ListenedAt", - "string": "%[1]v", - "type": "time.Time", - "underlyingType": "struct{wall uint64; ext int64; loc *time.Location}", - "argNum": 1, - "expr": "l.ListenedAt" - }, - { - "id": "TrackName", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "l.TrackName" - }, - { - "id": "ArtistName", - "string": "%[3]v", - "type": "string", - "underlyingType": "string", - "argNum": 3, - "expr": "l.ArtistName()" - }, - { - "id": "RecordingMBID", - "string": "%[4]v", - "type": "go.uploadedlobster.com/mbtypes.MBID", - "underlyingType": "string", - "argNum": 4, - "expr": "l.RecordingMBID" - } - ] - }, - { - "id": "Disable auto correction of submitted listens", - "message": "Disable auto correction of submitted listens", - "translation": "Autokorrektur für übermittelte Titel deaktivieren" - }, - { - "id": "Ignore skipped listens", - "message": "Ignore skipped listens", - "translation": "Übersprungene Listens ignorieren" - }, - { - "id": "Specify a time zone for the listen timestamps", - "message": "Specify a time zone for the listen timestamps", - "translation": "Zeitzone für den Abspiel-Zeitstempel" - }, - { - "id": "Directory path", - "message": "Directory path", - "translation": "Verzeichnispfad" - }, - { - "id": "Ignore listens in incognito mode", - "message": "Ignore listens in incognito mode", - "translation": "Listens im Inkognito-Modus ignorieren" - }, - { - "id": "Minimum playback duration for skipped tracks (seconds)", - "message": "Minimum playback duration for skipped tracks (seconds)", - "translation": "Minimale Wiedergabedauer für übersprungene Titel (Sekunden)" - }, - { - "id": "Visit the URL for authorization: {URL}", - "message": "Visit the URL for authorization: {URL}", - "translation": "Zur Anmeldung folgende URL aufrufen: {URL}", - "placeholders": [ - { - "id": "URL", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "authURL.URL" - } - ] - }, - { - "id": "Error: OAuth state mismatch", - "message": "Error: OAuth state mismatch", - "translation": "Fehler: OAuth-State stimmt nicht überein" - }, - { - "id": "Access token received, you can use {Name} now.", - "message": "Access token received, you can use {Name} now.", - "translation": "Zugriffstoken erhalten, {Name} kann jetzt verwendet werden.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - } - ] - }, - { - "id": "exporting", - "message": "exporting", - "translation": "exportiere", - "translatorComment": "Copied from source." - }, - { - "id": "importing", - "message": "importing", - "translation": "importiere", - "translatorComment": "Copied from source." - }, - { - "id": "aborted", - "message": "aborted", - "translation": "abgebrochen" - }, - { - "id": "done", - "message": "done", - "translation": "fertig", - "translatorComment": "Copied from source." - }, - { - "id": "Yes", - "message": "Yes", - "translation": "Ja" - }, - { - "id": "No", - "message": "No", - "translation": "Nein" - }, - { - "id": "no existing service configurations", - "message": "no existing service configurations", - "translation": "keine bestehenden Servicekonfigurationen" - }, - { - "id": "Service", - "message": "Service", - "translation": "Service" - }, - { - "id": "Backend", - "message": "Backend", - "translation": "Backend" - }, - { - "id": "Transferring {Entity} from {SourceName} to {TargetName}…", - "message": "Transferring {Entity} from {SourceName} to {TargetName}…", - "translation": "Übertrage {Entity} von {SourceName} nach {TargetName}…", - "placeholders": [ - { - "id": "Entity", - "string": "%[1]s", - "type": "go.uploadedlobster.com/scotty/internal/models.Entity", - "underlyingType": "string", - "argNum": 1, - "expr": "c.entity" - }, - { - "id": "SourceName", - "string": "%[2]s", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "c.sourceName" - }, - { - "id": "TargetName", - "string": "%[3]s", - "type": "string", - "underlyingType": "string", - "argNum": 3, - "expr": "c.targetName" - } - ] - }, - { - "id": "From timestamp: {Arg_1} ({Arg_2})", - "message": "From timestamp: {Arg_1} ({Arg_2})", - "translation": "Ab Zeitstempel: {Arg_1} ({Arg_2})", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 2 - } - ] - }, - { - "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", - "message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", - "translation": "{ImportCount} von {TotalCount} {Entity} in {TargetName} importiert.", - "placeholders": [ - { - "id": "ImportCount", - "string": "%[1]v", - "type": "int", - "underlyingType": "int", - "argNum": 1, - "expr": "result.ImportCount" - }, - { - "id": "TotalCount", - "string": "%[2]v", - "type": "int", - "underlyingType": "int", - "argNum": 2, - "expr": "result.TotalCount" - }, - { - "id": "Entity", - "string": "%[3]s", - "type": "go.uploadedlobster.com/scotty/internal/models.Entity", - "underlyingType": "string", - "argNum": 3, - "expr": "c.entity" - }, - { - "id": "TargetName", - "string": "%[4]v", - "type": "string", - "underlyingType": "string", - "argNum": 4, - "expr": "c.targetName" - } - ] - }, - { - "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "translation": "Import fehlgeschlagen, letzter Zeitstempel war {Arg_1} ({Arg_2})", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]s", - "type": "", - "underlyingType": "string", - "argNum": 2 - } - ] - }, - { - "id": "Import log:", - "message": "Import log:", - "translation": "Importlog:" - }, - { - "id": "{Type}: {Message}", - "message": "{Type}: {Message}", - "translation": "{Type}: {Message}", - "placeholders": [ - { - "id": "Type", - "string": "%[1]v", - "type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType", - "underlyingType": "string", - "argNum": 1, - "expr": "entry.Type" - }, - { - "id": "Message", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "entry.Message" - } - ] - }, - { - "id": "invalid timestamp string \"{FlagValue}\"", - "message": "invalid timestamp string \"{FlagValue}\"", - "translation": "ungültiger Zeitstempel „{FlagValue}“", - "placeholders": [ - { - "id": "FlagValue", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "flagValue" - } - ] - }, - { - "id": "Latest timestamp: {Arg_1} ({Arg_2})", - "message": "Latest timestamp: {Arg_1} ({Arg_2})", - "translation": "Letzter Zeitstempel: {Arg_1} ({Arg_2})", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 2 - } - ] - }, - { - "id": "no configuration file defined, cannot write config", - "message": "no configuration file defined, cannot write config", - "translation": "keine Konfigurationsdatei definiert, Konfiguration kann nicht geschrieben werden" - }, - { - "id": "key must only consist of A-Za-z0-9_-", - "message": "key must only consist of A-Za-z0-9_-", - "translation": "Schlüssel darf nur die Zeichen A-Za-z0-9_- beinhalten" - }, - { - "id": "no service configuration \"{Name}\"", - "message": "no service configuration \"{Name}\"", - "translation": "keine Servicekonfiguration „{Name}“", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "name" - } - ] - } - ] -} \ No newline at end of file diff --git a/internal/translations/locales/en/messages.gotext.json b/internal/translations/locales/en/messages.gotext.json deleted file mode 100644 index 878db22..0000000 --- a/internal/translations/locales/en/messages.gotext.json +++ /dev/null @@ -1,663 +0,0 @@ -{ - "language": "en", - "messages": [ - { - "id": "export: {ExportCapabilities__}", - "message": "export: {ExportCapabilities__}", - "translation": "export: {ExportCapabilities__}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "ExportCapabilities__", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "strings.Join(info.ExportCapabilities, \", \")" - } - ] - }, - { - "id": "import: {ImportCapabilities__}", - "message": "import: {ImportCapabilities__}", - "translation": "import: {ImportCapabilities__}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "ImportCapabilities__", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "strings.Join(info.ImportCapabilities, \", \")" - } - ] - }, - { - "id": "Failed reading config: {Err}", - "message": "Failed reading config: {Err}", - "translation": "Failed reading config: {Err}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Err", - "string": "%[1]v", - "type": "error", - "underlyingType": "interface{Error() string}", - "argNum": 1, - "expr": "err" - } - ] - }, - { - "id": "Service name", - "message": "Service name", - "translation": "Service name", - "translatorComment": "Copied from source." - }, - { - "id": "a service with this name already exists", - "message": "a service with this name already exists", - "translation": "a service with this name already exists", - "translatorComment": "Copied from source." - }, - { - "id": "Saved service {Name} using backend {Backend}", - "message": "Saved service {Name} using backend {Backend}", - "translation": "Saved service {Name} using backend {Backend}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - }, - { - "id": "Backend", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "service.Backend" - } - ] - }, - { - "id": "The backend {Backend} requires authentication. Authenticate now?", - "message": "The backend {Backend} requires authentication. Authenticate now?", - "translation": "The backend {Backend} requires authentication. Authenticate now?", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Backend" - } - ] - }, - { - "id": "Delete the service configuration \"{Service}\"?", - "message": "Delete the service configuration \"{Service}\"?", - "translation": "Delete the service configuration \"{Service}\"?", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Service", - "string": "%[1]v", - "type": "go.uploadedlobster.com/scotty/internal/config.ServiceConfig", - "underlyingType": "struct{Name string; Backend string; ConfigValues map[string]any}", - "argNum": 1, - "expr": "service" - } - ] - }, - { - "id": "Aborted", - "message": "Aborted", - "translation": "Aborted", - "translatorComment": "Copied from source." - }, - { - "id": "Service \"{Name}\" deleted", - "message": "Service \"{Name}\" deleted", - "translation": "Service \"{Name}\" deleted", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - } - ] - }, - { - "id": "Updated service {Name} using backend {Backend}", - "message": "Updated service {Name} using backend {Backend}", - "translation": "Updated service {Name} using backend {Backend}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - }, - { - "id": "Backend", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "service.Backend" - } - ] - }, - { - "id": "backend: {Backend}", - "message": "backend: {Backend}", - "translation": "backend: {Backend}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "s.Backend" - } - ] - }, - { - "id": "Token received, you can close this window now.", - "message": "Token received, you can close this window now.", - "translation": "Token received, you can close this window now.", - "translatorComment": "Copied from source." - }, - { - "id": "backend {Backend} does not implement {InterfaceName}", - "message": "backend {Backend} does not implement {InterfaceName}", - "translation": "backend {Backend} does not implement {InterfaceName}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "config.Backend" - }, - { - "id": "InterfaceName", - "string": "%[2]s", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "interfaceName" - } - ] - }, - { - "id": "unknown backend \"{BackendName}\"", - "message": "unknown backend \"{BackendName}\"", - "translation": "unknown backend \"{BackendName}\"", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "BackendName", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "backendName" - } - ] - }, - { - "id": "Client ID", - "message": "Client ID", - "translation": "Client ID", - "translatorComment": "Copied from source." - }, - { - "id": "Client secret", - "message": "Client secret", - "translation": "Client secret", - "translatorComment": "Copied from source." - }, - { - "id": "Server URL", - "message": "Server URL", - "translation": "Server URL", - "translatorComment": "Copied from source." - }, - { - "id": "User name", - "message": "User name", - "translation": "User name", - "translatorComment": "Copied from source." - }, - { - "id": "Access token", - "message": "Access token", - "translation": "Access token", - "translatorComment": "Copied from source." - }, - { - "id": "File path", - "message": "File path", - "translation": "File path", - "translatorComment": "Copied from source." - }, - { - "id": "Append to file", - "message": "Append to file", - "translation": "Append to file", - "translatorComment": "Copied from source." - }, - { - "id": "Playlist title", - "message": "Playlist title", - "translation": "Playlist title", - "translatorComment": "Copied from source." - }, - { - "id": "Unique playlist identifier", - "message": "Unique playlist identifier", - "translation": "Unique playlist identifier", - "translatorComment": "Copied from source." - }, - { - "id": "Check for duplicate listens on import (slower)", - "message": "Check for duplicate listens on import (slower)", - "translation": "Check for duplicate listens on import (slower)", - "translatorComment": "Copied from source." - }, - { - "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", - "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", - "translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "ListenedAt", - "string": "%[1]v", - "type": "time.Time", - "underlyingType": "struct{wall uint64; ext int64; loc *time.Location}", - "argNum": 1, - "expr": "l.ListenedAt" - }, - { - "id": "TrackName", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "l.TrackName" - }, - { - "id": "ArtistName", - "string": "%[3]v", - "type": "string", - "underlyingType": "string", - "argNum": 3, - "expr": "l.ArtistName()" - }, - { - "id": "RecordingMBID", - "string": "%[4]v", - "type": "go.uploadedlobster.com/mbtypes.MBID", - "underlyingType": "string", - "argNum": 4, - "expr": "l.RecordingMBID" - } - ] - }, - { - "id": "Disable auto correction of submitted listens", - "message": "Disable auto correction of submitted listens", - "translation": "Disable auto correction of submitted listens", - "translatorComment": "Copied from source." - }, - { - "id": "Ignore skipped listens", - "message": "Ignore skipped listens", - "translation": "Ignore skipped listens", - "translatorComment": "Copied from source." - }, - { - "id": "Specify a time zone for the listen timestamps", - "message": "Specify a time zone for the listen timestamps", - "translation": "Specify a time zone for the listen timestamps", - "translatorComment": "Copied from source." - }, - { - "id": "Directory path", - "message": "Directory path", - "translation": "Directory path", - "translatorComment": "Copied from source." - }, - { - "id": "Ignore listens in incognito mode", - "message": "Ignore listens in incognito mode", - "translation": "Ignore listens in incognito mode", - "translatorComment": "Copied from source." - }, - { - "id": "Minimum playback duration for skipped tracks (seconds)", - "message": "Minimum playback duration for skipped tracks (seconds)", - "translation": "Minimum playback duration for skipped tracks (seconds)", - "translatorComment": "Copied from source." - }, - { - "id": "Visit the URL for authorization: {URL}", - "message": "Visit the URL for authorization: {URL}", - "translation": "Visit the URL for authorization: {URL}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "URL", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "authURL.URL" - } - ] - }, - { - "id": "Error: OAuth state mismatch", - "message": "Error: OAuth state mismatch", - "translation": "Error: OAuth state mismatch", - "translatorComment": "Copied from source." - }, - { - "id": "Access token received, you can use {Name} now.", - "message": "Access token received, you can use {Name} now.", - "translation": "Access token received, you can use {Name} now.", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - } - ] - }, - { - "id": "exporting", - "message": "exporting", - "translation": "exporting", - "translatorComment": "Copied from source." - }, - { - "id": "importing", - "message": "importing", - "translation": "importing", - "translatorComment": "Copied from source." - }, - { - "id": "done", - "message": "done", - "translation": "done", - "translatorComment": "Copied from source." - }, - { - "id": "Yes", - "message": "Yes", - "translation": "Yes", - "translatorComment": "Copied from source." - }, - { - "id": "No", - "message": "No", - "translation": "No", - "translatorComment": "Copied from source." - }, - { - "id": "no existing service configurations", - "message": "no existing service configurations", - "translation": "no existing service configurations", - "translatorComment": "Copied from source." - }, - { - "id": "Service", - "message": "Service", - "translation": "Service", - "translatorComment": "Copied from source." - }, - { - "id": "Backend", - "message": "Backend", - "translation": "Backend", - "translatorComment": "Copied from source." - }, - { - "id": "Transferring {Entity} from {SourceName} to {TargetName}…", - "message": "Transferring {Entity} from {SourceName} to {TargetName}…", - "translation": "Transferring {Entity} from {SourceName} to {TargetName}…", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Entity", - "string": "%[1]s", - "type": "go.uploadedlobster.com/scotty/internal/models.Entity", - "underlyingType": "string", - "argNum": 1, - "expr": "c.entity" - }, - { - "id": "SourceName", - "string": "%[2]s", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "c.sourceName" - }, - { - "id": "TargetName", - "string": "%[3]s", - "type": "string", - "underlyingType": "string", - "argNum": 3, - "expr": "c.targetName" - } - ] - }, - { - "id": "From timestamp: {Arg_1} ({Arg_2})", - "message": "From timestamp: {Arg_1} ({Arg_2})", - "translation": "From timestamp: {Arg_1} ({Arg_2})", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 2 - } - ] - }, - { - "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "translation": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]s", - "type": "", - "underlyingType": "string", - "argNum": 2 - } - ] - }, - { - "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", - "message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", - "translation": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "ImportCount", - "string": "%[1]v", - "type": "int", - "underlyingType": "int", - "argNum": 1, - "expr": "result.ImportCount" - }, - { - "id": "TotalCount", - "string": "%[2]v", - "type": "int", - "underlyingType": "int", - "argNum": 2, - "expr": "result.TotalCount" - }, - { - "id": "Entity", - "string": "%[3]s", - "type": "go.uploadedlobster.com/scotty/internal/models.Entity", - "underlyingType": "string", - "argNum": 3, - "expr": "c.entity" - }, - { - "id": "TargetName", - "string": "%[4]v", - "type": "string", - "underlyingType": "string", - "argNum": 4, - "expr": "c.targetName" - } - ] - }, - { - "id": "Import log:", - "message": "Import log:", - "translation": "Import log:", - "translatorComment": "Copied from source." - }, - { - "id": "{Type}: {Message}", - "message": "{Type}: {Message}", - "translation": "{Type}: {Message}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Type", - "string": "%[1]v", - "type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType", - "underlyingType": "string", - "argNum": 1, - "expr": "entry.Type" - }, - { - "id": "Message", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "entry.Message" - } - ] - }, - { - "id": "invalid timestamp string \"{FlagValue}\"", - "message": "invalid timestamp string \"{FlagValue}\"", - "translation": "invalid timestamp string \"{FlagValue}\"", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "FlagValue", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "flagValue" - } - ] - }, - { - "id": "Latest timestamp: {Arg_1} ({Arg_2})", - "message": "Latest timestamp: {Arg_1} ({Arg_2})", - "translation": "Latest timestamp: {Arg_1} ({Arg_2})", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 2 - } - ] - }, - { - "id": "no configuration file defined, cannot write config", - "message": "no configuration file defined, cannot write config", - "translation": "no configuration file defined, cannot write config", - "translatorComment": "Copied from source." - }, - { - "id": "key must only consist of A-Za-z0-9_-", - "message": "key must only consist of A-Za-z0-9_-", - "translation": "key must only consist of A-Za-z0-9_-", - "translatorComment": "Copied from source." - }, - { - "id": "no service configuration \"{Name}\"", - "message": "no service configuration \"{Name}\"", - "translation": "no service configuration \"{Name}\"", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "name" - } - ] - } - ] -} diff --git a/internal/translations/locales/en/out.gotext.json b/internal/translations/locales/en/out.gotext.json deleted file mode 100644 index c2e0e84..0000000 --- a/internal/translations/locales/en/out.gotext.json +++ /dev/null @@ -1,670 +0,0 @@ -{ - "language": "en", - "messages": [ - { - "id": "export: {ExportCapabilities__}", - "message": "export: {ExportCapabilities__}", - "translation": "export: {ExportCapabilities__}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "ExportCapabilities__", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "strings.Join(info.ExportCapabilities, \", \")" - } - ] - }, - { - "id": "import: {ImportCapabilities__}", - "message": "import: {ImportCapabilities__}", - "translation": "import: {ImportCapabilities__}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "ImportCapabilities__", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "strings.Join(info.ImportCapabilities, \", \")" - } - ] - }, - { - "id": "Failed reading config: {Err}", - "message": "Failed reading config: {Err}", - "translation": "Failed reading config: {Err}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Err", - "string": "%[1]v", - "type": "error", - "underlyingType": "interface{Error() string}", - "argNum": 1, - "expr": "err" - } - ] - }, - { - "id": "Service name", - "message": "Service name", - "translation": "Service name", - "translatorComment": "Copied from source." - }, - { - "id": "a service with this name already exists", - "message": "a service with this name already exists", - "translation": "a service with this name already exists", - "translatorComment": "Copied from source." - }, - { - "id": "Saved service {Name} using backend {Backend}", - "message": "Saved service {Name} using backend {Backend}", - "translation": "Saved service {Name} using backend {Backend}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - }, - { - "id": "Backend", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "service.Backend" - } - ] - }, - { - "id": "The backend {Backend} requires authentication. Authenticate now?", - "message": "The backend {Backend} requires authentication. Authenticate now?", - "translation": "The backend {Backend} requires authentication. Authenticate now?", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Backend" - } - ] - }, - { - "id": "Delete the service configuration \"{Service}\"?", - "message": "Delete the service configuration \"{Service}\"?", - "translation": "Delete the service configuration \"{Service}\"?", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Service", - "string": "%[1]v", - "type": "go.uploadedlobster.com/scotty/internal/config.ServiceConfig", - "underlyingType": "struct{Name string; Backend string; ConfigValues map[string]any}", - "argNum": 1, - "expr": "service" - } - ] - }, - { - "id": "Aborted", - "message": "Aborted", - "translation": "Aborted", - "translatorComment": "Copied from source." - }, - { - "id": "Service \"{Name}\" deleted", - "message": "Service \"{Name}\" deleted", - "translation": "Service \"{Name}\" deleted", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - } - ] - }, - { - "id": "Updated service {Name} using backend {Backend}", - "message": "Updated service {Name} using backend {Backend}", - "translation": "Updated service {Name} using backend {Backend}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - }, - { - "id": "Backend", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "service.Backend" - } - ] - }, - { - "id": "backend: {Backend}", - "message": "backend: {Backend}", - "translation": "backend: {Backend}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "s.Backend" - } - ] - }, - { - "id": "Token received, you can close this window now.", - "message": "Token received, you can close this window now.", - "translation": "Token received, you can close this window now.", - "translatorComment": "Copied from source." - }, - { - "id": "backend {Backend} does not implement {InterfaceName}", - "message": "backend {Backend} does not implement {InterfaceName}", - "translation": "backend {Backend} does not implement {InterfaceName}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Backend", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "config.Backend" - }, - { - "id": "InterfaceName", - "string": "%[2]s", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "interfaceName" - } - ] - }, - { - "id": "unknown backend \"{BackendName}\"", - "message": "unknown backend \"{BackendName}\"", - "translation": "unknown backend \"{BackendName}\"", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "BackendName", - "string": "%[1]s", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "backendName" - } - ] - }, - { - "id": "Client ID", - "message": "Client ID", - "translation": "Client ID", - "translatorComment": "Copied from source." - }, - { - "id": "Client secret", - "message": "Client secret", - "translation": "Client secret", - "translatorComment": "Copied from source." - }, - { - "id": "Server URL", - "message": "Server URL", - "translation": "Server URL", - "translatorComment": "Copied from source." - }, - { - "id": "User name", - "message": "User name", - "translation": "User name", - "translatorComment": "Copied from source." - }, - { - "id": "Access token", - "message": "Access token", - "translation": "Access token", - "translatorComment": "Copied from source." - }, - { - "id": "File path", - "message": "File path", - "translation": "File path", - "translatorComment": "Copied from source." - }, - { - "id": "Append to file", - "message": "Append to file", - "translation": "Append to file", - "translatorComment": "Copied from source." - }, - { - "id": "Playlist title", - "message": "Playlist title", - "translation": "Playlist title", - "translatorComment": "Copied from source." - }, - { - "id": "Unique playlist identifier", - "message": "Unique playlist identifier", - "translation": "Unique playlist identifier", - "translatorComment": "Copied from source." - }, - { - "id": "Check for duplicate listens on import (slower)", - "message": "Check for duplicate listens on import (slower)", - "translation": "Check for duplicate listens on import (slower)", - "translatorComment": "Copied from source." - }, - { - "id": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", - "message": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", - "translation": "Ignored duplicate listen {ListenedAt}: \"{TrackName}\" by {ArtistName} ({RecordingMBID})", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "ListenedAt", - "string": "%[1]v", - "type": "time.Time", - "underlyingType": "struct{wall uint64; ext int64; loc *time.Location}", - "argNum": 1, - "expr": "l.ListenedAt" - }, - { - "id": "TrackName", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "l.TrackName" - }, - { - "id": "ArtistName", - "string": "%[3]v", - "type": "string", - "underlyingType": "string", - "argNum": 3, - "expr": "l.ArtistName()" - }, - { - "id": "RecordingMBID", - "string": "%[4]v", - "type": "go.uploadedlobster.com/mbtypes.MBID", - "underlyingType": "string", - "argNum": 4, - "expr": "l.RecordingMBID" - } - ] - }, - { - "id": "Disable auto correction of submitted listens", - "message": "Disable auto correction of submitted listens", - "translation": "Disable auto correction of submitted listens", - "translatorComment": "Copied from source." - }, - { - "id": "Ignore skipped listens", - "message": "Ignore skipped listens", - "translation": "Ignore skipped listens", - "translatorComment": "Copied from source." - }, - { - "id": "Specify a time zone for the listen timestamps", - "message": "Specify a time zone for the listen timestamps", - "translation": "Specify a time zone for the listen timestamps", - "translatorComment": "Copied from source." - }, - { - "id": "Directory path", - "message": "Directory path", - "translation": "Directory path", - "translatorComment": "Copied from source." - }, - { - "id": "Ignore listens in incognito mode", - "message": "Ignore listens in incognito mode", - "translation": "Ignore listens in incognito mode", - "translatorComment": "Copied from source." - }, - { - "id": "Minimum playback duration for skipped tracks (seconds)", - "message": "Minimum playback duration for skipped tracks (seconds)", - "translation": "Minimum playback duration for skipped tracks (seconds)", - "translatorComment": "Copied from source." - }, - { - "id": "Visit the URL for authorization: {URL}", - "message": "Visit the URL for authorization: {URL}", - "translation": "Visit the URL for authorization: {URL}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "URL", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "authURL.URL" - } - ] - }, - { - "id": "Error: OAuth state mismatch", - "message": "Error: OAuth state mismatch", - "translation": "Error: OAuth state mismatch", - "translatorComment": "Copied from source." - }, - { - "id": "Access token received, you can use {Name} now.", - "message": "Access token received, you can use {Name} now.", - "translation": "Access token received, you can use {Name} now.", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "service.Name" - } - ] - }, - { - "id": "exporting", - "message": "exporting", - "translation": "exporting", - "translatorComment": "Copied from source." - }, - { - "id": "importing", - "message": "importing", - "translation": "importing", - "translatorComment": "Copied from source." - }, - { - "id": "aborted", - "message": "aborted", - "translation": "aborted", - "translatorComment": "Copied from source.", - "fuzzy": true - }, - { - "id": "done", - "message": "done", - "translation": "done", - "translatorComment": "Copied from source." - }, - { - "id": "Yes", - "message": "Yes", - "translation": "Yes", - "translatorComment": "Copied from source." - }, - { - "id": "No", - "message": "No", - "translation": "No", - "translatorComment": "Copied from source." - }, - { - "id": "no existing service configurations", - "message": "no existing service configurations", - "translation": "no existing service configurations", - "translatorComment": "Copied from source." - }, - { - "id": "Service", - "message": "Service", - "translation": "Service", - "translatorComment": "Copied from source." - }, - { - "id": "Backend", - "message": "Backend", - "translation": "Backend", - "translatorComment": "Copied from source." - }, - { - "id": "Transferring {Entity} from {SourceName} to {TargetName}…", - "message": "Transferring {Entity} from {SourceName} to {TargetName}…", - "translation": "Transferring {Entity} from {SourceName} to {TargetName}…", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Entity", - "string": "%[1]s", - "type": "go.uploadedlobster.com/scotty/internal/models.Entity", - "underlyingType": "string", - "argNum": 1, - "expr": "c.entity" - }, - { - "id": "SourceName", - "string": "%[2]s", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "c.sourceName" - }, - { - "id": "TargetName", - "string": "%[3]s", - "type": "string", - "underlyingType": "string", - "argNum": 3, - "expr": "c.targetName" - } - ] - }, - { - "id": "From timestamp: {Arg_1} ({Arg_2})", - "message": "From timestamp: {Arg_1} ({Arg_2})", - "translation": "From timestamp: {Arg_1} ({Arg_2})", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 2 - } - ] - }, - { - "id": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", - "message": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", - "translation": "Imported {ImportCount} of {TotalCount} {Entity} into {TargetName}.", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "ImportCount", - "string": "%[1]v", - "type": "int", - "underlyingType": "int", - "argNum": 1, - "expr": "result.ImportCount" - }, - { - "id": "TotalCount", - "string": "%[2]v", - "type": "int", - "underlyingType": "int", - "argNum": 2, - "expr": "result.TotalCount" - }, - { - "id": "Entity", - "string": "%[3]s", - "type": "go.uploadedlobster.com/scotty/internal/models.Entity", - "underlyingType": "string", - "argNum": 3, - "expr": "c.entity" - }, - { - "id": "TargetName", - "string": "%[4]v", - "type": "string", - "underlyingType": "string", - "argNum": 4, - "expr": "c.targetName" - } - ] - }, - { - "id": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "message": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "translation": "Import failed, last reported timestamp was {Arg_1} ({Arg_2})", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]s", - "type": "", - "underlyingType": "string", - "argNum": 2 - } - ] - }, - { - "id": "Import log:", - "message": "Import log:", - "translation": "Import log:", - "translatorComment": "Copied from source." - }, - { - "id": "{Type}: {Message}", - "message": "{Type}: {Message}", - "translation": "{Type}: {Message}", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Type", - "string": "%[1]v", - "type": "go.uploadedlobster.com/scotty/internal/models.LogEntryType", - "underlyingType": "string", - "argNum": 1, - "expr": "entry.Type" - }, - { - "id": "Message", - "string": "%[2]v", - "type": "string", - "underlyingType": "string", - "argNum": 2, - "expr": "entry.Message" - } - ] - }, - { - "id": "invalid timestamp string \"{FlagValue}\"", - "message": "invalid timestamp string \"{FlagValue}\"", - "translation": "invalid timestamp string \"{FlagValue}\"", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "FlagValue", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "flagValue" - } - ] - }, - { - "id": "Latest timestamp: {Arg_1} ({Arg_2})", - "message": "Latest timestamp: {Arg_1} ({Arg_2})", - "translation": "Latest timestamp: {Arg_1} ({Arg_2})", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Arg_1", - "string": "%[1]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 1 - }, - { - "id": "Arg_2", - "string": "%[2]v", - "type": "", - "underlyingType": "interface{}", - "argNum": 2 - } - ] - }, - { - "id": "no configuration file defined, cannot write config", - "message": "no configuration file defined, cannot write config", - "translation": "no configuration file defined, cannot write config", - "translatorComment": "Copied from source." - }, - { - "id": "key must only consist of A-Za-z0-9_-", - "message": "key must only consist of A-Za-z0-9_-", - "translation": "key must only consist of A-Za-z0-9_-", - "translatorComment": "Copied from source." - }, - { - "id": "no service configuration \"{Name}\"", - "message": "no service configuration \"{Name}\"", - "translation": "no service configuration \"{Name}\"", - "translatorComment": "Copied from source.", - "placeholders": [ - { - "id": "Name", - "string": "%[1]v", - "type": "string", - "underlyingType": "string", - "argNum": 1, - "expr": "name" - } - ] - } - ] -} \ No newline at end of file diff --git a/internal/translations/translations.go b/internal/translations/translations.go deleted file mode 100644 index 9961c41..0000000 --- a/internal/translations/translations.go +++ /dev/null @@ -1,9 +0,0 @@ -/* -All Scotty user interface strings and their translations included in this -package are published under the conditions of CC0 1.0 Universal (CC0 1.0) -. -*/ - -package translations - -//go:generate go tool gotext -srclang=en update -out=catalog.go -lang=en,de go.uploadedlobster.com/scotty diff --git a/internal/util/util.go b/internal/util/util.go deleted file mode 100644 index 9ef4e14..0000000 --- a/internal/util/util.go +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -package util - -import "golang.org/x/exp/constraints" - -func Sum[T constraints.Integer | constraints.Float](v ...T) T { - var sum T - for _, i := range v { - sum += i - } - return sum -} - -func Average[T constraints.Integer | constraints.Float](v ...T) float64 { - length := len(v) - if length == 0 { - return 0.0 - } - return float64(Sum(v...)) / float64(length) -} diff --git a/internal/util/util_test.go b/internal/util/util_test.go deleted file mode 100644 index 077fedd..0000000 --- a/internal/util/util_test.go +++ /dev/null @@ -1,52 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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 . -*/ - -package util_test - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "go.uploadedlobster.com/scotty/internal/util" -) - -func ExampleSum() { - values := []float64{1.4, 2.2} - sum := util.Sum(values...) - fmt.Print(sum) - // Output: 3.6 -} - -func TestSumEmpty(t *testing.T) { - assert.Equal(t, 0, util.Sum([]int{}...)) -} - -func ExampleAverage() { - values := []float64{1.4, 2.2, 0.9} - sum := util.Average(values...) - fmt.Print(sum) - // Output: 1.5 -} - -func TestAverageEmpty(t *testing.T) { - assert.Equal(t, 0.0, util.Average([]int{}...)) -} - -func TestAverageInt(t *testing.T) { - assert := assert.New(t) - assert.Equal(3.0, util.Average([]int{2, 4, 3}...)) - assert.Equal(1.5, util.Average([]int{2, 1, 1, 2}...)) -} diff --git a/internal/version/version.go b/internal/version/version.go index f3bc081..9a4f9fc 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software @@ -17,8 +17,7 @@ package version const ( AppName = "scotty" - AppVersion = "0.6.0" - AppURL = "https://git.sr.ht/~phw/scotty/" + AppVersion = "0.2.0" ) func UserAgent() string { diff --git a/pages/scotty/index.html b/pages/scotty/index.html index 23f6c8b..2918ec5 100644 --- a/pages/scotty/index.html +++ b/pages/scotty/index.html @@ -4,13 +4,13 @@ - + scotty - Redirecting to scotty... + Redirecting to scotty... diff --git a/pkg/archive/archive.go b/pkg/archive/archive.go deleted file mode 100644 index 41c954f..0000000 --- a/pkg/archive/archive.go +++ /dev/null @@ -1,101 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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. -*/ - -// Implements generic access to files inside an archive. -// -// An archive in this context can be any container that holds files. -// In this implementation the archive can be a ZIP file or a directory. -package archive - -import ( - "fmt" - "io" - "io/fs" - "os" -) - -// Generic interface to access files inside an archive. -type ArchiveReader interface { - io.Closer - - // Open the file inside the archive identified by the given path. - // The path is relative to the archive's root. - // The caller must call [fs.File.Close] when finished using the file. - Open(path string) (fs.File, error) - - // List files inside the archive which satisfy the given glob pattern. - // This method only returns files, not directories. - Glob(pattern string) ([]FileInfo, error) -} - -// Open an archive in path. -// The archive can be a ZIP file or a directory. The implementation -// will detect the type of archive and return the appropriate -// implementation of the Archive interface. -func OpenArchive(path string) (ArchiveReader, error) { - fi, err := os.Stat(path) - if err != nil { - return nil, err - } - switch mode := fi.Mode(); { - case mode.IsRegular(): - archive := &zipArchive{} - err := archive.OpenArchive(path) - if err != nil { - return nil, err - } - return archive, nil - case mode.IsDir(): - archive := &dirArchive{} - err := archive.OpenArchive(path) - if err != nil { - return nil, err - } - return archive, nil - default: - return nil, fmt.Errorf("unsupported file mode: %s", mode) - } -} - -// Interface for a file that can be opened when needed. -type OpenableFile interface { - // Open the file for reading. - // The caller is responsible to call [io.ReadCloser.Close] when - // finished reading the file. - Open() (io.ReadCloser, error) -} - -// Generic information about a file inside an archive. -// This provides the filename and allows opening the file for reading. -type FileInfo struct { - Name string - File OpenableFile -} - -// A openable file in the filesystem. -type filesystemFile struct { - path string -} - -func (f *filesystemFile) Open() (io.ReadCloser, error) { - return os.Open(f.path) -} diff --git a/pkg/archive/archive_test.go b/pkg/archive/archive_test.go deleted file mode 100644 index f1bbd07..0000000 --- a/pkg/archive/archive_test.go +++ /dev/null @@ -1,189 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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 archive_test - -import ( - "fmt" - "io" - "log" - "slices" - "testing" - - "go.uploadedlobster.com/scotty/pkg/archive" -) - -func ExampleOpenArchive() { - a, err := archive.OpenArchive("testdata/archive.zip") - if err != nil { - log.Fatal(err) - } - - defer a.Close() - - files, err := a.Glob("a/*.txt") - for _, fi := range files { - fmt.Println(fi.Name) - f, err := fi.File.Open() - if err != nil { - log.Fatal(err) - } - - defer f.Close() - data, err := io.ReadAll(f) - if err != nil { - log.Fatal(err) - } - fmt.Println(string(data)) - } - - // Output: a/1.txt - // a1 -} - -var testArchives = []string{ - "testdata/archive", - "testdata/archive.zip", -} - -func TestGlob(t *testing.T) { - for _, path := range testArchives { - a, err := archive.OpenArchive(path) - if err != nil { - t.Fatal(err) - } - defer a.Close() - - files, err := a.Glob("[ab]/1.txt") - if err != nil { - t.Fatal(err) - } - - if len(files) != 2 { - t.Errorf("Expected 2 files, got %d", len(files)) - } - - expectedName := "b/1.txt" - var fileInfo *archive.FileInfo = nil - for _, file := range files { - if file.Name == expectedName { - fileInfo = &file - } - } - - if fileInfo == nil { - t.Fatalf("Expected file %q to be found", expectedName) - } - - if fileInfo.File == nil { - t.Fatalf("Expected FileInfo to hold an openable File") - } - - f, err := fileInfo.File.Open() - if err != nil { - t.Fatal(err) - } - - expectedData := "b1\n" - data, err := io.ReadAll(f) - if err != nil { - t.Fatal(err) - } - if string(data) != expectedData { - fmt.Printf("%s: Expected file content to be %q, got %q", - path, expectedData, string(data)) - } - } -} - -func TestGlobAll(t *testing.T) { - for _, path := range testArchives { - a, err := archive.OpenArchive(path) - if err != nil { - t.Fatal(err) - } - defer a.Close() - - files, err := a.Glob("*/*") - if err != nil { - t.Fatal(err) - } - - filenames := make([]string, 0, len(files)) - for _, f := range files { - fmt.Printf("%v: %v\n", path, f.Name) - filenames = append(filenames, f.Name) - } - - slices.Sort(filenames) - - expectedFilenames := []string{ - "a/1.txt", - "b/1.txt", - "b/2.txt", - } - if !slices.Equal(filenames, expectedFilenames) { - t.Errorf("%s: Expected filenames to be %q, got %q", - path, expectedFilenames, filenames) - } - } -} - -func TestOpen(t *testing.T) { - for _, path := range testArchives { - a, err := archive.OpenArchive(path) - if err != nil { - t.Fatal(err) - } - defer a.Close() - - f, err := a.Open("b/2.txt") - if err != nil { - t.Fatal(err) - } - - expectedData := "b2\n" - data, err := io.ReadAll(f) - if err != nil { - t.Fatal(err) - } - if string(data) != expectedData { - fmt.Printf("%s: Expected file content to be %q, got %q", - path, expectedData, string(data)) - } - } -} - -func TestOpenError(t *testing.T) { - for _, path := range testArchives { - a, err := archive.OpenArchive(path) - if err != nil { - t.Fatal(err) - } - defer a.Close() - - _, err = a.Open("b/3.txt") - if err == nil { - t.Errorf("%s: Expected the Open command to fail", path) - } - } -} diff --git a/pkg/archive/dir.go b/pkg/archive/dir.go deleted file mode 100644 index 166e70b..0000000 --- a/pkg/archive/dir.go +++ /dev/null @@ -1,77 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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 archive - -import ( - "io/fs" - "os" - "path/filepath" -) - -// An implementation of the [ArchiveReader] interface for directories. -type dirArchive struct { - path string - dirFS fs.FS -} - -func (a *dirArchive) OpenArchive(path string) error { - a.path = filepath.Clean(path) - a.dirFS = os.DirFS(path) - return nil -} - -func (a *dirArchive) Close() error { - return nil -} - -// Open opens the named file in the archive. -// [fs.File.Close] must be called to release any associated resources. -func (a *dirArchive) Open(path string) (fs.File, error) { - return a.dirFS.Open(path) -} - -func (a *dirArchive) Glob(pattern string) ([]FileInfo, error) { - files, err := fs.Glob(a.dirFS, pattern) - if err != nil { - return nil, err - } - result := make([]FileInfo, 0) - for _, name := range files { - stat, err := fs.Stat(a.dirFS, name) - if err != nil { - return nil, err - } - if stat.IsDir() { - continue - } - - fullPath := filepath.Join(a.path, name) - info := FileInfo{ - Name: name, - File: &filesystemFile{path: fullPath}, - } - result = append(result, info) - } - - return result, nil -} diff --git a/pkg/archive/testdata/archive.zip b/pkg/archive/testdata/archive.zip deleted file mode 100644 index 19923f6..0000000 Binary files a/pkg/archive/testdata/archive.zip and /dev/null differ diff --git a/pkg/archive/testdata/archive/a/1.txt b/pkg/archive/testdata/archive/a/1.txt deleted file mode 100644 index da0f8ed..0000000 --- a/pkg/archive/testdata/archive/a/1.txt +++ /dev/null @@ -1 +0,0 @@ -a1 diff --git a/pkg/archive/testdata/archive/b/1.txt b/pkg/archive/testdata/archive/b/1.txt deleted file mode 100644 index c9c6af7..0000000 --- a/pkg/archive/testdata/archive/b/1.txt +++ /dev/null @@ -1 +0,0 @@ -b1 diff --git a/pkg/archive/testdata/archive/b/2.txt b/pkg/archive/testdata/archive/b/2.txt deleted file mode 100644 index e6bfff5..0000000 --- a/pkg/archive/testdata/archive/b/2.txt +++ /dev/null @@ -1 +0,0 @@ -b2 diff --git a/pkg/archive/zip.go b/pkg/archive/zip.go deleted file mode 100644 index 0054cf3..0000000 --- a/pkg/archive/zip.go +++ /dev/null @@ -1,80 +0,0 @@ -/* -Copyright © 2025 Philipp Wolfer - -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 archive - -import ( - "archive/zip" - "io/fs" - "path/filepath" -) - -// An implementation of the [ArchiveReader] interface for zip files. -type zipArchive struct { - zip *zip.ReadCloser -} - -func (a *zipArchive) OpenArchive(path string) error { - zip, err := zip.OpenReader(path) - if err != nil { - return err - } - a.zip = zip - return nil -} - -func (a *zipArchive) Close() error { - if a.zip == nil { - return nil - } - return a.zip.Close() -} - -func (a *zipArchive) Glob(pattern string) ([]FileInfo, error) { - result := make([]FileInfo, 0) - for _, file := range a.zip.File { - if file.FileInfo().IsDir() { - continue - } - - if matched, err := filepath.Match(pattern, file.Name); matched { - if err != nil { - return nil, err - } - info := FileInfo{ - Name: file.Name, - File: file, - } - result = append(result, info) - } - } - - return result, nil -} - -func (a *zipArchive) Open(path string) (fs.File, error) { - file, err := a.zip.Open(path) - if err != nil { - return nil, err - } - return file, nil -} diff --git a/pkg/jspf/extensions.go b/pkg/jspf/extensions.go index 7cf99d3..fe5fef2 100644 --- a/pkg/jspf/extensions.go +++ b/pkg/jspf/extensions.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,34 +22,13 @@ THE SOFTWARE. package jspf -import ( - "encoding/json" - "fmt" - "time" -) - -// Represents a JSPF extension -type Extension any - -// A map of JSPF extensions -type ExtensionMap map[string]Extension - -// Parses the extension with the given ID and unmarshals it into "v". -// If the extensions is not found or the data cannot be unmarshalled, -// an error is returned. -func (e ExtensionMap) Get(id string, v any) error { - ext, ok := e[id] - if !ok { - return fmt.Errorf("extension %q not found", id) - } - return unmarshalExtension(ext, v) -} +import "time" const ( // The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension - MusicBrainzPlaylistExtensionID = "https://musicbrainz.org/doc/jspf#playlist" + MusicBrainzPlaylistExtensionId = "https://musicbrainz.org/doc/jspf#playlist" // The identifier for the MusicBrainz / ListenBrainz JSPF track extension - MusicBrainzTrackExtensionID = "https://musicbrainz.org/doc/jspf#track" + MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track" ) // MusicBrainz / ListenBrainz JSPF track extension @@ -72,8 +51,9 @@ type MusicBrainzPlaylistExtension struct { // deleted, this field will be set to true and the copied_from field will not // be returned. CopiedFromDeleted bool `json:"copied_from_deleted,omitempty"` - // Indicates if this playlist is public or private. - Public bool `json:"public,omitempty"` + // Indicates if this playlist is public or private. Must contain the value + // "true" or "false". + Public string `json:"public,omitempty"` // The timestamp for when this playlist was last modified. LastModifiedAt time.Time `json:"last_modified_at,omitempty"` // This dict allows a playlist creator to submit additional track metadata @@ -104,11 +84,3 @@ type MusicBrainzTrackExtension struct { // this document. AdditionalMetadata map[string]any `json:"additional_metadata,omitempty"` } - -func unmarshalExtension(ext Extension, v any) error { - asJson, err := json.Marshal(ext) - if err != nil { - return err - } - return json.Unmarshal(asJson, v) -} diff --git a/pkg/jspf/extensions_test.go b/pkg/jspf/extensions_test.go index 49d1bd5..8d8653d 100644 --- a/pkg/jspf/extensions_test.go +++ b/pkg/jspf/extensions_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -26,7 +26,6 @@ import ( "bytes" "fmt" "log" - "testing" "time" "go.uploadedlobster.com/scotty/pkg/jspf" @@ -39,8 +38,8 @@ func ExampleMusicBrainzTrackExtension() { Tracks: []jspf.Track{ { Title: "Oweynagat", - Extension: jspf.ExtensionMap{ - jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{ + Extension: map[string]any{ + jspf.MusicBrainzTrackExtensionId: jspf.MusicBrainzTrackExtension{ AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), AddedBy: "scotty", }, @@ -73,29 +72,3 @@ func ExampleMusicBrainzTrackExtension() { // } // } } - -func TestExtensionMapGet(t *testing.T) { - ext := jspf.ExtensionMap{ - jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{ - AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), - AddedBy: "scotty", - }, - } - var trackExt jspf.MusicBrainzTrackExtension - err := ext.Get(jspf.MusicBrainzTrackExtensionID, &trackExt) - if err != nil { - t.Fatal(err) - } - if trackExt.AddedBy != "scotty" { - t.Fatalf("expected 'scotty', got '%s'", trackExt.AddedBy) - } -} - -func TestExtensionMapGetNotFound(t *testing.T) { - ext := jspf.ExtensionMap{} - var trackExt jspf.MusicBrainzTrackExtension - err := ext.Get(jspf.MusicBrainzTrackExtensionID, &trackExt) - if err == nil { - t.Fatal("expected ExtensionMap.Get to return an error") - } -} diff --git a/pkg/jspf/models.go b/pkg/jspf/models.go index 829e922..d910367 100644 --- a/pkg/jspf/models.go +++ b/pkg/jspf/models.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -32,35 +32,35 @@ type JSPF struct { } type Playlist struct { - Title string `json:"title,omitempty"` - Creator string `json:"creator,omitempty"` - Annotation string `json:"annotation,omitempty"` - Info string `json:"info,omitempty"` - Location string `json:"location,omitempty"` - Identifier string `json:"identifier,omitempty"` - Image string `json:"image,omitempty"` - Date time.Time `json:"date,omitempty"` - License string `json:"license,omitempty"` - Attribution []Attribution `json:"attribution,omitempty"` - Links []Link `json:"link,omitempty"` - Meta []Meta `json:"meta,omitempty"` - Extension ExtensionMap `json:"extension,omitempty"` - Tracks []Track `json:"track"` + Title string `json:"title,omitempty"` + Creator string `json:"creator,omitempty"` + Annotation string `json:"annotation,omitempty"` + Info string `json:"info,omitempty"` + Location string `json:"location,omitempty"` + Identifier string `json:"identifier,omitempty"` + Image string `json:"image,omitempty"` + Date time.Time `json:"date,omitempty"` + License string `json:"license,omitempty"` + Attribution []Attribution `json:"attribution,omitempty"` + Links []Link `json:"link,omitempty"` + Meta []Meta `json:"meta,omitempty"` + Extension map[string]any `json:"extension,omitempty"` + Tracks []Track `json:"track"` } type Track struct { - Location []string `json:"location,omitempty"` - Identifier []string `json:"identifier,omitempty"` - Title string `json:"title,omitempty"` - Creator string `json:"creator,omitempty"` - Annotation string `json:"annotation,omitempty"` - Info string `json:"info,omitempty"` - Album string `json:"album,omitempty"` - TrackNum int `json:"trackNum,omitempty"` - Duration int64 `json:"duration,omitempty"` - Links []Link `json:"link,omitempty"` - Meta []Meta `json:"meta,omitempty"` - Extension ExtensionMap `json:"extension,omitempty"` + Location []string `json:"location,omitempty"` + Identifier []string `json:"identifier,omitempty"` + Title string `json:"title,omitempty"` + Creator string `json:"creator,omitempty"` + Annotation string `json:"annotation,omitempty"` + Info string `json:"info,omitempty"` + Album string `json:"album,omitempty"` + TrackNum int `json:"trackNum,omitempty"` + Duration int `json:"duration,omitempty"` + Links []Link `json:"link,omitempty"` + Meta []Meta `json:"meta,omitempty"` + Extension map[string]any `json:"extension,omitempty"` } type Attribution map[string]string diff --git a/pkg/scrobblerlog/parser.go b/pkg/scrobblerlog/parser.go deleted file mode 100644 index 48fadcf..0000000 --- a/pkg/scrobblerlog/parser.go +++ /dev/null @@ -1,332 +0,0 @@ -/* -Copyright © 2023-2025 Philipp Wolfer - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ - -// Package to parse and write .scrobbler.log files as written by Rockbox. -// -// The parser supports reading version 1.1 and 1.0 of the scrobbler log file -// format. The latter is only supported if encoded in UTF-8. -// -// When written it always writes version 1.1 of the scrobbler log file format, -// which includes the MusicBrainz recording ID as the last field of each row. -// -// See -// - https://www.rockbox.org/wiki/LastFMLog -// - https://git.rockbox.org/cgit/rockbox.git/tree/apps/plugins/lastfm_scrobbler.c -// - https://web.archive.org/web/20110110053056/http://www.audioscrobbler.net/wiki/Portable_Player_Logging -package scrobblerlog - -import ( - "bufio" - "encoding/csv" - "fmt" - "io" - "iter" - "strconv" - "strings" - "time" - - "go.uploadedlobster.com/mbtypes" -) - -// TZInfo is the timezone information in the header of the scrobbler log file. -// It can be "UTC" or "UNKNOWN", if the device writing the scrobbler log file -// knows the time, but not the timezone. -type TZInfo string - -const ( - TimezoneUnknown TZInfo = "UNKNOWN" - TimezoneUTC TZInfo = "UTC" -) - -// L if listened at least 50% or S if skipped -type Rating string - -const ( - RatingListened Rating = "L" - RatingSkipped Rating = "S" -) - -// A single entry of a track in the scrobbler log file. -type Record struct { - ArtistName string - AlbumName string - TrackName string - TrackNumber int - Duration time.Duration - Rating Rating - Timestamp time.Time - MusicBrainzRecordingID mbtypes.MBID -} - -// Represents a scrobbler log file. -type ScrobblerLog struct { - TZ TZInfo - Client string - Records []Record - // Timezone to be used for timestamps in the log file, - // if TZ is set to [TimezoneUnknown]. - FallbackTimezone *time.Location -} - -// Parses a scrobbler log file from the given reader. -// -// The reader must provide a valid scrobbler log file with a valid header. -// This function implicitly calls [ScrobblerLog.ReadHeader]. -func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error { - tsvReader, err := l.initReader(data) - if err != nil { - return err - } - - for _, err := range l.iterRecords(tsvReader, ignoreSkipped) { - if err != nil { - return err - } - } - - return nil -} - -// Parses a scrobbler log file from the given reader and returns an iterator over all records. -// -// The reader must provide a valid scrobbler log file with a valid header. -// This function implicitly calls [ScrobblerLog.ReadHeader]. -func (l *ScrobblerLog) ParseIter(data io.Reader, ignoreSkipped bool) iter.Seq2[Record, error] { - - tsvReader, err := l.initReader(data) - if err != nil { - return func(yield func(Record, error) bool) { - yield(Record{}, err) - } - } - - return l.iterRecords(tsvReader, ignoreSkipped) -} - -// Append writes the given records to the writer. -// -// The writer should be for an existing scrobbler log file or -// [ScrobblerLog.WriteHeader] should be called before this function. -// Returns the last timestamp of the records written. -func (l *ScrobblerLog) Append(data io.Writer, records []Record) (lastTimestamp time.Time, err error) { - tsvWriter := csv.NewWriter(data) - tsvWriter.Comma = '\t' - - for _, record := range records { - if record.Timestamp.After(lastTimestamp) { - lastTimestamp = record.Timestamp - } - - // A row is: - // artistName releaseName trackName trackNumber duration rating timestamp recordingMBID - err = tsvWriter.Write([]string{ - record.ArtistName, - record.AlbumName, - record.TrackName, - strconv.Itoa(record.TrackNumber), - strconv.Itoa(int(record.Duration.Seconds())), - string(record.Rating), - strconv.FormatInt(record.Timestamp.Unix(), 10), - string(record.MusicBrainzRecordingID), - }) - } - - tsvWriter.Flush() - return -} - -// Parses just the header of a scrobbler log file from the given reader. -// -// This function sets [ScrobblerLog.TZ] and [ScrobblerLog.Client]. -func (l *ScrobblerLog) ReadHeader(reader io.Reader) error { - return l.readHeader(bufio.NewReader(reader)) -} - -// Writes the header of a scrobbler log file to the given writer. -func (l *ScrobblerLog) WriteHeader(writer io.Writer) error { - headers := []string{ - "#AUDIOSCROBBLER/1.1\n", - "#TZ/" + string(l.TZ) + "\n", - "#CLIENT/" + l.Client + "\n", - } - for _, line := range headers { - _, err := writer.Write([]byte(line)) - if err != nil { - return err - } - } - return nil -} - -func (l *ScrobblerLog) initReader(data io.Reader) (*csv.Reader, error) { - reader := bufio.NewReader(data) - err := l.readHeader(reader) - if err != nil { - return nil, err - } - - tsvReader := csv.NewReader(reader) - tsvReader.Comma = '\t' - // Row length is often flexible - tsvReader.FieldsPerRecord = -1 - - return tsvReader, nil -} - -func (l *ScrobblerLog) readHeader(reader *bufio.Reader) error { - // Skip header - for i := 0; i < 3; i++ { - line, _, err := reader.ReadLine() - if err != nil { - return err - } - - if len(line) == 0 || line[0] != '#' { - err = fmt.Errorf("unexpected header (line %v)", i) - } else { - text := string(line) - if i == 0 && !strings.HasPrefix(text, "#AUDIOSCROBBLER/1") { - err = fmt.Errorf("not a scrobbler log file") - } - - // The timezone can be set to "UTC" or "UNKNOWN", if the device writing - // the log knows the time, but not the timezone. - timezone, found := strings.CutPrefix(text, "#TZ/") - if found { - l.TZ = TZInfo(timezone) - continue - } - - client, found := strings.CutPrefix(text, "#CLIENT/") - if found { - l.Client = client - continue - } - } - - if err != nil { - return err - } - } - return nil -} - -func (l *ScrobblerLog) iterRecords(reader *csv.Reader, ignoreSkipped bool) iter.Seq2[Record, error] { - return func(yield func(Record, error) bool) { - l.Records = make([]Record, 0) - for { - record, err := l.parseRow(reader) - if err == io.EOF { - break - } else if err != nil { - yield(Record{}, err) - break - } - - if ignoreSkipped && record.Rating == RatingSkipped { - continue - } - - l.Records = append(l.Records, *record) - if !yield(*record, nil) { - break - } - } - } -} - -func (l *ScrobblerLog) parseRow(reader *csv.Reader) (*Record, error) { - // A row is: - // artistName releaseName trackName trackNumber duration rating timestamp recordingMBID - row, err := reader.Read() - if err != nil { - return nil, err - } - - // fmt.Printf("row: %v\n", row) - - // We consider only the last field (recording MBID) optional - // This was added in the 1.1 file format. - if len(row) < 7 { - line, _ := reader.FieldPos(0) - return nil, fmt.Errorf("invalid record in scrobblerlog line %v", line) - } - - return l.rowToRecord(row) -} - -func (l ScrobblerLog) rowToRecord(row []string) (*Record, error) { - trackNumber, err := strconv.Atoi(row[3]) - if err != nil { - return nil, err - } - - duration, err := strconv.Atoi(row[4]) - if err != nil { - return nil, err - } - - timestamp, err := strconv.ParseInt(row[6], 10, 64) - if err != nil { - return nil, err - } - - var timezone *time.Location = nil - if l.TZ == TimezoneUnknown { - timezone = l.FallbackTimezone - } - - record := Record{ - ArtistName: row[0], - AlbumName: row[1], - TrackName: row[2], - TrackNumber: trackNumber, - Duration: time.Duration(duration) * time.Second, - Rating: Rating(row[5]), - Timestamp: timeFromLocalTimestamp(timestamp, timezone), - } - - if len(row) > 7 { - record.MusicBrainzRecordingID = mbtypes.MBID(row[7]) - } - - return &record, nil -} - -// Convert a Unix timestamp to a [time.Time] object, but treat the timestamp -// as being in the given location's timezone instead of UTC. -// If location is nil, the timestamp is returned assumed to already be in UTC -// and returned unchanged. -func timeFromLocalTimestamp(timestamp int64, location *time.Location) time.Time { - t := time.Unix(timestamp, 0) - - // The time is now in UTC. Get the offset to the requested timezone - // and shift the time accordingly. - if location != nil { - _, offset := t.In(location).Zone() - if offset != 0 { - t = t.Add(-time.Duration(offset) * time.Second) - } - } - - return t -} diff --git a/pkg/scrobblerlog/parser_test.go b/pkg/scrobblerlog/parser_test.go deleted file mode 100644 index 26990f9..0000000 --- a/pkg/scrobblerlog/parser_test.go +++ /dev/null @@ -1,175 +0,0 @@ -/* -Copyright © 2023 Philipp Wolfer - -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/mbtypes" - "go.uploadedlobster.com/scotty/pkg/scrobblerlog" -) - -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 -` - -var testScrobblerLogInvalid = `#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 -` - -func TestParse(t *testing.T) { - assert := assert.New(t) - data := bytes.NewBufferString(testScrobblerLog) - result := scrobblerlog.ScrobblerLog{} - err := result.Parse(data, false) - require.NoError(t, err) - assert.Equal(scrobblerlog.TimezoneUnknown, result.TZ) - assert.Equal("Rockbox sansaclipplus $Revision$", result.Client) - assert.Len(result.Records, 5) - record1 := result.Records[0] - assert.Equal("Özcan Deniz", record1.ArtistName) - assert.Equal("Ses ve Ayrilik", record1.AlbumName) - assert.Equal("Sevdanin rengi (sipacik) byMrTurkey", record1.TrackName) - assert.Equal(5, record1.TrackNumber) - assert.Equal(time.Duration(306*time.Second), record1.Duration) - assert.Equal(scrobblerlog.RatingListened, record1.Rating) - assert.Equal(time.Unix(1260342084, 0), record1.Timestamp) - assert.Equal(mbtypes.MBID(""), record1.MusicBrainzRecordingID) - record4 := result.Records[3] - assert.Equal(scrobblerlog.RatingSkipped, record4.Rating) - assert.Equal(mbtypes.MBID("385ba9e9-626d-4750-a607-58e541dca78e"), - record4.MusicBrainzRecordingID) -} - -func TestParseIgnoreSkipped(t *testing.T) { - assert := assert.New(t) - data := bytes.NewBufferString(testScrobblerLog) - result := scrobblerlog.ScrobblerLog{} - err := result.Parse(data, true) - require.NoError(t, err) - assert.Len(result.Records, 4) - record4 := result.Records[3] - assert.Equal(scrobblerlog.RatingListened, record4.Rating) - assert.Equal(mbtypes.MBID("1262beaf-19f8-4534-b9ed-7eef9ca8e83f"), - record4.MusicBrainzRecordingID) -} - -func TestParseFallbackTimezone(t *testing.T) { - assert := assert.New(t) - data := bytes.NewBufferString(testScrobblerLog) - result := scrobblerlog.ScrobblerLog{ - FallbackTimezone: time.FixedZone("UTC+2", 7200), - } - err := result.Parse(data, false) - require.NoError(t, err) - record1 := result.Records[0] - assert.Equal( - time.Unix(1260342084, 0).Add(-2*time.Hour), - record1.Timestamp, - ) -} - -func TestParseInvalid(t *testing.T) { - assert := assert.New(t) - data := bytes.NewBufferString(testScrobblerLogInvalid) - result := scrobblerlog.ScrobblerLog{} - err := result.Parse(data, true) - assert.ErrorContains(err, "invalid record in scrobblerlog line 2") -} - -func TestParseIter(t *testing.T) { - assert := assert.New(t) - data := bytes.NewBufferString(testScrobblerLog) - result := scrobblerlog.ScrobblerLog{} - records := make([]scrobblerlog.Record, 0) - for record, err := range result.ParseIter(data, false) { - require.NoError(t, err) - records = append(records, record) - } - - assert.Len(records, 5) - record1 := result.Records[0] - assert.Equal("Ses ve Ayrilik", record1.AlbumName) -} - -func TestAppend(t *testing.T) { - assert := assert.New(t) - data := make([]byte, 0, 10) - buffer := bytes.NewBuffer(data) - log := scrobblerlog.ScrobblerLog{ - TZ: scrobblerlog.TimezoneUnknown, - Client: "Rockbox foo $Revision$", - } - records := []scrobblerlog.Record{ - { - ArtistName: "Prinzhorn Dance School", - AlbumName: "Home Economics", - TrackName: "Reign", - TrackNumber: 1, - Duration: 271 * time.Second, - Rating: scrobblerlog.RatingListened, - Timestamp: time.Unix(1699572072, 0), - MusicBrainzRecordingID: mbtypes.MBID("b59cf4e7-caee-4019-a844-79d2c58d4dff"), - }, - } - err := log.WriteHeader(buffer) - require.NoError(t, err) - lastTimestamp, err := log.Append(buffer, records) - require.NoError(t, err) - result := buffer.String() - lines := strings.Split(result, "\n") - assert.Equal(5, len(lines)) - assert.Equal("#AUDIOSCROBBLER/1.1", lines[0]) - assert.Equal("#TZ/UNKNOWN", lines[1]) - assert.Equal("#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 := log.ReadHeader(reader) - assert.NoError(t, err) - assert.Equal(t, log.TZ, scrobblerlog.TimezoneUnknown) - assert.Equal(t, log.Client, "Rockbox sansaclipplus $Revision$") - assert.Empty(t, log.Records) -} diff --git a/scotty.example.toml b/scotty.example.toml new file mode 100644 index 0000000..391fda5 --- /dev/null +++ b/scotty.example.toml @@ -0,0 +1,103 @@ +# 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 = "" + +[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 +file-path = "data/.scrobbler.log" +# If true, reading listens from the file also returns listens marked as "skipped" +include-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 + +[service.jspf] +# Write listens and loves to JSPF playlist files (https://xspf.org/jspf) +backend = "jspf" +# The file path to the XSPF file +file-path = "data/playlist.jspf" +# Title of the playlist +title = "My Playlist" +# Creator of the playlist (only informational) +username = "" +# A unique identifier for your playlist +identifier = "" + +[service.spotify] +# Read listens and loves from a Spotify account +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:2222/callback/spotify" as the +# callback URI and enable "Web API". +client-id = "" +client-secret = "" + +[service.deezer] +# Read listens and loves from a Deezer account +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:2222/callback/deezer" as the +# callback URI. +client-id = "" +client-secret = "" + +[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:2222/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"