From c9fa21be73f539654e3124170b3145c2db53b11c Mon Sep 17 00:00:00 2001
From: Philipp Wolfer <ph.wolfer@gmail.com>
Date: Tue, 5 Dec 2023 23:25:15 +0100
Subject: [PATCH] Dynamic per-backend configuration options

---
 cmd/add.go                                    | 86 ++++++++++++++++---
 internal/backends/backends.go                 | 20 +++--
 internal/backends/deezer/deezer.go            | 12 +++
 internal/backends/dump/dump.go                |  2 +
 internal/backends/funkwhale/funkwhale.go      | 16 ++++
 internal/backends/jspf/jspf.go                | 20 +++++
 internal/backends/lastfm/lastfm.go            | 16 ++++
 .../backends/listenbrainz/listenbrainz.go     | 12 +++
 internal/backends/maloja/maloja.go            | 16 ++++
 .../backends/scrobblerlog/scrobblerlog.go     | 16 ++++
 internal/backends/spotify/spotify.go          | 12 +++
 internal/backends/subsonic/subsonic.go        | 16 ++++
 internal/models/interfaces.go                 |  3 +
 internal/models/options.go                    | 32 +++++++
 14 files changed, 262 insertions(+), 17 deletions(-)
 create mode 100644 internal/models/options.go

diff --git a/cmd/add.go b/cmd/add.go
index 1803e9b..a0867d0 100644
--- a/cmd/add.go
+++ b/cmd/add.go
@@ -28,6 +28,7 @@ import (
 	"github.com/spf13/cobra"
 	"go.uploadedlobster.com/scotty/internal/backends"
 	"go.uploadedlobster.com/scotty/internal/config"
+	"go.uploadedlobster.com/scotty/internal/models"
 )
 
 // addCmd represents the add command
@@ -43,10 +44,7 @@ var addCmd = &cobra.Command{
 			Size:  10,
 		}
 		_, backend, err := sel.Run()
-		if err != nil {
-			fmt.Printf("Prompt failed %v\n", err)
-			return
-		}
+		cobra.CheckErr(err)
 
 		// Set service name
 		prompt := promptui.Prompt{
@@ -54,21 +52,22 @@ var addCmd = &cobra.Command{
 			Validate: config.ValidateKey,
 			Default:  backend,
 		}
-
 		name, err := prompt.Run()
-		if err != nil {
-			fmt.Printf("Prompt failed %v\n", err)
-			return
-		}
+		cobra.CheckErr(err)
 
-		// Save the service config
+		// Prepate service config
 		service := config.ServiceConfig{
 			Name:    name,
 			Backend: backend,
 		}
-		err = service.Save()
+
+		// Additional options
+		err = extraOptions(&service)
 		cobra.CheckErr(err)
 
+		// Save the service config
+		err = service.Save()
+		cobra.CheckErr(err)
 		fmt.Printf("Saved service %v using backend %v\n", service.Name, service.Backend)
 	},
 }
@@ -86,3 +85,68 @@ func init() {
 	// is called directly, e.g.:
 	// addCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
 }
