Files
streamrip-go/internal/config/config.go
T
JorenandClaude Opus 5 b3b6a2eb1d refactor: delete dead code, dedupe helpers, add tests for untested packages
Dead code
  Removed, each verified to have no non-test caller: promptSearchSelection
  (77 lines, superseded by promptSearchSelectionMenu), AddPlaylistByTrackIDs,
  download.NewWithVerifySSL, qobuz.Client.GetTrackMetadata,
  tidal.Client.trackSupportsAtmos (test-only, and the comment above
  GetDownloadable already documented its removal from the live path) and its
  now-orphaned test, yandex.canonicalTrackRequestID (a no-op returning its
  trimmed input; the call site uses strings.TrimSpace directly) and the unused
  defaultEstimatedKbps constant.

Config keys that did nothing
  qobuz.download_booklets and misc.check_for_updates were declared, defaulted
  and documented in config.toml.example but read by no code. The whole
  [youtube] block went too: there is no YouTube provider. go-toml ignores
  unknown keys, verified, so existing user configs still load.

Duplicated helpers
  tidal.stringify and yandex.firstNonEmpty were byte-identical copies of
  jsonutil.StringFromAny and jsonutil.FirstNonEmpty. displayArtistNames
  existed three times, byte-identical, in internal/app, provider/yandex and
  provider/beatport; it now lives in jsonutil. The CLI's normalizeCodec
  delegates to config.NormalizeCodec so the flag and the config file are
  checked by the same rules.

Tests for packages that had none
  internal/jsonutil (0% -> 100%) and internal/netutil (0% -> 78%), the latter
  covering redactURL, which is a security control, and the zero-timeout
  contract that internal/download and the qobuz body copy depend on.
  internal/config rose to 82% with coverage of the new Validate(), the
  0600 permissions, CloneConfigData not sharing slices, and a concurrent
  UpdateTokens test that exercises the mutex under -race.

Test suite is ~85x faster
  Provider tests built clients with the default 60 requests/minute, so the
  rate limiter slept 1s per HTTP call: deezer took 31s, tidal 11s, qobuz 8s.
  Each provider test package now builds its config through a testConfigData
  helper that disables throttling. Whole suite: ~60s -> 0.7s, and 2.8s
  under -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 17:05:55 +02:00

