From 68f8297246b9516a0fa5d92f8d2a451845f3ef0c Mon Sep 17 00:00:00 2001 From: TX-RX Date: Wed, 8 Jul 2026 12:01:01 -0500 Subject: [PATCH 1/2] Extract %beatport_track_id% (and other named placeholders) from filenames parse_template previously only recognized %title%, %artist%, %artists%, and stripped every other placeholder into an unnamed catch-all. That meant templates like `%artists%-%title%-%version%-%beatport_track_id%` could never populate a Beatport track ID tag that platform matchers could pick up. - parse_template now promotes any identifier-safe %name% into a named capture, so downstream code sees them in `Regex::captures`. - load_file always runs the filename regex when a template is provided (not only when title/artist are missing) and merges every named capture other than title/artists into `info.tags` under the uppercased key. ID3 tags on the file still win over filename-derived tags. With this, files whose names encode the Beatport track ID in the tail segment land BEATPORT_TRACK_ID in info.tags, which beatport.rs already checks in its "Fetch by ID" path. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/onetagger-autotag/src/lib.rs | 39 +++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/crates/onetagger-autotag/src/lib.rs b/crates/onetagger-autotag/src/lib.rs index 43097470..41dd1fa2 100644 --- a/crates/onetagger-autotag/src/lib.rs +++ b/crates/onetagger-autotag/src/lib.rs @@ -436,10 +436,10 @@ impl AudioFileInfoImpl for AudioFileInfo { .map(|a| AudioFileInfo::parse_artist_tag(a.iter().map(|a| a.as_str()).collect())); // Parse filename - if (title.is_none() || artists.is_none()) && filename_template.is_some() { + let mut filename_extra_tags: HashMap> = HashMap::new(); + if let Some(re) = filename_template.as_ref() { let filename = path.as_ref().file_name().ok_or(anyhow!("Missing filename!"))?.to_str().ok_or(anyhow!("Missing filename"))?; - if let Some(captures) = filename_template.unwrap().captures(filename) { - + if let Some(captures) = re.captures(filename) { // Title if title.is_none() { if let Some(m) = captures.name("title") { @@ -452,6 +452,19 @@ impl AudioFileInfoImpl for AudioFileInfo { artists = Some(AudioFileInfo::parse_artist_tag(vec![m.as_str().trim()])); } } + // Any additional named captures (e.g. %beatport_track_id%) get promoted + // to tags so platform matchers can pick them up as if they were on the file. + for name in re.capture_names().flatten() { + if name == "title" || name == "artists" { + continue; + } + if let Some(m) = captures.name(name) { + let v = m.as_str().trim(); + if !v.is_empty() { + filename_extra_tags.insert(name.to_uppercase(), vec![v.to_string()]); + } + } + } } } @@ -477,6 +490,11 @@ impl AudioFileInfoImpl for AudioFileInfo { // Track number let track_number = tag.get_field(Field::TrackNumber).unwrap_or(vec![String::new()])[0].parse().ok(); + // Merge filename-derived tags (e.g. platform IDs). ID3 tags on the file win. + let mut tags = tag.all_tags(); + for (k, v) in filename_extra_tags { + tags.entry(k).or_insert(v); + } Ok(AudioFileInfo { format: tag_wrap.format(), title, @@ -486,7 +504,7 @@ impl AudioFileInfoImpl for AudioFileInfo { duration: None, track_number, tagged, - tags: tag.all_tags() + tags, }) } @@ -508,12 +526,13 @@ impl AudioFileInfoImpl for AudioFileInfo { for c in reserved.chars() { template = template.replace(c, &format!("\\{}", c)); }; - // Replace variables - template = template - .replace("%title%", "(?P.+?)") - .replace("%artist%", "(?P<artists>.+?)") - .replace("%artists%", "(?P<artists>.+?)"); - // Remove all remaining variables + // %artist% is an alias for %artists% + template = template.replace("%artist%", "%artists%"); + // Promote identifier-safe placeholders (e.g. %title%, %artists%, %beatport_track_id%) + // to named captures. Names with spaces or leading digits fall through to the catch-all. + let named_re = Regex::new("%([a-zA-Z_][a-zA-Z0-9_]*)%").unwrap(); + template = named_re.replace_all(&template, "(?P<$1>.+?)").to_string(); + // Remove any remaining variables (legacy names with spaces) let re = Regex::new("%[a-zA-Z0-9 ]+%").unwrap(); template = re.replace_all(&template, "(.+)").to_string(); // Extension From 09c50a5ee7fbcbacba7abf729dbfd25eaa0a9bd1 Mon Sep 17 00:00:00 2001 From: TX-RX <TX-RX@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:01:50 -0500 Subject: [PATCH 2/2] Backfill missing per-platform custom configs before tagging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the frontend hands us a TaggerConfig with an empty `custom` map, every platform's match_track fails immediately on `get_custom("beatport")?` with "Missing beatport custom config!" — before any actual tagging can start. Runtime logs confirm this happens even when settings.json has the custom map populated (the frontend isn't shipping it on tag start). Fill in defaults from custom_default() at the top of Tagger::tag_files so platforms stay functional regardless of what the frontend sends. Existing per-platform values in `custom` are preserved via HashMap::entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- crates/onetagger-autotag/src/lib.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/onetagger-autotag/src/lib.rs b/crates/onetagger-autotag/src/lib.rs index 41dd1fa2..1269a0d7 100644 --- a/crates/onetagger-autotag/src/lib.rs +++ b/crates/onetagger-autotag/src/lib.rs @@ -651,12 +651,22 @@ impl Tagger { pub fn tag_files(cfg: &TaggerConfig, mut files: Vec<PathBuf>, finished: Arc<Mutex<Option<TaggerFinishedData>>>) -> Receiver<TaggingStatusWrap> { STOP_TAGGING.store(false, Ordering::SeqCst); + // Backfill missing per-platform custom configs with defaults. The frontend + // sometimes ships an empty `custom` map, which trips `get_custom("beatport")?` + // (and similar) with "Missing beatport custom config!" before any tagging can + // start. Filling from `custom_default()` keeps the platforms functional. + let mut config = cfg.clone(); + let default_custom = TaggerConfig::custom_default().custom; + for (platform, options) in default_custom.0.into_iter() { + config.custom.0.entry(platform).or_insert(options); + } + // Shuffle so album tag is more "efficient" - if cfg.album_tagging { + if config.album_tagging { let mut rng = rand::rng(); files.shuffle(&mut rng); } - + // let original_files = files.clone(); let mut succesful_files = vec![]; let mut failed_files = vec![]; @@ -665,7 +675,6 @@ impl Tagger { // Create thread let (tx, rx) = unbounded(); - let config = cfg.clone(); std::thread::spawn(move || { // Tag for (platform_index, platform) in config.platforms.iter().enumerate() {