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
18pub 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 pub interval: u64,
129 pub dry_run: bool,
131 pub require_confirmation: bool,
133 pub require_proposed_rule_confirmation: bool,
135 pub auto_start: bool,
137 pub track_provider: TrackProviderType,
139 pub json_logging: JsonLoggingConfig,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct ProvidersConfig {
145 pub enable_rewrite_rules: bool,
147 pub enable_openai: bool,
149 pub enable_http: bool,
151 pub enable_musicbrainz: bool,
153 pub openai: Option<OpenAIProviderConfig>,
155 pub http: Option<HttpProviderConfig>,
157 pub musicbrainz: Option<MusicBrainzProviderConfig>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
162pub struct OpenAIProviderConfig {
163 pub api_key: String,
165 pub model: Option<String>,
167 pub system_prompt: Option<String>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct HttpProviderConfig {
173 pub endpoint_url: String,
175 pub timeout_seconds: u64,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
180#[serde(rename_all = "snake_case")]
181pub enum ReleaseFilterType {
182 ExcludeDemo,
184 ExcludeSpecialEdition,
186 PreferNonJapanese,
188 ExcludeByDisambiguation { terms: Vec<String> },
190 ExcludeByCountry { countries: Vec<String> },
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
195pub struct ReleaseFilterConfig {
196 pub filters: Vec<ReleaseFilterType>,
198 pub prefer_original_releases: bool,
200 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 pub confidence_threshold: f32,
222 pub max_results: usize,
224 pub api_delay_ms: u64,
226 }
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct StorageConfig {
232 pub state_file: String,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct LastFmConfig {
238 pub username: String,
240 pub password: String,
242 pub base_url: Option<String>,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct JsonLoggingConfig {
248 pub enabled: bool,
250 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 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 "track_edits.jsonl".to_string()
281 }
282 }
283
284 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, }
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, max_results: 5, api_delay_ms: 100, }
322 }
323}
324
325impl StorageConfig {
326 pub fn get_default_state_file_path() -> String {
329 Self::get_default_state_file_path_for_user(None)
330 }
331
332 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 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 if let Some(user) = username {
350 format!("{user}_scrobble_state.db")
351 } else {
352 "scrobble_state.db".to_string()
353 }
354 }
355 }
356
357 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 "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 #[must_use]
396 pub fn get_default_config_paths() -> Vec<PathBuf> {
397 let mut paths = Vec::new();
398
399 paths.push(PathBuf::from("scrobble-scrubber.toml"));
401 paths.push(PathBuf::from("config/scrobble-scrubber.toml"));
402
403 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 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 #[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 pub fn load() -> Result<Self, ConfigError> {
441 Self::load_with_file::<&str>(None)
442 }
443
444 pub fn load_with_file<P: AsRef<Path>>(config_file: Option<P>) -> Result<Self, ConfigError> {
446 let mut builder = Config::builder();
447
448 builder = builder.add_source(Config::try_from(&Self::default())?);
450
451 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 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 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 #[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}