mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 22:07:30 +02:00
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>
582 lines
18 KiB
Go
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
|
|
}
|