forked from Keelrleek/SoulStream
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.
222 lines
7.3 KiB
Rust
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()
|
|
}
|