mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 13:57:25 +02:00
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>
500 lines
15 KiB
Go
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
|
|
}
|