//! Go/no-go experiment for merging the identity pass's two release-group //! queries into one (theory 5 of the 2026-07-22 load audit). //! //! Current scheme (2 queries): //! STUDIO: arid:X AND primarytype:album AND -secondarytype:* AND status:official (1 page) //! HINTS: arid:X AND primarytype:album AND secondarytype:* (≤5 pages) //! //! Candidate merged scheme (1 query): //! ALL-OFFICIAL: arid:X AND primarytype:album AND status:official (≤5 pages) //! → client-side split: no secondary types ⇒ studio, some ⇒ hint. //! //! The merge is adoptable ONLY if, for every probed artist, the split //! reproduces BOTH current sets exactly. The known hazard: the hints query //! has no status filter today, so status:official could drop bootleg-only //! compilation groups from the hint set — a classification change. //! //! Usage: cargo run -p metadata --example mb_query_merge_probe -- "Artist" ... use std::collections::BTreeSet; use std::time::Duration; const UA: &str = "SoulSeekPlus/0.1 (dev build)"; #[tokio::main] async fn main() { let artists: Vec = std::env::args().skip(1).collect(); if artists.is_empty() { eprintln!("usage: mb_query_merge_probe -- \"Artist\" ..."); return; } let http = reqwest::Client::builder() .user_agent(UA) .timeout(Duration::from_secs(15)) .build() .expect("client"); let meta = metadata::MetadataClient::new(); let mut all_ok = true; for artist in &artists { let search = meta.catalog_search(artist).await; let Some(deezer_id) = search .artists .iter() .find(|a| norm(&a.name) == norm(artist)) .map(|a| a.id) else { println!("== {artist}: no exact Deezer artist, skipped =="); continue; }; let Some(mbid) = linked_mbid(&http, deezer_id).await else { println!("== {artist}: no linked MBID, skipped =="); continue; }; let studio_q = format!("arid:{mbid} AND primarytype:album AND -secondarytype:* AND status:official"); let hints_q = format!("arid:{mbid} AND primarytype:album AND secondarytype:*"); let merged_q = format!("arid:{mbid} AND primarytype:album AND status:official"); // Current studio behavior is a SINGLE page. let studio_now: BTreeSet = fetch_groups(&http, &studio_q, 1) .await .into_iter() .filter(|g| g.secondary.is_empty()) .map(|g| g.title) .collect(); let hints_now: BTreeSet = fetch_groups(&http, &hints_q, 5) .await .into_iter() .map(|g| format!("{} :: {:?}", g.title, g.secondary)) .collect(); let merged = fetch_groups(&http, &merged_q, 5).await; let studio_merged: BTreeSet = merged .iter() .filter(|g| g.secondary.is_empty()) .map(|g| g.title.clone()) .collect(); let hints_merged: BTreeSet = merged .iter() .filter(|g| !g.secondary.is_empty()) .map(|g| format!("{} :: {:?}", g.title, g.secondary)) .collect(); let studio_ok = studio_now == studio_merged; let hints_ok = hints_now == hints_merged; all_ok &= studio_ok && hints_ok; println!( "== {artist} == studio {}/{} {} hints {}/{} {}", studio_now.len(), studio_merged.len(), if studio_ok { "IDENTICAL" } else { "DIFFERS" }, hints_now.len(), hints_merged.len(), if hints_ok { "IDENTICAL" } else { "DIFFERS" }, ); if !studio_ok { for extra in studio_merged.difference(&studio_now) { println!(" studio only-in-merged: {extra}"); } for missing in studio_now.difference(&studio_merged) { println!(" studio missing-from-merged: {missing}"); } } if !hints_ok { for extra in hints_merged.difference(&hints_now) { println!(" hint only-in-merged: {extra}"); } for missing in hints_now.difference(&hints_merged) { println!(" hint missing-from-merged: {missing}"); } } } println!( "\nVERDICT: {}", if all_ok { "merged query reproduced both sets exactly for every probed artist" } else { "NOT identical — do not merge" } ); } struct Group { title: String, secondary: Vec, } async fn fetch_groups(http: &reqwest::Client, query: &str, max_pages: usize) -> Vec { let mut out = Vec::new(); for page in 0..max_pages { throttle().await; let url = format!( "https://musicbrainz.org/ws/2/release-group/?query={}&limit=100&offset={}&fmt=json", urlencoding(query), page * 100 ); let Ok(resp) = http.get(&url).send().await else { break; }; let Ok(body) = resp.json::().await else { break; }; let Some(groups) = body.get("release-groups").and_then(|v| v.as_array()) else { break; }; if groups.is_empty() { break; } let page_len = groups.len(); for g in groups { let Some(title) = g.get("title").and_then(|v| v.as_str()) else { continue; }; let secondary = g .get("secondary-types") .and_then(|v| v.as_array()) .map(|a| { let mut s: Vec = a .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(); s.sort(); s }) .unwrap_or_default(); out.push(Group { title: title.to_string(), secondary, }); } if page_len < 100 { break; } } out } async fn linked_mbid(http: &reqwest::Client, deezer_id: u64) -> Option { throttle().await; let resource = urlencoding(&format!("https://www.deezer.com/artist/{deezer_id}")); let url = format!("https://musicbrainz.org/ws/2/url?resource={resource}&inc=artist-rels&fmt=json"); let body = http .get(&url) .send() .await .ok()? .json::() .await .ok()?; body.get("relations")?.as_array()?.iter().find_map(|r| { r.pointer("/artist/id") .and_then(|v| v.as_str()) .map(String::from) }) } async fn throttle() { tokio::time::sleep(Duration::from_millis(1100)).await; } fn urlencoding(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(b as char) } _ => out.push_str(&format!("%{b:02X}")), } } out } fn norm(s: &str) -> String { s.to_lowercase() .chars() .filter(|c| c.is_alphanumeric()) .collect() }