367 lines
10 KiB
Go
367 lines
10 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v2"
|
|
)
|
|
|
|
type Server struct {
|
|
Port int `yaml:"port" json:"port"`
|
|
Address string `yaml:"address" json:"address"`
|
|
DownloadPath string `yaml:"download_path" json:"download_path"`
|
|
MaximumActiveDownloads int `yaml:"maximum_active_downloads_per_domain" json:"maximum_active_downloads_per_domain"`
|
|
}
|
|
|
|
// DownloadProfile holds the details for executing a downloader
|
|
type DownloadProfile struct {
|
|
Name string `yaml:"name" json:"name"`
|
|
Command string `yaml:"command" json:"command"`
|
|
Args []string `yaml:"args" json:"args"`
|
|
}
|
|
|
|
// DownloadOption contains configuration for extra arguments to pass to the download command
|
|
type DownloadOption struct {
|
|
Name string `yaml:"name" json:"name"`
|
|
Args []string `yaml:"args" json:"args"`
|
|
}
|
|
|
|
// UI holds the configuration for the user interface
|
|
type UI struct {
|
|
PopupWidth int `yaml:"popup_width" json:"popup_width"`
|
|
PopupHeight int `yaml:"popup_height" json:"popup_height"`
|
|
}
|
|
|
|
// Destination is the path for a place that a download can be moved to
|
|
type Destination struct {
|
|
Name string `yaml:"name" json:"name"` // Name for this location
|
|
Path string `yaml:"path" json:"path"` // Path on disk
|
|
}
|
|
|
|
// Config is the top level of the user configuration
|
|
type Config struct {
|
|
ConfigVersion int `yaml:"config_version" json:"config_version"`
|
|
Server Server `yaml:"server" json:"server"`
|
|
UI UI `yaml:"ui" json:"ui"`
|
|
Destinations []Destination `yaml:"destinations" json:"destinations"` // no longer in use, see DownloadOptions
|
|
DownloadProfiles []DownloadProfile `yaml:"profiles" json:"profiles"`
|
|
DownloadOptions []DownloadOption `yaml:"download_options" json:"download_options"`
|
|
}
|
|
|
|
// ConfigService is a struct to handle configuration requests, allowing for the
|
|
// location that config files are loaded to be customised.
|
|
type ConfigService struct {
|
|
Config *Config
|
|
ConfigPath string
|
|
}
|
|
|
|
func (cs *ConfigService) LoadTestConfig() {
|
|
cs.LoadDefaultConfig()
|
|
cs.Config.Server.DownloadPath = "/tmp"
|
|
cs.Config.DownloadProfiles = []DownloadProfile{{Name: "test profile", Command: "/bin/sleep", Args: []string{"5"}}}
|
|
}
|
|
|
|
func (cs *ConfigService) LoadDefaultConfig() {
|
|
defaultConfig := Config{}
|
|
stdProfile := DownloadProfile{Name: "standard video", Command: "yt-dlp", Args: []string{
|
|
"--newline",
|
|
"--write-info-json",
|
|
"-f",
|
|
"bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
|
|
}}
|
|
mp3Profile := DownloadProfile{Name: "standard mp3", Command: "yt-dlp", Args: []string{
|
|
"--newline",
|
|
"--write-info-json",
|
|
"--extract-audio",
|
|
"--audio-format", "mp3",
|
|
}}
|
|
|
|
defaultConfig.DownloadProfiles = append(defaultConfig.DownloadProfiles, stdProfile)
|
|
defaultConfig.DownloadProfiles = append(defaultConfig.DownloadProfiles, mp3Profile)
|
|
|
|
defaultConfig.Server.Port = 6123
|
|
defaultConfig.Server.Address = "http://localhost:6123"
|
|
defaultConfig.Server.DownloadPath = "/downloads"
|
|
|
|
defaultConfig.UI.PopupWidth = 500
|
|
defaultConfig.UI.PopupHeight = 500
|
|
|
|
defaultConfig.Server.MaximumActiveDownloads = 2
|
|
|
|
defaultConfig.Destinations = nil
|
|
defaultConfig.DownloadOptions = make([]DownloadOption, 0)
|
|
|
|
defaultConfig.ConfigVersion = 4
|
|
|
|
cs.Config = &defaultConfig
|
|
|
|
}
|
|
|
|
// ProfileCalled returns the corresponding DownloadProfile, or nil if it does not exist
|
|
func (c *Config) ProfileCalled(name string) *DownloadProfile {
|
|
for _, p := range c.DownloadProfiles {
|
|
if p.Name == name {
|
|
return &p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DownloadOptionCalled returns the corresponding DownloadOption, or nil if it does not exist
|
|
func (c *Config) DownloadOptionCalled(name string) *DownloadOption {
|
|
for _, o := range c.DownloadOptions {
|
|
if o.Name == name {
|
|
return &o
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) UpdateFromJSON(j []byte) error {
|
|
newConfig := Config{}
|
|
err := json.Unmarshal(j, &newConfig)
|
|
if err != nil {
|
|
log.Printf("Unmarshal error in config: %v", err)
|
|
return err
|
|
}
|
|
|
|
// sanity checks
|
|
if newConfig.UI.PopupHeight < 100 || newConfig.UI.PopupHeight > 2000 {
|
|
return errors.New("invalid popup height - should be 100-2000")
|
|
}
|
|
if newConfig.UI.PopupWidth < 100 || newConfig.UI.PopupWidth > 2000 {
|
|
return errors.New("invalid popup width - should be 100-2000")
|
|
}
|
|
|
|
// check listen port
|
|
if newConfig.Server.Port < 1 || newConfig.Server.Port > 65535 {
|
|
return errors.New("invalid server listen port")
|
|
}
|
|
|
|
// check download path
|
|
fi, err := os.Stat(newConfig.Server.DownloadPath)
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("path '%s' does not exist", newConfig.Server.DownloadPath)
|
|
}
|
|
if !fi.IsDir() {
|
|
return fmt.Errorf("path '%s' is not a directory", newConfig.Server.DownloadPath)
|
|
}
|
|
|
|
if newConfig.Server.MaximumActiveDownloads < 0 {
|
|
return fmt.Errorf("maximum active downloads can not be < 0")
|
|
}
|
|
|
|
// check profile name uniqueness
|
|
for i, p1 := range newConfig.DownloadProfiles {
|
|
for j, p2 := range newConfig.DownloadProfiles {
|
|
if i != j && p1.Name == p2.Name {
|
|
return fmt.Errorf("duplicate download profile name '%s'", p1.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// remove leading/trailing spaces from args and commands and check for emptiness
|
|
for i := range newConfig.DownloadProfiles {
|
|
newConfig.DownloadProfiles[i].Name = strings.TrimSpace(newConfig.DownloadProfiles[i].Name)
|
|
|
|
if newConfig.DownloadProfiles[i].Name == "" {
|
|
return errors.New("profile name cannot be empty")
|
|
}
|
|
|
|
newConfig.DownloadProfiles[i].Command = strings.TrimSpace(newConfig.DownloadProfiles[i].Command)
|
|
if newConfig.DownloadProfiles[i].Command == "" {
|
|
return fmt.Errorf("command in profile '%s' cannot be empty", newConfig.DownloadProfiles[i].Name)
|
|
}
|
|
|
|
// check the args
|
|
for j := range newConfig.DownloadProfiles[i].Args {
|
|
newConfig.DownloadProfiles[i].Args[j] = strings.TrimSpace(newConfig.DownloadProfiles[i].Args[j])
|
|
if newConfig.DownloadProfiles[i].Args[j] == "" {
|
|
return fmt.Errorf("argument %d of profile '%s' is empty", j+1, newConfig.DownloadProfiles[i].Name)
|
|
}
|
|
}
|
|
|
|
// check the command exists
|
|
|
|
_, err := AbsPathToExecutable(newConfig.DownloadProfiles[i].Command)
|
|
if err != nil {
|
|
return fmt.Errorf("problem with command '%s': %s", newConfig.DownloadProfiles[i].Command, err)
|
|
}
|
|
}
|
|
|
|
*c = newConfig
|
|
return nil
|
|
}
|
|
|
|
// DetermineConfigDir determines where the config is (or should be) stored.
|
|
func (cs *ConfigService) DetermineConfigDir() {
|
|
// check binary path first, for a file called gropple.yml
|
|
binaryPath := os.Args[0]
|
|
binaryDir := filepath.Dir(binaryPath)
|
|
potentialConfigPath := filepath.Join(binaryDir, "gropple.yml")
|
|
|
|
_, err := os.Stat(potentialConfigPath)
|
|
if err == nil {
|
|
// exists in binary directory, use that
|
|
// fully qualify, just for clarity in the log
|
|
config, err := filepath.Abs(potentialConfigPath)
|
|
if err == nil {
|
|
log.Printf("found portable config in %s", config)
|
|
cs.ConfigPath = config
|
|
return
|
|
} else {
|
|
log.Printf("got error when trying to convert config to absolute path: %s", err)
|
|
log.Print("falling back to using UserConfigDir")
|
|
}
|
|
}
|
|
|
|
// otherwise fall back to using the UserConfigDir
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
log.Fatalf("cannot find a directory to store config: %v", err)
|
|
}
|
|
appDir := "gropple"
|
|
|
|
fullPath := dir + string(os.PathSeparator) + appDir
|
|
_, err = os.Stat(fullPath)
|
|
|
|
if os.IsNotExist(err) {
|
|
err := os.Mkdir(fullPath, 0777)
|
|
if err != nil {
|
|
log.Fatalf("Could not create config dir '%s': %v", fullPath, err)
|
|
}
|
|
}
|
|
|
|
fullFilename := fullPath + string(os.PathSeparator) + "config.yml"
|
|
cs.ConfigPath = fullFilename
|
|
}
|
|
|
|
// ConfigFileExists checks if the config file already exists, and also checks
|
|
// if there is an error accessing it
|
|
func (cs *ConfigService) ConfigFileExists() (bool, error) {
|
|
path := cs.ConfigPath
|
|
info, err := os.Stat(path)
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("could not check if '%s' exists: %s", path, err)
|
|
}
|
|
if info.Size() == 0 {
|
|
return false, errors.New("config file is 0 bytes")
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// LoadConfig loads the configuration from disk, migrating and updating it to the
|
|
// latest version if needed.
|
|
func (cs *ConfigService) LoadConfig() error {
|
|
path := cs.ConfigPath
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("could not read config '%s': %v", path, err)
|
|
}
|
|
c := Config{}
|
|
cs.Config = &c
|
|
|
|
err = yaml.Unmarshal(b, &c)
|
|
if err != nil {
|
|
return fmt.Errorf("could not parse YAML config '%s': %v", path, err)
|
|
}
|
|
|
|
// do migrations
|
|
configMigrated := false
|
|
if c.ConfigVersion == 1 {
|
|
c.Server.MaximumActiveDownloads = 2
|
|
c.ConfigVersion = 2
|
|
configMigrated = true
|
|
log.Print("migrated config from version 1 => 2")
|
|
|
|
}
|
|
|
|
if c.ConfigVersion == 2 {
|
|
c.Destinations = make([]Destination, 0)
|
|
c.ConfigVersion = 3
|
|
configMigrated = true
|
|
log.Print("migrated config from version 2 => 3")
|
|
}
|
|
|
|
if c.ConfigVersion == 3 {
|
|
c.ConfigVersion = 4
|
|
for i := range c.Destinations {
|
|
newDownloadOption := DownloadOption{
|
|
Name: c.Destinations[i].Name,
|
|
Args: []string{"-o", fmt.Sprintf("%s/%%(title)s [%%(id)s].%%(ext)s", c.Destinations[i].Path)},
|
|
}
|
|
c.DownloadOptions = append(c.DownloadOptions, newDownloadOption)
|
|
}
|
|
c.Destinations = nil
|
|
configMigrated = true
|
|
log.Print("migrated config from version 3 => 4")
|
|
}
|
|
|
|
if configMigrated {
|
|
log.Print("Writing new config after version migration")
|
|
cs.WriteConfig()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WriteConfig writes the in-memory config to disk.
|
|
func (cs *ConfigService) WriteConfig() {
|
|
s, err := yaml.Marshal(cs.Config)
|
|
if err != nil {
|
|
log.Printf("error writing config: %s", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
path := cs.ConfigPath
|
|
file, err := os.Create(
|
|
path,
|
|
)
|
|
|
|
if err != nil {
|
|
log.Fatalf("Could not open config file %s: %s", path, err)
|
|
}
|
|
defer file.Close()
|
|
|
|
_, err = file.Write(s)
|
|
if err != nil {
|
|
log.Fatalf("could not write config file %s: %s", path, err)
|
|
}
|
|
file.Close()
|
|
}
|
|
|
|
// AbsPathToExecutable takes a command name, which may or may not be path-qualified,
|
|
// and returns the fully qualified path to it, or an error if could not be found, or
|
|
// if it does not appear to be a file.
|
|
func AbsPathToExecutable(cmd string) (string, error) {
|
|
|
|
pathCmd, err := exec.LookPath(cmd)
|
|
if err != nil {
|
|
return "", fmt.Errorf("could not LookPath '%s': %w", cmd, err)
|
|
}
|
|
|
|
execAbsolutePath, err := filepath.Abs(pathCmd)
|
|
if err != nil {
|
|
return "", fmt.Errorf("could not get absolute path to '%s': %w", cmd, err)
|
|
}
|
|
fi, err := os.Stat(execAbsolutePath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("could not get stat '%s': %w", cmd, err)
|
|
}
|
|
if !fi.Mode().IsRegular() {
|
|
return "", fmt.Errorf("'%s' is not a regular file: %w", cmd, err)
|
|
}
|
|
|
|
return execAbsolutePath, nil
|
|
}
|