mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 13:57:25 +02:00
Completes the correctness pass. Download database migration no longer destroys dedupe state The pre-source-column migration carried every legacy row over with source='' and then dropped downloads_legacy, while IsDownloaded matched source exactly. The result was that every previously recorded download looked new, the user silently re-downloaded their whole library, and the original rows were gone. Now the file is copied to <path>.bak-<unixtime> first, downloads_legacy is kept, and IsDownloaded falls back to the sourceless rows (over-matching across sources costs a skipped re-download, which is the cheaper error). Schema state is tracked in PRAGMA user_version so later migrations can be ordered explicitly. Databases already migrated by the old code are detected on open. Also enables WAL and a 10s busy_timeout and caps the pool at one connection, so a concurrent reader no longer gets SQLITE_BUSY with no retry, and builds the DSN through url.URL so a path containing '?' is not parsed as query parameters. --quality now applies to every source applyGlobalConfigOverrides set only qobuz, tidal, deezer and soundcloud, so `rip -q 4 url <beatport-url>` was a silent no-op even though qualityForSource reads Yandex and Beatport. All six are set, with the per-source floor applied and reported rather than silently ignored. config: validation and a fixed reset Load checked nothing but misc.version, so a negative max_connections or a bogus conversion codec was accepted and only surfaced mid-rip. Adds Validate() covering per-source quality ranges, max_connections, requests_per_minute and truncate_to, and normalizes the conversion codec from the file through the same path as --codec. `config reset` assigned one value to both Config.File and Config.Session, so their Secrets slices shared a backing array; it now takes separate copies. Tests cover the shipped defaults and config.toml.example, which is how the over-strict artwork max-width bound in the first draft was caught: -1 is the documented "do not downscale" sentinel. soundcloud: use the shared HTTP client It was the only provider constructing its own http.Client, so it silently ignored downloads.verify_ssl and max_connections and bypassed the transport that redacts credentials from -vv logs. It also had no rate limiter at all. qobuz: mobile fallback could not download a long track http.Client.Timeout bounds the whole request including the body read, so the 30s API client aborted any track that took longer than 30s to stream. The body copy now uses a no-timeout client, as internal/download already does. qobuz: HKDF no longer fails silently hexDecodeOrNil discarded its error, so a non-hex app secret keyed HKDF on nil, derived a wrong KEK and produced silently corrupt audio instead of an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
519 lines
15 KiB
Go
519 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"`
|
|
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"`
|
|
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 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"`
|
|
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"`
|
|
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)
|
|
}
|
|
|
|
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,
|
|
DownloadBooklets: true,
|
|
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,
|
|
},
|
|
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,
|
|
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,
|
|
CheckForUpdates: true,
|
|
},
|
|
}
|
|
|
|
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")
|
|
}
|
|
if data.Youtube.VideoDownloadsFolder == "" {
|
|
data.Youtube.VideoDownloadsFolder = filepath.Join(data.Downloads.Folder, "YouTubeVideos")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|