+
+func extraOptions(config *config.ServiceConfig) error {
+	backend, err := backends.BackendByName(config.Backend)
+	if err != nil {
+		return err
+	}
+	opts := backend.Options()
+	if opts == nil {
+		return nil
+	}
+
+	values := make(map[string]any, len(*opts))
+	for _, opt := range *opts {
+		var val any
+		var err error
+		switch opt.Type {
+		case models.Bool:
+			val, err = promptBool(opt)
+		case models.Secret:
+			val, err = promptSecret(opt)
+		case models.String:
+			val, err = promptString(opt)
+		}
+		if err != nil {
+			return err
+		}
+		values[opt.Name] = val
+
+	}
+
+	config.ConfigValues = values
+	return nil
+}
+
+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) {
+	sel := promptui.Select{
+		Label: opt.Label,
+		Items: []string{"Yes", "No"},
+	}
+	_, val, err := sel.Run()
+	return val == "Yes", err
+}
diff --git a/internal/backends/backends.go b/internal/backends/backends.go
index 3f4e2c9..089f8ab 100644
--- a/internal/backends/backends.go
+++ b/internal/backends/backends.go
@@ -63,7 +63,7 @@ func (l BackendList) Swap(i, j int) {
 type Capability = string
 
 func ResolveBackend[T interface{}](config *viper.Viper) (T, error) {
-	backendName, backend, err := resolveBackend(config)
+	backendName, backend, err := backendWithConfig(config)
 	var result T
 	if err != nil {
 		return result, err
@@ -78,6 +78,14 @@ func ResolveBackend[T interface{}](config *viper.Viper) (T, error) {
 	return result, err
 }
 
+func BackendByName(backendName string) (models.Backend, error) {
+	backendType := knownBackends[backendName]
+	if backendType == nil {
+		return nil, fmt.Errorf("unknown backend %s", backendName)
+	}
+	return backendType(), nil
+}
+
 func GetBackends() BackendList {
 	backends := make(BackendList, 0)
 	for name, backendFunc := range knownBackends {
@@ -107,13 +115,13 @@ var knownBackends = map[string]func() models.Backend{
 	"subsonic":      func() models.Backend { return &subsonic.SubsonicApiBackend{} },
 }
 
-func resolveBackend(config *viper.Viper) (string, models.Backend, error) {
+func backendWithConfig(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)
+	backend, err := BackendByName(backendName)
+	if err != nil {
+		return backendName, nil, err
 	}
-	return backendName, backendType().FromConfig(config), nil
+	return backendName, backend.FromConfig(config), nil
 }
 
 func ImplementsInterface[T interface{}](backend *models.Backend) (bool, string) {
diff --git a/internal/backends/deezer/deezer.go b/internal/backends/deezer/deezer.go
index df36280..6fcff52 100644
--- a/internal/backends/deezer/deezer.go
+++ b/internal/backends/deezer/deezer.go
@@ -36,6 +36,18 @@ type DeezerApiBackend struct {
 
 func (b *DeezerApiBackend) Name() string { return "deezer" }
 
+func (b *DeezerApiBackend) Options() *[]models.BackendOption {
+	return &[]models.BackendOption{{
+		Name:  "client-id",
+		Label: "Client ID",
+		Type:  models.String,
+	}, {
+		Name:  "client-secret",
+		Label: "Client secret",
+		Type:  models.Secret,
+	}}
+}
+
 func (b *DeezerApiBackend) FromConfig(config *viper.Viper) models.Backend {
 	b.clientId = config.GetString("client-id")
 	b.clientSecret = config.GetString("client-secret")
diff --git a/internal/backends/dump/dump.go b/internal/backends/dump/dump.go
index e85495f..6a600ee 100644
--- a/internal/backends/dump/dump.go
+++ b/internal/backends/dump/dump.go
@@ -25,6 +25,8 @@ type DumpBackend struct{}
 
 func (b *DumpBackend) Name() string { return "dump" }
 
+func (b *DumpBackend) Options() *[]models.BackendOption { return nil }
+
 func (b *DumpBackend) FromConfig(config *viper.Viper) models.Backend {
 	return b
 }
diff --git a/internal/backends/funkwhale/funkwhale.go b/internal/backends/funkwhale/funkwhale.go
index 4a4bfb1..7388b62 100644
--- a/internal/backends/funkwhale/funkwhale.go
+++ b/internal/backends/funkwhale/funkwhale.go
@@ -33,6 +33,22 @@ type FunkwhaleApiBackend struct {
 
 func (b *FunkwhaleApiBackend) Name() string { return "funkwhale" }
 
+func (b *FunkwhaleApiBackend) Options() *[]models.BackendOption {
+	return &[]models.BackendOption{{
+		Name:  "server-url",
+		Label: "Server URL",
+		Type:  models.String,
+	}, {
+		Name:  "username",
+		Label: "User name",
+		Type:  models.String,
+	}, {
+		Name:  "token",
+		Label: "Access token",
+		Type:  models.Secret,
+	}}
+}
+
 func (b *FunkwhaleApiBackend) FromConfig(config *viper.Viper) models.Backend {
 	b.client = NewClient(
 		config.GetString("server-url"),
diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go
index 47bd2ec..38a1697 100644
--- a/internal/backends/jspf/jspf.go
+++ b/internal/backends/jspf/jspf.go
@@ -36,6 +36,26 @@ type JSPFBackend struct {
 
 func (b *JSPFBackend) Name() string { return "jspf" }
 
+func (b *JSPFBackend) Options() *[]models.BackendOption {
+	return &[]models.BackendOption{{
+		Name:  "file-path",
+		Label: "File path",
+		Type:  models.String,
+	}, {
+		Name:  "title",
+		Label: "Playlist title",
+		Type:  models.String,
+	}, {
+		Name:  "username",
+		Label: "User name",
+		Type:  models.String,
+	}, {
+		Name:  "identifier",
+		Label: "Unique playlist identifier",
+		Type:  models.String,
+	}}
+}
+
 func (b *JSPFBackend) FromConfig(config *viper.Viper) models.Backend {
 	b.filePath = config.GetString("file-path")
 	b.title = config.GetString("title")
diff --git a/internal/backends/lastfm/lastfm.go b/internal/backends/lastfm/lastfm.go
index 326be6e..8154e38 100644
--- a/internal/backends/lastfm/lastfm.go
+++ b/internal/backends/lastfm/lastfm.go
@@ -43,6 +43,22 @@ type LastfmApiBackend struct {
 
 func (b *LastfmApiBackend) Name() string { return "lastfm" }
 
+func (b *LastfmApiBackend) Options() *[]models.BackendOption {
+	return &[]models.BackendOption{{
+		Name:  "username",
+		Label: "User name",
+		Type:  models.String,
+	}, {
+		Name:  "client-id",
+		Label: "Client ID",
+		Type:  models.String,
+	}, {
+		Name:  "client-secret",
+		Label: "Client secret",
+		Type:  models.Secret,
+	}}
+}
+
 func (b *LastfmApiBackend) FromConfig(config *viper.Viper) models.Backend {
 	clientId := config.GetString("client-id")
 	clientSecret := config.GetString("client-secret")
diff --git a/internal/backends/listenbrainz/listenbrainz.go b/internal/backends/listenbrainz/listenbrainz.go
index 27c8102..dbc1022 100644
--- a/internal/backends/listenbrainz/listenbrainz.go
+++ b/internal/backends/listenbrainz/listenbrainz.go
@@ -34,6 +34,18 @@ type ListenBrainzApiBackend struct {
 
 func (b *ListenBrainzApiBackend) Name() string { return "listenbrainz" }
 
+func (b *ListenBrainzApiBackend) Options() *[]models.BackendOption {
+	return &[]models.BackendOption{{
+		Name:  "username",
+		Label: "User name",
+		Type:  models.String,
+	}, {
+		Name:  "token",
+		Label: "Access token",
+		Type:  models.Secret,
+	}}
+}
+
 func (b *ListenBrainzApiBackend) FromConfig(config *viper.Viper) models.Backend {
 	b.client = NewClient(config.GetString("token"))
 	b.client.MaxResults = MaxItemsPerGet
diff --git a/internal/backends/maloja/maloja.go b/internal/backends/maloja/maloja.go
index 4fa57ad..80d9369 100644
--- a/internal/backends/maloja/maloja.go
+++ b/internal/backends/maloja/maloja.go
@@ -33,6 +33,22 @@ type MalojaApiBackend struct {
 
 func (b *MalojaApiBackend) Name() string { return "maloja" }
 
+func (b *MalojaApiBackend) Options() *[]models.BackendOption {
+	return &[]models.BackendOption{{
+		Name:  "server-url",
+		Label: "Server URL",
+		Type:  models.String,
+	}, {
+		Name:  "token",
+		Label: "Access token",
+		Type:  models.Secret,
+	}, {
+		Name:  "nofix",
+		Label: "Disable auto correction of submitted listens",
+		Type:  models.Bool,
+	}}
+}
+
 func (b *MalojaApiBackend) FromConfig(config *viper.Viper) models.Backend {
 	b.client = NewClient(
 		config.GetString("server-url"),
diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go
index cbd0aa8..0267b0e 100644
--- a/internal/backends/scrobblerlog/scrobblerlog.go
+++ b/internal/backends/scrobblerlog/scrobblerlog.go
@@ -36,6 +36,22 @@ type ScrobblerLogBackend struct {
 
 func (b *ScrobblerLogBackend) Name() string { return "scrobbler-log" }
 
+func (b *ScrobblerLogBackend) Options() *[]models.BackendOption {
+	return &[]models.BackendOption{{
+		Name:  "file-path",
+		Label: "File path",
+		Type:  models.String,
+	}, {
+		Name:  "include-skipped",
+		Label: "Include skipped listens",
+		Type:  models.Bool,
+	}, {
+		Name:  "append",
+		Label: "Append to file",
+		Type:  models.Bool,
+	}}
+}
+
 func (b *ScrobblerLogBackend) FromConfig(config *viper.Viper) models.Backend {
 	b.filePath = config.GetString("file-path")
 	b.includeSkipped = config.GetBool("include-skipped")
diff --git a/internal/backends/spotify/spotify.go b/internal/backends/spotify/spotify.go
index 454c1fa..9131841 100644
--- a/internal/backends/spotify/spotify.go
+++ b/internal/backends/spotify/spotify.go
@@ -39,6 +39,18 @@ type SpotifyApiBackend struct {
 
 func (b *SpotifyApiBackend) Name() string { return "spotify" }
 
+func (b *SpotifyApiBackend) Options() *[]models.BackendOption {
+	return &[]models.BackendOption{{
+		Name:  "client-id",
+		Label: "Client ID",
+		Type:  models.String,
+	}, {
+		Name:  "client-secret",
+		Label: "Client secret",
+		Type:  models.Secret,
+	}}
+}
+
 func (b *SpotifyApiBackend) FromConfig(config *viper.Viper) models.Backend {
 	b.clientId = config.GetString("client-id")
 	b.clientSecret = config.GetString("client-secret")
diff --git a/internal/backends/subsonic/subsonic.go b/internal/backends/subsonic/subsonic.go
index 1167f87..bd2acbc 100644
--- a/internal/backends/subsonic/subsonic.go
+++ b/internal/backends/subsonic/subsonic.go
@@ -34,6 +34,22 @@ type SubsonicApiBackend struct {
 
 func (b *SubsonicApiBackend) Name() string { return "subsonic" }
 
+func (b *SubsonicApiBackend) Options() *[]models.BackendOption {
+	return &[]models.BackendOption{{
+		Name:  "server-url",
+		Label: "Server URL",
+		Type:  models.String,
+	}, {
+		Name:  "username",
+		Label: "User name",
+		Type:  models.String,
+	}, {
+		Name:  "token",
+		Label: "Access token",
+		Type:  models.Secret,
+	}}
+}
+
 func (b *SubsonicApiBackend) FromConfig(config *viper.Viper) models.Backend {
 	b.client = subsonic.Client{
 		Client:     &http.Client{},
diff --git a/internal/models/interfaces.go b/internal/models/interfaces.go
index 9078eba..ab2339b 100644
--- a/internal/models/interfaces.go
+++ b/internal/models/interfaces.go
@@ -33,6 +33,9 @@ type Backend interface {
 
 	// Initialize the backend from a config.
 	FromConfig(config *viper.Viper) Backend
+
+	// Return configuration options
+	Options() *[]BackendOption
 }
 
 type ImportBackend interface {
diff --git a/internal/models/options.go b/internal/models/options.go
new file mode 100644
index 0000000..0763032
--- /dev/null
+++ b/internal/models/options.go
@@ -0,0 +1,32 @@
+/*
+Copyright © 2023 Philipp Wolfer <phw@uploadedlobster.com>
+
+Scotty is free software: you can redistribute it and/or modify it under the
+terms of the GNU General Public License as published by the Free Software
+Foundation, either version 3 of the License, or (at your option) any later version.
+
+Scotty is distributed in the hope that it will be useful, but WITHOUT ANY
+WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with
+Scotty. If not, see <https://www.gnu.org/licenses/>.
+*/
+
+package models
+
+type OptionType string
+
+const (
+	Bool   OptionType = "bool"
+	Secret OptionType = "secret"
+	String OptionType = "string"
+)
+
+type BackendOption struct {
+	Name     string
+	Label    string
+	Type     OptionType
+	Default  string
+	Validate func(string) error
+}