Files
streamrip-go/internal/config/config.go
T
JorenandClaude Opus 5 ff5fb1cdb5 chore: add README, Makefile, CI, lint config and --version
The repo had no README, Makefile, CI, linter config or version string.

- README.md: requirements (ffmpeg/ffprobe, plus yt-dlp for SoundCloud), build,
  the per-source credentials table, usage, the quality scale and the download
  database's migration behaviour. Every claim was checked against the code
  rather than written from memory, which is how the quality-range bug below was
  found. It also records the two deferred refactors (no shared HTTP layer, and
  metadata as map[string]any) so they are documented rather than lore, and
  notes that there is no LICENSE file.
- Makefile: build, install, test, race, cover, fmt, fmt-check, vet, lint,
  check, clean, help. `make build` stamps version/commit/date via -ldflags.
- .github/workflows/ci.yml: gofmt, vet, build, test and test -race, plus a
  lint job. The linter is intentionally unpinned, with a comment: a
  golangci-lint built against an older Go cannot analyse a go1.25 module, which
  is the same reason a locally installed staticcheck built with go1.24 cannot
  run here.
- .golangci.yml: standard set plus bodyclose, errorlint, nilerr and
  govet/copylocks. copylocks matters now that config.Config carries a mutex;
  bodyclose and errorlint target defects this codebase actually has.
- `rip --version` / `-V`, documented in the help output, falling back to the
  VCS revision the toolchain embeds for `go install` builds.

Fixes a bug introduced in the previous commit: Validate capped deezer quality
at 2 and soundcloud at 1, but config.toml.example documents deezer as 2/3/4 and
the global -q flag accepts 0-4, so a documented config would have failed to
load. deezer's buildFormatPriority treats anything >= 2 as FLAC and soundcloud
ignores the value entirely, so the real ceiling is 4 everywhere; only the Qobuz
and Beatport floor of 1 is a genuine constraint. Pinned with a test that walks
the whole documented range.

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

582 lines
18 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"`
// Vendor app credentials for the mobile API fallback. Defaulted in code;
// override here when Qobuz rotates them, instead of waiting for a release.
MobileAppID string `toml:"mobile_app_id"`
MobileAppSecret string `toml:"mobile_app_secret"`
}
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"`
// Vendor OAuth app credentials. Defaulted in code; override on rotation.
AppClientID string `toml:"app_client_id"`
AppClientSecret string `toml:"app_client_secret"`
}
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"`
// Vendor protocol keys. Defaulted in code; override on rotation.
APIKey string `toml:"api_key"`
GatewayKey string `toml:"gateway_key"`
BlowfishSecret string `toml:"blowfish_secret"`
}
type YandexConfig struct {
Quality int `toml:"quality"`
AccessToken string `toml:"access_token"`
UserID string `toml:"user_id"`
// Vendor request-signing keys. Defaulted in code; override on rotation.
SignKey string `toml:"sign_key"`
LegacyMP3SignSalt string `toml:"legacy_mp3_sign_salt"`
}
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"`
// Vendor OAuth app credential. Defaulted in code; override on rotation.
AppClientID string `toml:"app_client_id"`
}
// SoundcloudConfig has no client_id/app_version: soundcloud is handled via
// yt-dlp, which supplies its own, so those keys were read by no code.
type SoundcloudConfig struct {
Quality int `toml:"quality"`
}
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
}
// Vendor app credentials and protocol keys.
//
// These are the provider apps' own identifiers and signing keys, not user
// secrets, and they are baked in so the tool works out of the box. They are
// mirrored into the config so that when a provider rotates one, a user can fix
// their install with a config edit instead of waiting for a new release.
const (
DefaultQobuzMobileAppID = "312369995"
DefaultQobuzMobileAppSecret = "e79f8b9be485692b0e5f9dd895826368"
DefaultTidalAppClientID = "fX2JxdmntZWK0ixT"
DefaultTidalAppClientSecret = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg="
DefaultDeezerAPIKey = "4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE"
DefaultDeezerGatewayKey = "VBK1FSUEXHTSDBJJ"
DefaultDeezerBlowfishSecret = "g4el58wc0zvf9na1"
DefaultYandexSignKey = "kzqU4XhfCaY6B6JTHODeq5"
DefaultYandexLegacyMP3Salt = "XGRlBW9FXlekgbPrRHuSiA"
DefaultBeatportAppClientID = "ryZ8LuyQVPqbK2mBX2Hwt4qSMtnWuTYSqBPO92yQ"
)
// applyVendorDefaults fills in any vendor credential the user has not set, so
// the provider clients never have to nil-check them.
func applyVendorDefaults(data *ConfigData) {
if data.Qobuz.MobileAppID == "" {
data.Qobuz.MobileAppID = DefaultQobuzMobileAppID
}
if data.Qobuz.MobileAppSecret == "" {
data.Qobuz.MobileAppSecret = DefaultQobuzMobileAppSecret
}
if data.Tidal.AppClientID == "" {
data.Tidal.AppClientID = DefaultTidalAppClientID
}
if data.Tidal.AppClientSecret == "" {
data.Tidal.AppClientSecret = DefaultTidalAppClientSecret
}
if data.Deezer.APIKey == "" {
data.Deezer.APIKey = DefaultDeezerAPIKey
}
if data.Deezer.GatewayKey == "" {
data.Deezer.GatewayKey = DefaultDeezerGatewayKey
}
if data.Deezer.BlowfishSecret == "" {
data.Deezer.BlowfishSecret = DefaultDeezerBlowfishSecret
}
if data.Yandex.SignKey == "" {
data.Yandex.SignKey = DefaultYandexSignKey
}
if data.Yandex.LegacyMP3SignSalt == "" {
data.Yandex.LegacyMP3SignSalt = DefaultYandexLegacyMP3Salt
}
if data.Beatport.AppClientID == "" {
data.Beatport.AppClientID = DefaultBeatportAppClientID
}
}
// qualityRange is the accepted quality id range per source on the global 0-4
// scale.
//
// The ceiling is 4 everywhere because each provider maps the scale onto its own
// ladder and saturates at the top: deezer's buildFormatPriority treats anything
// >= 2 as FLAC, and soundcloud ignores the value entirely. Only the floor
// differs: Qobuz and Beatport have no equivalent of tier 0.
var qualityRange = map[string][2]int{
"qobuz": {1, 4},
"tidal": {0, 4},
"deezer": {0, 4},
"soundcloud": {0, 4},
"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,
},
}
applyVendorDefaults(&data)
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) {
applyVendorDefaults(data)
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
}