Files
SoulStream/crates/metadata/examples/mb_query_merge_probe.rs
T
KeelrLeek a667b4b28e Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
2026-07-22 17:57:00 -05:00

222 lines
7.3 KiB
Rust

//! 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<String> = 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<String> = fetch_groups(&http, &studio_q, 1)
.await
.into_iter()
.filter(|g| g.secondary.is_empty())
.map(|g| g.title)
.collect();
let hints_now: BTreeSet<String> = 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<String> = merged
.iter()
.filter(|g| g.secondary.is_empty())
.map(|g| g.title.clone())
.collect();
let hints_merged: BTreeSet<String> = 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<String>,
}
async fn fetch_groups(http: &reqwest::Client, query: &str, max_pages: usize) -> Vec<Group> {
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::<serde_json::Value>().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<String> = 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<String> {
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::<serde_json::Value>()
.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()
}