Files
streamrip-go/internal/config/config.go
Joren 0ba8faa943 improve CLI error semantics and soundcloud canonicalization
Auto-upgrade outdated configs on startup, add actionable SSL verification hints in rip error paths, and harden SoundCloud search/metadata with canonical URL handling and richer source IDs.
2026-04-20 15:16:59 +02:00

353 lines
10 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/pelletier/go-toml/v2"
)
const CurrentConfigVersion = "2.2.0"
var ErrOutdatedConfig = errors.New("config version mismatch")
type Config struct {
Path string
File ConfigData
Session ConfigData
}
type ConfigData struct {
Downloads DownloadsConfig `toml:"downloads"`
Qobuz QobuzConfig `toml:"qobuz"`
Tidal TidalConfig `toml:"tidal"`
Deezer DeezerConfig `toml:"deezer"`
Soundcloud SoundcloudConfig `toml:"soundcloud"`
Youtube YoutubeConfig `toml:"youtube"`
Database DatabaseConfig `toml:"database"`
Conversion ConversionConfig `toml:"conversion"`
QobuzFilters QobuzDiscographyFilterConfig `toml:"qobuz_filters"`
Artwork ArtworkConfig `toml:"artwork"`
Metadata MetadataConfig `toml:"metadata"`
Filepaths FilepathsConfig `toml:"filepaths"`
LastFM LastFMConfig `toml:"lastfm"`
CLI CLIConfig `toml:"cli"`
Misc MiscConfig `toml:"misc"`
}
type DownloadsConfig struct {
Folder string `toml:"folder"`
SourceSubdirectories bool `toml:"source_subdirectories"`
DiscSubdirectories bool `toml:"disc_subdirectories"`
Concurrency bool `toml:"concurrency"`
MaxConnections int `toml:"max_connections"`
RequestsPerMinute int `toml:"requests_per_minute"`
VerifySSL bool `toml:"verify_ssl"`
}
type QobuzConfig struct {
Quality int `toml:"quality"`
DownloadBooklets bool `toml:"download_booklets"`
UseAuthToken bool `toml:"use_auth_token"`
EmailOrUserID string `toml:"email_or_userid"`
PasswordOrToken string `toml:"password_or_token"`
AppID string `toml:"app_id"`
Secrets []string `toml:"secrets"`
}
type TidalConfig struct {
Quality int `toml:"quality"`
DownloadVideos bool `toml:"download_videos"`
UserID string `toml:"user_id"`
CountryCode string `toml:"country_code"`
AccessToken string `toml:"access_token"`
RefreshToken string `toml:"refresh_token"`
TokenExpiry int64 `toml:"token_expiry"`
}
type DeezerConfig struct {
Quality int `toml:"quality"`
LowerQualityIfNotAvailable bool `toml:"lower_quality_if_not_available"`
ARL string `toml:"arl"`
UseDeezloader bool `toml:"use_deezloader"`
DeezloaderWarnings bool `toml:"deezloader_warnings"`
}
type SoundcloudConfig struct {
Quality int `toml:"quality"`
ClientID string `toml:"client_id"`
AppVersion string `toml:"app_version"`
}
type YoutubeConfig struct {
Quality int `toml:"quality"`
DownloadVideos bool `toml:"download_videos"`
VideoDownloadsFolder string `toml:"video_downloads_folder"`
}
type DatabaseConfig struct {
DownloadsEnabled bool `toml:"downloads_enabled"`
DownloadsPath string `toml:"downloads_path"`
FailedDownloadsEnabled bool `toml:"failed_downloads_enabled"`
FailedDownloadsPath string `toml:"failed_downloads_path"`
}
type ConversionConfig struct {
Enabled bool `toml:"enabled"`
Codec string `toml:"codec"`
SamplingRate int `toml:"sampling_rate"`
BitDepth int `toml:"bit_depth"`
LossyBitrate int `toml:"lossy_bitrate"`
}
type QobuzDiscographyFilterConfig struct {
Extras bool `toml:"extras"`
Repeats bool `toml:"repeats"`
NonAlbums bool `toml:"non_albums"`
Features bool `toml:"features"`
NonStudioAlbums bool `toml:"non_studio_albums"`
NonRemaster bool `toml:"non_remaster"`
}
type ArtworkConfig struct {
Embed bool `toml:"embed"`
EmbedSize string `toml:"embed_size"`
EmbedMaxWidth int `toml:"embed_max_width"`
SaveArtwork bool `toml:"save_artwork"`
SavedMaxWidth int `toml:"saved_max_width"`
}
type MetadataConfig struct {
SetPlaylistToAlbum bool `toml:"set_playlist_to_album"`
RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"`
Exclude []string `toml:"exclude"`
}
type FilepathsConfig struct {
AddSinglesToFolder bool `toml:"add_singles_to_folder"`
FolderFormat string `toml:"folder_format"`
TrackFormat string `toml:"track_format"`
RestrictCharacters bool `toml:"restrict_characters"`
TruncateTo int `toml:"truncate_to"`
}
type LastFMConfig struct {
Source string `toml:"source"`
FallbackSource string `toml:"fallback_source"`
}
type CLIConfig struct {
TextOutput bool `toml:"text_output"`
ProgressBars bool `toml:"progress_bars"`
MaxSearchResults int `toml:"max_search_results"`
}
type MiscConfig struct {
Version string `toml:"version"`
CheckForUpdates bool `toml:"check_for_updates"`
}
func Load(path string) (*Config, error) {
resolvedPath, err := resolvePath(path)
if err != nil {
return nil, err
}
if _, err = os.Stat(resolvedPath); errors.Is(err, os.ErrNotExist) {
cfg := DefaultConfigData()
if err = saveConfigData(resolvedPath, cfg); err != nil {
return nil, err
}
return &Config{Path: resolvedPath, File: cfg, Session: cloneConfigData(cfg)}, nil
}
if err != nil {
return nil, err
}
raw, err := os.ReadFile(resolvedPath)
if err != nil {
return nil, err
}
data := DefaultConfigData()
if err = toml.Unmarshal(raw, &data); err != nil {
return nil, err
}
applyRuntimeDefaults(&data)
if data.Misc.Version != CurrentConfigVersion {
return nil, fmt.Errorf("%w: need to update from %q to %q", ErrOutdatedConfig, data.Misc.Version, CurrentConfigVersion)
}
return &Config{Path: resolvedPath, File: data, Session: cloneConfigData(data)}, nil
}
func UpgradeOutdated(path string) (string, error) {
resolvedPath, err := resolvePath(path)
if err != nil {
return "", err
}
raw, err := os.ReadFile(resolvedPath)
if err != nil {
return "", err
}
data := DefaultConfigData()
if err = toml.Unmarshal(raw, &data); err != nil {
return "", err
}
applyRuntimeDefaults(&data)
data.Misc.Version = CurrentConfigVersion
if err = saveConfigData(resolvedPath, data); err != nil {
return "", err
}
return resolvedPath, nil
}
func (c *Config) SaveFile() error {
return saveConfigData(c.Path, c.File)
}
func DefaultConfigData() ConfigData {
home, _ := os.UserHomeDir()
appDir := defaultAppDir()
downloadsFolder := filepath.Join(home, "StreamripDownloads")
data := ConfigData{
Downloads: DownloadsConfig{
Folder: downloadsFolder,
SourceSubdirectories: false,
DiscSubdirectories: true,
Concurrency: true,
MaxConnections: 6,
RequestsPerMinute: 60,
VerifySSL: true,
},
Qobuz: QobuzConfig{
Quality: 3,
DownloadBooklets: true,
UseAuthToken: false,
},
Tidal: TidalConfig{
Quality: 3,
DownloadVideos: true,
},
Deezer: DeezerConfig{
Quality: 2,
LowerQualityIfNotAvailable: true,
UseDeezloader: true,
DeezloaderWarnings: true,
},
Soundcloud: SoundcloudConfig{
Quality: 0,
},
Youtube: YoutubeConfig{
Quality: 0,
DownloadVideos: false,
VideoDownloadsFolder: filepath.Join(downloadsFolder, "YouTubeVideos"),
},
Database: DatabaseConfig{
DownloadsEnabled: true,
DownloadsPath: filepath.Join(appDir, "downloads.db"),
FailedDownloadsEnabled: true,
FailedDownloadsPath: filepath.Join(appDir, "failed_downloads.db"),
},
Conversion: ConversionConfig{
Enabled: false,
Codec: "ALAC",
SamplingRate: 48000,
BitDepth: 24,
LossyBitrate: 320,
},
QobuzFilters: QobuzDiscographyFilterConfig{},
Artwork: ArtworkConfig{
Embed: true,
EmbedSize: "large",
EmbedMaxWidth: -1,
SaveArtwork: true,
SavedMaxWidth: -1,
},
Metadata: MetadataConfig{
SetPlaylistToAlbum: true,
RenumberPlaylistTracks: true,
Exclude: []string{},
},
Filepaths: FilepathsConfig{
AddSinglesToFolder: false,
FolderFormat: "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{sampling_rate}kHz]",
TrackFormat: "{tracknumber:02}. {artist} - {title}{explicit}",
RestrictCharacters: false,
TruncateTo: 120,
},
LastFM: LastFMConfig{
Source: "qobuz",
},
CLI: CLIConfig{
TextOutput: true,
ProgressBars: true,
MaxSearchResults: 100,
},
Misc: MiscConfig{
Version: CurrentConfigVersion,
CheckForUpdates: true,
},
}
return data
}
func resolvePath(path string) (string, error) {
if path != "" {
return path, os.MkdirAll(filepath.Dir(path), 0o755)
}
appDir := defaultAppDir()
if err := os.MkdirAll(appDir, 0o755); err != nil {
return "", err
}
return filepath.Join(appDir, "config.toml"), nil
}
func defaultAppDir() string {
base, err := os.UserConfigDir()
if err != nil {
return "."
}
return filepath.Join(base, "streamrip")
}
func saveConfigData(path string, data ConfigData) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := toml.Marshal(data)
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
func applyRuntimeDefaults(data *ConfigData) {
home, _ := os.UserHomeDir()
appDir := defaultAppDir()
if data.Downloads.Folder == "" {
data.Downloads.Folder = filepath.Join(home, "StreamripDownloads")
}
if data.Database.DownloadsPath == "" {
data.Database.DownloadsPath = filepath.Join(appDir, "downloads.db")
}
if data.Database.FailedDownloadsPath == "" {
data.Database.FailedDownloadsPath = filepath.Join(appDir, "failed_downloads.db")
}
if data.Youtube.VideoDownloadsFolder == "" {
data.Youtube.VideoDownloadsFolder = filepath.Join(data.Downloads.Folder, "YouTubeVideos")
}
}
func cloneConfigData(in ConfigData) ConfigData {
out := in
out.Qobuz.Secrets = append([]string(nil), in.Qobuz.Secrets...)
out.Metadata.Exclude = append([]string(nil), in.Metadata.Exclude...)
return out
}