500 lines
15 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/pelletier/go-toml/v2"
)
const CurrentConfigVersion = "2.2.0"
var ErrOutdatedConfig = errors.New("config version mismatch")
// Config holds the on-disk config (File) and the runtime view with CLI
// overrides applied (Session).
//
// mu guards File, Session and the write to Path. Provider clients refresh OAuth
// tokens from inside concurrent download workers, so both the struct fields and
// the config-file write have to be serialized; without this, two workers
// refreshing at once raced each other and issued two concurrent os.WriteFile
// calls against the same path.
type Config struct {
Path string
mu sync.Mutex
File ConfigData
Session ConfigData
}
type ConfigData struct {
Downloads DownloadsConfig `toml:"downloads"`
Qobuz QobuzConfig `toml:"qobuz"`
Tidal TidalConfig `toml:"tidal"`
Deezer DeezerConfig `toml:"deezer"`
Yandex YandexConfig `toml:"yandex"`
Beatport BeatportConfig `toml:"beatport"`
Soundcloud SoundcloudConfig `toml:"soundcloud"`
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"`
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"`
PreferAtmos bool `toml:"prefer_atmos"`
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"`
Email string `toml:"email"`
Password string `toml:"password"`
RefreshToken string `toml:"refresh_token"`
}
type YandexConfig struct {
Quality int `toml:"quality"`
AccessToken string `toml:"access_token"`
UserID string `toml:"user_id"`
}
type BeatportConfig struct {
Quality int `toml:"quality"`
VariousArtistsThreshold int `toml:"various_artists_threshold"`
Username string `toml:"username"`
Password string `toml:"password"`
AccessToken string `toml:"access_token"`
RefreshToken string `toml:"refresh_token"`
TokenExpiry int64 `toml:"token_expiry"`
}
type SoundcloudConfig struct {
Quality int `toml:"quality"`
ClientID string `toml:"client_id"`
AppVersion string `toml:"app_version"`
}
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"`
ArtistSeparator string `toml:"artist_separator"`
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"`
}
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)
}
if err = Validate(&data); err != nil {
return nil, err
}
return &Config{Path: resolvedPath, File: data, Session: cloneConfigData(data)}, nil
}
// qualityRange is the accepted quality id range per source. Qobuz and Beatport
// have no "lowest" tier, so their floor is 1.
var qualityRange = map[string][2]int{
"qobuz": {1, 4},
"tidal": {0, 4},
"deezer": {0, 2},
"soundcloud": {0, 1},
"yandex": {0, 4},
"beatport": {1, 4},
}
// Validate rejects config values that would otherwise fail much later, deep in
// a rip, or be silently patched over at individual call sites. Load previously
// checked nothing but misc.version, so a negative max_connections or a bogus
// conversion codec was accepted and only noticed mid-download.
//
// It normalizes what can be normalized (the conversion codec, which until now
// was only canonicalized when it arrived via --codec) and errors on the rest.
func Validate(data *ConfigData) error {
for source, bounds := range qualityRange {
var q int
switch source {
case "qobuz":
q = data.Qobuz.Quality
case "tidal":
q = data.Tidal.Quality
case "deezer":
q = data.Deezer.Quality
case "soundcloud":
q = data.Soundcloud.Quality
case "yandex":
q = data.Yandex.Quality
case "beatport":
q = data.Beatport.Quality
}
if q < bounds[0] || q > bounds[1] {
return fmt.Errorf("config: %s.quality is %d, must be %d-%d", source, q, bounds[0], bounds[1])
}
}
if data.Downloads.MaxConnections < 1 {
return fmt.Errorf("config: downloads.max_connections is %d, must be >= 1", data.Downloads.MaxConnections)
}
if data.Downloads.RequestsPerMinute == 0 {
return errors.New("config: downloads.requests_per_minute is 0; use a positive value, or a negative value to disable throttling")
}
if data.Filepaths.TruncateTo < 1 {
return fmt.Errorf("config: filepaths.truncate_to is %d, must be >= 1", data.Filepaths.TruncateTo)
}
if data.Conversion.Enabled {
codec, err := NormalizeCodec(data.Conversion.Codec)
if err != nil {
return fmt.Errorf("config: conversion.codec: %w", err)
}
data.Conversion.Codec = codec
}
// artwork.*_max_width uses -1 (and any non-positive value) to mean "do not
// downscale", so there is no lower bound to enforce here.
return nil
}
// NormalizeCodec canonicalizes a conversion codec name. It lives here, rather
// than only in the CLI flag parser, so a codec set in the config file goes
// through the same check.
func NormalizeCodec(raw string) (string, error) {
codec := strings.ToUpper(strings.TrimSpace(raw))
switch codec {
case "ALAC", "FLAC", "MP3", "AAC", "VORBIS":
return codec, nil
case "OGG":
return "VORBIS", nil
default:
return "", fmt.Errorf("unsupported codec %q (expected ALAC, FLAC, OGG, MP3, AAC)", raw)
}
}
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 {
c.mu.Lock()
defer c.mu.Unlock()
return c.saveFileLocked()
}
func (c *Config) saveFileLocked() error {
return saveConfigData(c.Path, c.File)
}
// UpdateTokens applies fn to both the on-disk and session copies of the config
// under the config lock, then persists the result. Provider token refresh must
// go through this rather than mutating c.File/c.Session directly, because it
// runs concurrently from download workers.
func (c *Config) UpdateTokens(fn func(file, session *ConfigData)) error {
if fn == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
fn(&c.File, &c.Session)
// An empty Path means there is no config file backing this Config (tests,
// and callers that loaded defaults only); the in-memory update still has to
// happen, but there is nothing to write.
if strings.TrimSpace(c.Path) == "" {
return nil
}
return c.saveFileLocked()
}
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,
UseAuthToken: false,
},
Tidal: TidalConfig{
Quality: 3,
DownloadVideos: true,
PreferAtmos: false,
},
Deezer: DeezerConfig{
Quality: 2,
LowerQualityIfNotAvailable: true,
},
Yandex: YandexConfig{
Quality: 2,
},
Beatport: BeatportConfig{
Quality: 3,
VariousArtistsThreshold: 3,
},
Soundcloud: SoundcloudConfig{
Quality: 0,
},
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,
ArtistSeparator: "; ",
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,
},
}
return data
}
func resolvePath(path string) (string, error) {
// 0o700: this directory holds the credential-bearing config and the
// download databases.
if path != "" {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", err
}
return path, nil
}
appDir := defaultAppDir()
if err := os.MkdirAll(appDir, 0o700); 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 {
// 0o700/0o600: this file holds plaintext provider passwords, Deezer ARL
// cookies and OAuth access/refresh tokens, so it must not be world-readable.
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
b, err := toml.Marshal(data)
if err != nil {
return err
}
if err := os.WriteFile(path, b, 0o600); err != nil {
return err
}
// Tighten an existing file that a previous version created as 0o644.
return os.Chmod(path, 0o600)
}
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")
}
}
// CloneConfigData returns a deep copy of in. The slice fields must not be
// shared between Config.File and Config.Session, or writing one silently
// mutates the other.
func CloneConfigData(in ConfigData) ConfigData {
return cloneConfigData(in)
}
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
}