Skip to main content

scrobble_scrubber/
config.rs

1use config::{Config, ConfigError, Environment, File};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6#[serde(rename_all = "lowercase")]
7pub enum TrackProviderType {
8    Cached,
9    Direct,
10}
11
12impl Default for TrackProviderType {
13    fn default() -> Self {
14        Self::Direct
15    }
16}
17
18/// Default system prompt for AI providers
19pub const DEFAULT_CLAUDE_SYSTEM_PROMPT: &str = "You are a music metadata cleaning assistant with function calling tools available. You work alongside automated rewrite rules and have two main responsibilities:
20
211. SUGGEST IMMEDIATE CORRECTIONS for complex metadata issues
222. RECOMMEND NEW REWRITE RULES when you identify patterns that could be automated
23
24AVAILABLE FUNCTIONS:
25- suggest_track_edit: Propose immediate metadata corrections for this specific track
26- suggest_rewrite_rule: Recommend new rewrite rules for patterns that could be automated
27
28If no changes are needed, simply don't call any functions.
29
30WHEN TO SUGGEST IMMEDIATE CORRECTIONS (suggest_track_edit):
31- Complex typos requiring musical knowledge to identify
32- Album name corrections from compilations to original albums
33- Artist name standardization (e.g. \"The Beatles\" vs \"Beatles\")
34- Context-dependent punctuation/capitalization fixes
35- Album artist corrections for compilations vs. regular albums
36- Complex featuring/collaboration format restructuring
37- Issues that don't match existing automated rule patterns
38
39WHEN TO RECOMMEND NEW REWRITE RULES:
40If you notice patterns that could be automated, mention in your reasoning:
41\"PATTERN DETECTED: This issue could be handled by a rewrite rule like [pattern] → [replacement]\"
42
43PRIORITY CLEANUP TARGETS:
44Always prioritize removing these types of extraneous information from track names:
45- Remaster indicators: \"2009 Remaster\", \"Remastered\", \"2024 Remaster\", etc.
46- Version indicators: \"Deluxe Version\", \"Anniversary Edition\", \"Special Edition\", etc.
47- Year suffixes: \"- 2010 Version\", \"(2015 Remaster)\", etc.
48- Edition markers: \"(Deluxe)\", \"(Extended)\", \"(Single Version)\", etc.
49- Format indicators: \"(Radio Edit)\", \"(Album Version)\", \"(Clean)\", etc.
50- Streaming artifacts: \"(feat. [artist])\" when it should be \"feat. [artist]\"
51
52REWRITE RULE SYNTAX:
53Rewrite rules support both regex and literal string replacement:
54
55IMPORTANT: All regex patterns MUST use anchors (^ and $) to match the entire input string.
56All replacements reconstruct the complete output string using capture groups.
57
58REGEX RULES (most common):
59- Pattern: r\"^(.*)([0-9]{4}) Remaster(.*)$\" → Replacement: \"${1}${2} Version${3}\"
60- Pattern: r\"^(.*) - [0-9]{4} Remaster$\" → Replacement: \"${1}\" (removes suffix)
61- Pattern: r\"^(.+) ft\\. (.+)$\" → Replacement: \"${1} feat. ${2}\" (capture groups)
62- Pattern: r\"^(.*\\S)\\s+$\" → Replacement: \"${1}\" (trim trailing whitespace)
63
64LITERAL RULES (exact string matching):
65- Pattern: \"feat.\" → Replacement: \"featuring\" (simple replacement)
66- Pattern: \" ft. \" → Replacement: \" feat. \" (normalize featuring)
67
68REGEX FLAGS (optional):
69- 'i' = case insensitive
70- 'w' = word boundaries (\\b...\\b)
71- 's' = dot matches newline
72
73FIELD TARGETS:
74Rules can target: track_name, artist_name, album_name, album_artist_name
75
76Examples of rule-worthy patterns (PRIORITIZE THESE):
77- Remaster removal: r\"^(.*) - [0-9]{4} (Remaster|Version)$\" → \"${1}\"
78- Remaster removal: r\"^(.*)\\s*\\([0-9]{4} Remaster\\)$\" → \"${1}\"
79- Version removal: r\"^(.*)\\s*\\((Deluxe|Special|Anniversary) (Edition|Version)\\)$\" → \"${1}\"
80- Edition removal: r\"^(.*)\\s*\\((Deluxe|Extended|Single)\\)$\" → \"${1}\"
81- Format removal: r\"^(.*)\\s*\\((Radio Edit|Album Version|Clean)\\)$\" → \"${1}\"
82- Year suffix removal: r\"^(.*) - [0-9]{4}$\" → \"${1}\"
83- Featuring normalization: r\"^(.*) ft\\. (.*)$\" → \"${1} feat. ${2}\"
84- Parenthetical featuring fix: r\"^(.*)\\s*\\(feat\\. (.*)\\)$\" → \"${1} feat. ${2}\"
85- Whitespace cleanup: r\"^(.*)\\s{2,}(.*)$\" → \"${1} ${2}\"
86
87GUIDELINES:
88- Always use available functions - don't just provide text responses
89- CHECK PENDING ITEMS: Review existing rewrite rules, pending edits, and pending rules to avoid duplicates
90- DO NOT suggest edits for tracks that already have pending edits awaiting approval
91- DO NOT propose rewrite rules that are already pending or similar to pending rules
92- PRIORITIZE CLEANUP: Always suggest rules to remove remaster/version/edition information when found
93- Focus on issues requiring musical knowledge or complex judgment for immediate fixes
94- Suggest new rules for any consistent patterns you identify (only if not already pending)
95- Only suggest changes when confident they improve metadata quality
96- Consider original album/single releases when correcting compilations
97- CLEAN TRACK NAMES: The goal is clean, canonical track names without extraneous suffixes or parentheticals
98
99REWRITE RULE BEST PRACTICES:
100- GENERIC RULES: Create rules that work across all artists, not artist-specific ones
101- REPRESENTATIVE EXAMPLES: When suggesting rules, provide examples that clearly show the transformation
102- GOOD EXAMPLE: \"Bohemian Rhapsody - 2011 Remaster\" → \"Bohemian Rhapsody\" (demonstrates remaster removal)
103- BAD EXAMPLE: \"Hey Jude\" → \"Hey Jude\" (shows no change, not helpful)
104- AVOID ARTIST-SPECIFIC: Don't create rules like \"Beatles\" → \"The Beatles\" unless specifically correcting misspellings
105- PATTERN FOCUS: Rules should target formatting patterns (remasters, editions, etc.) not content-specific changes
106- MOTIVATION CLARITY: Explain WHY the rule helps (\"Removes distracting remaster suffixes for cleaner track names\")
107
108EXAMPLE QUALITY:
109When providing examples in your motivation, choose tracks that actually demonstrate the rule's effect:
110- Show the BEFORE and AFTER transformation clearly
111- Pick common scenarios where the rule would apply
112- Use diverse examples (different genres/eras) to show broad applicability
113- Make it obvious why the change improves the metadata
114
115Help build a smarter cleaning system by identifying both immediate fixes AND patterns for future automation!";
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ScrobbleScrubberConfig {
119    pub scrubber: ScrubberConfig,
120    pub providers: ProvidersConfig,
121    pub storage: StorageConfig,
122    pub lastfm: LastFmConfig,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ScrubberConfig {
127    /// Check interval in seconds
128    pub interval: u64,
129    /// Dry run mode - don't actually make any edits
130    pub dry_run: bool,
131    /// Global setting to require confirmation for all edits (deprecated - use persistent state)
132    pub require_confirmation: bool,
133    /// Require confirmation for proposed rewrite rules (deprecated - use persistent state)
134    pub require_proposed_rule_confirmation: bool,
135    /// Automatically start scrubber on application startup
136    pub auto_start: bool,
137    /// Track provider type
138    pub track_provider: TrackProviderType,
139    /// JSON logging configuration
140    pub json_logging: JsonLoggingConfig,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct ProvidersConfig {
145    /// Enable rewrite rules provider
146    pub enable_rewrite_rules: bool,
147    /// Enable `OpenAI` provider
148    pub enable_openai: bool,
149    /// Enable HTTP provider
150    pub enable_http: bool,
151    /// Enable MusicBrainz provider
152    pub enable_musicbrainz: bool,
153    /// `OpenAI` configuration
154    pub openai: Option<OpenAIProviderConfig>,
155    /// HTTP provider configuration
156    pub http: Option<HttpProviderConfig>,
157    /// MusicBrainz provider configuration
158    pub musicbrainz: Option<MusicBrainzProviderConfig>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
162pub struct OpenAIProviderConfig {
163    /// `OpenAI` API key
164    pub api_key: String,
165    /// Model to use (defaults to gpt-4o-mini)
166    pub model: Option<String>,
167    /// Custom system prompt
168    pub system_prompt: Option<String>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct HttpProviderConfig {
173    /// HTTP endpoint URL
174    pub endpoint_url: String,
175    /// Request timeout in seconds
176    pub timeout_seconds: u64,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
180#[serde(rename_all = "snake_case")]
181pub enum ReleaseFilterType {
182    /// Exclude demo releases
183    ExcludeDemo,
184    /// Exclude special editions (deluxe, legacy, expanded, etc.)
185    ExcludeSpecialEdition,
186    /// Prefer non-Japanese releases when multiple are available
187    PreferNonJapanese,
188    /// Exclude releases with specific disambiguation terms
189    ExcludeByDisambiguation { terms: Vec<String> },
190    /// Exclude releases from specific countries
191    ExcludeByCountry { countries: Vec<String> },
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
195pub struct ReleaseFilterConfig {
196    /// List of active filters to apply
197    pub filters: Vec<ReleaseFilterType>,
198    /// General preference for original releases over reissues
199    pub prefer_original_releases: bool,
200    /// Additional custom terms to exclude from disambiguation
201    pub custom_exclusion_terms: Vec<String>,
202}
203
204impl Default for ReleaseFilterConfig {
205    fn default() -> Self {
206        Self {
207            filters: vec![
208                ReleaseFilterType::ExcludeDemo,
209                ReleaseFilterType::PreferNonJapanese,
210                ReleaseFilterType::ExcludeSpecialEdition,
211            ],
212            prefer_original_releases: true,
213            custom_exclusion_terms: Vec::new(),
214        }
215    }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219pub struct MusicBrainzProviderConfig {
220    /// Confidence threshold for accepting MusicBrainz matches (0.0-1.0)
221    pub confidence_threshold: f32,
222    /// Maximum number of search results to examine
223    pub max_results: usize,
224    /// Request delay in milliseconds to be respectful to MusicBrainz API
225    pub api_delay_ms: u64,
226    // NOTE: Release filters are now configured per-rewrite rule, not globally
227    // Use RewriteRule.musicbrainz_release_filters for per-rule filtering
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct StorageConfig {
232    /// Path to state file for persistence
233    pub state_file: String,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct LastFmConfig {
238    /// Last.fm username
239    pub username: String,
240    /// Last.fm password
241    pub password: String,
242    /// Base URL for Last.fm (defaults to <https://www.last.fm>)
243    pub base_url: Option<String>,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct JsonLoggingConfig {
248    /// Enable JSON logging of track edit events
249    pub enabled: bool,
250    /// Path to JSON log file (defaults to XDG data dir)
251    pub log_file: Option<String>,
252}
253
254impl Default for ScrubberConfig {
255    fn default() -> Self {
256        Self {
257            interval: 300,
258            dry_run: false,
259            require_confirmation: false,
260            require_proposed_rule_confirmation: true,
261            auto_start: false,
262            track_provider: TrackProviderType::Direct,
263            json_logging: JsonLoggingConfig::default(),
264        }
265    }
266}
267
268impl JsonLoggingConfig {
269    /// Get the default JSON log file path using XDG Base Directory specification
270    /// Falls back to current directory if XDG data directory is not available
271    pub fn get_default_log_file_path() -> String {
272        if let Some(data_dir) = dirs::data_dir() {
273            let scrobble_data_dir = data_dir.join("scrobble-scrubber");
274            scrobble_data_dir
275                .join("track_edits.jsonl")
276                .to_string_lossy()
277                .to_string()
278        } else {
279            // Fallback to current directory if XDG data directory is not available
280            "track_edits.jsonl".to_string()
281        }
282    }
283
284    /// Get the configured log file path or the default
285    pub fn log_file_path(&self) -> String {
286        self.log_file
287            .clone()
288            .unwrap_or_else(Self::get_default_log_file_path)
289    }
290}
291
292impl Default for JsonLoggingConfig {
293    fn default() -> Self {
294        Self {
295            enabled: true,
296            log_file: None, // Will use XDG data dir default
297        }
298    }
299}
300
301impl Default for ProvidersConfig {
302    fn default() -> Self {
303        Self {
304            enable_rewrite_rules: true,
305            enable_openai: false,
306            enable_http: false,
307            enable_musicbrainz: false,
308            openai: None,
309            http: None,
310            musicbrainz: None,
311        }
312    }
313}
314
315impl Default for MusicBrainzProviderConfig {
316    fn default() -> Self {
317        Self {
318            confidence_threshold: 0.8, // 80% confidence required
319            max_results: 5,            // Check top 5 results
320            api_delay_ms: 100,         // 100ms delay between requests
321        }
322    }
323}
324
325impl StorageConfig {
326    /// Get the default state file path using XDG Base Directory specification
327    /// Falls back to current directory if XDG data directory is not available
328    pub fn get_default_state_file_path() -> String {
329        Self::get_default_state_file_path_for_user(None)
330    }
331
332    /// Get the default state file path for a specific user using XDG Base Directory specification
333    /// Falls back to current directory if XDG data directory is not available
334    pub fn get_default_state_file_path_for_user(username: Option<&str>) -> String {
335        if let Some(data_dir) = dirs::data_dir() {
336            let mut scrobble_data_dir = data_dir.join("scrobble-scrubber");
337
338            // Add per-user subdirectory if username is provided
339            if let Some(user) = username {
340                scrobble_data_dir = scrobble_data_dir.join("users").join(user);
341            }
342
343            scrobble_data_dir
344                .join("state.db")
345                .to_string_lossy()
346                .to_string()
347        } else {
348            // Fallback to current directory if XDG data directory is not available
349            if let Some(user) = username {
350                format!("{user}_scrobble_state.db")
351            } else {
352                "scrobble_state.db".to_string()
353            }
354        }
355    }
356
357    /// Get the edit log file path based on the state file path
358    /// Uses the same directory as the state file but with a cleaner name
359    pub fn get_edit_log_path(state_file_path: &str) -> String {
360        if let Some(parent) = std::path::Path::new(state_file_path).parent() {
361            parent.join("edit_log.jsonl").to_string_lossy().to_string()
362        } else {
363            // If no parent directory, put it in the same location
364            "edit_log.jsonl".to_string()
365        }
366    }
367}
368
369impl Default for StorageConfig {
370    fn default() -> Self {
371        Self {
372            state_file: Self::get_default_state_file_path(),
373        }
374    }
375}
376
377impl Default for ScrobbleScrubberConfig {
378    fn default() -> Self {
379        Self {
380            scrubber: ScrubberConfig::default(),
381            providers: ProvidersConfig::default(),
382            storage: StorageConfig::default(),
383            lastfm: LastFmConfig {
384                username: String::new(),
385                password: String::new(),
386                base_url: None,
387            },
388        }
389    }
390}
391
392impl ScrobbleScrubberConfig {
393    /// Get default configuration file paths in order of preference
394    /// Uses XDG Base Directory specification
395    #[must_use]
396    pub fn get_default_config_paths() -> Vec<PathBuf> {
397        let mut paths = Vec::new();
398
399        // Current directory
400        paths.push(PathBuf::from("scrobble-scrubber.toml"));
401        paths.push(PathBuf::from("config/scrobble-scrubber.toml"));
402
403        // XDG config directory
404        if let Some(config_dir) = dirs::config_dir() {
405            paths.push(config_dir.join("scrobble-scrubber").join("config.toml"));
406            paths.push(
407                config_dir
408                    .join("scrobble-scrubber")
409                    .join("scrobble-scrubber.toml"),
410            );
411        }
412
413        // Legacy home directory location
414        if let Some(home_dir) = dirs::home_dir() {
415            paths.push(
416                home_dir
417                    .join(".config")
418                    .join("scrobble-scrubber")
419                    .join("config.toml"),
420            );
421            paths.push(home_dir.join(".scrobble-scrubber.toml"));
422        }
423
424        paths
425    }
426
427    /// Get the preferred configuration file path for creating new config files
428    /// Returns the XDG config directory path
429    #[must_use]
430    pub fn get_preferred_config_path() -> Option<PathBuf> {
431        dirs::config_dir()
432            .map(|config_dir| config_dir.join("scrobble-scrubber").join("config.toml"))
433    }
434
435    /// Load configuration from multiple sources with priority:
436    /// 1. Command line arguments (highest priority)
437    /// 2. Environment variables
438    /// 3. Configuration file
439    /// 4. Defaults (lowest priority)
440    pub fn load() -> Result<Self, ConfigError> {
441        Self::load_with_file::<&str>(None)
442    }
443
444    /// Load configuration with a specific config file
445    pub fn load_with_file<P: AsRef<Path>>(config_file: Option<P>) -> Result<Self, ConfigError> {
446        let mut builder = Config::builder();
447
448        // Start with defaults
449        builder = builder.add_source(Config::try_from(&Self::default())?);
450
451        // Add config file if it exists
452        if let Some(file_path) = config_file {
453            if file_path.as_ref().exists() {
454                builder = builder.add_source(File::from(file_path.as_ref()));
455            }
456        } else {
457            // Try common config file locations in order of preference
458            let config_paths = Self::get_default_config_paths();
459
460            for config_path in config_paths {
461                if config_path.exists() {
462                    builder = builder.add_source(File::from(config_path));
463                    break;
464                }
465            }
466        }
467
468        // Add environment variables with prefix
469        builder = builder.add_source(
470            Environment::with_prefix("SCROBBLE_SCRUBBER")
471                .separator("_")
472                .try_parsing(true),
473        );
474
475        builder.build()?.try_deserialize()
476    }
477
478    /// Get the Last.fm base URL with fallback to default
479    #[must_use]
480    pub fn lastfm_base_url(&self) -> &str {
481        self.lastfm
482            .base_url
483            .as_deref()
484            .unwrap_or("https://www.last.fm")
485    }
486}